From 19691c0592bf3b8d5f4bbe43f923bdcf1f8a786c Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:37:27 +0800 Subject: [PATCH 01/28] feat(stats): add token ledger and migration foundation (cherry picked from commit 00212cb56c0363cbb6f47a5a3df0988c8a13e92d) --- app/build.gradle.kts | 9 + .../core/application/OperitApplication.kt | 12 + .../operit/data/dao/TokenStatsDao.kt | 197 +++++ .../assistance/operit/data/db/AppDatabase.kt | 167 +++- .../data/model/TokenStatBaselineEntity.kt | 52 ++ .../data/model/TokenStatDisplayModelEntity.kt | 25 + .../operit/data/model/TokenStatEventEntity.kt | 62 ++ .../data/model/TokenStatIdentityEntity.kt | 33 + .../model/TokenStatPriceOverrideEntity.kt | 113 +++ .../operit/data/preferences/ApiPreferences.kt | 53 +- .../data/stats/LegacyTokenStatsSnapshot.kt | 241 ++++++ .../data/stats/TokenBaselineImportRunner.kt | 208 +++++ .../data/stats/TokenBaselineMigrator.kt | 265 ++++++ .../operit/data/stats/TokenCostCalculator.kt | 161 ++++ .../operit/data/stats/TokenPriceResolver.kt | 232 +++++ .../data/stats/TokenStatIdentityResolver.kt | 45 + .../operit/data/stats/TokenStatTypes.kt | 57 ++ .../screens/TokenUsageStatisticsScreen.kt | 33 +- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ApiPreferencesResetFailureTest.kt | 178 ++++ .../operit/data/stats/JdbcSQLiteDriver.kt | 236 +++++ .../data/stats/JvmSupportSQLiteDatabase.kt | 371 ++++++++ .../stats/LegacyTokenStatsSnapshotTest.kt | 133 +++ .../stats/TokenBaselineImportRunnerTest.kt | 497 +++++++++++ .../data/stats/TokenBaselineMigratorTest.kt | 819 ++++++++++++++++++ .../data/stats/TokenCostCalculatorTest.kt | 400 +++++++++ .../data/stats/TokenPriceResolverTest.kt | 330 +++++++ .../data/stats/TokenStatsRoomMigrationTest.kt | 638 ++++++++++++++ terminal | 2 +- 30 files changed, 5555 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e23d6cfc..70801c135 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -763,6 +763,15 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.compose.bom)) + // JVM 上的真实 Room 迁移/DAO 测试:Android Room 2.8 生成的实现基于 + // androidx.sqlite KMP 接口,用 sqlite-jdbc 实现纯 JVM 驱动(见测试支撑类 + // JdbcSQLiteDriver),仅在单元测试使用。 + testImplementation("org.xerial:sqlite-jdbc:3.46.1.3") + + // 入口级恢复测试:RawSnapshotBackupManager 内部使用 Dispatchers.Main 汇报 + // 进度,JVM 测试用 setMain 安装测试主调度器。 + testImplementation(libs.coroutines.test) + // Apache POI - for Document processing (DOC, DOCX, etc.) implementation(libs.poi) implementation(libs.poi.ooxml) diff --git a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt index bdd438fd3..8da572077 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt @@ -45,6 +45,7 @@ import com.ai.assistance.operit.data.preferences.initAndroidPermissionPreference import com.ai.assistance.operit.data.preferences.initUserPreferencesManager import com.ai.assistance.operit.data.preferences.preferencesManager import com.ai.assistance.operit.data.repository.CustomEmojiRepository +import com.ai.assistance.operit.data.stats.TokenBaselineImportRunner import com.ai.assistance.operit.ui.features.chat.webview.LocalWebServer import com.ai.assistance.operit.ui.features.chat.webview.workspace.editor.language.LanguageFactory import com.ai.assistance.operit.util.GlobalExceptionHandler @@ -292,6 +293,17 @@ class OperitApplication : Application(), ImageLoaderFactory, WorkConfiguration.P AppLogger.d(TAG, "【启动计时】数据库预加载完成(异步) - ${System.currentTimeMillis() - dbStartTime}ms") } + // 旧 DataStore 累计统计 → baseline 幂等导入(一次性;已存在 baseline 冻结)。 + applicationScope.launch { + val statsStartTime = System.currentTimeMillis() + TokenBaselineImportRunner.ensureMigrated(applicationContext) + AppLogger.d( + TAG, + "【启动计时】旧累计统计 baseline 导入完成(异步) - " + + "${System.currentTimeMillis() - statsStartTime}ms" + ) + } + // 初始化全局图片加载器,设置强大的缓存策略 // 创建自定义 OkHttp 客户端,增加超时时间以支持慢速图片服务器 val imageOkHttpClient = OkHttpClient.Builder() diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt new file mode 100644 index 000000000..bbaeeb237 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -0,0 +1,197 @@ +package com.ai.assistance.operit.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import kotlinx.coroutines.flow.Flow + +/** + * 统计账本 DAO(阶段 1)。 + * + * - 事件按 [TokenStatEventEntity.eventId] 幂等插入(重复标识忽略,不重复入账)。 + * - baseline 以 identityId 为主键整体替换(受控补导时 REPLACE;普通导入只新增)。 + * - 身份绝不可走删除式 REPLACE(会级联删除该身份的事件):新增用 INSERT IGNORE, + * 分组变更走显式安全 UPDATE。 + * - 价格覆盖:**唯一写入入口是 [upsertPriceOverride]**(校验 scope 枚举 + + * 规范化业务字段,见 [TokenStatPriceOverrideEntity.normalized]); + * 底层 [insertPriceOverride] 为 protected,不暴露任意 entity 插入的公开路径, + * 防止绕过规范化写入导致唯一索引失效。 + */ +@Dao +abstract class TokenStatsDao { + + // ==== 事件 ==== + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertEvent(event: TokenStatEventEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertEvents(events: List) + + @Query("SELECT * FROM token_stat_events WHERE eventId = :eventId") + abstract suspend fun getEvent(eventId: String): TokenStatEventEntity? + + @Query("SELECT COUNT(*) FROM token_stat_events") + abstract suspend fun countEvents(): Int + + @Query("SELECT * FROM token_stat_events WHERE statIdentityId = :identityId") + abstract fun observeEventsByIdentity(identityId: String): Flow> + + @Query("DELETE FROM token_stat_events WHERE statIdentityId = :identityId") + abstract suspend fun deleteEventsByIdentity(identityId: String): Int + + @Query( + "DELETE FROM token_stat_events WHERE statIdentityId IN " + + "(SELECT identityId FROM token_stat_identities " + + "WHERE provider = :provider AND model = :model)" + ) + abstract suspend fun deleteEventsByProviderModel(provider: String, model: String): Int + + @Query("DELETE FROM token_stat_events") + abstract suspend fun deleteAllEvents(): Int + + // ==== 统计身份 ==== + // 身份绝不可走删除式 REPLACE:REPLACE = DELETE + INSERT,会通过外键 + // 级联删除该身份下的全部事件(token_stat_events ON DELETE CASCADE)。 + // 新增身份用 INSERT IGNORE(已存在则跳过);分组变更走显式安全 UPDATE。 + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertIdentityIfAbsent(identity: TokenStatIdentityEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertIdentitiesIfAbsent(identities: List): List + + @Query( + "UPDATE token_stat_identities SET displayModelId = :displayModelId " + + "WHERE identityId = :identityId" + ) + abstract suspend fun updateIdentityDisplayModel(identityId: String, displayModelId: String): Int + + @Query("SELECT * FROM token_stat_identities WHERE identityId = :identityId") + abstract suspend fun getIdentity(identityId: String): TokenStatIdentityEntity? + + @Query( + "SELECT * FROM token_stat_identities " + + "WHERE configId = :configId AND provider = :provider AND model = :model " + + "LIMIT 1" + ) + abstract suspend fun getIdentityByTriple( + configId: String, + provider: String, + model: String, + ): TokenStatIdentityEntity? + + @Query("SELECT * FROM token_stat_identities") + abstract suspend fun getAllIdentities(): List + + // ==== 展示模型分组 ==== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertDisplayModel(displayModel: TokenStatDisplayModelEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertDisplayModels(displayModels: List) + + @Query("SELECT * FROM token_stat_display_models WHERE displayModelId = :displayModelId") + abstract suspend fun getDisplayModel(displayModelId: String): TokenStatDisplayModelEntity? + + @Query("SELECT * FROM token_stat_display_models") + abstract suspend fun getAllDisplayModels(): List + + // ==== 价格覆盖 ==== + // 唯一性由 (scope, provider, model, configId) 规范化业务字段的唯一索引强制; + // rowId 是内部自增主键,不承载业务语义。公开写入唯一入口会校验 scope 枚举 + // 并规范化字段(TokenStatPriceOverrideEntity.normalized),非法输入抛 + // IllegalArgumentException;底层插入不公开,防止绕过规范化。 + + /** + * 价格覆盖唯一写入入口:校验 scope 固定枚举、规范化 provider/model/configId + * 后落库;规范化后相同业务组合在数据库唯一索引上冲突,REPLACE 后写覆盖。 + * @throws IllegalArgumentException scope 非固定枚举名或 provider/model 空白。 + */ + suspend fun upsertPriceOverride( + scope: String, + provider: String, + model: String, + configId: String?, + billingMode: String, + pricingCurrency: String, + inputPricePerMillion: Double? = null, + cachedInputPricePerMillion: Double? = null, + cacheWritePricePerMillion: Double? = null, + outputPricePerMillion: Double? = null, + pricePerRequest: Double? = null, + ) { + insertPriceOverride( + TokenStatPriceOverrideEntity.normalized( + scope = scope, + provider = provider, + model = model, + configId = configId, + billingMode = billingMode, + pricingCurrency = pricingCurrency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + ) + } + + /** 规范化后的实际落库(受保护:唯一入口是 [upsertPriceOverride])。 */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insertPriceOverride(override: TokenStatPriceOverrideEntity) + + @Query( + "SELECT * FROM token_stat_price_overrides " + + "WHERE scope = :scope AND provider = :provider " + + "AND model = :model AND configId = :configId LIMIT 1" + ) + abstract suspend fun getPriceOverride( + scope: String, + provider: String, + model: String, + configId: String, + ): TokenStatPriceOverrideEntity? + + @Query("SELECT * FROM token_stat_price_overrides") + abstract suspend fun getAllPriceOverrides(): List + + // ==== baseline ==== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertBaseline(baseline: TokenStatBaselineEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertBaselines(baselines: List) + + @Query("SELECT * FROM token_stat_baselines WHERE identityId = :identityId") + abstract suspend fun getBaseline(identityId: String): TokenStatBaselineEntity? + + @Query("SELECT * FROM token_stat_baselines") + abstract suspend fun getAllBaselines(): List + + @Query("SELECT COUNT(*) FROM token_stat_baselines") + abstract suspend fun countBaselines(): Int + + @Query("DELETE FROM token_stat_baselines WHERE identityId = :identityId") + abstract suspend fun deleteBaseline(identityId: String): Int + + @Query( + "DELETE FROM token_stat_baselines WHERE identityId IN " + + "(SELECT identityId FROM token_stat_identities " + + "WHERE provider = :provider AND model = :model)" + ) + abstract suspend fun deleteBaselinesByProviderModel(provider: String, model: String): Int + + @Query("DELETE FROM token_stat_baselines") + abstract suspend fun deleteAllBaselines(): Int +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 86f762fff..797b27f6f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -10,27 +10,39 @@ import com.ai.assistance.operit.data.dao.ChatContentDao import com.ai.assistance.operit.data.dao.ChatDao import com.ai.assistance.operit.data.dao.MessageDao import com.ai.assistance.operit.data.dao.MessageVariantDao +import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.model.ChatEntity import com.ai.assistance.operit.data.model.MessageEntity import com.ai.assistance.operit.data.model.MessageVariantEntity - +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity /** 应用数据库,包含聊天表和消息表 */ @Database( - entities = [ChatEntity::class, MessageEntity::class, MessageVariantEntity::class], - version = 20, + entities = [ + ChatEntity::class, + MessageEntity::class, + MessageVariantEntity::class, + TokenStatIdentityEntity::class, + TokenStatDisplayModelEntity::class, + TokenStatPriceOverrideEntity::class, + TokenStatEventEntity::class, + TokenStatBaselineEntity::class, + ], + version = 21, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { - /** 获取聊天DAO */ abstract fun chatDao(): ChatDao /** 获取消息DAO */ abstract fun messageDao(): MessageDao - abstract fun messageVariantDao(): MessageVariantDao - abstract fun chatContentDao(): ChatContentDao + abstract fun tokenStatsDao(): TokenStatsDao companion object { @Volatile @@ -221,6 +233,146 @@ abstract class AppDatabase : RoomDatabase() { } } + /** + * v20 → v21:token 统计账本表(全部为纯新增,幂等可重入)。 + * 事件表通过外键级联到身份表;baseline 冻结价格语义见 + * [com.ai.assistance.operit.data.stats.TokenBaselineMigrator]。 + */ + private val MIGRATION_20_21 = + object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_identities` ( + `identityId` TEXT NOT NULL, + `configId` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + `displayModelId` TEXT NOT NULL, + PRIMARY KEY(`identityId`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_token_stat_identities_configId_provider_model` " + + "ON `token_stat_identities` (`configId`, `provider`, `model`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` " + + "ON `token_stat_identities` (`displayModelId`)" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_display_models` ( + `displayModelId` TEXT NOT NULL, + `normalizedModel` TEXT NOT NULL, + `displayName` TEXT NOT NULL, + PRIMARY KEY(`displayModelId`) + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_token_stat_display_models_normalizedModel` " + + "ON `token_stat_display_models` (`normalizedModel`)" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_price_overrides` ( + `rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `scope` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + `configId` TEXT NOT NULL, + `billingMode` TEXT NOT NULL, + `pricingCurrency` TEXT NOT NULL, + `inputPricePerMillion` REAL, + `cachedInputPricePerMillion` REAL, + `cacheWritePricePerMillion` REAL, + `outputPricePerMillion` REAL, + `pricePerRequest` REAL + ) + """.trimIndent() + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_token_stat_price_overrides_scope_provider_model_configId` " + + "ON `token_stat_price_overrides` (`scope`, `provider`, `model`, `configId`)" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_events` ( + `eventId` TEXT NOT NULL, + `statIdentityId` TEXT NOT NULL, + `category` TEXT NOT NULL, + `status` TEXT NOT NULL, + `startedAtMs` INTEGER NOT NULL, + `endedAtMs` INTEGER NOT NULL, + `firstTokenAtMs` INTEGER, + `uncachedInputTokens` INTEGER, + `cachedInputTokens` INTEGER, + `cacheWriteTokens` INTEGER, + `outputTokens` INTEGER, + `reasoningTokens` INTEGER, + `reasoningIncludedInOutput` INTEGER, + `billingMode` TEXT NOT NULL, + `pricingCurrency` TEXT NOT NULL, + `inputPricePerMillion` REAL, + `cachedInputPricePerMillion` REAL, + `cacheWritePricePerMillion` REAL, + `outputPricePerMillion` REAL, + `pricePerRequest` REAL, + `pricingSource` TEXT NOT NULL, + `costInPricingCurrency` REAL, + PRIMARY KEY(`eventId`), + FOREIGN KEY(`statIdentityId`) + REFERENCES `token_stat_identities`(`identityId`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS " + + "`index_token_stat_events_statIdentityId_startedAtMs` " + + "ON `token_stat_events` (`statIdentityId`, `startedAtMs`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` " + + "ON `token_stat_events` (`startedAtMs`)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` " + + "ON `token_stat_events` (`category`, `startedAtMs`)" + ) + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_baselines` ( + `identityId` TEXT NOT NULL, + `inputTokens` INTEGER NOT NULL, + `cachedInputTokens` INTEGER NOT NULL, + `outputTokens` INTEGER NOT NULL, + `requestCount` INTEGER NOT NULL, + `pricingCurrency` TEXT NOT NULL, + `costInPricingCurrency` REAL, + `isEstimated` INTEGER NOT NULL, + `fingerprint` TEXT NOT NULL, + `importedAtMs` INTEGER NOT NULL, + `frozenBillingMode` TEXT NOT NULL, + `frozenInputPricePerMillion` REAL, + `frozenCachedInputPricePerMillion` REAL, + `frozenOutputPricePerMillion` REAL, + `frozenPricePerRequest` REAL, + PRIMARY KEY(`identityId`), + FOREIGN KEY(`identityId`) + REFERENCES `token_stat_identities`(`identityId`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } + } + // 定义从版本2到3的迁移 private val MIGRATION_2_3 = object : Migration(2, 3) { @@ -337,7 +489,8 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, - MIGRATION_19_20 + MIGRATION_19_20, + MIGRATION_20_21 ) // 添加新的迁移 .build() INSTANCE = instance diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt new file mode 100644 index 000000000..a4d0c6b19 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt @@ -0,0 +1,52 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +/** + * 旧 DataStore 累计统计迁移出的 baseline(生命周期总览数据)。 + * + * - baseline 无可靠时间分布,不进入事件表,也不进入时间趋势图。 + * - 每个统计身份至多一行;**首次迁移即冻结**:普通启动/价格 setter 永不重估 + * baseline。只有真实备份恢复流程完成(偏好文件恢复后显式触发的受控补导, + * 见 TokenBaselineImportRunner.markRestorePending/consumePendingRestore) + * 才以 `forceReplace` 语义整体重导一次。 + * - [fingerprint] 只摘要**旧迁移源的累计计数**(不含价格设置),用于诊断与 + * 恢复补导的幂等核对,不再作为普通重导的判据。 + * - [frozen*] 记录本次估算所用价格快照(冻结):恢复补导前保持原值, + * 改价不会改写历史估算。 + * - 费用始终按迁移时可用配置估算并标记 [isEstimated],不能伪装成精确历史账单; + * 无可用定价时 [costInPricingCurrency] 为 null(未知,而非 0)。 + * - 展示分组不在此表重复保存:唯一事实来源是 + * [TokenStatIdentityEntity.displayModelId](经 identityId 外键关联)。 + */ +@Entity( + tableName = "token_stat_baselines", + foreignKeys = [ + ForeignKey( + entity = TokenStatIdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["identityId"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class TokenStatBaselineEntity( + @PrimaryKey @ColumnInfo(name = "identityId") val identityId: String, + @ColumnInfo(name = "inputTokens") val inputTokens: Long, + @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Long, + @ColumnInfo(name = "outputTokens") val outputTokens: Long, + @ColumnInfo(name = "requestCount") val requestCount: Long, + @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, + @ColumnInfo(name = "costInPricingCurrency") val costInPricingCurrency: Double? = null, + @ColumnInfo(name = "isEstimated") val isEstimated: Boolean = true, + @ColumnInfo(name = "fingerprint") val fingerprint: String, + @ColumnInfo(name = "importedAtMs") val importedAtMs: Long, + @ColumnInfo(name = "frozenBillingMode") val frozenBillingMode: String, + @ColumnInfo(name = "frozenInputPricePerMillion") val frozenInputPricePerMillion: Double? = null, + @ColumnInfo(name = "frozenCachedInputPricePerMillion") val frozenCachedInputPricePerMillion: Double? = null, + @ColumnInfo(name = "frozenOutputPricePerMillion") val frozenOutputPricePerMillion: Double? = null, + @ColumnInfo(name = "frozenPricePerRequest") val frozenPricePerRequest: Double? = null, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt new file mode 100644 index 000000000..872241f90 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt @@ -0,0 +1,25 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * 展示模型分组:默认把规范化后同名模型合并为一个展示模型,并允许用户设置手动别名。 + * + * 合并只影响展示与聚合;每条事件仍按自己的身份与价格计算。 + * [displayModelId] 默认为规范化模型名;用户设置别名后 [displayName] 保存别名, + * 其余身份仍通过 [displayModelId] 归属同一分组。 + */ +@Entity( + tableName = "token_stat_display_models", + indices = [ + Index(value = ["normalizedModel"], unique = true), + ], +) +data class TokenStatDisplayModelEntity( + @PrimaryKey @ColumnInfo(name = "displayModelId") val displayModelId: String, + @ColumnInfo(name = "normalizedModel") val normalizedModel: String, + @ColumnInfo(name = "displayName") val displayName: String, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt new file mode 100644 index 000000000..8383cc4e6 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt @@ -0,0 +1,62 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * 逐请求统计事件账本(阶段 1 数据契约)。 + * + * - 一行代表一次真实请求/尝试;[eventId] 由记录链路提供稳定标识,用于防止重复入账。 + * - 数值型 token/价格/成本字段为 null 表示“未知”,禁止静默当作 0; + * 0 表示 provider 确认该分量(如无缓存读取、无缓存写入)为 0。 + * - 事件保存发生时的原币价格快照与原币成本([pricingCurrency]/[costInPricingCurrency]), + * 不冻结汇率;跨币种展示换算始终使用当前手动汇率。 + * - [reasoningIncludedInOutput] 是 provider 适配层规范化后的边界声明: + * true = provider 的 output 计数已包含推理 token(计费时不得再加推理); + * false = 推理 token 独立计数,需按输出单价补算; + * null = provider 未声明,计费时按“已包含”处理以避免重复收费。 + * - 不保存提示词/回复正文,也不保存任何凭据。 + */ +@Entity( + tableName = "token_stat_events", + foreignKeys = [ + ForeignKey( + entity = TokenStatIdentityEntity::class, + parentColumns = ["identityId"], + childColumns = ["statIdentityId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index(value = ["statIdentityId", "startedAtMs"]), + Index(value = ["startedAtMs"]), + Index(value = ["category", "startedAtMs"]), + ], +) +data class TokenStatEventEntity( + @PrimaryKey @ColumnInfo(name = "eventId") val eventId: String, + @ColumnInfo(name = "statIdentityId") val statIdentityId: String, + @ColumnInfo(name = "category") val category: String, + @ColumnInfo(name = "status") val status: String, + @ColumnInfo(name = "startedAtMs") val startedAtMs: Long, + @ColumnInfo(name = "endedAtMs") val endedAtMs: Long, + @ColumnInfo(name = "firstTokenAtMs") val firstTokenAtMs: Long? = null, + @ColumnInfo(name = "uncachedInputTokens") val uncachedInputTokens: Int? = null, + @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Int? = null, + @ColumnInfo(name = "cacheWriteTokens") val cacheWriteTokens: Int? = null, + @ColumnInfo(name = "outputTokens") val outputTokens: Int? = null, + @ColumnInfo(name = "reasoningTokens") val reasoningTokens: Int? = null, + @ColumnInfo(name = "reasoningIncludedInOutput") val reasoningIncludedInOutput: Boolean? = null, + @ColumnInfo(name = "billingMode") val billingMode: String, + @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, + @ColumnInfo(name = "inputPricePerMillion") val inputPricePerMillion: Double? = null, + @ColumnInfo(name = "cachedInputPricePerMillion") val cachedInputPricePerMillion: Double? = null, + @ColumnInfo(name = "cacheWritePricePerMillion") val cacheWritePricePerMillion: Double? = null, + @ColumnInfo(name = "outputPricePerMillion") val outputPricePerMillion: Double? = null, + @ColumnInfo(name = "pricePerRequest") val pricePerRequest: Double? = null, + @ColumnInfo(name = "pricingSource") val pricingSource: String, + @ColumnInfo(name = "costInPricingCurrency") val costInPricingCurrency: Double? = null, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt new file mode 100644 index 000000000..7bcff63db --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt @@ -0,0 +1,33 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * 统计身份:按 API 配置实例 + provider + model 区分的最小身份。 + * + * 同一 provider/model 配置在不同 API 配置实例下是不同的统计身份,避免不同价格或 + * 路由相互覆盖。旧 DataStore 累计数据不区分配置实例,其身份使用空 [configId]。 + * [identityId] 由 [com.ai.assistance.operit.data.stats.TokenStatIdentityResolver] 稳定生成。 + */ +@Entity( + tableName = "token_stat_identities", + indices = [ + Index(value = ["configId", "provider", "model"], unique = true), + Index(value = ["displayModelId"]), + ], +) +data class TokenStatIdentityEntity( + @PrimaryKey @ColumnInfo(name = "identityId") val identityId: String, + @ColumnInfo(name = "configId") val configId: String, + @ColumnInfo(name = "provider") val provider: String, + @ColumnInfo(name = "model") val model: String, + @ColumnInfo(name = "displayModelId") val displayModelId: String, +) { + /** 兼容旧系统约定的 “provider:model” 复合标识(含空配置实例)。 */ + val providerModel: String + get() = "$provider:$model" +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt new file mode 100644 index 000000000..994ecb91b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt @@ -0,0 +1,113 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** 价格覆盖的作用范围(固定枚举,数据库只持久化枚举名)。 */ +enum class PriceOverrideScope { + PROVIDER_MODEL, + CONFIG; + + companion object { + /** + * 严格解析:非固定枚举名返回 null。写入边界用它拒绝非法 scope, + * 数据库只可能包含本枚举的 name。 + */ + fun fromNameOrNull(name: String?): PriceOverrideScope? = + entries.firstOrNull { it.name == name } + } +} + +/** + * 价格覆盖:`内置模型默认价 -> provider/model 覆盖 -> 特定 API 配置覆盖` 层级中的 + * 用户覆盖层(后两层)。 + * + * - [scope] = PROVIDER_MODEL:适用于所有配置实例的 provider:model。 + * - [scope] = CONFIG:仅适用于指定 [configId] 的配置实例,优先级最高。 + * 价格为原币单价(每百万 token,或按次计费单价),由 [pricingCurrency] 声明币种。 + * 价格字段为 null 表示该计费方式下不使用。 + * + * 唯一性由**规范化业务字段本身**在数据库层强制(UNIQUE 索引): + * `(scope, provider, model, configId)` 四个字段均为非空规范化值—— + * provider/model 规范化(trim + 小写 + 空白压缩),[configId] 仅 trim; + * PROVIDER_MODEL 范围用空串 `""` 表示“不限定配置实例”。 + * [rowId] 只是内部自增主键,不承载业务语义,REPLACE 后可能变化。 + * + * 写入必须经过 [TokenStatPriceOverrideEntity.normalized](或等价边界): + * 该工厂是唯一保证“规范化后才落库”的构造入口,非法 scope / 空白 provider/model + * 直接抛 [IllegalArgumentException]。DAO 的公开写入方法只接受本工厂产物, + * 不保留任意 entity 的公开插入路径。 + */ +@Entity( + tableName = "token_stat_price_overrides", + indices = [Index(value = ["scope", "provider", "model", "configId"], unique = true)], +) +data class TokenStatPriceOverrideEntity( + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "rowId") val rowId: Long = 0, + @ColumnInfo(name = "scope") val scope: String, + @ColumnInfo(name = "provider") val provider: String, + @ColumnInfo(name = "model") val model: String, + @ColumnInfo(name = "configId") val configId: String, + @ColumnInfo(name = "billingMode") val billingMode: String, + @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, + @ColumnInfo(name = "inputPricePerMillion") val inputPricePerMillion: Double? = null, + @ColumnInfo(name = "cachedInputPricePerMillion") val cachedInputPricePerMillion: Double? = null, + @ColumnInfo(name = "cacheWritePricePerMillion") val cacheWritePricePerMillion: Double? = null, + @ColumnInfo(name = "outputPricePerMillion") val outputPricePerMillion: Double? = null, + @ColumnInfo(name = "pricePerRequest") val pricePerRequest: Double? = null, +) { + companion object { + private fun normalizeProvider(provider: String): String = provider.trim().lowercase() + + private fun normalizeModel(model: String): String = + model.trim().lowercase().replace(Regex("\\s+"), " ") + + /** + * 规范化构造(唯一写入入口):scope 必须是固定枚举名,provider/model 规范化, + * configId 仅 trim;PROVIDER_MODEL 范围强制 configId 为空串。 + * 非法 scope 或规范化后为空白的 provider/model 抛 [IllegalArgumentException]。 + * 规范化后相同业务组合在数据库中必然冲突并 REPLACE 覆盖(见实体唯一索引)。 + */ + fun normalized( + scope: String, + provider: String, + model: String, + configId: String?, + billingMode: String, + pricingCurrency: String, + inputPricePerMillion: Double? = null, + cachedInputPricePerMillion: Double? = null, + cacheWritePricePerMillion: Double? = null, + outputPricePerMillion: Double? = null, + pricePerRequest: Double? = null, + ): TokenStatPriceOverrideEntity { + val validScope = + PriceOverrideScope.fromNameOrNull(scope) + ?: throw IllegalArgumentException("非法价格覆盖 scope: $scope") + val canonicalProvider = normalizeProvider(provider) + val canonicalModel = normalizeModel(model) + require(canonicalProvider.isNotEmpty()) { "provider must not be blank" } + require(canonicalModel.isNotEmpty()) { "model must not be blank" } + return TokenStatPriceOverrideEntity( + scope = validScope.name, + provider = canonicalProvider, + model = canonicalModel, + configId = + if (validScope == PriceOverrideScope.PROVIDER_MODEL) { + "" + } else { + configId?.trim().orEmpty() + }, + billingMode = billingMode, + pricingCurrency = pricingCurrency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 229775991..6cebb0341 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -17,6 +17,7 @@ import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ParameterCategory import com.ai.assistance.operit.data.model.ParameterValueType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -210,6 +211,8 @@ class ApiPreferences private constructor(private val context: Context) { // API 配置默认值 const val DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions" const val DEFAULT_MODEL_NAME = "deepseek-v4-flash" + + private const val TAG = "ApiPreferences" } @Serializable @@ -646,8 +649,13 @@ class ApiPreferences private constructor(private val context: Context) { } } - // 重置所有供应商:模型的token计数 - suspend fun resetAllProviderModelTokenCounts() { + /** + * 重置所有供应商:模型的token计数,并同步清空新统计账本(事件 + baseline)。 + * @return true = 旧计数与新账本均清零成功;false = 旧计数已清零但新账本清理失败 + * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 + * 协程取消(CancellationException)不在此吞掉,向上传播。 + */ + suspend fun resetAllProviderModelTokenCounts(): Boolean { context.apiDataStore.edit { preferences -> val keysToRemove = mutableListOf>() preferences.asMap().forEach { (key, _) -> @@ -660,10 +668,26 @@ class ApiPreferences private constructor(private val context: Context) { preferences.remove(key) } } + return try { + com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator + .resetAllStatistics(context) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "重置全部统计:新账本清理失败", e) + false + } } - // 重置指定供应商:模型的token计数 - suspend fun resetProviderModelTokenCounts(providerModel: String) { + /** + * 重置指定供应商:模型的token计数,并同步清空该模型在新账本中的事件与 baseline + * (所有配置实例身份,见 TokenStatsResetCoordinator)。 + * @return true = 旧计数与新账本均清零成功;false = 旧计数已清零但新账本清理失败 + * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 + * 协程取消(CancellationException)不在此吞掉,向上传播。 + */ + suspend fun resetProviderModelTokenCounts(providerModel: String): Boolean { context.apiDataStore.edit { preferences -> removeTokenCountKeys( preferences, @@ -676,6 +700,27 @@ class ApiPreferences private constructor(private val context: Context) { preferences[getTokenOutputKey(providerModel)] = 0L preferences[getRequestCountKey(providerModel)] = 0 } + return try { + com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator + .resetStatisticsForProviderModel(context, providerModel) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "重置模型统计:新账本清理失败", e) + false + } + } + + /** + * 旧累计统计快照(迁移来源)。新统计系统只把这里作为一次性迁移读取, + * 不再作为第二套账本写入点。 + */ + suspend fun legacyStatsSnapshot(): com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot { + val preferences = context.apiDataStore.data.first() + return com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( + preferences.asMap().mapKeys { it.key.name } + ) } private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt new file mode 100644 index 000000000..ebf3db9f1 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt @@ -0,0 +1,241 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.model.ApiProviderType +import com.ai.assistance.operit.data.model.BillingMode + +/** 旧 DataStore 中某个 provider:model 的累计统计与用户价格设置。 */ +data class LegacyProviderModelStats( + val providerModel: String, + val inputTokens: Long, + val cachedInputTokens: Long, + val outputTokens: Long, + val requestCount: Long, + val priceSettings: LegacyPriceSettings, +) { + val hasAnyData: Boolean + get() = inputTokens > 0L || cachedInputTokens > 0L || outputTokens > 0L || requestCount > 0L +} + +/** + * 旧 DataStore(api_settings)累计统计的一次快照解析。 + * + * 键约定与 [com.ai.assistance.operit.data.preferences.ApiPreferences] 保持一致: + * `token_input_` / `token_cached_input_` / `token_output_` + * (Long)、`request_count_`(Int)、`model_input_price_` 等(Float)、 + * `billing_mode_`(String)、`price_per_request_`(Float), + * 其中 `` 为 “provider:model” 的 “:” 被替换为 “_” 后的形式。 + * + * 旧系统语义:键缺失 = 该计数为 0(累计值从 0 开始);价格键缺失 = 未设置。 + * 解析结果只用于迁移估算,不保留正文或凭据。 + */ +data class LegacyTokenStatsSnapshot( + val providerModels: Map, +) { + companion object { + + private val providerNameCandidates = + ApiProviderType.entries.map { it.name }.sortedByDescending { it.length } + + fun parse(rawPreferences: Map): LegacyTokenStatsSnapshot { + val builders = linkedMapOf() + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(TOKEN_INPUT_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_INPUT_PREFIX)) + if (providerModel.isNotBlank()) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .inputTokens = readTokenCountValue(value) + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(TOKEN_CACHED_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_CACHED_PREFIX)) + if (providerModel.isNotBlank()) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .cachedInputTokens = readTokenCountValue(value) + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(TOKEN_OUTPUT_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_OUTPUT_PREFIX)) + if (providerModel.isNotBlank()) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .outputTokens = readTokenCountValue(value) + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(REQUEST_COUNT_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(REQUEST_COUNT_PREFIX)) + if (providerModel.isNotBlank()) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .requestCount = (value as? Int)?.toLong() ?: 0L + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(PRICE_INPUT_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_INPUT_PREFIX)) + if (providerModel.isNotBlank()) { + val price = (value as? Float)?.toDouble() + if (price != null) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + inputPricePerMillion = price.takeIf { it > 0.0 } + ) + } + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(PRICE_CACHED_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_CACHED_PREFIX)) + if (providerModel.isNotBlank()) { + val price = (value as? Float)?.toDouble() + if (price != null) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + cachedInputPricePerMillion = price.takeIf { it > 0.0 } + ) + } + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(PRICE_OUTPUT_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_OUTPUT_PREFIX)) + if (providerModel.isNotBlank()) { + val price = (value as? Float)?.toDouble() + if (price != null) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + outputPricePerMillion = price.takeIf { it > 0.0 } + ) + } + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(BILLING_MODE_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(BILLING_MODE_PREFIX)) + if (providerModel.isNotBlank()) { + val mode = BillingMode.fromString(value as? String) + if (mode != BillingMode.TOKEN) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + billingMode = mode + ) + } + } + } + } + + rawPreferences.forEach { (key, value) -> + val keyName = key + if (keyName.startsWith(PRICE_PER_REQUEST_PREFIX)) { + val providerModel = + decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_PER_REQUEST_PREFIX)) + if (providerModel.isNotBlank()) { + val price = (value as? Float)?.toDouble() + if (price != null) { + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + pricePerRequest = price.takeIf { it > 0.0 } + ) + } + } + } + } + + return LegacyTokenStatsSnapshot( + providerModels = + builders.values + .map { it.build() } + .filter { it.hasAnyData } + .associateBy { it.providerModel } + ) + } + + private class StatsBuilder(val providerModel: String) { + var inputTokens: Long = 0L + var cachedInputTokens: Long = 0L + var outputTokens: Long = 0L + var requestCount: Long = 0L + var priceSettings: LegacyPriceSettings = LegacyPriceSettings() + + fun build(): LegacyProviderModelStats = + LegacyProviderModelStats( + providerModel = providerModel, + inputTokens = inputTokens, + cachedInputTokens = cachedInputTokens, + outputTokens = outputTokens, + requestCount = requestCount, + priceSettings = priceSettings, + ) + } + + private fun readTokenCountValue(value: Any?): Long = + when (value) { + is Long -> value + is Int -> if (value < 0) value.toLong() and 0xFFFF_FFFFL else value.toLong() + else -> 0L + } + + /** 与 ApiPreferences 一致的 “provider:model” 后缀解码。 */ + private fun decodeProviderModelFromKeySuffix(encoded: String): String { + val matchedProvider = + providerNameCandidates.firstOrNull { + encoded == it || encoded.startsWith("${it}_") + } + return if (matchedProvider != null) { + if (encoded.length == matchedProvider.length) { + matchedProvider + } else { + "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" + } + } else { + encoded.replace("_", ":") + } + } + + private const val TOKEN_INPUT_PREFIX = "token_input_" + private const val TOKEN_CACHED_PREFIX = "token_cached_input_" + private const val TOKEN_OUTPUT_PREFIX = "token_output_" + private const val REQUEST_COUNT_PREFIX = "request_count_" + private const val PRICE_INPUT_PREFIX = "model_input_price_" + private const val PRICE_CACHED_PREFIX = "model_cached_input_price_" + private const val PRICE_OUTPUT_PREFIX = "model_output_price_" + private const val BILLING_MODE_PREFIX = "billing_mode_" + private const val PRICE_PER_REQUEST_PREFIX = "price_per_request_" + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt new file mode 100644 index 000000000..e712b72ed --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt @@ -0,0 +1,208 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.withTransaction +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.CancellationException + +/** + * 旧 DataStore 累计统计 → baseline 的导入执行器(启动时一次性、冻结价格语义)。 + * + * 普通导入([ensureMigrated]):计数指纹变化时(旧系统累计 setter 增长,或用户 + * reset 后降低)用 baseline 行内**冻结价格**重估计数/成本,整体替换为快照绝对值; + * 计数不变时普通价格 setter 不重估。普通导入**只更新快照中明确存在的模型**, + * 快照缺失的模型保持原样(不删除——偏好文件可能暂时缺失;显式删除 + * 走 [TokenStatsResetCoordinator] 的用户重置路径)。普通启动的空快照安全 no-op + * (见 [runImport] 的空快照守卫)。 + */ +object TokenBaselineImportRunner { + + private const val TAG = "TokenBaselineImport" + + /** + * 测试注入缝:生产代码始终为 null,走 [AppDatabase.getDatabase] 的真实事务; + * 测试注入时由测试自行提供真实 Room 数据库(JVM 驱动),验证导入语义。 + */ + internal var databaseProvider: ((Context) -> AppDatabase)? = null + + suspend fun ensureMigrated(context: Context) { + try { + runImport(context.applicationContext, forceReplace = false) + } catch (e: CancellationException) { + // 取消必须向上传播,不能当作迁移失败吞掉 + throw e + } catch (e: Exception) { + // 迁移失败不影响主流程;下次启动会重试(指纹与事务保证幂等)。 + AppLogger.e(TAG, "旧累计统计导入失败(将在下次启动重试)", e) + } + } + + // ==== 导入 ==== + internal suspend fun runImport(appContext: Context, forceReplace: Boolean) { + // 普通启动守卫:空快照直接返回,不触碰数据库(取消/空源都安全,绝不删除)。 + // 注意:受控补导(consumePendingLocked)不走此入口,空快照也以 + // forceReplace 语义执行删除计划。 + val snapshot = ApiPreferences.getInstance(appContext).legacyStatsSnapshot() + if (snapshot.providerModels.isEmpty()) return + + val injected = databaseProvider + val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + val dao = database.tokenStatsDao() + if (injected != null) { + runImport(appContext, dao, snapshot, forceReplace) + } else { + database.withTransaction { + runImport(appContext, dao, snapshot, forceReplace) + } + } + } + + private suspend fun runImport( + appContext: Context, + dao: TokenStatsDao, + snapshot: LegacyTokenStatsSnapshot, + forceReplace: Boolean, + ) { + val existingBaselines = dao.getAllBaselines().associateBy { it.identityId } + val existingIdentities = dao.getAllIdentities().associateBy { it.identityId } + val existingDisplayModels = dao.getAllDisplayModels().associateBy { it.displayModelId } + val plan = + TokenBaselineMigrator.planImport( + snapshot = snapshot, + existingBaselines = existingBaselines, + nowMs = System.currentTimeMillis(), + forceReplace = forceReplace, + resolveIdentity = { providerModel -> ensureIdentity(providerModel) }, + resolveDisplayModel = { providerModel -> + TokenBaselineMigrator.defaultDisplayModel(providerModel) + }, + resolvePricing = { providerModel -> + resolvePricingFor(providerModel, snapshot) + }, + existingIdentities = existingIdentities, + ) + val preserved = + TokenBaselineMigrator.preserveExistingGroups( + plan = plan, + existingIdentities = existingIdentities, + existingDisplayModels = existingDisplayModels, + ) + + // 身份绝不可 REPLACE(会级联删除该身份的事件): + // - 新身份 → INSERT IGNORE; + // - 已存在身份 → 只做安全 UPDATE(分组展示列),不改 identityId。 + val newIdentities = preserved.identities.filter { it.identityId !in existingIdentities } + if (newIdentities.isNotEmpty()) { + dao.insertIdentitiesIfAbsent(newIdentities) + } + preserved.identities + .filter { it.identityId in existingIdentities } + .filter { it.displayModelId != existingIdentities.getValue(it.identityId).displayModelId } + .forEach { dao.updateIdentityDisplayModel(it.identityId, it.displayModelId) } + + if (preserved.displayModels.isNotEmpty()) { + dao.upsertDisplayModels(preserved.displayModels) + } + if (preserved.baselines.isNotEmpty()) { + dao.upsertBaselines(preserved.baselines) + } + // 快照中消失的模型:仅受控补导(forceReplace)时删除其旧系统 baseline + //(恢复快照是权威全量;普通导入绝不删除——缺失可能只是偏好文件暂缺)。 + if (preserved.removedBaselineIdentityIds.isNotEmpty()) { + preserved.removedBaselineIdentityIds.forEach { dao.deleteBaseline(it) } + } + if (preserved.skippedProviderModels.isNotEmpty()) { + AppLogger.w( + TAG, + "跳过无法映射到模型身份的旧统计键: ${preserved.skippedProviderModels}" + ) + } + AppLogger.i( + TAG, + "旧累计统计导入完成: 导入 ${preserved.baselines.size} 个 baseline, " + + "跳过 ${preserved.skippedProviderModels.size} 个无模型键" + ) + } + + private fun ensureIdentity(providerModel: String): TokenStatIdentityEntity { + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + return TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("", provider, model), + configId = "", + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + } + + /** + * baseline 估算只使用旧配置链(旧 DataStore 价格 → 内置默认价), + * 不读取新系统当前价格覆盖:保证已迁移快照不受用户后续改价影响(见 + * [TokenBaselineMigrator] 冻结语义)。 + */ + private fun resolvePricingFor( + providerModel: String, + snapshot: LegacyTokenStatsSnapshot, + ): ResolvedPricing { + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + return TokenPriceResolver.resolve( + provider = provider, + model = model, + configId = null, + overrides = emptyList(), + legacyOverride = snapshot.providerModels[providerModel]?.priceSettings, + defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), + ) + } +} + +/** + * 统计重置接线:把新账本(事件 + baseline)接入仓库现有“全量重置/按模型重置”机制。 + * + * - 全量重置:清空全部事件与 baseline(不删除身份、展示分组与价格覆盖, + * 与旧系统“重置只清计数、保留配置”语义一致)。 + * - 按模型重置:在单个数据库事务中删除该 provider/model 下**所有配置实例** + * 身份的事件与 baseline;旧 DataStore 无配置实例区分,其 baseline 身份的 + * configId 为空串,同样被覆盖。 + * + * [daoProvider] 为测试注入缝:生产代码始终为 null,走 [AppDatabase] 的真实事务; + * 测试注入时由测试自行验证调用语义(生产原子性由 withTransaction 保证)。 + */ +object TokenStatsResetCoordinator { + + internal var daoProvider: ((Context) -> TokenStatsDao)? = null + + suspend fun resetAllStatistics(context: Context) { + withTransaction(context) { dao -> + dao.deleteAllEvents() + dao.deleteAllBaselines() + } + } + + suspend fun resetStatisticsForProviderModel(context: Context, providerModel: String) { + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + if (model.isBlank()) return + withTransaction(context) { dao -> + dao.deleteEventsByProviderModel(provider, model) + dao.deleteBaselinesByProviderModel(provider, model) + } + } + + private suspend fun withTransaction( + context: Context, + block: suspend (TokenStatsDao) -> Unit, + ) { + val injected = daoProvider + if (injected != null) { + block(injected(context)) + return + } + val database = AppDatabase.getDatabase(context.applicationContext) + database.withTransaction { block(database.tokenStatsDao()) } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt new file mode 100644 index 000000000..6b66ce5f3 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt @@ -0,0 +1,265 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import java.security.MessageDigest + +/** 一次导入的完整计划(纯决策,便于测试中断/恢复/幂等语义)。 */ +data class BaselineImportPlan( + val identities: List, + val displayModels: List, + val baselines: List, + val skippedProviderModels: List, + /** 受控补导(forceReplace)时应删除的 baseline identityId:恢复后的权威 + * 快照中该旧系统模型已消失,且该身份确为旧系统迁移身份(configId 为空)。 + * 普通导入恒为空(快照缺失不代表用户数据应被删除)。 */ + val removedBaselineIdentityIds: List = emptyList(), +) + +/** + * 旧 DataStore 累计统计到 baseline 的导入核心(纯逻辑,不依赖 Android)。 + * + * 语义(无启发式): + * - 每个统计身份至多一行 baseline。 + * - **普通启动**:计数指纹变化时(旧系统累计 setter 增长,或用户 reset 后降低), + * 用 baseline 行内**已冻结价格**重新估算并整体替换计数/成本——计数永远是 + * 快照的绝对值(不叠加增量),降低不会产生负增量;**计数不变**时普通价格 + * setter 不触发任何重估(指纹只含计数)。冻结价格(frozen*)与币种永不被 + * 普通启动替换。 + * - **受控补导**([forceReplace] = true,真实备份恢复完成后触发, + * 见 TokenBaselineImportRunner.consumePendingRestore):以恢复后的偏好快照 + * 重新解析定价并整体替换(含冻结价格),同时记录恢复 generation 保证幂等。 + * - **删除语义**:普通导入(forceReplace = false)绝不对快照中缺失的模型做任何 + * 删除——当前快照可能因偏好文件暂时缺失/部分恢复而不完整,删除会造成 + * baseline 丢失;显式删除走 TokenStatsResetCoordinator(用户重置)。只有 + * 完整受控补导(forceReplace = true,恢复后的偏好快照是旧系统的权威全量) + * 才把快照中缺失的模型列入 [BaselineImportPlan.removedBaselineIdentityIds], + * 且只针对旧系统迁移身份(configId 为空);配置实例身份(configId 非空) + * 的 baseline 不属于旧累计快照,绝不因恢复被删除。 + * + * [TokenStatBaselineEntity.fingerprint] 只摘要**旧迁移源的累计计数** + * (不含价格设置)。展示别名/分组:baseline 不保存 displayModelId(单一事实源 + * 是 identity),重导时通过 [preserveExistingGroups] 保留用户已设置的别名与分组。 + * + * - 迁移中断:整个导入必须由调用方放在单个数据库事务中,中断则整体回滚,重跑即可。 + * - 数据库恢复:baseline 表回到旧状态 → 身份无 baseline → 重新导入。 + * - baseline 无时间分布,不进入事件表。 + */ +object TokenBaselineMigrator { + + fun planImport( + snapshot: LegacyTokenStatsSnapshot, + existingBaselines: Map, + nowMs: Long, + forceReplace: Boolean = false, + resolveIdentity: (providerModel: String) -> TokenStatIdentityEntity, + resolveDisplayModel: (providerModel: String) -> TokenStatDisplayModelEntity, + resolvePricing: (providerModel: String) -> ResolvedPricing, + existingIdentities: Map = emptyMap(), + ): BaselineImportPlan { + val identities = mutableListOf() + val displayModels = mutableListOf() + val baselines = mutableListOf() + val skipped = mutableListOf() + val presentIdentityIds = mutableSetOf() + + snapshot.providerModels.keys.sorted().forEach { providerModel -> + val stats = snapshot.providerModels.getValue(providerModel) + val (_, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + if (model.isBlank()) { + skipped += providerModel + return@forEach + } + + val identity = resolveIdentity(providerModel) + presentIdentityIds += identity.identityId + val existing = existingBaselines[identity.identityId] + val fingerprint = fingerprint(stats) + + if (existing != null && !forceReplace) { + // 计数未变:普通价格 setter 不触发重估。 + if (existing.fingerprint == fingerprint) { + return@forEach + } + // 计数变化(增长或降低):用行内冻结价格重估,整体替换为快照绝对值。 + val pricing = frozenPricingOf(existing) + identities += identity + baselines += + existing.copy( + inputTokens = stats.inputTokens, + cachedInputTokens = stats.cachedInputTokens, + outputTokens = stats.outputTokens, + requestCount = stats.requestCount, + costInPricingCurrency = estimateCost(stats, pricing), + fingerprint = fingerprint, + importedAtMs = nowMs, + ) + return@forEach + } + + val displayModel = resolveDisplayModel(providerModel) + val pricing = resolvePricing(providerModel) + val estimatedCost = estimateCost(stats, pricing) + + identities += identity + displayModels += displayModel + baselines += + TokenStatBaselineEntity( + identityId = identity.identityId, + inputTokens = stats.inputTokens, + cachedInputTokens = stats.cachedInputTokens, + outputTokens = stats.outputTokens, + requestCount = stats.requestCount, + pricingCurrency = pricing.currency.name, + costInPricingCurrency = estimatedCost, + isEstimated = true, + fingerprint = fingerprint, + importedAtMs = nowMs, + frozenBillingMode = pricing.billingMode.name, + frozenInputPricePerMillion = pricing.inputPricePerMillion, + frozenCachedInputPricePerMillion = pricing.cachedInputPricePerMillion, + frozenOutputPricePerMillion = pricing.outputPricePerMillion, + frozenPricePerRequest = pricing.pricePerRequest, + ) + } + + // 快照中消失的模型 → 仅受控补导(forceReplace)删除,且只删除旧系统 + // 迁移身份(configId 为空)的 baseline: + // - 普通导入绝不删除:当前快照可能因偏好文件缺失/部分恢复而暂缺模型, + // 删除会造成用户数据丢失;显式删除由 TokenStatsResetCoordinator 提供。 + // - forceReplace 的恢复快照是旧累计统计的权威全量,缺失即旧系统无此 + // 模型;但配置实例身份(configId 非空)的 baseline 不属于旧累计快照 + // 的范围,恢复不得误删。 + // - 身份信息未知时保守保留(外键保证 baseline 必有身份,完整身份表由 + // 调用方传入;缺失该行是数据异常,不应据此删除)。 + val removed = + if (forceReplace) { + existingBaselines.keys + .filter { it !in presentIdentityIds } + .filter { existingIdentities[it]?.configId == "" } + .sorted() + } else { + emptyList() + } + + return BaselineImportPlan( + identities = identities, + displayModels = displayModels, + baselines = baselines, + skippedProviderModels = skipped, + removedBaselineIdentityIds = removed, + ) + } + + /** 从已持久化的冻结价格快照重建定价(普通启动计数变化时重估用)。 */ + fun frozenPricingOf(baseline: TokenStatBaselineEntity): ResolvedPricing { + val billingMode = BillingMode.fromString(baseline.frozenBillingMode) + val known = + if (billingMode == BillingMode.COUNT) { + baseline.frozenPricePerRequest != null + } else { + baseline.frozenInputPricePerMillion != null || + baseline.frozenOutputPricePerMillion != null + } + return ResolvedPricing( + billingMode = billingMode, + currency = parseCurrency(baseline.pricingCurrency), + inputPricePerMillion = baseline.frozenInputPricePerMillion, + cachedInputPricePerMillion = baseline.frozenCachedInputPricePerMillion, + outputPricePerMillion = baseline.frozenOutputPricePerMillion, + pricePerRequest = baseline.frozenPricePerRequest, + source = PricingSource.LEGACY_OVERRIDE, + known = known, + ) + } + + /** + * 重导时保留用户已设置的展示分组/别名(单一事实源): + * - identity 已存在 → 保留其 displayModelId(不因重导重置为默认分组); + * - 展示模型已存在 → 不重写(保留用户别名 [TokenStatDisplayModelEntity.displayName])。 + */ + fun preserveExistingGroups( + plan: BaselineImportPlan, + existingIdentities: Map, + existingDisplayModels: Map, + ): BaselineImportPlan { + val identities = + plan.identities.map { identity -> + val existing = existingIdentities[identity.identityId] + if (existing != null) { + if (existing.displayModelId == identity.displayModelId) identity else + identity.copy(displayModelId = existing.displayModelId) + } else { + identity + } + } + val displayModels = plan.displayModels.filter { it.displayModelId !in existingDisplayModels } + return plan.copy(identities = identities, displayModels = displayModels) + } + + /** + * baseline 估算费用:按导入时可用的**旧配置链**估算(旧 DataStore 价格 + * → 内置默认价,不读取新系统当前价格覆盖),并始终标记为估算。 + * TOKEN 模式下旧 inputTokens 为总输入(含缓存),按 legacy 公式 + * (非缓存输入 × 输入价 + 缓存 × 缓存价 + 输出 × 输出价)估算; + * 旧系统不跟踪缓存写入,估算按 legacy 计费语义视为 0(文档化,非猜测); + * 定价未知时成本为 null(未知),不得静默为 0。 + * 全部使用 Long/Double 计算,避免累计值超过 Int.MAX_VALUE 溢出。 + */ + private fun estimateCost( + stats: LegacyProviderModelStats, + pricing: ResolvedPricing, + ): Double? { + if (!pricing.known) return null + return when (pricing.billingMode) { + BillingMode.COUNT -> { + pricing.pricePerRequest?.times(stats.requestCount.toDouble()) + } + BillingMode.TOKEN -> { + val uncachedInput = (stats.inputTokens - stats.cachedInputTokens).coerceAtLeast(0L) + val cachedInput = stats.cachedInputTokens + val output = stats.outputTokens + val inputPrice = pricing.inputPricePerMillion ?: return null + val cachedPrice = pricing.cachedInputPricePerMillion ?: return null + val outputPrice = pricing.outputPricePerMillion ?: return null + uncachedInput / 1_000_000.0 * inputPrice + + cachedInput / 1_000_000.0 * cachedPrice + + output / 1_000_000.0 * outputPrice + } + } + } + + /** + * 幂等指纹:只摘要**旧迁移源的累计计数**。 + * 旧 DataStore 价格设置、新系统当前价格覆盖、展示别名/分组的任何变化 + * 都不改变指纹(价格变化不触发重导,见类注释的冻结语义)。 + */ + fun fingerprint(stats: LegacyProviderModelStats): String { + val canonical = + buildString { + append(stats.inputTokens).append('|') + append(stats.cachedInputTokens).append('|') + append(stats.outputTokens).append('|') + append(stats.requestCount) + } + val digest = MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8)) + return digest.joinToString(separator = "") { byte -> "%02x".format(byte) } + } + + private fun parseCurrency(raw: String): PricingCurrency = + if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD + + /** 展示模型默认分组:以规范化模型名为默认别名。 */ + fun defaultDisplayModel(providerModel: String): TokenStatDisplayModelEntity { + val (_, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + val normalized = TokenStatIdentityResolver.normalizeModelName(model) + return TokenStatDisplayModelEntity( + displayModelId = normalized, + normalizedModel = normalized, + displayName = normalized, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt new file mode 100644 index 000000000..204c52318 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -0,0 +1,161 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode + +/** + * 事件用量输入(provider 适配层规范化后的结果)。 + * + * - null 字段表示“未知”,不允许静默当作 0;0 表示 provider 确认该分量为 0 + * (例如确认无缓存读取/无缓存写入)。任一未知分量导致 TOKEN 模式成本未知。 + * - [reasoningIncludedInOutput] 是推理 token 归一化边界: + * true = provider 的 output 计数已包含推理 token; + * false = 推理 token 独立计数,计费时按输出单价补算; + * null = provider 未声明,按“已包含”处理,避免重复收费。 + */ +data class TokenUsageInput( + val uncachedInputTokens: Int? = null, + val cachedInputTokens: Int? = null, + val cacheWriteTokens: Int? = null, + val outputTokens: Int? = null, + val reasoningTokens: Int? = null, + val reasoningIncludedInOutput: Boolean? = null, +) + +/** 单次请求的原币成本计算结果;[amount] 为 null 表示未知(非 0)。 */ +data class TokenCostResult( + val amount: Double?, + val currency: PricingCurrency, + val billedInputTokens: Int? = null, + val billedCacheWriteTokens: Int? = null, + val billedOutputTokens: Int? = null, +) + +/** + * 原币费用计算。 + * + * - TOKEN 模式:计费输入 = uncached + cached(两者都必须已知,null 即未知→成本 null); + * 缓存写入按 cacheWriteTokens 独立计费(未知→成本 null;0 跳过;>0 需要缓存写入单价, + * 该单价未解析到时成本 null);计费输出 = output + 独立计数的 reasoning。 + * - COUNT 模式:成本 = 单次价格(每事件一次请求)。 + * - 价格为“每百万 token”原币单价;cached 单价缺省已由 [TokenPriceResolver] 回填。 + */ +object TokenCostCalculator { + + fun billedOutputTokens(usage: TokenUsageInput): Int? { + val output = usage.outputTokens ?: return null + val separateReasoning = + if (usage.reasoningIncludedInOutput == false && usage.reasoningTokens != null) { + usage.reasoningTokens + } else { + 0 + } + return output + separateReasoning + } + + fun billedInputTokens(usage: TokenUsageInput): Int? { + val uncached = usage.uncachedInputTokens ?: return null + val cached = usage.cachedInputTokens ?: return null + return uncached + cached + } + + fun computeCost(usage: TokenUsageInput, pricing: ResolvedPricing): TokenCostResult { + if (pricing.billingMode == BillingMode.COUNT) { + val price = pricing.pricePerRequest + return TokenCostResult( + amount = price, + currency = pricing.currency, + ) + } + + val billedInput = billedInputTokens(usage) + val billedOutput = billedOutputTokens(usage) + if (billedInput == null || billedOutput == null) { + return TokenCostResult(amount = null, currency = pricing.currency) + } + val inputPrice = pricing.inputPricePerMillion + val cachedPrice = pricing.cachedInputPricePerMillion + val outputPrice = pricing.outputPricePerMillion + if (inputPrice == null || cachedPrice == null || outputPrice == null) { + return TokenCostResult(amount = null, currency = pricing.currency) + } + + val cachedTokens = usage.cachedInputTokens ?: 0 + val uncachedTokens = billedInput - cachedTokens + var amount = + uncachedTokens / 1_000_000.0 * inputPrice + + cachedTokens / 1_000_000.0 * cachedPrice + + billedOutput / 1_000_000.0 * outputPrice + + // 缓存写入:未知 → 成本未知;确认 0 → 不参与;> 0 → 需要缓存写入单价 + val cacheWriteTokens = usage.cacheWriteTokens + if (cacheWriteTokens == null) { + return TokenCostResult( + amount = null, + currency = pricing.currency, + billedInputTokens = billedInput, + billedCacheWriteTokens = null, + billedOutputTokens = billedOutput, + ) + } + if (cacheWriteTokens > 0) { + val cacheWritePrice = pricing.cacheWritePricePerMillion + if (cacheWritePrice == null) { + return TokenCostResult(amount = null, currency = pricing.currency) + } + amount += cacheWriteTokens / 1_000_000.0 * cacheWritePrice + } + + return TokenCostResult( + amount = amount, + currency = pricing.currency, + billedInputTokens = billedInput, + billedCacheWriteTokens = cacheWriteTokens, + billedOutputTokens = billedOutput, + ) + } +} + +/** + * 币种换算语义(阶段 1 核心): + * + * - 汇率只由用户手动设置;未设置时使用默认估算值 7.0(界面必须标记为估算)。 + * - 事件保存发生时的原币价格与原币成本,不冻结汇率;换算只发生在展示层, + * 使用当前手动汇率。因此修改汇率会改变“统一币种”的换算总计, + * 但不会改变历史原币成本。 + */ +object TokenCostCurrency { + + /** 未设置手动汇率时的默认估算值(1 USD = 7.0 CNY)。 */ + const val DEFAULT_USD_TO_CNY_RATE = 7.0 + + /** 历史原币成本按当前手动汇率换算到目标币种;未知(null)原样保持未知。 */ + fun historicalCostConverted( + costInPricingCurrency: Double?, + costCurrency: PricingCurrency, + targetCurrency: PricingCurrency, + manualRate: Double, + ): Double? { + val cost = costInPricingCurrency ?: return null + return convertTo(cost, costCurrency, targetCurrency, manualRate) + } + + /** 当前价格重估:以当前解析价格 × 事件用量(原币),不触碰历史快照。 */ + fun revaluedCost(usage: TokenUsageInput, currentPricing: ResolvedPricing): TokenCostResult = + TokenCostCalculator.computeCost(usage, currentPricing) + + fun convertTo( + amount: Double, + from: PricingCurrency, + to: PricingCurrency, + manualRate: Double, + ): Double { + require(manualRate > 0.0) { "manual rate must be positive" } + if (from == to) return amount + return if (from == PricingCurrency.USD) { + amount * manualRate + } else { + amount / manualRate + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt new file mode 100644 index 000000000..9e2ae5896 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt @@ -0,0 +1,232 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.ModelPricingDefaults +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity + +/** 旧系统(DataStore)中用户保存的价格设置;null 字段表示“未设置”。 */ +data class LegacyPriceSettings( + val billingMode: BillingMode? = null, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) { + /** + * 旧系统约定:价格键缺失时读数为 0,且 0 与“未设置”不可区分, + * 因此只有 > 0 的值才视为用户设置。 + */ + fun hasAnyUserSetting(): Boolean = + billingMode != null || + (inputPricePerMillion ?: 0.0) > 0.0 || + (cachedInputPricePerMillion ?: 0.0) > 0.0 || + (outputPricePerMillion ?: 0.0) > 0.0 || + (pricePerRequest ?: 0.0) > 0.0 +} + +/** + * 解析完成的定价:TOKEN 模式下价格均已按层级回填(cached 缺省回退到 input), + * [known] 为 false 表示“未知定价”,对应成本必须为 null,不得静默当作 0。 + * + * [cacheWritePricePerMillion] 无内置/旧系统数据来源时保持 null(未知): + * 事件中 cacheWriteTokens > 0 且价格未知时成本为 null;cacheWriteTokens == 0 + * (确认无缓存写入)时不需要该价格。不猜测缓存写入单价。 + */ +data class ResolvedPricing( + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, + val source: PricingSource, + val known: Boolean, +) + +/** + * 价格层级解析:`内置模型默认价 -> provider/model 覆盖 -> 特定 API 配置覆盖`, + * 阶段 1 额外桥接旧 DataStore 中用户保存的 provider/model 价格([LegacyPriceSettings]), + * 顺序为:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧系统价格 > 内置默认价。 + */ +object TokenPriceResolver { + + const val SCOPE_CONFIG = "CONFIG" + const val SCOPE_PROVIDER_MODEL = "PROVIDER_MODEL" + + /** + * 构造已规范化的覆盖行(便捷工厂,等价于 + * [TokenStatPriceOverrideEntity.normalized])。 + * provider/model 规范化(trim + 小写 + 空白压缩)、configId 仅 trim; + * PROVIDER_MODEL 范围强制 configId 为空串(“不限定配置实例”)。 + * 非法 scope 或空白 provider/model 抛 [IllegalArgumentException]。 + * 规范化后相同业务组合在数据库中必然冲突并 REPLACE 覆盖(见实体唯一索引)。 + */ + fun normalizedOverride( + scope: String, + provider: String, + model: String, + configId: String?, + billingMode: BillingMode, + pricingCurrency: String, + inputPricePerMillion: Double? = null, + cachedInputPricePerMillion: Double? = null, + cacheWritePricePerMillion: Double? = null, + outputPricePerMillion: Double? = null, + pricePerRequest: Double? = null, + ): TokenStatPriceOverrideEntity = + TokenStatPriceOverrideEntity.normalized( + scope = scope, + provider = provider, + model = model, + configId = configId, + billingMode = billingMode.name, + pricingCurrency = pricingCurrency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + + /** + * 解析定价:按**规范化业务字段**(而非任何主键)匹配覆盖行, + * 行内容与查询键一致才命中,键/内容错配不可能造成错误解析。 + * 顺序:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧系统价格 > 内置默认价。 + */ + fun resolve( + provider: String, + model: String, + configId: String?, + overrides: List, + legacyOverride: LegacyPriceSettings?, + defaults: ModelPricingDefaults, + ): ResolvedPricing { + val canonicalProvider = TokenStatIdentityResolver.normalizeProvider(provider) + val canonicalModel = TokenStatIdentityResolver.normalizeModelName(model) + val canonicalConfigId = configId?.trim().orEmpty() + + if (canonicalConfigId.isNotEmpty()) { + overrides.firstOrNull { + it.scope == SCOPE_CONFIG && + TokenStatIdentityResolver.normalizeProvider(it.provider) == canonicalProvider && + TokenStatIdentityResolver.normalizeModelName(it.model) == canonicalModel && + it.configId.trim() == canonicalConfigId + }?.let { return fromOverrideRow(it, PricingSource.CONFIG_OVERRIDE) } + } + + overrides.firstOrNull { + it.scope == SCOPE_PROVIDER_MODEL && + TokenStatIdentityResolver.normalizeProvider(it.provider) == canonicalProvider && + TokenStatIdentityResolver.normalizeModelName(it.model) == canonicalModel && + it.configId.isBlank() + }?.let { return fromOverrideRow(it, PricingSource.PROVIDER_MODEL_OVERRIDE) } + + if (legacyOverride != null && legacyOverride.hasAnyUserSetting()) { + return fromLegacy(legacyOverride, defaults) + } + + return fromDefaults(defaults) + } + + /** 数据库覆盖行:显式实体,null 表示未使用;价格 0 是用户的真实设置。 */ + private fun fromOverrideRow( + row: TokenStatPriceOverrideEntity, + source: PricingSource, + ): ResolvedPricing { + val billingMode = BillingMode.fromString(row.billingMode) + val currency = parseCurrency(row.pricingCurrency) + return if (billingMode == BillingMode.COUNT) { + ResolvedPricing( + billingMode = billingMode, + currency = currency, + pricePerRequest = row.pricePerRequest, + source = source, + known = row.pricePerRequest != null, + ) + } else { + val input = row.inputPricePerMillion + val cached = row.cachedInputPricePerMillion ?: input + val output = row.outputPricePerMillion + ResolvedPricing( + billingMode = billingMode, + currency = currency, + inputPricePerMillion = input, + cachedInputPricePerMillion = cached, + cacheWritePricePerMillion = row.cacheWritePricePerMillion, + outputPricePerMillion = output, + source = source, + known = input != null || output != null, + ) + } + } + + /** 旧系统价格:缺省分量回退到内置默认价;> 0 才算用户设置(旧约定 0 == 未设置)。 */ + private fun fromLegacy( + legacy: LegacyPriceSettings, + defaults: ModelPricingDefaults, + ): ResolvedPricing { + val billingMode = legacy.billingMode ?: defaults.billingMode + return if (billingMode == BillingMode.COUNT) { + val pricePerRequest = + legacy.pricePerRequest?.takeIf { it > 0.0 } ?: defaults.pricePerRequest + ResolvedPricing( + billingMode = billingMode, + currency = defaults.currency, + pricePerRequest = pricePerRequest, + source = PricingSource.LEGACY_OVERRIDE, + known = pricePerRequest > 0.0, + ) + } else { + val input = + legacy.inputPricePerMillion?.takeIf { it > 0.0 } + ?: defaults.inputPricePerMillion + val cached = + legacy.cachedInputPricePerMillion?.takeIf { it > 0.0 } + ?: defaults.cachedInputPricePerMillion + ?: input + val output = + legacy.outputPricePerMillion?.takeIf { it > 0.0 } + ?: defaults.outputPricePerMillion + ResolvedPricing( + billingMode = billingMode, + currency = defaults.currency, + inputPricePerMillion = input, + cachedInputPricePerMillion = cached, + // 旧系统没有缓存写入计费,不猜测:保持未知 + cacheWritePricePerMillion = null, + outputPricePerMillion = output, + source = PricingSource.LEGACY_OVERRIDE, + known = (input ?: 0.0) > 0.0 || (cached ?: 0.0) > 0.0 || (output ?: 0.0) > 0.0, + ) + } + } + + /** 内置默认价;默认值表对未知模型/供应商给出全 0 缺省(zeroPricing),视为未知。 */ + private fun fromDefaults(defaults: ModelPricingDefaults): ResolvedPricing { + val known = + if (defaults.billingMode == BillingMode.COUNT) { + defaults.pricePerRequest > 0.0 + } else { + defaults.inputPricePerMillion > 0.0 || + defaults.cachedInputPricePerMillion > 0.0 || + defaults.outputPricePerMillion > 0.0 + } + return ResolvedPricing( + billingMode = defaults.billingMode, + currency = defaults.currency, + inputPricePerMillion = defaults.inputPricePerMillion, + cachedInputPricePerMillion = defaults.cachedInputPricePerMillion, + // 内置价格表没有缓存写入单价,不猜测:保持未知 + cacheWritePricePerMillion = null, + outputPricePerMillion = defaults.outputPricePerMillion, + pricePerRequest = defaults.pricePerRequest, + source = if (known) PricingSource.DEFAULT else PricingSource.UNKNOWN, + known = known, + ) + } + + private fun parseCurrency(raw: String): PricingCurrency = + if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt new file mode 100644 index 000000000..76b2fd65e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt @@ -0,0 +1,45 @@ +package com.ai.assistance.operit.data.stats + +import java.security.MessageDigest + +/** + * 统计身份的规范化与稳定标识生成。 + * + * - 身份 = (configId, provider, model):同一 provider/model 在不同配置实例下是不同身份, + * 旧 DataStore 累计数据不区分配置实例,使用空 [configId]。 + * - [identityId] 必须稳定:相同三元组总是得到相同 ID,避免重复入账或身份漂移。 + * - [normalizeModelName] 是展示合并的规范化依据:同名模型默认归入同一展示分组。 + */ +object TokenStatIdentityResolver { + + /** 规范化模型名:trim + 小写 + 压缩连续空白,作为展示分组的默认 key。 */ + fun normalizeModelName(modelName: String): String = + modelName.trim().lowercase().replace(Regex("\\s+"), " ") + + /** provider 标识规范化:trim + 小写(与配置系统 normalizeProviderId 一致)。 */ + fun normalizeProvider(provider: String): String = provider.trim().lowercase() + + /** 默认展示模型分组 ID:规范化模型名。 */ + fun displayModelIdFor(modelName: String): String = normalizeModelName(modelName) + + /** 生成稳定身份 ID(SHA-256),与展示名无关,只依赖身份三元组。 */ + fun identityId(configId: String, provider: String, model: String): String { + val canonicalConfigId = configId.trim() + val canonicalProvider = provider.trim() + val canonicalModel = model.trim() + require(canonicalProvider.isNotEmpty()) { "provider must not be blank" } + require(canonicalModel.isNotEmpty()) { "model must not be blank" } + val input = listOf(canonicalConfigId, canonicalProvider, canonicalModel) + .joinToString(separator = "\u0000") + val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) + return digest.joinToString(separator = "") { byte -> "%02x".format(byte) } + } + + /** 解析 “provider:model” 复合标识(旧系统约定)为 (provider, model)。 */ + fun splitProviderModel(providerModel: String): Pair { + val trimmed = providerModel.trim() + val colonIndex = trimmed.indexOf(':') + if (colonIndex <= 0) return trimmed to "" + return trimmed.substring(0, colonIndex) to trimmed.substring(colonIndex + 1) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt new file mode 100644 index 000000000..ebae4764f --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt @@ -0,0 +1,57 @@ +package com.ai.assistance.operit.data.stats + +/** + * 事件业务分类(阶段 1 固定契约;统计页默认包含全部分类并允许筛选)。 + * 所有实际模型调用都应落入其中一种,包括连接测试等探测调用。 + */ +enum class TokenStatCategory { + CHAT, + SUBAGENT, + SUMMARY, + TITLE, + MEMORY, + CHARACTER_GENERATION, + CONNECTION_TEST, + OTHER; + + companion object { + fun fromName(name: String?): TokenStatCategory = + entries.firstOrNull { it.name == name } ?: OTHER + } +} + +/** 事件结束状态:正常完成、取消、超时、失败。 */ +enum class TokenStatStatus { + COMPLETED, + CANCELLED, + TIMEOUT, + FAILED; + + companion object { + fun fromName(name: String?): TokenStatStatus = + entries.firstOrNull { it.name == name } ?: FAILED + } +} + +/** 事件价格快照的来源层级,用于解释单价的取值。 */ +enum class PricingSource { + /** 内置模型默认价(可能为 0 的“未知”缺省,见 [TokenPriceResolver])。 */ + DEFAULT, + + /** provider/model 覆盖。 */ + PROVIDER_MODEL_OVERRIDE, + + /** 特定 API 配置覆盖。 */ + CONFIG_OVERRIDE, + + /** 旧系统(DataStore)中用户保存的 provider/model 价格(阶段 1 桥接)。 */ + LEGACY_OVERRIDE, + + /** 无法解析出定价(未知,对应成本为 null)。 */ + UNKNOWN; + + companion object { + fun fromName(name: String?): PricingSource = + entries.firstOrNull { it.name == name } ?: UNKNOWN + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt index 7fd2ad18f..c72570cc0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import android.widget.Toast import com.ai.assistance.operit.R import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect import com.ai.assistance.operit.data.collects.PricingCurrency @@ -583,6 +584,9 @@ fun TokenUsageStatisticsScreen( } if (showResetModelDialog && resetModel.isNotEmpty()) { + // 重组安全位置预取失败文案:协程/Toast 分支不得在 Composable 中 + // 调用 context.getString(触发 LocalContextGetResourceValueCall)。 + val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) AlertDialog( onDismissRequest = { showResetModelDialog = false }, title = { @@ -595,8 +599,18 @@ fun TokenUsageStatisticsScreen( TextButton( onClick = { scope.launch { - apiPreferences.resetProviderModelTokenCounts(resetModel) - providerModelRequestCounts.remove(resetModel) + val succeeded = + apiPreferences.resetProviderModelTokenCounts(resetModel) + if (succeeded) { + providerModelRequestCounts.remove(resetModel) + } else { + // 新账本清理失败:不伪装成功、不清本地展示 + Toast.makeText( + context, + resetFailedMessage, + Toast.LENGTH_SHORT + ).show() + } } showResetModelDialog = false }, @@ -616,6 +630,8 @@ fun TokenUsageStatisticsScreen( } if (showResetDialog) { + // 重组安全位置预取失败文案(同 showResetModelDialog 分支) + val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) AlertDialog( onDismissRequest = { showResetDialog = false }, title = { @@ -628,8 +644,17 @@ fun TokenUsageStatisticsScreen( TextButton( onClick = { scope.launch { - apiPreferences.resetAllProviderModelTokenCounts() - providerModelRequestCounts.clear() + val succeeded = apiPreferences.resetAllProviderModelTokenCounts() + if (succeeded) { + providerModelRequestCounts.clear() + } else { + // 新账本清理失败:不伪装成功、不清本地展示 + Toast.makeText( + context, + resetFailedMessage, + Toast.LENGTH_SHORT + ).show() + } } showResetDialog = false }, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 30029430b..b12edc604 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3613,6 +3613,7 @@ Reset This Model Reset Model Confirmation This will clear token and request count statistics for %1$s. This action cannot be undone. + Failed to reset statistics. Please try again. Reset Edit Pricing Input Tokens diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef4041970..4e019721a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3526,6 +3526,7 @@ 重置该模型 重置模型确认 这将清除 %1$s 的Token和请求次数统计,此操作不可恢复。 + 重置统计失败,请重试 重置 编辑定价 输入Token diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt new file mode 100644 index 000000000..98a66ecce --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt @@ -0,0 +1,178 @@ +package com.ai.assistance.operit.data.preferences + +import android.content.Context +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * 重置失败语义:旧 DataStore 计数清零后,新账本(事件 + baseline)清理失败时 + * 必须返回失败(false)并记录日志,不能假装成功;成功时返回 true。 + * + * 隔离说明:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建一个 + * DataStore 实例(绑定首个访问它的 Context),后续测试共享同一文件,Windows + * 上对同一文件重复写入会失败。因此每个测试在 [Before] 中通过反射清空该单例, + * 使每次测试都绑定到自己的临时目录,每个测试最多一次真实写入。 + */ +class ApiPreferencesResetFailureTest { + + @Before + fun isolateDataStoreSingleton() { + clearApiDataStoreSingleton() + } + + private fun contextWithFiles(tempDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.filesDir).thenReturn(tempDir) + return context + } + + private fun constructApiPreferences(context: Context): ApiPreferences { + val constructor = + ApiPreferences::class.java + .getDeclaredConstructor(Context::class.java) + .apply { isAccessible = true } + return constructor.newInstance(context) + } + + /** + * 清空 `Context.apiDataStore` 委托缓存的数据存储单例,使下一个测试能绑定 + * 到自己的临时目录。委托与单例字段均为 Kotlin 生成物,字段名随编译固定; + * 若未来布局变化导致失败,测试类会直接报错而非静默跳过。 + */ + private fun clearApiDataStoreSingleton() { + val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") + val delegateField = facade.getDeclaredField("apiDataStore\$delegate") + delegateField.isAccessible = true + val delegate = delegateField.get(null) + val instanceField = + delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } + instanceField.set(delegate, null) + } + + @Test + fun `reset all returns false when new ledger cleanup fails`() { + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + + TokenStatsResetCoordinator.daoProvider = + { throw RuntimeException("db unavailable") } + try { + assertFalse(prefs.resetAllProviderModelTokenCounts()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + } + + @Test + fun `reset model returns false when new ledger cleanup fails`() { + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + + TokenStatsResetCoordinator.daoProvider = + { throw RuntimeException("db unavailable") } + try { + assertFalse(prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat")) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + } + + @Test + fun `reset all returns true and clears ledger when cleanup succeeds`() = runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + val dao = mock() + + TokenStatsResetCoordinator.daoProvider = { dao } + try { + assertTrue(prefs.resetAllProviderModelTokenCounts()) + verify(dao).deleteAllEvents() + verify(dao).deleteAllBaselines() + Unit + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + + @Test + fun `reset model returns true and clears ledger when cleanup succeeds`() = runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + val dao = mock() + + TokenStatsResetCoordinator.daoProvider = { dao } + try { + assertTrue(prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat")) + verify(dao).deleteEventsByProviderModel("DEEPSEEK", "deepseek-chat") + verify(dao).deleteBaselinesByProviderModel("DEEPSEEK", "deepseek-chat") + Unit + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + + @Test + fun `reset all propagates cancellation instead of swallowing it`() { + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + + TokenStatsResetCoordinator.daoProvider = + { throw CancellationException("reset all cancelled") } + try { + prefs.resetAllProviderModelTokenCounts() + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + assertEquals("reset all cancelled", e.message) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + } + + @Test + fun `reset model propagates cancellation instead of swallowing it`() { + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + + TokenStatsResetCoordinator.daoProvider = + { throw CancellationException("reset model cancelled") } + try { + prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat") + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + assertEquals("reset model cancelled", e.message) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt new file mode 100644 index 000000000..702153825 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt @@ -0,0 +1,236 @@ +package com.ai.assistance.operit.data.stats + +import androidx.sqlite.SQLITE_DATA_BLOB +import androidx.sqlite.SQLITE_DATA_FLOAT +import androidx.sqlite.SQLITE_DATA_INTEGER +import androidx.sqlite.SQLITE_DATA_NULL +import androidx.sqlite.SQLITE_DATA_TEXT +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement +import java.sql.Connection as JdbcConnection +import java.sql.DriverManager +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.ResultSetMetaData +import java.sql.SQLException +import java.sql.Savepoint +import java.sql.Types + +/** + * 单元测试用纯 JVM SQLite 驱动:基于 org.xerial:sqlite-jdbc 实现 + * androidx.sqlite 的 KMP [SQLiteDriver]/[SQLiteConnection]/[SQLiteStatement] 接口, + * 让 Android Room 2.8 生成的数据库实现(kapt 产物)可以在 JVM 单元测试中真实打开、 + * 迁移与读写。 + * + * 仅用于测试;应用运行时仍使用 Android 平台的驱动。 + * + * 实现约定: + * - 列索引与 androidx 原生(sqlite3 C API)一致,从 0 开始;JDBC 从 1 开始,内部 +1。 + * - 事务命令(BEGIN/END/ROLLBACK/SAVEPOINT/RELEASE)在 JDBC 层直接翻译, + * 避免 sqlite-jdbc 在显式事务 SQL 下的内部状态冲突。 + * - 无结果集的语句(DDL、PRAGMA 赋值等)在 sqlite-jdbc 的 executeQuery 下会抛出 + * “query does not return ResultSet”,按 step() 返回 false 处理;其他 SQLException 照常抛出。 + */ +class JdbcSQLiteDriver : SQLiteDriver { + override fun open(fileName: String): SQLiteConnection = JdbcSQLiteConnection(fileName) +} + +class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { + + private val connection: JdbcConnection = + DriverManager.getConnection("jdbc:sqlite:$fileName").apply { + // Room 连接池会在同一文件上开多个连接;Windows 上 sqlite-jdbc 的 + // journal 文件删除(SQLITE_IOERR_DELETE)会被其它连接/杀软短暂锁定。 + // 内存日志模式彻底避开 journal 文件(仅测试用,无崩溃恢复需求)。 + createStatement().use { it.execute("PRAGMA journal_mode = MEMORY") } + } + + private val savepoints = HashMap() + + override fun prepare(sql: String): SQLiteStatement { + val trimmed = sql.trim() + return when { + trimmed.startsWith("BEGIN ") -> + TransactionStatement { beginJdbcTransaction() } + trimmed == "END TRANSACTION" -> + TransactionStatement { endJdbcTransaction(commit = true) } + trimmed.startsWith("ROLLBACK TRANSACTION TO SAVEPOINT") -> + TransactionStatement { rollbackToSavepoint(extractName(trimmed)) } + trimmed == "ROLLBACK TRANSACTION" -> + TransactionStatement { endJdbcTransaction(commit = false) } + trimmed.startsWith("SAVEPOINT ") -> + TransactionStatement { createSavepoint(extractName(trimmed)) } + trimmed.startsWith("RELEASE SAVEPOINT ") -> + TransactionStatement { releaseSavepoint(extractName(trimmed)) } + else -> JdbcSQLiteStatement(connection.prepareStatement(sql)) + } + } + + override fun inTransaction(): Boolean = !connection.autoCommit + + override fun close() { + connection.close() + } + + private fun beginJdbcTransaction() { + if (connection.autoCommit) { + connection.autoCommit = false + } + } + + private fun endJdbcTransaction(commit: Boolean) { + if (connection.autoCommit) return + if (commit) connection.commit() else connection.rollback() + connection.autoCommit = true + } + + private fun createSavepoint(name: String) { + savepoints[name] = connection.setSavepoint(name) + } + + private fun releaseSavepoint(name: String) { + val savepoint = savepoints.remove(name) ?: return + connection.releaseSavepoint(savepoint) + } + + private fun rollbackToSavepoint(name: String) { + // SQL 语义:回滚到保存点不会释放保存点。 + val savepoint = savepoints[name] ?: return + connection.rollback(savepoint) + } + + private fun extractName(sql: String): String { + val start = sql.indexOf('\'') + val end = sql.lastIndexOf('\'') + if (start < 0 || end <= start) return sql.substringAfterLast(' ').trim() + return sql.substring(start + 1, end) + } + + private class TransactionStatement(private val action: () -> Unit) : SQLiteStatement { + override fun bindBlob(index: Int, value: ByteArray) = Unit + override fun bindDouble(index: Int, value: Double) = Unit + override fun bindLong(index: Int, value: Long) = Unit + override fun bindText(index: Int, value: String) = Unit + override fun bindNull(index: Int) = Unit + override fun getBlob(index: Int): ByteArray = ByteArray(0) + override fun getDouble(index: Int): Double = 0.0 + override fun getLong(index: Int): Long = 0L + override fun getText(index: Int): String = "" + override fun isNull(index: Int): Boolean = true + override fun getColumnCount(): Int = 0 + override fun getColumnName(index: Int): String = "" + override fun getColumnType(index: Int): Int = SQLITE_DATA_NULL + override fun step(): Boolean { + action() + return false + } + override fun reset() = Unit + override fun clearBindings() = Unit + override fun close() = Unit + } +} + +private class JdbcSQLiteStatement( + private val statement: PreparedStatement, +) : SQLiteStatement { + + private var resultSet: ResultSet? = null + private var executed = false + + override fun bindBlob(index: Int, value: ByteArray) = statement.setBytes(index, value) + + override fun bindDouble(index: Int, value: Double) = statement.setDouble(index, value) + + override fun bindLong(index: Int, value: Long) = statement.setLong(index, value) + + override fun bindText(index: Int, value: String) = statement.setString(index, value) + + override fun bindNull(index: Int) = statement.setNull(index, Types.NULL) + + override fun getBlob(index: Int): ByteArray = resultSetOrThrow().getBytes(index + 1) ?: ByteArray(0) + + override fun getDouble(index: Int): Double = resultSetOrThrow().getDouble(index + 1) + + override fun getLong(index: Int): Long = resultSetOrThrow().getLong(index + 1) + + override fun getText(index: Int): String = resultSetOrThrow().getString(index + 1) + + override fun isNull(index: Int): Boolean = resultSetOrThrow().getObject(index + 1) == null + + override fun getColumnCount(): Int = metadataOrNull()?.columnCount ?: 0 + + override fun getColumnName(index: Int): String = + metadataOrNull()?.getColumnName(index + 1) ?: "" + + override fun getColumnType(index: Int): Int { + val meta = metadataOrNull() ?: return SQLITE_DATA_NULL + return when (meta.getColumnType(index + 1)) { + Types.INTEGER, Types.SMALLINT, Types.TINYINT, Types.BIGINT, Types.BIT, Types.BOOLEAN -> + SQLITE_DATA_INTEGER + Types.REAL, Types.FLOAT, Types.DOUBLE, Types.NUMERIC, Types.DECIMAL -> + SQLITE_DATA_FLOAT + Types.BLOB, Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY -> + SQLITE_DATA_BLOB + Types.NULL -> + SQLITE_DATA_NULL + else -> + SQLITE_DATA_TEXT + } + } + + override fun step(): Boolean { + ensureExecuted() + return resultSet?.next() ?: false + } + + /** + * 尽早执行查询:Room 生成的代码在 step() 之前先取列元数据 + * (getColumnIndexOrThrow),而 sqlite-jdbc 的 PreparedStatement.getMetaData() + * 会在同一连接上再开一条语句导致 “inconsistent internal state”。 + * 因此元数据访问触发真正的执行,之后 step() 直接从结果集取行。 + */ + private fun ensureExecuted() { + if (executed) return + executed = true + try { + resultSet = statement.executeQuery() + } catch (e: SQLException) { + // sqlite-jdbc 的 executeQuery 对无结果集语句(DDL、PRAGMA 赋值、 + // INSERT/UPDATE/DELETE 等)会直接抛错且**不执行**语句, + // 这里改用 execute() 真正执行(报错文案随版本不同)。 + val message = e.message.orEmpty() + if (message.contains("does not return", ignoreCase = true)) { + statement.execute() + } else { + throw e + } + } + } + + override fun reset() { + resultSet?.close() + resultSet = null + executed = false + } + + override fun clearBindings() { + statement.clearParameters() + } + + override fun close() { + resultSet?.close() + statement.close() + } + + private fun resultSetOrThrow(): ResultSet = + resultSet ?: throw IllegalStateException("statement has not been stepped") + + /** 列元数据:已执行时直接用结果集元数据,未执行时先执行查询再取。 */ + private fun metadataOrNull(): ResultSetMetaData? { + if (resultSet == null) { + ensureExecuted() + } + return runCatching { resultSet?.metaData }.getOrNull() + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt new file mode 100644 index 000000000..6edd66519 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt @@ -0,0 +1,371 @@ +package com.ai.assistance.operit.data.stats + +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.util.Pair +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet +import java.sql.ResultSetMetaData +import java.util.Locale + +/** + * 纯 JVM 的最小 [SupportSQLiteDatabase] 测试替身(基于 sqlite-jdbc), + * 用于直接驱动生产 `Migration.migrate(SupportSQLiteDatabase)` 变体。 + * + * 只实现迁移路径实际用到的方法(execSQL / query / close / isOpen), + * 其余方法抛 [UnsupportedOperationException],避免无意义的全量模拟。 + * + * 残余风险:生产环境该变体由 Room 的兼容模式(RoomOpenHelper + + * SupportSQLiteConnection)驱动,包含事务包装与 schema 校验;本替身只覆盖 + * 迁移对象本身与共享 SQL 的真实执行,不覆盖 Room 兼容模式编排(需 Android 框架)。 + */ +class JvmSupportSQLiteDatabase(private val connection: Connection) : SupportSQLiteDatabase { + + override fun execSQL(sql: String) { + connection.createStatement().use { it.execute(sql) } + } + + override fun execSQL(sql: String, bindArgs: Array) { + connection.prepareStatement(sql).use { statement -> + bindArgs.forEachIndexed { index, arg -> + when (arg) { + null -> statement.setNull(index + 1, java.sql.Types.NULL) + is Long -> statement.setLong(index + 1, arg) + is Int -> statement.setLong(index + 1, arg.toLong()) + is Double -> statement.setDouble(index + 1, arg) + is Float -> statement.setDouble(index + 1, arg.toDouble()) + is Boolean -> statement.setInt(index + 1, if (arg) 1 else 0) + is ByteArray -> statement.setBytes(index + 1, arg) + else -> statement.setString(index + 1, arg.toString()) + } + } + statement.execute() + } + } + + override fun query(query: String): Cursor { + val resultSet = connection.createStatement().executeQuery(query) + return JvmCursor(resultSet) + } + + override fun query(query: String, bindArgs: Array): Cursor { + val statement = connection.prepareStatement(query) + bindArgs.forEachIndexed { index, arg -> + when (arg) { + null -> statement.setNull(index + 1, java.sql.Types.NULL) + is Long -> statement.setLong(index + 1, arg) + is Int -> statement.setLong(index + 1, arg.toLong()) + is Double -> statement.setDouble(index + 1, arg) + is Float -> statement.setDouble(index + 1, arg.toDouble()) + is Boolean -> statement.setInt(index + 1, if (arg) 1 else 0) + is ByteArray -> statement.setBytes(index + 1, arg) + else -> statement.setString(index + 1, arg.toString()) + } + } + return JvmCursor(statement.executeQuery(), statement) + } + + override fun query(query: SupportSQLiteQuery): Cursor { + val statement = connection.prepareStatement(query.sql) + query.bindTo(object : androidx.sqlite.db.SupportSQLiteProgram { + override fun bindNull(index: Int) = statement.setNull(index, java.sql.Types.NULL) + override fun bindLong(index: Int, value: Long) = statement.setLong(index, value) + override fun bindDouble(index: Int, value: Double) = statement.setDouble(index, value) + override fun bindString(index: Int, value: String) = statement.setString(index, value) + override fun bindBlob(index: Int, value: ByteArray) = statement.setBytes(index, value) + override fun clearBindings() = statement.clearParameters() + override fun close() = statement.close() + }) + return JvmCursor(statement.executeQuery(), statement) + } + + override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = + query(query) + + override fun close() { + connection.close() + } + + override val isOpen: Boolean + get() = !connection.isClosed + + override val path: String? + get() = "jvm-sqlite" + + override val isReadOnly: Boolean + get() = false + + override var version: Int + get() = unsupported("version") + set(value) = unsupported("version") + + override val maximumSize: Long + get() = unsupported("maximumSize") + + override fun setMaximumSize(numBytes: Long): Long = unsupported("setMaximumSize") + + override var pageSize: Long + get() = unsupported("pageSize") + set(value) = unsupported("pageSize") + + override val isDbLockedByCurrentThread: Boolean + get() = unsupported("isDbLockedByCurrentThread") + + override val isWriteAheadLoggingEnabled: Boolean + get() = unsupported("isWriteAheadLoggingEnabled") + + override val attachedDbs: List>? + get() = unsupported("attachedDbs") + + override val isDatabaseIntegrityOk: Boolean + get() = unsupported("isDatabaseIntegrityOk") + + override fun compileStatement(sql: String): SupportSQLiteStatement = + unsupported("compileStatement") + + override fun beginTransaction() = unsupported("beginTransaction") + + override fun beginTransactionNonExclusive() = unsupported("beginTransactionNonExclusive") + + override fun beginTransactionWithListener(listener: android.database.sqlite.SQLiteTransactionListener) = + unsupported("beginTransactionWithListener") + + override fun beginTransactionWithListenerNonExclusive( + listener: android.database.sqlite.SQLiteTransactionListener, + ) = unsupported("beginTransactionWithListenerNonExclusive") + + override fun endTransaction() = unsupported("endTransaction") + + override fun setTransactionSuccessful() = unsupported("setTransactionSuccessful") + + override fun inTransaction(): Boolean = unsupported("inTransaction") + + override fun yieldIfContendedSafely(): Boolean = unsupported("yieldIfContendedSafely") + + override fun yieldIfContendedSafely(sleepAfterYieldDelayMillis: Long): Boolean = + unsupported("yieldIfContendedSafely") + + override fun insert(table: String, conflictAlgorithm: Int, values: ContentValues): Long = + unsupported("insert") + + override fun delete(table: String, whereClause: String?, whereArgs: Array?): Int = + unsupported("delete") + + override fun update( + table: String, + conflictAlgorithm: Int, + values: ContentValues, + whereClause: String?, + whereArgs: Array?, + ): Int = unsupported("update") + + override fun needUpgrade(newVersion: Int): Boolean = unsupported("needUpgrade") + + override fun setLocale(locale: Locale) = unsupported("setLocale") + + override fun setMaxSqlCacheSize(cacheSize: Int) = unsupported("setMaxSqlCacheSize") + + override fun setForeignKeyConstraintsEnabled(enable: Boolean) = + unsupported("setForeignKeyConstraintsEnabled") + + override fun enableWriteAheadLogging(): Boolean = unsupported("enableWriteAheadLogging") + + override fun disableWriteAheadLogging() = unsupported("disableWriteAheadLogging") + + private fun unsupported(method: String): Nothing = + throw UnsupportedOperationException( + "JvmSupportSQLiteDatabase does not support $method (test double)" + ) + + companion object { + fun open(dbPath: String): JvmSupportSQLiteDatabase = + JvmSupportSQLiteDatabase(DriverManager.getConnection("jdbc:sqlite:$dbPath")) + } +} + +/** 最小 android.database.Cursor 实现:迁移路径只用到读取行与列。 */ +private class JvmCursor( + private val resultSet: ResultSet, + private val closeable: AutoCloseable? = null, +) : Cursor { + + private val rows: List> = materialize(resultSet) + private val columnNames: Array = columnNames(resultSet.metaData) + private val columnIndexByName: Map = + columnNames.withIndex().associate { (index, name) -> name.lowercase() to index } + private var position = -1 + private var closed = false + + override fun getCount(): Int = rows.size + + override fun getPosition(): Int = position + + override fun move(position: Int): Boolean = moveToPosition(this.position + position) + + override fun moveToPosition(position: Int): Boolean { + if (position < -1 || position >= rows.size) { + this.position = -1 + return false + } + this.position = position + return true + } + + override fun moveToFirst(): Boolean = moveToPosition(0) + + override fun moveToLast(): Boolean = moveToPosition(rows.size - 1) + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun isFirst(): Boolean = position == 0 && rows.isNotEmpty() + + override fun isLast(): Boolean = position == rows.size - 1 && position >= 0 + + override fun isBeforeFirst(): Boolean = position < 0 && rows.isNotEmpty() + + override fun isAfterLast(): Boolean = position >= rows.size + + override fun getColumnCount(): Int = columnNames.size + + override fun getColumnIndex(columnName: String): Int = + columnIndexByName[columnName.lowercase()] ?: -1 + + override fun getColumnIndexOrThrow(columnName: String): Int { + val index = getColumnIndex(columnName) + if (index < 0) throw IllegalArgumentException("column '$columnName' does not exist") + return index + } + + override fun getColumnName(columnIndex: Int): String = columnNames[columnIndex] + + override fun getColumnNames(): Array = columnNames.copyOf() + + override fun getString(columnIndex: Int): String { + val value = row()[columnIndex] + return when (value) { + null -> "" + is ByteArray -> String(value) + else -> value.toString() + } + } + + override fun getLong(columnIndex: Int): Long { + val value = row()[columnIndex] + return when (value) { + null -> 0L + is Number -> value.toLong() + else -> value.toString().toLong() + } + } + + override fun getInt(columnIndex: Int): Int = getLong(columnIndex).toInt() + + override fun getShort(columnIndex: Int): Short = getLong(columnIndex).toShort() + + override fun getFloat(columnIndex: Int): Float { + val value = row()[columnIndex] + return when (value) { + null -> 0f + is Number -> value.toFloat() + else -> value.toString().toFloat() + } + } + + override fun getDouble(columnIndex: Int): Double { + val value = row()[columnIndex] + return when (value) { + null -> 0.0 + is Number -> value.toDouble() + else -> value.toString().toDouble() + } + } + + override fun getBlob(columnIndex: Int): ByteArray = (row()[columnIndex] as? ByteArray) ?: ByteArray(0) + + override fun isNull(columnIndex: Int): Boolean = row()[columnIndex] == null + + override fun getType(columnIndex: Int): Int { + val value = row()[columnIndex] + return when (value) { + null -> android.database.Cursor.FIELD_TYPE_NULL + is ByteArray -> android.database.Cursor.FIELD_TYPE_BLOB + is String -> android.database.Cursor.FIELD_TYPE_STRING + is Number -> android.database.Cursor.FIELD_TYPE_INTEGER + else -> android.database.Cursor.FIELD_TYPE_STRING + } + } + + override fun close() { + if (!closed) { + closed = true + closeable?.close() + resultSet.close() + } + } + + override fun isClosed(): Boolean = closed + + override fun deactivate() = Unit + + override fun requery(): Boolean = false + + override fun copyStringToBuffer(columnIndex: Int, buffer: android.database.CharArrayBuffer) = + unsupported("copyStringToBuffer") + + override fun getWantsAllOnMoveCalls(): Boolean = false + + override fun getExtras(): Bundle? = null + + override fun setExtras(extras: Bundle?) = Unit + + override fun respond(extras: Bundle?): Bundle? = null + + override fun getNotificationUri(): Uri? = null + + override fun setNotificationUri(cr: android.content.ContentResolver, notifyUri: Uri?) = Unit + + override fun registerContentObserver(observer: android.database.ContentObserver) = Unit + + override fun unregisterContentObserver(observer: android.database.ContentObserver) = Unit + + override fun registerDataSetObserver(observer: android.database.DataSetObserver) = Unit + + override fun unregisterDataSetObserver(observer: android.database.DataSetObserver) = Unit + + private fun row(): Array { + if (position < 0 || position >= rows.size) { + throw IllegalStateException("cursor position $position is out of range") + } + return rows[position] + } + + private fun unsupported(method: String): Nothing = + throw UnsupportedOperationException("JvmCursor does not support $method (test double)") + + private companion object { + fun materialize(resultSet: ResultSet): List> { + val rows = mutableListOf>() + val columnCount = resultSet.metaData.columnCount + while (resultSet.next()) { + val row = arrayOfNulls(columnCount) + for (i in 1..columnCount) { + row[i - 1] = resultSet.getObject(i) + } + rows += row + } + return rows + } + + fun columnNames(metaData: ResultSetMetaData): Array = + Array(metaData.columnCount) { index -> metaData.getColumnName(index + 1) } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt new file mode 100644 index 000000000..3eaf41dda --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt @@ -0,0 +1,133 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.model.BillingMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class LegacyTokenStatsSnapshotTest { + + private fun legacyPreferences( + vararg pairs: Pair, + ): Map = mapOf(*pairs) + + @Test + fun `parses token counts request count and prices for provider model`() { + val raw = + legacyPreferences( + "token_input_DEEPSEEK_deepseek-chat" to 100L, + "token_cached_input_DEEPSEEK_deepseek-chat" to 30L, + "token_output_DEEPSEEK_deepseek-chat" to 50L, + "request_count_DEEPSEEK_deepseek-chat" to 4, + "model_input_price_DEEPSEEK_deepseek-chat" to 1.0f, + "model_cached_input_price_DEEPSEEK_deepseek-chat" to 0.5f, + "model_output_price_DEEPSEEK_deepseek-chat" to 2.0f, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + assertEquals(1, snapshot.providerModels.size) + val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") + assertEquals(100L, stats.inputTokens) + assertEquals(30L, stats.cachedInputTokens) + assertEquals(50L, stats.outputTokens) + assertEquals(4L, stats.requestCount) + assertEquals(1.0, stats.priceSettings.inputPricePerMillion!!, 1e-9) + assertEquals(0.5, stats.priceSettings.cachedInputPricePerMillion!!, 1e-9) + assertEquals(2.0, stats.priceSettings.outputPricePerMillion!!, 1e-9) + } + + @Test + fun `count mode billing and per request price are captured`() { + val raw = + legacyPreferences( + "request_count_OPENAI_gpt-4o" to 2, + "billing_mode_OPENAI_gpt-4o" to "COUNT", + "price_per_request_OPENAI_gpt-4o" to 0.02f, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + val stats = snapshot.providerModels.getValue("OPENAI:gpt-4o") + assertEquals(BillingMode.COUNT, stats.priceSettings.billingMode) + assertEquals(0.02, stats.priceSettings.pricePerRequest!!, 1e-9) + assertEquals(2L, stats.requestCount) + } + + @Test + fun `missing counters are zero and all-zero models are dropped`() { + val raw = + legacyPreferences( + "token_input_DEEPSEEK_deepseek-chat" to 10L, + "token_cached_input_DEEPSEEK_deepseek-chat" to 0L, + "token_output_DEEPSEEK_deepseek-chat" to 0L, + "token_input_OTHER_some-model" to 0L, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + assertEquals(1, snapshot.providerModels.size) + val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") + assertEquals(10L, stats.inputTokens) + assertEquals(0L, stats.cachedInputTokens) + assertEquals(0L, stats.outputTokens) + } + + @Test + fun `int token values are widened to long`() { + val raw = + legacyPreferences( + "token_input_MOONSHOT_moonshot-v1-8k" to 42, + "token_output_MOONSHOT_moonshot-v1-8k" to 7, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + val stats = snapshot.providerModels.getValue("MOONSHOT:moonshot-v1-8k") + assertEquals(42L, stats.inputTokens) + assertEquals(7L, stats.outputTokens) + } + + @Test + fun `provider only keys are kept for the migrator to skip`() { + val raw = + legacyPreferences( + "token_input_DEEPSEEK" to 10L, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + assertEquals(1, snapshot.providerModels.size) + assertTrue(snapshot.providerModels.containsKey("DEEPSEEK")) + } + + @Test + fun `underscore fallback decoding handles unknown providers`() { + val raw = + legacyPreferences( + "token_input_MY_CUSTOM_PROVIDER_my-model" to 10L, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + // 未知 provider 前缀按旧约定整体替换 “_” 为 “:” + assertTrue(snapshot.providerModels.containsKey("MY:CUSTOM:PROVIDER:my-model")) + } + + @Test + fun `zero prices are treated as unset`() { + val raw = + legacyPreferences( + "token_input_DEEPSEEK_deepseek-chat" to 10L, + "model_input_price_DEEPSEEK_deepseek-chat" to 0.0f, + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") + assertNull(stats.priceSettings.inputPricePerMillion) + assertFalse(stats.priceSettings.hasAnyUserSetting()) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt new file mode 100644 index 000000000..57f040d08 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -0,0 +1,497 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.room.Room +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * 导入器测试: + * 1. 取消传播:ensureMigrated 的 catch(Exception) 不得吞掉 CancellationException。 + * 2. 冻结价格 + 计数跟踪 + 恢复生命周期(真实 ApiPreferences 快照路径 + 真实 + * Room 数据库): + * - 无自定义价格迁移也冻结;计数不变时普通 setter/快照变化不重估 baseline; + * - 计数变化(真实累计 setter 增长 / 用户 reset 降低)用行内冻结价格重估, + * 整体替换为快照绝对值,不产生负增量;冻结价格列永不被普通启动替换; + * consumePendingRestore)才受控补导一次(替换冻结价格);部分字段恢复不触发 + * 任何启发式,直到 completion hook 才统一处理;相同 generation 重复消费幂等; + * - 受控补导不删除/不覆盖活动 DataStore 文件、不级联删除事件。 + * + * DataStore 隔离:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建一个 + * DataStore 实例(绑定首个访问它的 Context),且每个文件的 DataStore 写入在 + * Windows 上不稳定(tmp→目标 renameTo)。因此: + * - 每个“生命周期阶段”使用独立 filesDir 临时目录,阶段间通过反射清空单例, + * 使每阶段只读/写自己的文件; + * - “旧 DataStore 文件恢复”用种子文件(独立 DataStore 实例单次 edit 生成) + * 复制到目标阶段目录的 datastore/ 来真实模拟,之后重建 ApiPreferences + * 读取恢复后的文件——与恢复完成→冷启动的实际生命周期一致,全程不删除 + * 或覆盖活动 actor 的文件。 + */ +class TokenBaselineImportRunnerTest { + + @Before + fun isolate() { + clearApiDataStoreSingleton() + TokenBaselineImportRunner.databaseProvider = null + injectApiPreferences(null) + } + + /** 清空 `Context.apiDataStore` 委托缓存的数据存储单例(隔离生命周期)。 */ + private fun clearApiDataStoreSingleton() { + val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") + val delegateField = facade.getDeclaredField("apiDataStore\$delegate") + delegateField.isAccessible = true + val delegate = delegateField.get(null) + val instanceField = + delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } + instanceField.set(delegate, null) + } + + private fun injectApiPreferences(instance: ApiPreferences?) { + val field = + ApiPreferences::class.java + .getDeclaredField("INSTANCE") + .apply { isAccessible = true } + field.set(null, instance) + } + + private fun constructApiPreferences(context: Context): ApiPreferences { + val constructor = + ApiPreferences::class.java + .getDeclaredConstructor(Context::class.java) + .apply { isAccessible = true } + return constructor.newInstance(context) + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.noBackupFilesDir).thenReturn(File(filesDir, "no_backup")) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + /** 真实 Room 数据库(JVM 驱动),与迁移测试同一套支撑。 */ + private fun openDatabase(filesDir: File): AppDatabase = + Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_28_29) + .allowMainThreadQueries() + .build() + + // ==== 旧 DataStore 文件种子与“恢复”模拟 ==== + + private val providerModel = "DEEPSEEK:deepseek-chat" + private val providerModelB = "OPENAI:gpt-4o" + + private fun seedTwoModels(seedFile: File, modelBStats: Triple) { + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L + prefs[ApiPreferences.getTokenInputKey(providerModelB)] = modelBStats.first + prefs[ApiPreferences.getTokenCachedInputKey(providerModelB)] = modelBStats.second + prefs[ApiPreferences.getTokenOutputKey(providerModelB)] = modelBStats.third + } + } + + /** + * 用独立 DataStore 实例单次 edit 生成“旧偏好文件”种子(等价于备份中的 + * api_settings.preferences_pb)。种子文件独立于被测阶段目录,不触碰任何 + * 活动 actor 的文件。 + * + * 注意:不取消 scope——取消会打断 DataStore 内部 actor 并触发 + * CompletionHandlerException;测试进程短命,遗留的闲置 actor 无影响。 + */ + private fun seedPreferencesFile(seedFile: File, block: (MutablePreferences) -> Unit) { + seedFile.parentFile?.mkdirs() + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val store = + PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { seedFile }, + ) + runBlocking { store.edit { block(it) } } + } + + private fun seedCountsOnly(seedFile: File) { + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L + } + } + + private fun seedWithInputPrice(seedFile: File, inputPrice: Double) { + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L + prefs[ApiPreferences.getModelInputPriceKey(providerModel)] = inputPrice.toFloat() + } + } + + private fun seedWithOutputPrice(seedFile: File, outputPrice: Double) { + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L + prefs[ApiPreferences.getModelOutputPriceKey(providerModel)] = outputPrice.toFloat() + } + } + + /** + * 模拟“恢复完成”:把种子文件复制到目标阶段的 datastore 目录,随后该阶段 + * 重建 ApiPreferences(首次访问读取恢复后的文件)——与恢复完成→冷启动的 + * 真实生命周期一致。复制发生在该阶段 DataStore actor 创建之前,不删除/ + * 不覆盖任何活动 actor 的文件。 + */ + private fun restorePreferencesInto(filesDir: File, seedFile: File) { + val target = File(filesDir, "datastore/api_settings.preferences_pb") + target.parentFile?.mkdirs() + seedFile.copyTo(target, overwrite = true) + } + + private suspend fun assertBaselineFrozenAt( + database: AppDatabase, + expectedCost: Double, + expectedInputTokens: Long, + ) { + val dao = database.tokenStatsDao() + val baseline = dao.getAllBaselines().single() + assertEquals(expectedCost, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(expectedInputTokens, baseline.inputTokens) + } + + // ==== 测试 ==== + + @Test + fun `cancellation propagates through import runner instead of being swallowed`() = + runBlocking { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val prefs = mock() + whenever(prefs.legacyStatsSnapshot()) + .thenThrow(CancellationException("import cancelled")) + injectApiPreferences(prefs) + try { + TokenBaselineImportRunner.ensureMigrated(context) + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + assertEquals("import cancelled", e.message) + } finally { + injectApiPreferences(null) + } + } + + @Test + fun `migration without custom price freezes and later price change does not reprice`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } + + // 阶段 A:旧偏好文件只有计数、没有自定义价格(普通用户从未自定义价格 + // 也代表完整状态)→ 首次迁移按内置默认价估算并冻结 + val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileA = File(seedA, "seed.preferences_pb") + seedCountsOnly(seedFileA) + restorePreferencesInto(phaseA, seedFileA) + val ctxA = mockContext(phaseA) + val prefsA = constructApiPreferences(ctxA) + injectApiPreferences(prefsA) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxA) + } + // 800k*1 + 200k*0.02 + 500k*2 = 1.804 + assertBaselineFrozenAt(database, 1.804, 1_000_000L) + } finally { + injectApiPreferences(null) + } + + // 阶段 B:冷启动后快照含用户价格(相当于首次普通改价的快照路径), + // 但没有恢复生命周期信号 → 已冻结 baseline 不得重估 + clearApiDataStoreSingleton() + val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileB = File(seedB, "seed.preferences_pb") + seedWithInputPrice(seedFileB, 2.0) + restorePreferencesInto(phaseB, seedFileB) + val ctxB = mockContext(phaseB) + val prefsB = constructApiPreferences(ctxB) + injectApiPreferences(prefsB) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxB) + } + assertBaselineFrozenAt(database, 1.804, 1_000_000L) + assertEquals(1, database.tokenStatsDao().countBaselines()) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + fun `normal import preserves baseline for model missing from current snapshot`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } + + // 阶段 A:旧偏好含 A+B 两个模型 → 两个 baseline + val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileA = File(seedA, "seed.preferences_pb") + seedTwoModels(seedFileA, Triple(2_000_000L, 0L, 1_000_000L)) + restorePreferencesInto(phaseA, seedFileA) + val ctxA = mockContext(phaseA) + val prefsA = constructApiPreferences(ctxA) + injectApiPreferences(prefsA) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxA) + } + assertEquals(2, database.tokenStatsDao().countBaselines()) + } finally { + injectApiPreferences(null) + } + + // 阶段 B:快照暂时只含 A(B 的偏好键缺失/被清空)→ 普通导入 + // 不得删除 B 的 baseline,只更新明确存在的 A + clearApiDataStoreSingleton() + val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileB = File(seedB, "seed.preferences_pb") + seedPreferencesFile(seedFileB) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 2_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 1_000_000L + } + restorePreferencesInto(phaseB, seedFileB) + val ctxB = mockContext(phaseB) + val prefsB = constructApiPreferences(ctxB) + injectApiPreferences(prefsB) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxB) + } + val dao = database.tokenStatsDao() + assertEquals("B baseline must survive a normal import", 2, dao.countBaselines()) + val identityB = + TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") + val baselineB = dao.getBaseline(identityB)!! + assertEquals(2_000_000L, baselineB.inputTokens) + assertEquals(1_000_000L, baselineB.outputTokens) + val identityA = + TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + assertEquals(2_000_000L, dao.getBaseline(identityA)!!.inputTokens) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + @Test + fun `explicit reset deletes only the reset model baseline`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } + + val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedTwoModels(seedFile, Triple(2_000_000L, 0L, 1_000_000L)) + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val prefs = constructApiPreferences(ctx) + injectApiPreferences(prefs) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + val dao = database.tokenStatsDao() + assertEquals(2, dao.countBaselines()) + + // 显式重置 B:独立删除路径,只删 B 的 baseline + TokenStatsResetCoordinator.daoProvider = { dao } + try { + TokenStatsResetCoordinator.resetStatisticsForProviderModel( + ctx, + providerModelB, + ) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + assertEquals(1, dao.countBaselines()) + val identityB = + TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") + assertEquals(null, dao.getBaseline(identityB)) + val identityA = + TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + assertEquals(1_000_000L, dao.getBaseline(identityA)!!.inputTokens) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + @Test + + @Test + fun `cumulative setter growth on normal startup updates counts with frozen pricing`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } + + // 阶段 A:首次导入 N(含自定义价格 1.0/2.0)→ 冻结 + val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileA = File(seedA, "seed.preferences_pb") + seedWithInputPrice(seedFileA, 1.0) + restorePreferencesInto(phaseA, seedFileA) + val ctxA = mockContext(phaseA) + val prefsA = constructApiPreferences(ctxA) + injectApiPreferences(prefsA) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxA) + } + val dao = database.tokenStatsDao() + val before = dao.getAllBaselines().single() + assertEquals(1_000_000L, before.inputTokens) + assertEquals(1.804, before.costInPricingCurrency!!, 1e-9) + val frozenInput = before.frozenInputPricePerMillion + val frozenOutput = before.frozenOutputPricePerMillion + } finally { + injectApiPreferences(null) + } + + // 阶段 B:真实累计 setter 增长计数(updateTokensForProviderModel 是 + // 现有累计 setter;新阶段独立 DataStore 文件,首写安全),再次普通启动: + // 计数更新为 N+X,但冻结价格不变(按冻结价重估成本)。 + clearApiDataStoreSingleton() + val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val ctxB = mockContext(phaseB) + val prefsB = constructApiPreferences(ctxB) + injectApiPreferences(prefsB) + try { + // 真实累计 setter:在空快照上累计写入 N+X(输入 2M、输出 1M、缓存 200k) + prefsB.updateTokensForProviderModel( + providerModel, + inputTokens = 2_000_000, + outputTokens = 1_000_000, + cachedInputTokens = 200_000, + ) + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxB) + } + val dao = database.tokenStatsDao() + val after = dao.getAllBaselines().single() + assertEquals(2_000_000L, after.inputTokens) + assertEquals(1_000_000L, after.outputTokens) + assertEquals(200_000L, after.cachedInputTokens) + // 冻结价格列不被普通启动替换(输入仍 1.0、输出仍 2.0) + assertEquals(1.0, after.frozenInputPricePerMillion!!, 1e-9) + assertEquals(2.0, after.frozenOutputPricePerMillion!!, 1e-9) + // 按冻结价重估:1.8M*1.0 + 200k*0.02 + 1M*2.0 = 1.8+0.004+2.0 + assertEquals(3.804, after.costInPricingCurrency!!, 1e-9) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + @Test + fun `user reset drop on normal startup replaces baseline with absolute smaller value`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } + + // 阶段 A:首次导入 N(含自定义价格) + val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileA = File(seedA, "seed.preferences_pb") + seedWithInputPrice(seedFileA, 1.0) + restorePreferencesInto(phaseA, seedFileA) + val ctxA = mockContext(phaseA) + val prefsA = constructApiPreferences(ctxA) + injectApiPreferences(prefsA) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxA) + } + assertEquals(1_000_000L, database.tokenStatsDao().getAllBaselines().single().inputTokens) + } finally { + injectApiPreferences(null) + } + + // 阶段 B:用户 reset 旧统计 → 快照计数变小(绝对值替换,不产生负增量) + clearApiDataStoreSingleton() + val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFileB = File(seedB, "seed.preferences_pb") + seedPreferencesFile(seedFileB) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModel)] = 100_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 0L + prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 50_000L + prefs[ApiPreferences.getModelInputPriceKey(providerModel)] = 1.0f + } + restorePreferencesInto(phaseB, seedFileB) + val ctxB = mockContext(phaseB) + val prefsB = constructApiPreferences(ctxB) + injectApiPreferences(prefsB) + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctxB) + } + val dao = database.tokenStatsDao() + val after = dao.getAllBaselines().single() + assertEquals(100_000L, after.inputTokens) + assertEquals(50_000L, after.outputTokens) + // 冻结价重估:100k*1.0 + 50k*2.0 = 0.1 + 0.1 + assertEquals(0.2, after.costInPricingCurrency!!, 1e-9) + assertEquals(1.0, after.frozenInputPricePerMillion!!, 1e-9) + assertEquals(2.0, after.frozenOutputPricePerMillion!!, 1e-9) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt new file mode 100644 index 000000000..d8df457c6 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt @@ -0,0 +1,819 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.ModelPricingDefaults +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TokenBaselineMigratorTest { + + private val knownDefaults = + ModelPricingDefaults( + billingMode = BillingMode.TOKEN, + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + pricePerRequest = 0.01, + currency = PricingCurrency.USD, + ) + + private val zeroDefaults = + ModelPricingDefaults( + billingMode = BillingMode.TOKEN, + inputPricePerMillion = 0.0, + outputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + pricePerRequest = 0.01, + currency = PricingCurrency.CNY, + ) + + private fun snapshotOf( + providerModel: String, + input: Long = 0L, + cached: Long = 0L, + output: Long = 0L, + requests: Long = 0L, + priceSettings: LegacyPriceSettings = LegacyPriceSettings(), + ) = LegacyTokenStatsSnapshot( + providerModels = + mapOf( + providerModel to + LegacyProviderModelStats( + providerModel = providerModel, + inputTokens = input, + cachedInputTokens = cached, + outputTokens = output, + requestCount = requests, + priceSettings = priceSettings, + ) + ) + ) + + private fun plan( + snapshot: LegacyTokenStatsSnapshot, + existingBaselines: Map = emptyMap(), + defaults: ModelPricingDefaults = knownDefaults, + nowMs: Long = 1_000L, + forceReplace: Boolean = false, + legacyOverrideOverrides: List = emptyList(), + existingIdentities: Map = emptyMap(), + ) = + TokenBaselineMigrator.planImport( + snapshot = snapshot, + existingBaselines = existingBaselines, + nowMs = nowMs, + forceReplace = forceReplace, + resolveIdentity = { providerModel -> + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("", provider, model), + configId = "", + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + }, + resolveDisplayModel = { TokenBaselineMigrator.defaultDisplayModel(it) }, + resolvePricing = { providerModel -> + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + TokenPriceResolver.resolve( + provider = provider, + model = model, + configId = null, + overrides = legacyOverrideOverrides, + legacyOverride = snapshot.providerModels[providerModel]?.priceSettings, + defaults = defaults, + ) + }, + existingIdentities = existingIdentities, + ) + + /** + * 与导入执行器一致:已有 baseline 都对应旧系统迁移身份(configId 空)。 + * 快照中仍存在的身份用快照解析结果,快照外的 baseline 身份用合成旧系统身份 + * (删除过滤只依赖 configId 是否为空,provider/model 内容不影响语义)。 + */ + private fun legacyIdentitiesFor( + baselines: Map, + snapshot: LegacyTokenStatsSnapshot = snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3), + ): Map { + val result = + snapshot.providerModels.keys.map { providerModel -> + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("", provider, model), + configId = "", + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + }.associateBy { it.identityId }.toMutableMap() + baselines.keys.forEach { id -> + result.putIfAbsent( + id, + TokenStatIdentityEntity( + identityId = id, + configId = "", + provider = "", + model = "", + displayModelId = id, + ) + ) + } + return result + } + + @Test + fun `first import creates estimated baseline with fingerprint`() { + val p = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + + assertEquals(1, p.baselines.size) + val baseline = p.baselines.single() + assertTrue(baseline.isEstimated) + assertTrue(baseline.fingerprint.isNotBlank()) + assertEquals(1, p.identities.size) + assertEquals(1, p.displayModels.size) + } + + @Test + fun `baseline cost is estimated from legacy pricing chain in native currency`() { + val p = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + cached = 200_000, + output = 500_000, + requests = 3, + ) + ) + + val baseline = p.baselines.single() + // 800k*1 + 200k*0.5 + 500k*2 = 0.8 + 0.1 + 1.0 (每百万计价) + assertEquals(1.9, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(PricingCurrency.USD.name, baseline.pricingCurrency) + } + + @Test + fun `count mode baseline cost is per request price times request count`() { + val legacy = + LegacyPriceSettings( + billingMode = BillingMode.COUNT, + pricePerRequest = 0.02, + ) + val p = plan(snapshotOf("OPENAI:gpt-4o", requests = 5, priceSettings = legacy)) + + val baseline = p.baselines.single() + assertEquals(0.1, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(5L, baseline.requestCount) + } + + @Test + fun `unknown pricing yields null estimated cost not zero`() { + val p = plan(snapshotOf("MYSTERY:model-x", input = 1000), defaults = zeroDefaults) + + val baseline = p.baselines.single() + assertNull(baseline.costInPricingCurrency) + assertTrue(baseline.isEstimated) + } + + @Test + fun `same snapshot imported twice skips second time`() { + val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + val existing = first.baselines.associateBy { it.identityId } + + val second = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3), existing) + + assertEquals(0, second.baselines.size) + assertEquals(0, second.identities.size) + } + + @Test + fun `growing legacy counters re-estimate with frozen pricing`() { + val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + val existing = first.baselines.associateBy { it.identityId } + + // 普通导入:计数增长 → 用冻结价重估(此处默认价 1.0/0.5/2.0) + val second = + plan( + snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4), + existing, + ) + assertEquals(1, second.baselines.size) + val regrown = second.baselines.single() + assertEquals(1500L, regrown.inputTokens) + assertEquals(4L, regrown.requestCount) + assertFalse(regrown.fingerprint == existing.getValue(regrown.identityId).fingerprint) + + // 受控补导(forceReplace):以当前快照重新解析定价并整体替换 + val forced = + plan( + snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4), + existing, + forceReplace = true, + ) + assertEquals(1, forced.baselines.size) + } + + @Test + fun `database restore triggers full reimport`() { + // 第一次导入成功,随后数据库被恢复到导入前的状态(baseline 表为空) + val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + + val afterRestore = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + + assertEquals(1, first.baselines.size) + assertEquals(1, afterRestore.baselines.size) + assertEquals(first.baselines.single().fingerprint, afterRestore.baselines.single().fingerprint) + assertEquals(1000L, afterRestore.baselines.single().inputTokens) + } + + @Test + fun `interrupted import reruns and converges to single baseline`() { + // 中断 = 什么都没写入(existing 为空),重跑与首次结果一致且只产生一行 + val interruptedRun = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 800, output = 300, requests = 2)) + val retry = + plan( + snapshotOf("DEEPSEEK:deepseek-chat", input = 800, output = 300, requests = 2), + emptyMap(), + ) + + assertEquals(interruptedRun.baselines.single().fingerprint, retry.baselines.single().fingerprint) + assertEquals(1, retry.baselines.size) + } + + @Test + fun `preferences changed later without lifecycle signal still track counts with frozen pricing`() { + val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) + val existing = first.baselines.associateBy { it.identityId } + + // 快照计数变化(偏好文件被覆盖)但没有恢复生命周期信号: + // 普通导入用冻结价跟踪计数;只有受控补导(forceReplace)才重解析价格 + val restored = + plan( + snapshotOf("DEEPSEEK:deepseek-chat", input = 600, output = 200, requests = 1), + existing, + ) + + assertEquals(1, restored.baselines.size) + val baseline = restored.baselines.single() + assertEquals(600L, baseline.inputTokens) + assertEquals(1L, baseline.requestCount) + } + + @Test + fun `count growth re-estimates with frozen pricing while keeping frozen prices`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + val before = first.baselines.single() + assertEquals(2.0, before.costInPricingCurrency!!, 1e-9) + + // 普通启动:累计 setter 增长计数(同时快照价格被普通 setter 改为 99)—— + // 计数变化触发重估,但必须用行内冻结价 (1.0/2.0),冻结价格列不变。 + val regrown = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 2_000_000, + output = 1_000_000, + requests = 6, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 99.0, + outputPricePerMillion = 99.0, + ), + ), + existing, + ) + + assertEquals(1, regrown.baselines.size) + val baseline = regrown.baselines.single() + // 按冻结价 (1.0/2.0) 重估:2M*1.0 + 1M*2.0 = 2.0 + 2.0 + assertEquals(4.0, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(2_000_000L, baseline.inputTokens) + assertEquals(1_000_000L, baseline.outputTokens) + assertEquals(6L, baseline.requestCount) + // 冻结价格列不被普通启动替换(仍是 1.0/2.0,而非 99) + assertEquals(1.0, baseline.frozenInputPricePerMillion!!, 1e-9) + assertEquals(2.0, baseline.frozenOutputPricePerMillion!!, 1e-9) + } + + @Test + fun `count drop after user reset replaces baseline with absolute value`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + + // 用户 reset 旧统计:计数变小(绝对值替换,不产生负增量/不拒绝) + val dropped = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 100_000, + output = 50_000, + requests = 1, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ), + existing, + ) + + assertEquals(1, dropped.baselines.size) + val baseline = dropped.baselines.single() + assertEquals(100_000L, baseline.inputTokens) + assertEquals(50_000L, baseline.outputTokens) + assertEquals(1L, baseline.requestCount) + // 冻结价重估:100k*1.0 + 50k*2.0 = 0.1 + 0.1 + assertEquals(0.2, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(1.0, baseline.frozenInputPricePerMillion!!, 1e-9) + } + + @Test + fun `normal import never removes baseline for model missing from snapshot`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + val identities = legacyIdentitiesFor(existing) + + // 普通启动:当前快照不再包含该模型(偏好文件暂时缺失/部分恢复/被清空)—— + // 必须保留其 baseline,绝不因快照缺失删除(显式删除走用户重置路径)。 + val normal = plan(emptySnapshot(), existing, existingIdentities = identities) + // 快照中 OTHER:model-x 正常导入;DEEPSEEK 的 baseline 不得被更新或删除 + assertEquals(1, normal.baselines.size) + assertTrue(normal.baselines.none { it.identityId == existing.keys.single() }) + assertEquals("normal import must not delete missing baselines", emptyList(), normal.removedBaselineIdentityIds) + } + + @Test + fun `controlled restore removes legacy baseline missing from restored snapshot but not config baselines`() { + val legacy = + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + val first = plan(legacy) + val legacyBaseline = first.baselines.single() + + // 配置实例身份(configId 非空)的 baseline:不属于旧累计快照范围, + // 受控补导也必须保留 + val configIdentity = + TokenStatIdentityEntity( + identityId = "config-identity", + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + val configBaseline = legacyBaseline.copy(identityId = configIdentity.identityId) + val existing = mapOf(legacyBaseline.identityId to legacyBaseline, configBaseline.identityId to configBaseline) + val identities = legacyIdentitiesFor(existing, legacy) + mapOf(configIdentity.identityId to configIdentity) + + // 恢复快照只含另一个模型(DEEPSEEK:deepseek-chat 消失)→ forceReplace: + // 只删除旧系统身份(configId 空)的 baseline,配置身份 baseline 保留 + val restoredSnapshot = + LegacyTokenStatsSnapshot( + providerModels = mapOf( + "OTHER:model-x" to + LegacyProviderModelStats( + providerModel = "OTHER:model-x", + inputTokens = 10L, + cachedInputTokens = 0L, + outputTokens = 0L, + requestCount = 0L, + priceSettings = LegacyPriceSettings(), + ) + ) + ) + val forced = plan(restoredSnapshot, existing, forceReplace = true, existingIdentities = identities) + + assertEquals(listOf(legacyBaseline.identityId), forced.removedBaselineIdentityIds) + } + + @Test + fun `model disappearing from snapshot is marked for removal only on controlled restore`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + val identities = legacyIdentitiesFor(existing) + + // 恢复快照不再包含该模型(旧统计在备份中已清空)→ 受控补导删除其 baseline + val empty = + LegacyTokenStatsSnapshot( + providerModels = mapOf( + "OTHER:model-x" to + LegacyProviderModelStats( + providerModel = "OTHER:model-x", + inputTokens = 10L, + cachedInputTokens = 0L, + outputTokens = 0L, + requestCount = 0L, + priceSettings = LegacyPriceSettings(), + ) + ) + ) + val forcedPlan = plan(empty, existing, forceReplace = true, existingIdentities = identities) + + assertEquals( + listOf(existing.keys.single()), + forcedPlan.removedBaselineIdentityIds + ) + } + + @Test + fun `controlled restore with empty snapshot removes all legacy baselines but keeps config baselines`() { + val legacy = + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + val first = plan(legacy) + val legacyBaseline = first.baselines.single() + + val configIdentity = + TokenStatIdentityEntity( + identityId = "config-identity", + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + val configBaseline = legacyBaseline.copy(identityId = configIdentity.identityId) + val existing = mapOf(legacyBaseline.identityId to legacyBaseline, configBaseline.identityId to configBaseline) + val identities = legacyIdentitiesFor(existing, legacy) + mapOf(configIdentity.identityId to configIdentity) + + // 恢复后的权威旧偏好快照完全为空:forceReplace 仍产出删除计划—— + // 全部 legacy(configId 空)baseline 被删除,config baseline 保留 + val empty = LegacyTokenStatsSnapshot(providerModels = emptyMap()) + val forced = plan(empty, existing, forceReplace = true, existingIdentities = identities) + + assertEquals(0, forced.baselines.size) + assertEquals(listOf(legacyBaseline.identityId), forced.removedBaselineIdentityIds) + } + + private fun emptySnapshot() = + LegacyTokenStatsSnapshot( + providerModels = mapOf( + "OTHER:model-x" to + LegacyProviderModelStats( + providerModel = "OTHER:model-x", + inputTokens = 10L, + cachedInputTokens = 0L, + outputTokens = 0L, + requestCount = 0L, + priceSettings = LegacyPriceSettings(), + ) + ) + ) + + @Test + fun `existing baseline is frozen when counts and prices change`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + val baselineBefore = first.baselines.single() + + // 普通用户从未自定义价格也代表完整状态;首次迁移无价格也冻结。 + val noCustomPrice = + plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3)) + assertEquals(1, noCustomPrice.baselines.size) + + // 计数不变 + 价格变化(普通 setter 改价):不重估(指纹只含计数) + val repriced = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 99.0, + outputPricePerMillion = 99.0, + ), + ), + existing, + ) + + assertEquals(0, repriced.baselines.size) + assertEquals(2.0, baselineBefore.costInPricingCurrency!!, 1e-9) + } + + @Test + fun `force replace re-imports all baselines from the current snapshot`() { + val first = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ), + ) + ) + val existing = first.baselines.associateBy { it.identityId } + + // 受控补导(恢复后):forceReplace 忽略已有 baseline,用当前快照重估 + val restored = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 1_000_000, + output = 500_000, + requests = 3, + priceSettings = + LegacyPriceSettings( + inputPricePerMillion = 2.0, + outputPricePerMillion = 4.0, + ), + ), + existing, + forceReplace = true, + ) + + assertEquals(1, restored.baselines.size) + val baseline = restored.baselines.single() + // 1000k*2 + 500k*4 = 2.0 + 2.0 + assertEquals(4.0, baseline.costInPricingCurrency!!, 1e-9) + } + + @Test + fun `current price override change does not re-estimate imported baseline`() { + // 首次导入时旧配置链与当前覆盖并存(覆盖为空 → 走旧配置链) + val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3)) + val existing = first.baselines.associateBy { it.identityId } + val baselineBefore = first.baselines.single() + + // 用户后续新增当前价格覆盖(新系统价格):指纹与估算都必须不受影响 + val override = + TokenPriceResolver.normalizedOverride( + scope = "PROVIDER_MODEL", + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 99.0, + outputPricePerMillion = 99.0, + ) + + val second = + plan( + snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3), + existing, + legacyOverrideOverrides = listOf(override), + ) + + assertEquals(0, second.baselines.size) + assertEquals(2.0, baselineBefore.costInPricingCurrency!!, 1e-9) + } + + @Test + fun `provider-only legacy keys are skipped without crash`() { + val p = plan(snapshotOf("DEEPSEEK", input = 10L)) + + assertEquals(0, p.baselines.size) + assertEquals(listOf("DEEPSEEK"), p.skippedProviderModels) + } + + @Test + fun `multiple provider models produce one baseline each`() { + val snapshot = + LegacyTokenStatsSnapshot( + providerModels = + mapOf( + "DEEPSEEK:deepseek-chat" to + LegacyProviderModelStats( + providerModel = "DEEPSEEK:deepseek-chat", + inputTokens = 10L, + cachedInputTokens = 0L, + outputTokens = 5L, + requestCount = 1L, + priceSettings = LegacyPriceSettings(), + ), + "OPENAI:gpt-4o" to + LegacyProviderModelStats( + providerModel = "OPENAI:gpt-4o", + inputTokens = 20L, + cachedInputTokens = 0L, + outputTokens = 8L, + requestCount = 2L, + priceSettings = LegacyPriceSettings(), + ), + ) + ) + + val p = plan(snapshot) + + assertEquals(2, p.baselines.size) + } + + @Test + fun `baseline estimate is safe for cumulative values beyond Int max`() { + val hugeInput = 3_000_000_000L + val hugeCached = 1_500_000_000L + val hugeOutput = 2_000_000_000L + val p = + plan( + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = hugeInput, + cached = hugeCached, + output = hugeOutput, + requests = 4, + ) + ) + + val baseline = p.baselines.single() + val uncached = hugeInput - hugeCached + val expected = uncached / 1_000_000.0 * 1.0 + hugeCached / 1_000_000.0 * 0.5 + hugeOutput / 1_000_000.0 * 2.0 + assertEquals(expected, baseline.costInPricingCurrency!!, 1e-9) + assertEquals(hugeInput, baseline.inputTokens) + } + + @Test + fun `reimport preserves manually merged display group and alias`() { + val snapshot = snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3) + val first = plan(snapshot) + val identity = first.identities.single() + val displayModel = first.displayModels.single() + + // 用户手动合并展示组:别名 + 新的分组 ID + val mergedGroupId = "my-deepseek-group" + val existingIdentities = + mapOf( + identity.identityId to + identity.copy(displayModelId = mergedGroupId) + ) + val existingDisplayModels = + mapOf( + mergedGroupId to + TokenStatDisplayModelEntity( + displayModelId = mergedGroupId, + normalizedModel = "deepseek-chat", + displayName = "我的 DeepSeek", + ), + displayModel.displayModelId to displayModel, + ) + + // 受控补导(恢复后 forceReplace):分组与别名必须保留 + val repricedSnapshot = + snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4) + val plan = + TokenBaselineMigrator.planImport( + snapshot = repricedSnapshot, + existingBaselines = first.baselines.associateBy { it.identityId }, + nowMs = 2_000L, + forceReplace = true, + resolveIdentity = { pm -> + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(pm) + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("", provider, model), + configId = "", + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + }, + resolveDisplayModel = { TokenBaselineMigrator.defaultDisplayModel(it) }, + resolvePricing = { pm -> + TokenPriceResolver.resolve( + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = null, + overrides = emptyList(), + legacyOverride = repricedSnapshot.providerModels[pm]?.priceSettings, + defaults = knownDefaults, + ) + }, + ) + + val preserved = + TokenBaselineMigrator.preserveExistingGroups( + plan = plan, + existingIdentities = existingIdentities, + existingDisplayModels = existingDisplayModels, + ) + + assertEquals(mergedGroupId, preserved.identities.single().displayModelId) + // 已存在的展示模型不重写(别名保留),只补缺省模型行 + assertTrue(preserved.displayModels.none { it.displayModelId == mergedGroupId }) + assertEquals(1, preserved.baselines.size) + assertEquals(1500L, preserved.baselines.single().inputTokens) + } + + @Test + fun `fingerprint covers counts only and ignores legacy price settings`() { + val statsA = + snapshotOf("DEEPSEEK:deepseek-chat", input = 10L) + .providerModels.getValue("DEEPSEEK:deepseek-chat") + val statsB = + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 10L, + priceSettings = LegacyPriceSettings(inputPricePerMillion = 2.0), + ).providerModels.getValue("DEEPSEEK:deepseek-chat") + val statsC = + snapshotOf( + "DEEPSEEK:deepseek-chat", + input = 20L, + priceSettings = LegacyPriceSettings(inputPricePerMillion = 2.0), + ).providerModels.getValue("DEEPSEEK:deepseek-chat") + + // 价格设置变化不改变指纹(价格编辑不触发重导) + assertEquals(TokenBaselineMigrator.fingerprint(statsA), TokenBaselineMigrator.fingerprint(statsB)) + // 计数变化改变指纹(计数变化触发重导) + assertFalse( + TokenBaselineMigrator.fingerprint(statsB) == + TokenBaselineMigrator.fingerprint(statsC) + ) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt new file mode 100644 index 000000000..b14ae812f --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -0,0 +1,400 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TokenCostCalculatorTest { + + private val tokenPricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 0.75, + outputPricePerMillion = 2.0, + source = PricingSource.DEFAULT, + known = true, + ) + + private val unknownPricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.CNY, + source = PricingSource.UNKNOWN, + known = false, + ) + + private val countPricing = + ResolvedPricing( + billingMode = BillingMode.COUNT, + currency = PricingCurrency.CNY, + pricePerRequest = 0.01, + source = PricingSource.DEFAULT, + known = true, + ) + + @Test + fun `token cost is computed per million in native currency`() { + val cost = + TokenCostCalculator.computeCost( + usage = + TokenUsageInput( + uncachedInputTokens = 800, + cachedInputTokens = 200, + cacheWriteTokens = 0, + outputTokens = 500, + ), + pricing = tokenPricing, + ) + + assertEquals(0.0019, cost.amount!!, 1e-12) + // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 = 0.0008 + 0.0001 + 0.001 + assertEquals(PricingCurrency.USD, cost.currency) + assertEquals(1000, cost.billedInputTokens) + assertEquals(500, cost.billedOutputTokens) + } + + @Test + fun `reasoning included in output is not billed twice`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + reasoningTokens = 300, + reasoningIncludedInOutput = true, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertEquals(500, cost.billedOutputTokens) + // 1000/1e6*1 + 0 + 500/1e6*2 = 0.001 + 0.001 + assertEquals(0.002, cost.amount!!, 1e-12) + } + + @Test + fun `reasoning declared separate is added to billed output`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + reasoningTokens = 300, + reasoningIncludedInOutput = false, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertEquals(800, cost.billedOutputTokens) + // 1000/1e6*1 + 800/1e6*2 = 0.001 + 0.0016 + assertEquals(0.0026, cost.amount!!, 1e-12) + } + + @Test + fun `reasoning without inclusion declaration defaults to included`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + outputTokens = 500, + reasoningTokens = 300, + reasoningIncludedInOutput = null, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertEquals(500, cost.billedOutputTokens) + } + + @Test + fun `unknown output tokens produce unknown cost not zero`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + outputTokens = null, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertNull(cost.amount) + } + + @Test + fun `unknown input tokens produce unknown cost not zero`() { + val usage = + TokenUsageInput( + uncachedInputTokens = null, + cachedInputTokens = 0, + outputTokens = 500, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertNull(cost.amount) + } + + @Test + fun `null cached input keeps cost unknown while zero cached input is a real zero`() { + val unknownCache = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = null, + outputTokens = 500, + ) + val noCacheRead = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + ) + + assertNull(TokenCostCalculator.computeCost(unknownCache, tokenPricing).amount) + // 确认无缓存读取:1000/1e6*1 + 0 + 500/1e6*2 + assertEquals(0.002, TokenCostCalculator.computeCost(noCacheRead, tokenPricing).amount!!, 1e-12) + } + + @Test + fun `null cache write keeps cost unknown while zero cache write is a real zero`() { + val unknownWrite = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = null, + outputTokens = 500, + ) + val noCacheWrite = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + ) + + assertNull(TokenCostCalculator.computeCost(unknownWrite, tokenPricing).amount) + assertEquals( + 0.002, + TokenCostCalculator.computeCost(noCacheWrite, tokenPricing).amount!!, + 1e-12 + ) + } + + @Test + fun `cache write tokens are billed at cache write price when known`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 400, + outputTokens = 500, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + // 1000/1e6*1 + 0 + 400/1e6*0.75 + 500/1e6*2 = 0.001 + 0.0003 + 0.001 + assertEquals(0.0023, cost.amount!!, 1e-12) + assertEquals(400, cost.billedCacheWriteTokens) + } + + @Test + fun `cache write without known price keeps cost unknown`() { + val pricingWithoutWritePrice = + tokenPricing.copy(cacheWritePricePerMillion = null) + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 400, + outputTokens = 500, + ) + + val cost = TokenCostCalculator.computeCost(usage, pricingWithoutWritePrice) + + assertNull(cost.amount) + } + + @Test + fun `unknown pricing produces unknown cost not zero`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + outputTokens = 500, + ) + + val cost = TokenCostCalculator.computeCost(usage, unknownPricing) + + assertNull(cost.amount) + } + + @Test + fun `count mode cost equals per request price`() { + val cost = + TokenCostCalculator.computeCost( + usage = TokenUsageInput(outputTokens = 10), + pricing = countPricing, + ) + + assertEquals(0.01, cost.amount!!, 1e-12) + assertEquals(PricingCurrency.CNY, cost.currency) + } + + @Test + fun `known zero pricing yields real zero cost`() { + val zeroPricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + outputPricePerMillion = 0.0, + source = PricingSource.PROVIDER_MODEL_OVERRIDE, + known = true, + ) + + val cost = + TokenCostCalculator.computeCost( + usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + ), + pricing = zeroPricing, + ) + + assertEquals(0.0, cost.amount!!, 1e-12) + } +} + +class TokenCostCurrencyTest { + + @Test + fun `default manual rate is 7`() { + assertEquals(7.0, TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, 1e-12) + } + + @Test + fun `usd converts to cny with manual rate`() { + val converted = + TokenCostCurrency.convertTo( + amount = 10.0, + from = PricingCurrency.USD, + to = PricingCurrency.CNY, + manualRate = 7.0, + ) + assertEquals(70.0, converted, 1e-12) + } + + @Test + fun `cny converts to usd by dividing manual rate`() { + val converted = + TokenCostCurrency.convertTo( + amount = 70.0, + from = PricingCurrency.CNY, + to = PricingCurrency.USD, + manualRate = 7.0, + ) + assertEquals(10.0, converted, 1e-12) + } + + @Test + fun `same currency conversion is identity`() { + val converted = + TokenCostCurrency.convertTo( + amount = 5.0, + from = PricingCurrency.CNY, + to = PricingCurrency.CNY, + manualRate = 7.0, + ) + assertEquals(5.0, converted, 1e-12) + } + + @Test + fun `changing manual rate changes converted total but not native cost`() { + val nativeCost = 10.0 // USD + + val atRate7 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 7.0) + val atRate8 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 8.0) + + assertEquals(70.0, atRate7, 1e-12) + assertEquals(80.0, atRate8, 1e-12) + assertEquals(10.0, nativeCost, 1e-12) // 原币成本不受汇率影响 + } + + @Test + fun `historical unknown cost stays unknown after conversion`() { + val converted = + TokenCostCurrency.historicalCostConverted( + costInPricingCurrency = null, + costCurrency = PricingCurrency.USD, + targetCurrency = PricingCurrency.CNY, + manualRate = 7.0, + ) + assertNull(converted) + } + + @Test + fun `revaluation uses current pricing instead of historical snapshot`() { + val usage = + TokenUsageInput( + uncachedInputTokens = 1000, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 500, + ) + val historicalSnapshot = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 2.0, + source = PricingSource.DEFAULT, + known = true, + ) + val currentPricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 1.0, + outputPricePerMillion = 4.0, + source = PricingSource.PROVIDER_MODEL_OVERRIDE, + known = true, + ) + + val historical = TokenCostCalculator.computeCost(usage, historicalSnapshot) + val revalued = TokenCostCurrency.revaluedCost(usage, currentPricing) + + // 历史按事件快照价:0.001 + 0.001 = 0.002 + assertEquals(0.002, historical.amount!!, 1e-12) + // 重估按当前价:0.002 + 0.002 = 0.004 + assertEquals(0.004, revalued.amount!!, 1e-12) + // 两种模式按同一手动汇率换算为统一币种 + val historicalCny = + TokenCostCurrency.historicalCostConverted( + historical.amount, + historical.currency, + PricingCurrency.CNY, + 7.0, + ) + val revaluedCny = + TokenCostCurrency.convertTo( + revalued.amount!!, + revalued.currency, + PricingCurrency.CNY, + 7.0, + ) + assertEquals(0.014, historicalCny!!, 1e-12) + assertEquals(0.028, revaluedCny, 1e-12) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt new file mode 100644 index 000000000..8ebc3aae4 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt @@ -0,0 +1,330 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.ModelPricingDefaults +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TokenPriceResolverTest { + + private val knownDefaults = + ModelPricingDefaults( + billingMode = BillingMode.TOKEN, + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + pricePerRequest = 0.01, + currency = PricingCurrency.USD, + ) + + private val zeroDefaults = + ModelPricingDefaults( + billingMode = BillingMode.TOKEN, + inputPricePerMillion = 0.0, + outputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + pricePerRequest = 0.01, + currency = PricingCurrency.CNY, + ) + + private fun overrideRow( + scope: String, + provider: String, + model: String, + configId: String? = null, + billingMode: BillingMode = BillingMode.TOKEN, + currency: String = PricingCurrency.USD.name, + input: Double? = 3.0, + cached: Double? = 1.5, + cacheWrite: Double? = 0.75, + output: Double? = 6.0, + perRequest: Double? = null, + ) = TokenPriceResolver.normalizedOverride( + scope = scope, + provider = provider, + model = model, + configId = configId, + billingMode = billingMode, + pricingCurrency = currency, + inputPricePerMillion = input, + cachedInputPricePerMillion = cached, + cacheWritePricePerMillion = cacheWrite, + outputPricePerMillion = output, + pricePerRequest = perRequest, + ) + + private fun resolve( + provider: String = "DEEPSEEK", + model: String = "deepseek-chat", + configId: String? = null, + overrides: List = emptyList(), + legacy: LegacyPriceSettings? = null, + defaults: ModelPricingDefaults = knownDefaults, + ) = + TokenPriceResolver.resolve( + provider = provider, + model = model, + configId = configId, + overrides = overrides, + legacyOverride = legacy, + defaults = defaults, + ) + + @Test + fun `config override beats provider model override and defaults`() { + val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") + val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") + + val resolved = resolve(configId = "cfg-1", overrides = listOf(config, providerModel)) + + assertEquals(PricingSource.CONFIG_OVERRIDE, resolved.source) + assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) + assertTrue(resolved.known) + } + + @Test + fun `config override only applies to its own config instance`() { + val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") + val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") + + val resolved = resolve(configId = "cfg-other", overrides = listOf(config, providerModel)) + + assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) + assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) + } + + @Test + fun `provider model override beats built-in defaults`() { + val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") + + val resolved = resolve(overrides = listOf(providerModel)) + + assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) + assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) + assertTrue(resolved.known) + } + + @Test + fun `override cached price falls back to input price`() { + val providerModel = + overrideRow( + "PROVIDER_MODEL", + "DEEPSEEK", + "deepseek-chat", + input = 3.0, + cached = null, + output = 6.0, + ) + + val resolved = resolve(overrides = listOf(providerModel)) + + assertEquals(3.0, resolved.cachedInputPricePerMillion!!, 1e-9) + } + + @Test + fun `legacy user price is used when no db override exists`() { + val legacy = + LegacyPriceSettings( + inputPricePerMillion = 4.0, + cachedInputPricePerMillion = 2.0, + outputPricePerMillion = 8.0, + ) + + val resolved = resolve(legacy = legacy) + + assertEquals(PricingSource.LEGACY_OVERRIDE, resolved.source) + assertEquals(4.0, resolved.inputPricePerMillion!!, 1e-9) + assertEquals(8.0, resolved.outputPricePerMillion!!, 1e-9) + assertTrue(resolved.known) + } + + @Test + fun `legacy zero or absent values fall back to defaults and stay known`() { + val legacy = + LegacyPriceSettings( + inputPricePerMillion = 4.0, + cachedInputPricePerMillion = 0.0, + outputPricePerMillion = null, + ) + + val resolved = resolve(legacy = legacy) + + assertEquals(4.0, resolved.inputPricePerMillion!!, 1e-9) + assertEquals(0.5, resolved.cachedInputPricePerMillion!!, 1e-9) + assertEquals(2.0, resolved.outputPricePerMillion!!, 1e-9) + assertTrue(resolved.known) + } + + @Test + fun `legacy with no user setting falls through to defaults`() { + val resolved = resolve(legacy = LegacyPriceSettings()) + + assertEquals(PricingSource.DEFAULT, resolved.source) + assertEquals(1.0, resolved.inputPricePerMillion!!, 1e-9) + assertTrue(resolved.known) + } + + @Test + fun `known built-in defaults resolve as DEFAULT source`() { + val resolved = resolve(defaults = knownDefaults) + + assertEquals(PricingSource.DEFAULT, resolved.source) + assertTrue(resolved.known) + } + + @Test + fun `unknown model falls back to zero pricing marked unknown`() { + val resolved = resolve(defaults = zeroDefaults) + + assertEquals(PricingSource.UNKNOWN, resolved.source) + assertFalse(resolved.known) + } + + @Test + fun `unknown pricing currency still follows provider convention`() { + val resolved = resolve(defaults = zeroDefaults) + + assertEquals(PricingCurrency.CNY, resolved.currency) + assertEquals(BillingMode.TOKEN, resolved.billingMode) + } + + @Test + fun `normalized override rows carry normalized business columns`() { + val rowA = TokenPriceResolver.normalizedOverride("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat", null, BillingMode.TOKEN, PricingCurrency.USD.name) + val rowB = TokenPriceResolver.normalizedOverride("PROVIDER_MODEL", "deepseek", "DeepSeek-Chat", null, BillingMode.TOKEN, PricingCurrency.USD.name) + val rowConfig = TokenPriceResolver.normalizedOverride("CONFIG", "DEEPSEEK", "deepseek-chat", " cfg-1 ", BillingMode.TOKEN, PricingCurrency.USD.name) + + // 规范化后业务列一致(provider/model 小写、configId trim) + assertEquals(rowA.provider, rowB.provider) + assertEquals(rowA.model, rowB.model) + assertEquals("deepseek", rowA.provider) + assertEquals("deepseek-chat", rowA.model) + assertEquals("", rowA.configId) // PROVIDER_MODEL 范围用空串 + assertEquals("cfg-1", rowConfig.configId) + assertTrue(rowA.configId != rowConfig.configId) + } + + @Test + fun `resolver matches override rows by normalized business columns`() { + // 行以原始大小写写入,但业务列规范化;查询侧规范化后仍命中 + val row = + TokenStatPriceOverrideEntity( + scope = "PROVIDER_MODEL", + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 3.0, + ) + + val resolved = resolve(provider = "deepseek", model = "DeepSeek-Chat", overrides = listOf(row)) + + assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) + assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) + } + + @Test + fun `row business columns and query mismatch cannot resolve wrongly`() { + // 行内容(业务列)是 openai/gpt-4o:无论怎样“伪造”都不能被 deepseek 查询命中 + val row = + TokenStatPriceOverrideEntity( + scope = "PROVIDER_MODEL", + provider = "openai", + model = "gpt-4o", + configId = "", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 3.0, + ) + + val resolved = resolve(provider = "DEEPSEEK", model = "deepseek-chat", overrides = listOf(row)) + + // 不命中伪造行 → 落到内置默认价(而不是错误使用 openai 的价格) + assertEquals(PricingSource.DEFAULT, resolved.source) + assertEquals(1.0, resolved.inputPricePerMillion!!, 1e-9) + } + + @Test + fun `config and provider model rows with same normalized model stay distinct`() { + val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") + val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") + + assertTrue(config.configId != providerModel.configId) + assertEquals( + PricingSource.CONFIG_OVERRIDE, + resolve(configId = "cfg-1", overrides = listOf(config, providerModel)).source + ) + } + + @Test + fun `override cache write price is preserved when present`() { + val providerModel = + overrideRow( + "PROVIDER_MODEL", + "DEEPSEEK", + "deepseek-chat", + cacheWrite = 0.75, + ) + + val resolved = resolve(overrides = listOf(providerModel)) + + assertEquals(0.75, resolved.cacheWritePricePerMillion!!, 1e-9) + } + + @Test + fun `built-in defaults and legacy pricing have no cache write price`() { + assertNull(resolve(defaults = knownDefaults).cacheWritePricePerMillion) + assertNull( + resolve( + legacy = + LegacyPriceSettings( + inputPricePerMillion = 4.0, + cachedInputPricePerMillion = 2.0, + outputPricePerMillion = 8.0, + ) + ).cacheWritePricePerMillion + ) + } + + @Test + fun `count mode override is known only with per request price`() { + val withPrice = + overrideRow( + "PROVIDER_MODEL", + "DEEPSEEK", + "deepseek-chat", + billingMode = BillingMode.COUNT, + input = null, + cached = null, + output = null, + perRequest = 0.02, + ) + // 同一规范化业务键、无按次价格 → 解析结果确定:known = false + val withoutPrice = withPrice.copy(pricePerRequest = null) + + assertTrue(resolve(overrides = listOf(withPrice)).known) + assertFalse(resolve(overrides = listOf(withoutPrice)).known) + assertNull(resolve(overrides = listOf(withoutPrice)).pricePerRequest) + } + + @Test + fun `legacy count mode uses stored per request price`() { + val legacy = + LegacyPriceSettings( + billingMode = BillingMode.COUNT, + pricePerRequest = 0.05, + ) + + val resolved = resolve(legacy = legacy) + + assertEquals(BillingMode.COUNT, resolved.billingMode) + assertEquals(0.05, resolved.pricePerRequest!!, 1e-9) + assertTrue(resolved.known) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt new file mode 100644 index 000000000..8544b7c55 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt @@ -0,0 +1,638 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import java.io.File +import java.sql.DriverManager +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * 真实 Room 迁移测试(纯 JVM,sqlite-jdbc): + * 用内嵌 v20 schema SQL 构造 v20 数据库 → 通过 [AppDatabase] + [AppDatabase.MIGRATION_20_21] + * 真实打开 → 验证迁移、schema 校验(Room 会做 identityHash/TableInfo 校验)、 + * 旧数据保留、新表 DAO 读写与幂等语义。 + * + * 事件/价格/别名/baseline 与聊天等旧表位于同一个 app_database 文件,因此现有 + * 整库文件级备份/恢复自动覆盖它们(无需逐表接线)。 + */ +class TokenStatsRoomMigrationTest { + + private fun mockContext(tempDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + // 模拟 Android Context 的数据库目录解析:/ + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(tempDir, invocation.getArgument(0)) + } + return context + } + + /** 用内嵌 v20 schema SQL 构造一个真实的 v20 数据库文件(上游不导出 schema JSON)。 */ + private fun buildV20Database(dbPath: String) { + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> + connection.createStatement().use { statement -> + statement.execute( + """ + CREATE TABLE IF NOT EXISTS `chats` ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL, + `inputTokens` INTEGER NOT NULL, + `outputTokens` INTEGER NOT NULL, + `currentWindowSize` INTEGER NOT NULL, + `group` TEXT, + `displayOrder` INTEGER NOT NULL, + `workspace` TEXT, + `workspaceEnv` TEXT, + `parentChatId` TEXT, + `characterCardName` TEXT, + `characterGroupId` TEXT, + `locked` INTEGER NOT NULL, + `pinned` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + statement.execute( + """ + CREATE TABLE IF NOT EXISTS `messages` ( + `messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `chatId` TEXT NOT NULL, + `sender` TEXT NOT NULL, + `content` TEXT NOT NULL, + `timestamp` INTEGER NOT NULL, + `orderIndex` INTEGER NOT NULL, + `roleName` TEXT NOT NULL, + `selectedVariantIndex` INTEGER NOT NULL, + `provider` TEXT NOT NULL, + `modelName` TEXT NOT NULL, + `inputTokens` INTEGER NOT NULL, + `outputTokens` INTEGER NOT NULL, + `cachedInputTokens` INTEGER NOT NULL, + `sentAt` INTEGER NOT NULL, + `outputDurationMs` INTEGER NOT NULL, + `waitDurationMs` INTEGER NOT NULL, + `completedAt` INTEGER NOT NULL, + `displayMode` TEXT NOT NULL, + `isFavorite` INTEGER NOT NULL, + FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + statement.execute( + "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `messages` (`chatId`)" + ) + statement.execute( + "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` " + + "ON `messages` (`chatId`, `timestamp`)" + ) + statement.execute( + """ + CREATE TABLE IF NOT EXISTS `message_variants` ( + `variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `chatId` TEXT NOT NULL, + `messageTimestamp` INTEGER NOT NULL, + `variantIndex` INTEGER NOT NULL, + `content` TEXT NOT NULL, + `roleName` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `modelName` TEXT NOT NULL, + `inputTokens` INTEGER NOT NULL, + `outputTokens` INTEGER NOT NULL, + `cachedInputTokens` INTEGER NOT NULL, + `sentAt` INTEGER NOT NULL, + `outputDurationMs` INTEGER NOT NULL, + `waitDurationMs` INTEGER NOT NULL, + `completedAt` INTEGER NOT NULL, + FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + statement.execute( + "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` " + + "ON `message_variants` (`chatId`, `messageTimestamp`)" + ) + statement.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_message_variants_chatId_messageTimestamp_variantIndex` " + + "ON `message_variants` (`chatId`, `messageTimestamp`, `variantIndex`)" + ) + // 与真实 v20 Room 数据库一致的 master 表 + statement.execute( + "CREATE TABLE IF NOT EXISTS room_master_table " + + "(id INTEGER PRIMARY KEY, identity_hash TEXT NOT NULL)" + ) + statement.execute( + "INSERT OR REPLACE INTO room_master_table (id, identity_hash) " + + "VALUES(42, 'unused-v20-hash')" + ) + statement.execute("PRAGMA user_version = 20") + // 旧数据:迁移前插入一条聊天,验证迁移后数据保留 + statement.execute( + "INSERT INTO chats " + + "(id, title, createdAt, updatedAt, inputTokens, outputTokens, " + + "currentWindowSize, displayOrder, locked, pinned, isFavorite) " + + "VALUES ('legacy-chat', 'legacy', 1, 2, 3, 4, 5, 6, 0, 0, 0)" + ) + } + } + } + + @Test + fun `v20 database opens through real Room migration and preserves legacy data`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) + + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo) + val legacyChat = database.chatDao().getChatById("legacy-chat") + assertNotNull("migration must preserve legacy rows", legacyChat) + assertEquals("legacy", legacyChat!!.title) + + // 新表已存在(独立连接读取同一文件) + val tables = queryTables(dbFile.absolutePath) + assertTrue("token_stat_events", tables.contains("token_stat_events")) + assertTrue("token_stat_identities", tables.contains("token_stat_identities")) + assertTrue("token_stat_display_models", tables.contains("token_stat_display_models")) + assertTrue("token_stat_price_overrides", tables.contains("token_stat_price_overrides")) + assertTrue("token_stat_baselines", tables.contains("token_stat_baselines")) + + // 迁移可重入(CREATE IF NOT EXISTS):以驱动变体再跑一次 + JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> + AppDatabase.MIGRATION_20_21.migrate(connection) + assertEquals(21, userVersion(connection)) + } + } finally { + database.close() + } + } + + @Test + fun `stats dao roundtrips with identity fk and idempotent event inserts`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) + + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + val dao = database.tokenStatsDao() + + val identityId = + TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = "", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + ) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = "deepseek-chat", + normalizedModel = "deepseek-chat", + displayName = "deepseek-chat", + ) + ) + assertEquals(identityId, dao.getIdentityByTriple("", "DEEPSEEK", "deepseek-chat")?.identityId) + + val event = + TokenStatEventEntity( + eventId = "req-1", + statIdentityId = identityId, + category = TokenStatCategory.CHAT.name, + status = TokenStatStatus.COMPLETED.name, + startedAtMs = 1000L, + endedAtMs = 2000L, + firstTokenAtMs = 1200L, + uncachedInputTokens = 800, + cachedInputTokens = 200, + cacheWriteTokens = 100, + outputTokens = 500, + reasoningTokens = 100, + reasoningIncludedInOutput = true, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 0.75, + outputPricePerMillion = 2.0, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.001975, + ) + dao.insertEvent(event) + dao.insertEvent(event) // 重复 eventId 必须被忽略 + assertEquals(1, dao.countEvents()) + val readBack = dao.getEvent("req-1")!! + assertEquals(0.001975, readBack.costInPricingCurrency!!, 1e-12) + assertEquals(100, readBack.cacheWriteTokens) + assertEquals(0.75, readBack.cacheWritePricePerMillion!!, 1e-12) + + // 未知分量以 null 落库,0 是确认值:null vs 0 必须可区分 + val nullFieldsEvent = + event.copy( + eventId = "req-2", + uncachedInputTokens = null, + cachedInputTokens = null, + cacheWriteTokens = null, + outputTokens = null, + costInPricingCurrency = null, + ) + dao.insertEvent(nullFieldsEvent) + val nullReadBack = dao.getEvent("req-2")!! + assertNull(nullReadBack.uncachedInputTokens) + assertNull(nullReadBack.cachedInputTokens) + assertNull(nullReadBack.cacheWriteTokens) + assertNull(nullReadBack.outputTokens) + assertNull(nullReadBack.costInPricingCurrency) + assertEquals(2, dao.countEvents()) + + // baseline 以 identityId 为键整体替换(幂等;baseline 无子表,REPLACE 安全) + val baseline = + TokenStatBaselineEntity( + identityId = identityId, + inputTokens = 1000L, + cachedInputTokens = 200L, + outputTokens = 500L, + requestCount = 3L, + pricingCurrency = "USD", + costInPricingCurrency = 1.9, + isEstimated = true, + fingerprint = "fp-1", + importedAtMs = 100L, + frozenBillingMode = BillingMode.TOKEN.name, + frozenInputPricePerMillion = 1.0, + frozenCachedInputPricePerMillion = 0.5, + frozenOutputPricePerMillion = 2.0, + ) + dao.upsertBaseline(baseline) + dao.upsertBaseline(baseline.copy(costInPricingCurrency = 2.5, fingerprint = "fp-2")) + assertEquals(1, dao.countBaselines()) + assertEquals("fp-2", dao.getBaseline(identityId)!!.fingerprint) + + // 价格覆盖:唯一写入入口是 upsertPriceOverride(校验 scope 枚举 + + // 规范化字段)。大小写/空白不同的原始输入 → 规范化后同一业务列 → + // 唯一索引冲突 REPLACE 覆盖,表内只能一行。 + dao.upsertPriceOverride( + scope = "PROVIDER_MODEL", + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 3.0, + cachedInputPricePerMillion = 1.5, + cacheWritePricePerMillion = 0.75, + outputPricePerMillion = 6.0, + ) + dao.upsertPriceOverride( + scope = "PROVIDER_MODEL", + provider = " deepseek ", + model = " DeepSeek-Chat ", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 9.0, + cachedInputPricePerMillion = 4.5, + cacheWritePricePerMillion = 2.25, + outputPricePerMillion = 18.0, + ) + assertEquals(1, dao.getAllPriceOverrides().size) + // 落库的必须是规范化后的业务列 + val override = + dao.getPriceOverride("PROVIDER_MODEL", "deepseek", "deepseek-chat", "") + assertNotNull(override) + assertEquals("deepseek", override!!.provider) + assertEquals("deepseek-chat", override.model) + assertEquals("", override.configId) + assertEquals(9.0, override.inputPricePerMillion!!, 1e-9) + assertEquals(2.25, override.cacheWritePricePerMillion!!, 1e-9) + // rowId 是内部主键,行内容按业务列解析,与 rowId 无关 + assertTrue(override.rowId > 0) + + // CONFIG 范围与 PROVIDER_MODEL 范围同 provider/model 可并存 + dao.upsertPriceOverride( + scope = "CONFIG", + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = " cfg-1 ", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 12.0, + ) + assertEquals(2, dao.getAllPriceOverrides().size) + val configOverride = + dao.getPriceOverride("CONFIG", "deepseek", "deepseek-chat", "cfg-1") + assertNotNull(configOverride) + assertEquals(12.0, configOverride!!.inputPricePerMillion!!, 1e-9) + + // 业务列错配不命中:另一 provider 的查询不会读到该行 + assertNull(dao.getPriceOverride("PROVIDER_MODEL", "openai", "deepseek-chat", "")) + assertNull(dao.getPriceOverride("CONFIG", "deepseek", "deepseek-chat", "other-cfg")) + + // 非法 scope 必须在写入边界被拒绝(不落库) + try { + dao.upsertPriceOverride( + scope = "BOGUS_SCOPE", + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + ) + fail("expected IllegalArgumentException for illegal scope") + } catch (e: IllegalArgumentException) { + // expected + } + assertEquals(2, dao.getAllPriceOverrides().size) + + // 按身份删除事件与 baseline(重置语义) + assertEquals(2, dao.deleteEventsByIdentity(identityId)) + assertEquals(1, dao.deleteBaseline(identityId)) + assertEquals(0, dao.countEvents()) + assertEquals(0, dao.countBaselines()) + + // 外键级联:删除身份时事件与 baseline 跟随删除 + dao.insertEvent(event) + dao.upsertBaseline(baseline) + JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> + // 关闭日志模式,避免 Windows 上删除 journal 文件的 IOERR_DELETE 抖动 + connection.prepare("PRAGMA journal_mode = OFF").use { it.step() } + connection.prepare("PRAGMA foreign_keys = ON").use { it.step() } + connection.prepare( + "DELETE FROM token_stat_identities WHERE identityId = '$identityId'" + ).use { it.step() } + } + assertEquals(0, dao.countEvents()) + assertEquals(0, dao.countBaselines()) + } finally { + database.close() + } + } + + @Test + fun `reset by provider model deletes events across config identities and baseline`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) + + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + val dao = database.tokenStatsDao() + + // 同一 provider/model 的多个配置实例身份 + 一个无关模型 + val identities = + listOf("", "cfg-1", "cfg-2").mapIndexed { index, configId -> + TokenStatIdentityEntity( + identityId = + TokenStatIdentityResolver.identityId(configId, "DEEPSEEK", "deepseek-chat"), + configId = configId, + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ).also { dao.insertIdentityIfAbsent(it) } + } + val otherIdentity = + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o"), + configId = "", + provider = "OPENAI", + model = "gpt-4o", + displayModelId = "gpt-4o", + ).also { dao.insertIdentityIfAbsent(it) } + + fun eventOf(id: String, identity: TokenStatIdentityEntity) = + TokenStatEventEntity( + eventId = id, + statIdentityId = identity.identityId, + category = TokenStatCategory.CHAT.name, + status = TokenStatStatus.COMPLETED.name, + startedAtMs = 1000L, + endedAtMs = 2000L, + uncachedInputTokens = 100, + cachedInputTokens = 0, + cacheWriteTokens = 0, + outputTokens = 50, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 0.75, + outputPricePerMillion = 2.0, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.0002, + ) + identities.forEachIndexed { index, identity -> + dao.insertEvent(eventOf("deepseek-event-$index", identity)) + } + dao.insertEvent(eventOf("openai-event", otherIdentity)) + // 旧系统 baseline 身份 configId 为空串,属于被重置范围 + dao.upsertBaseline( + TokenStatBaselineEntity( + identityId = identities.first().identityId, + inputTokens = 100L, + cachedInputTokens = 0L, + outputTokens = 50L, + requestCount = 1L, + pricingCurrency = "USD", + costInPricingCurrency = 0.0002, + isEstimated = true, + fingerprint = "fp", + importedAtMs = 1L, + frozenBillingMode = BillingMode.TOKEN.name, + frozenInputPricePerMillion = 1.0, + frozenOutputPricePerMillion = 2.0, + ) + ) + assertEquals(4, dao.countEvents()) + assertEquals(1, dao.countBaselines()) + + // 按 provider/model 重置:所有配置实例的事件 + 全部匹配 baseline 一起清。 + // 通过 daoProvider 注入缝把真实 DAO 交给协调器(生产路径用 + // AppDatabase.withTransaction 包同一组删除)。 + TokenStatsResetCoordinator.daoProvider = { dao } + try { + TokenStatsResetCoordinator + .resetStatisticsForProviderModel(mockContext(tempDir), "DEEPSEEK:deepseek-chat") + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + + assertEquals(1, dao.countEvents()) + assertEquals("openai-event", dao.getEvent("openai-event")!!.eventId) + assertEquals(0, dao.countBaselines()) + } finally { + database.close() + } + } + + @Test + fun `identity reinsert and display model update never cascade delete events`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) + + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + val dao = database.tokenStatsDao() + val identityId = + TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + val identity = + TokenStatIdentityEntity( + identityId = identityId, + configId = "", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + dao.insertIdentityIfAbsent(identity) + val event = + TokenStatEventEntity( + eventId = "req-1", + statIdentityId = identityId, + category = TokenStatCategory.CHAT.name, + status = TokenStatStatus.COMPLETED.name, + startedAtMs = 1000L, + endedAtMs = 2000L, + uncachedInputTokens = 800, + cachedInputTokens = 200, + outputTokens = 500, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 2.0, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.0019, + ) + dao.insertEvent(event) + assertEquals(1, dao.countEvents()) + + // 重导路径:同一身份再次插入 → INSERT IGNORE,绝不可 REPLACE 删除 + //(REPLACE = DELETE + INSERT 会通过外键级联删除该身份的事件) + dao.insertIdentityIfAbsent(identity) + dao.insertIdentitiesIfAbsent(listOf(identity)) + assertEquals(1, dao.countEvents()) + assertEquals(identityId, dao.getIdentity(identityId)!!.identityId) + + // 分组变更走显式安全 UPDATE,同样不得级联删除事件 + dao.updateIdentityDisplayModel(identityId, "merged-group") + assertEquals(1, dao.countEvents()) + assertEquals("merged-group", dao.getIdentity(identityId)!!.displayModelId) + val readBack = dao.getEvent("req-1")!! + assertEquals(800, readBack.uncachedInputTokens) + assertEquals(0.0019, readBack.costInPricingCurrency!!, 1e-12) + } finally { + database.close() + } + } + + @Test + fun `production support sqlite migration variant runs the shared sql on a real v20 database`() { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) + + JvmSupportSQLiteDatabase.open(dbFile.absolutePath).use { supportDb -> + // 生产默认路径:migrate(SupportSQLiteDatabase) 变体 + AppDatabase.MIGRATION_20_21.migrate(supportDb) + + // 新表真实存在且旧数据保留 + val tables = queryTables(dbFile.absolutePath) + assertTrue("token_stat_events", tables.contains("token_stat_events")) + assertTrue("token_stat_identities", tables.contains("token_stat_identities")) + assertTrue("token_stat_display_models", tables.contains("token_stat_display_models")) + assertTrue("token_stat_price_overrides", tables.contains("token_stat_price_overrides")) + assertTrue("token_stat_baselines", tables.contains("token_stat_baselines")) + + JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> + connection.prepare("SELECT title FROM chats WHERE id = 'legacy-chat'").use { statement -> + assertTrue(statement.step()) + assertEquals("legacy", statement.getText(0)) + } + // 事件表带缓存写入列:prepare 即校验列存在(空表无行) + connection.prepare( + "SELECT cacheWriteTokens, cacheWritePricePerMillion " + + "FROM token_stat_events LIMIT 1" + ).use { statement -> + assertFalse(statement.step()) + } + } + + // 迁移可重入(CREATE IF NOT EXISTS) + AppDatabase.MIGRATION_20_21.migrate(supportDb) + } + } + + private fun queryTables(dbPath: String): Set { + val tables = mutableSetOf() + JdbcSQLiteConnection(dbPath).use { connection -> + connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").use { statement -> + while (statement.step()) { + tables += statement.getText(0) + } + } + } + return tables + } + + private fun userVersion(connection: androidx.sqlite.SQLiteConnection): Int = + connection.prepare("PRAGMA user_version").use { statement -> + statement.step() + statement.getLong(0).toInt() + } + +} diff --git a/terminal b/terminal index e4442bc6a..f85be5794 160000 --- a/terminal +++ b/terminal @@ -1 +1 @@ -Subproject commit e4442bc6a047b6165bf59103721ad143149c620d +Subproject commit f85be57944b806de4d863dee8b10d80d04daa236 From 05ce1f1beb9cdb1e1ca27a533dd673976917fefe Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:18:43 +0800 Subject: [PATCH 02/28] feat(stats): unify token usage recording (cherry picked from commit 96488ae33e222e1842d59233d8fd4563e0aad1dd) --- app/build.gradle.kts | 4 + .../30.json | 1274 ++++ .../operit/api/chat/EnhancedAIService.kt | 24 +- .../api/chat/enhance/ConversationService.kt | 53 +- .../operit/api/chat/library/MemoryLibrary.kt | 29 +- .../operit/api/chat/llmprovider/AIService.kt | 21 +- .../api/chat/llmprovider/AIServiceFactory.kt | 19 +- .../api/chat/llmprovider/ClaudeProvider.kt | 65 +- .../api/chat/llmprovider/DeepseekProvider.kt | 6 +- .../api/chat/llmprovider/GeminiProvider.kt | 49 +- .../api/chat/llmprovider/KimiProvider.kt | 8 +- .../api/chat/llmprovider/LlamaProvider.kt | 73 +- .../chat/llmprovider/LocalGenerationEnd.kt | 71 + .../api/chat/llmprovider/MNNProvider.kt | 65 +- .../ModelConfigConnectionTester.kt | 15 +- .../api/chat/llmprovider/OpenAIProvider.kt | 46 +- .../api/chat/llmprovider/QwenAIProvider.kt | 6 +- .../chat/llmprovider/RateLimitedAIService.kt | 8 +- .../llmprovider/TokenTrackingAIService.kt | 453 ++ .../llmprovider/ToolPkgJsAiProviderService.kt | 230 +- .../core/application/OperitApplication.kt | 4 + .../data/backup/RawSnapshotBackupManager.kt | 653 -- .../operit/data/dao/TokenStatsDao.kt | 99 + .../assistance/operit/data/db/AppDatabase.kt | 55 + .../operit/data/model/TokenStatEventEntity.kt | 24 +- .../data/model/TokenStatResetCutoffEntity.kt | 36 + .../operit/data/preferences/ApiPreferences.kt | 25 + .../data/stats/ProviderUsageSnapshot.kt | 360 ++ .../data/stats/TokenBaselineImportRunner.kt | 28 +- .../operit/data/stats/TokenCostCalculator.kt | 100 +- .../data/stats/TokenStatRequestContext.kt | 352 ++ .../operit/data/stats/TokenStatSpool.kt | 3506 +++++++++++ .../operit/data/stats/TokenStatsLedger.kt | 303 + .../core/MessageCoordinationDelegate.kt | 3 +- .../screens/ChatBackupSettingsScreen.kt | 419 +- .../screens/FunctionalConfigScreen.kt | 24 +- .../screens/PersonaCardGenerationScreen.kt | 15 +- .../ai/assistance/operit/util/OperitPaths.kt | 5 +- app/src/main/res/values-en/strings.xml | 21 + app/src/main/res/values/strings.xml | 21 + .../llmprovider/LocalGenerationEndTest.kt | 99 + .../OpenAIResponsesPayloadAdapterTest.kt | 66 + .../llmprovider/TokenTrackingAIServiceTest.kt | 2164 +++++++ .../ToolPkgJsAiProviderServiceTest.kt | 444 ++ .../ApiPreferencesResetFailureTest.kt | 10 +- .../operit/data/stats/JdbcSQLiteDriver.kt | 46 +- .../data/stats/ProviderUsageNormalizerTest.kt | 435 ++ .../stats/TokenBaselineImportRunnerTest.kt | 2 +- .../data/stats/TokenCostCalculatorTest.kt | 174 +- .../data/stats/TokenStatReliabilityTest.kt | 5609 +++++++++++++++++ .../operit/data/stats/TokenStatsLedgerTest.kt | 826 +++ .../data/stats/TokenStatsRoomMigrationTest.kt | 127 +- .../screens/QuarantineExportCleanupTest.kt | 134 + 53 files changed, 17656 insertions(+), 1052 deletions(-) create mode 100644 app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json create mode 100644 app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70801c135..33be97feb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -768,6 +768,10 @@ dependencies { // JdbcSQLiteDriver),仅在单元测试使用。 testImplementation("org.xerial:sqlite-jdbc:3.46.1.3") + // 单元测试中真实 org.json(Android 桩在 JVM 测试里会抛 Stub! 异常); + // 统计 usage 归一化测试需要解析 JSONObject。 + testImplementation("org.json:json:20240303") + // 入口级恢复测试:RawSnapshotBackupManager 内部使用 Dispatchers.Main 汇报 // 进度,JVM 测试用 setMain 安装测试主调度器。 testImplementation(libs.coroutines.test) diff --git a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json new file mode 100644 index 000000000..81c77fb30 --- /dev/null +++ b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json @@ -0,0 +1,1274 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "bff25c874a4958bb715deb3b47854c70", + "entities": [ + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `currentWindowSize` INTEGER NOT NULL, `group` TEXT, `folderId` TEXT, `displayOrder` INTEGER NOT NULL, `workspace` TEXT, `workspaceEnv` TEXT, `parentChatId` TEXT, `chatKind` TEXT NOT NULL DEFAULT 'NORMAL', `characterCardName` TEXT, `characterGroupId` TEXT, `locked` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastMessageAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`folderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentWindowSize", + "columnName": "currentWindowSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT" + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workspace", + "columnName": "workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "workspaceEnv", + "columnName": "workspaceEnv", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatId", + "columnName": "parentChatId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatKind", + "columnName": "chatKind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NORMAL'" + }, + { + "fieldPath": "characterCardName", + "columnName": "characterCardName", + "affinity": "TEXT" + }, + { + "fieldPath": "characterGroupId", + "columnName": "characterGroupId", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageAt", + "columnName": "lastMessageAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_chats_chatKind", + "unique": false, + "columnNames": [ + "chatKind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_chatKind` ON `${TABLE_NAME}` (`chatKind`)" + }, + { + "name": "index_chats_parentChatId_chatKind", + "unique": false, + "columnNames": [ + "parentChatId", + "chatKind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_parentChatId_chatKind` ON `${TABLE_NAME}` (`parentChatId`, `chatKind`)" + } + ], + "foreignKeys": [ + { + "table": "chat_folders", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "folderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chat_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `parentFolderId` TEXT, `displayOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_folders_parentFolderId_displayOrder", + "unique": false, + "columnNames": [ + "parentFolderId", + "displayOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_folders_parentFolderId_displayOrder` ON `${TABLE_NAME}` (`parentFolderId`, `displayOrder`)" + } + ], + "foreignKeys": [ + { + "table": "chat_folders", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `sender` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `roleName` TEXT NOT NULL, `selectedVariantIndex` INTEGER NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "roleName", + "columnName": "roleName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedVariantIndex", + "columnName": "selectedVariantIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelName", + "columnName": "modelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sentAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputDurationMs", + "columnName": "outputDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "waitDurationMs", + "columnName": "waitDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayMode", + "columnName": "displayMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "messageId" + ] + }, + "indices": [ + { + "name": "index_messages_chatId", + "unique": false, + "columnNames": [ + "chatId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `${TABLE_NAME}` (`chatId`)" + }, + { + "name": "index_messages_chatId_timestamp", + "unique": false, + "columnNames": [ + "chatId", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` ON `${TABLE_NAME}` (`chatId`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "message_variants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `messageTimestamp` INTEGER NOT NULL, `variantIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, `roleName` TEXT NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "variantId", + "columnName": "variantId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageTimestamp", + "columnName": "messageTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variantIndex", + "columnName": "variantIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roleName", + "columnName": "roleName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelName", + "columnName": "modelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sentAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputDurationMs", + "columnName": "outputDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "waitDurationMs", + "columnName": "waitDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "variantId" + ] + }, + "indices": [ + { + "name": "index_message_variants_chatId_messageTimestamp", + "unique": false, + "columnNames": [ + "chatId", + "messageTimestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`)" + }, + { + "name": "index_message_variants_chatId_messageTimestamp_variantIndex", + "unique": true, + "columnNames": [ + "chatId", + "messageTimestamp", + "variantIndex" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp_variantIndex` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`, `variantIndex`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "subagent_runs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentChatId` TEXT NOT NULL, `childChatId` TEXT NOT NULL, `parentToolCallId` TEXT, `agentProfileId` TEXT NOT NULL, `title` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `startedAt` INTEGER, `completedAt` INTEGER, `error` TEXT, `agentConfigSnapshot` TEXT, `modelConfigIdSnapshot` TEXT, `modelIndexSnapshot` INTEGER, `toolInvocationCount` INTEGER NOT NULL, `archivedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`parentChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`childChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatId", + "columnName": "parentChatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childChatId", + "columnName": "childChatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentToolCallId", + "columnName": "parentToolCallId", + "affinity": "TEXT" + }, + { + "fieldPath": "agentProfileId", + "columnName": "agentProfileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "agentConfigSnapshot", + "columnName": "agentConfigSnapshot", + "affinity": "TEXT" + }, + { + "fieldPath": "modelConfigIdSnapshot", + "columnName": "modelConfigIdSnapshot", + "affinity": "TEXT" + }, + { + "fieldPath": "modelIndexSnapshot", + "columnName": "modelIndexSnapshot", + "affinity": "INTEGER" + }, + { + "fieldPath": "toolInvocationCount", + "columnName": "toolInvocationCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archivedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subagent_runs_parentChatId", + "unique": false, + "columnNames": [ + "parentChatId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId` ON `${TABLE_NAME}` (`parentChatId`)" + }, + { + "name": "index_subagent_runs_childChatId", + "unique": true, + "columnNames": [ + "childChatId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subagent_runs_childChatId` ON `${TABLE_NAME}` (`childChatId`)" + }, + { + "name": "index_subagent_runs_parentChatId_parentToolCallId", + "unique": false, + "columnNames": [ + "parentChatId", + "parentToolCallId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId_parentToolCallId` ON `${TABLE_NAME}` (`parentChatId`, `parentToolCallId`)" + }, + { + "name": "index_subagent_runs_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "parentChatId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "childChatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_stat_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `statIdentityId` TEXT NOT NULL, `category` TEXT NOT NULL, `status` TEXT NOT NULL, `acceptedGeneration` INTEGER NOT NULL, `startedAtMs` INTEGER NOT NULL, `endedAtMs` INTEGER NOT NULL, `firstTokenAtMs` INTEGER, `uncachedInputTokens` INTEGER, `cachedInputTokens` INTEGER, `cacheWriteTokens` INTEGER, `totalInputTokens` INTEGER, `outputTokens` INTEGER, `reasoningTokens` INTEGER, `reasoningIncludedInOutput` INTEGER, `cacheWriteSeparateBilling` INTEGER, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL, `pricingSource` TEXT NOT NULL, `costInPricingCurrency` REAL, `diagnosticsJson` TEXT, PRIMARY KEY(`eventId`), FOREIGN KEY(`statIdentityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statIdentityId", + "columnName": "statIdentityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acceptedGeneration", + "columnName": "acceptedGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtMs", + "columnName": "startedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAtMs", + "columnName": "endedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstTokenAtMs", + "columnName": "firstTokenAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "uncachedInputTokens", + "columnName": "uncachedInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "cacheWriteTokens", + "columnName": "cacheWriteTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalInputTokens", + "columnName": "totalInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "reasoningTokens", + "columnName": "reasoningTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "reasoningIncludedInOutput", + "columnName": "reasoningIncludedInOutput", + "affinity": "INTEGER" + }, + { + "fieldPath": "cacheWriteSeparateBilling", + "columnName": "cacheWriteSeparateBilling", + "affinity": "INTEGER" + }, + { + "fieldPath": "billingMode", + "columnName": "billingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputPricePerMillion", + "columnName": "inputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cachedInputPricePerMillion", + "columnName": "cachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cacheWritePricePerMillion", + "columnName": "cacheWritePricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "outputPricePerMillion", + "columnName": "outputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "pricePerRequest", + "columnName": "pricePerRequest", + "affinity": "REAL" + }, + { + "fieldPath": "pricingSource", + "columnName": "pricingSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "costInPricingCurrency", + "columnName": "costInPricingCurrency", + "affinity": "REAL" + }, + { + "fieldPath": "diagnosticsJson", + "columnName": "diagnosticsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + }, + "indices": [ + { + "name": "index_token_stat_events_statIdentityId_startedAtMs", + "unique": false, + "columnNames": [ + "statIdentityId", + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_statIdentityId_startedAtMs` ON `${TABLE_NAME}` (`statIdentityId`, `startedAtMs`)" + }, + { + "name": "index_token_stat_events_startedAtMs", + "unique": false, + "columnNames": [ + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` ON `${TABLE_NAME}` (`startedAtMs`)" + }, + { + "name": "index_token_stat_events_category_startedAtMs", + "unique": false, + "columnNames": [ + "category", + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` ON `${TABLE_NAME}` (`category`, `startedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "token_stat_identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "statIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "token_stat_identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `configId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `displayModelId` TEXT NOT NULL, PRIMARY KEY(`identityId`))", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configId", + "columnName": "configId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayModelId", + "columnName": "displayModelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_token_stat_identities_configId_provider_model", + "unique": true, + "columnNames": [ + "configId", + "provider", + "model" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_identities_configId_provider_model` ON `${TABLE_NAME}` (`configId`, `provider`, `model`)" + }, + { + "name": "index_token_stat_identities_displayModelId", + "unique": false, + "columnNames": [ + "displayModelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` ON `${TABLE_NAME}` (`displayModelId`)" + } + ] + }, + { + "tableName": "token_stat_display_models", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`displayModelId` TEXT NOT NULL, `normalizedModel` TEXT NOT NULL, `displayName` TEXT NOT NULL, PRIMARY KEY(`displayModelId`))", + "fields": [ + { + "fieldPath": "displayModelId", + "columnName": "displayModelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedModel", + "columnName": "normalizedModel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "displayModelId" + ] + }, + "indices": [ + { + "name": "index_token_stat_display_models_normalizedModel", + "unique": true, + "columnNames": [ + "normalizedModel" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_display_models_normalizedModel` ON `${TABLE_NAME}` (`normalizedModel`)" + } + ] + }, + { + "tableName": "token_stat_price_overrides", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scope` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `configId` TEXT NOT NULL, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configId", + "columnName": "configId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billingMode", + "columnName": "billingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputPricePerMillion", + "columnName": "inputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cachedInputPricePerMillion", + "columnName": "cachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cacheWritePricePerMillion", + "columnName": "cacheWritePricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "outputPricePerMillion", + "columnName": "outputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "pricePerRequest", + "columnName": "pricePerRequest", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_token_stat_price_overrides_scope_provider_model_configId", + "unique": true, + "columnNames": [ + "scope", + "provider", + "model", + "configId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_price_overrides_scope_provider_model_configId` ON `${TABLE_NAME}` (`scope`, `provider`, `model`, `configId`)" + } + ] + }, + { + "tableName": "token_stat_baselines", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `requestCount` INTEGER NOT NULL, `pricingCurrency` TEXT NOT NULL, `costInPricingCurrency` REAL, `isEstimated` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `importedAtMs` INTEGER NOT NULL, `frozenBillingMode` TEXT NOT NULL, `frozenInputPricePerMillion` REAL, `frozenCachedInputPricePerMillion` REAL, `frozenOutputPricePerMillion` REAL, `frozenPricePerRequest` REAL, PRIMARY KEY(`identityId`), FOREIGN KEY(`identityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestCount", + "columnName": "requestCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "costInPricingCurrency", + "columnName": "costInPricingCurrency", + "affinity": "REAL" + }, + { + "fieldPath": "isEstimated", + "columnName": "isEstimated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frozenBillingMode", + "columnName": "frozenBillingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frozenInputPricePerMillion", + "columnName": "frozenInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenCachedInputPricePerMillion", + "columnName": "frozenCachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenOutputPricePerMillion", + "columnName": "frozenOutputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenPricePerRequest", + "columnName": "frozenPricePerRequest", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "foreignKeys": [ + { + "table": "token_stat_identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "token_stat_restore_generations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` TEXT NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", + "fields": [ + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appliedAtMs", + "columnName": "appliedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "generation" + ] + } + }, + { + "tableName": "token_stat_reset_cutoffs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `generation` INTEGER NOT NULL, PRIMARY KEY(`kind`, `provider`, `model`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind", + "provider", + "model" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bff25c874a4958bb715deb3b47854c70')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt index 4a6ec391f..2ac9aa961 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt @@ -1110,7 +1110,13 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0) _perRequestTokenCounts.value = Pair(input, output) }, - onNonFatalError = onNonFatalError + onNonFatalError = onNonFatalError, + statsCategory = + if (isSubTask) { + com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT + } else { + com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT + } ) val revisableStream = responseStream as? TextStreamEventCarrier @@ -1211,10 +1217,6 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestInputTokenCount = 0L currentRequestOutputTokenCount = 0L currentRequestCachedInputTokenCount = 0L - apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count - apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel) AppLogger.d( TAG, @@ -2330,7 +2332,13 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0) _perRequestTokenCounts.value = Pair(input, output) }, - onNonFatalError = onNonFatalError + onNonFatalError = onNonFatalError, + statsCategory = + if (isSubTask) { + com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT + } else { + com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT + } ) // 更新状态为接收中 @@ -2425,10 +2433,6 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestInputTokenCount = 0L currentRequestOutputTokenCount = 0L currentRequestCachedInputTokenCount = 0L - apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count - apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel) AppLogger.d( TAG, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt index f15de6c36..9d03a604b 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt @@ -269,7 +269,8 @@ class ConversationService( summaryService.sendMessage( context = context, chatHistory = preparedHistory, - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.SUMMARY ) // 收集流中的所有内容 @@ -320,18 +321,7 @@ class ConversationService( return "Conversation Summary: Unable to generate valid summary." } - // 将总结token计数添加到用户偏好分析的token统计中 - try { - AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") - apiPreferences.updateTokensForProviderModel(summaryService.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count for summary generation - apiPreferences.incrementRequestCountForProviderModel(summaryService.providerModel) - - AppLogger.d(TAG, "已将总结token统计添加到用户偏好分析token计数中") - } catch (e: Exception) { - AppLogger.e(TAG, "更新token统计失败", e) - } + AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") return summaryContent } catch (e: Exception) { @@ -368,28 +358,18 @@ class ConversationService( chatHistory = preparedHistory, modelParameters = modelParameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.TITLE ).collect { content -> contentBuilder.append(content) } val title = sanitizeConversationTitle( ChatUtils.removeThinkingContent(contentBuilder.toString().trim()) ) - try { - val inputTokens = titleService.inputTokenCount - val cachedInputTokens = titleService.cachedInputTokenCount - val outputTokens = titleService.outputTokenCount - apiPreferences.updateTokensForProviderModel( - titleService.providerModel, - inputTokens, - outputTokens, - cachedInputTokens - ) - apiPreferences.incrementRequestCountForProviderModel(titleService.providerModel) - AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") - } catch (e: Exception) { - AppLogger.e(TAG, "更新标题生成token统计失败", e) - } + val inputTokens = titleService.inputTokenCount + val cachedInputTokens = titleService.cachedInputTokenCount + val outputTokens = titleService.outputTokenCount + AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") title } catch (e: Exception) { @@ -1132,7 +1112,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} val stream = translationService.sendMessage( context = context, chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = translationPrompt), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { content -> @@ -1191,7 +1172,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} val stream = summaryService.sendMessage( context = context, chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = descriptionPrompt), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { content -> @@ -1249,7 +1231,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } @@ -1294,7 +1277,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } @@ -1337,7 +1321,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt index ccf50c3a1..107b62ba3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt @@ -202,22 +202,12 @@ object MemoryLibrary { val stream = aiService.sendMessage( context = context, - chatHistory = messages + chatHistory = messages, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY ) stream.collect { content -> result.append(content) } } - - // 更新 token 统计 - apiPreferences?.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - - // Update request count - apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel) - + // 解析 AI 返回的 JSON 并更新记忆 parseAndApplyCategorization(result.toString(), memories, repository) } @@ -592,21 +582,12 @@ object MemoryLibrary { val stream = aiService.sendMessage( context = context, - chatHistory = messages + chatHistory = messages, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY ) stream.collect { content -> result.append(content) } } - apiPreferences?.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - - // Update request count - apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel) - return parseAnalysisResult(context, ChatUtils.removeThinkingContent(result.toString())) } catch (e: Exception) { AppLogger.e(TAG, "生成分析失败", e) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt index 9d7f1e856..739664187 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt @@ -5,6 +5,8 @@ import com.ai.assistance.operit.core.chat.hooks.PromptTurn import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ToolPrompt +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.util.stream.Stream /** AI服务接口,定义与不同AI提供商进行交互的标准方法 */ @@ -44,8 +46,14 @@ interface AIService { * @param enableThinking 是否启用思考模式 * @param stream 是否使用流式输出,true为流式,false为非流式(但返回值仍为Stream) * @param availableTools 可用工具列表(用于Tool Call API),如果为null则使用系统提示词中的工具描述 - * @param onTokensUpdated Token更新回调 + * @param onTokensUpdated Token更新回调(UI 计数通道,可能携带估算值) + * @param onUsageReported 规范化 usage 上报回调(统计账本通道;只在解析到 + * provider 真实 usage/本地实测计数时回调,估算值不上报;可被多次调用, + * 第二次参数为 provider 内部尝试序号 attempt(从 1 开始,内部重试递增), + * 记录方按 attempt 聚合:同一 attempt 取最后一次,不同 attempt 累加) * @param onNonFatalError 非致命错误回调 + * @param enableRetry 是否允许内部重试 + * @param statsCategory 业务分类(统计账本);null 表示调用方未声明(按 OTHER 记录) * @return 流式响应内容的Stream(无论stream参数如何,都返回Stream) */ suspend fun sendMessage( @@ -57,17 +65,24 @@ interface AIService { availableTools: List? = null, preserveThinkInHistory: Boolean = false, // 新增参数,控制是否保留历史中的思考过程 onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit = { _, _, _ -> }, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, onNonFatalError: suspend (error: String) -> Unit = {}, - enableRetry: Boolean = true + enableRetry: Boolean = true, + statsCategory: TokenStatCategory? = null ): Stream /** * 测试与AI服务的连接 * * @param context Android Context + * @param onUsageReported 与 [sendMessage] 相同的 usage 上报回调;实现内部 + * 通过 sendMessage 发起测试模型调用时必须透传,使探测用量进入统计账本。 * @return 成功时返回成功信息,失败时返回包含错误的Result */ - suspend fun testConnection(context: Context): Result + suspend fun testConnection( + context: Context, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null + ): Result /** * 精确计算下一次请求的输入Token数量 diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt index d9489cd51..4a0ae16b0 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt @@ -255,7 +255,11 @@ object AIServiceFactory { } /** - * 创建AI服务实例 + * 创建AI服务实例(统一统计记录边界)。 + * + * 所有服务(包括连接测试器直接创建的探测服务)都在这里统一包装 + * [TokenTrackingAIService]:任何 sendMessage/testConnection 调用都会落入 + * 统计账本,业务分类由调用方通过 sendMessage 的 statsCategory 声明。 * * @param config 模型配置数据 * @param modelConfigManager 模型配置管理器,用于多API Key模式 @@ -266,6 +270,19 @@ object AIServiceFactory { config: ModelConfigData, modelConfigManager: ModelConfigManager, context: Context + ): AIService { + val rawService = buildService(config, modelConfigManager, context) + return TokenTrackingAIService( + delegate = rawService, + context = context, + configId = config.id, + ) + } + + private fun buildService( + config: ModelConfigData, + modelConfigManager: ModelConfigManager, + context: Context ): AIService { val providerTypeId = config.apiProviderTypeId.trim() ToolPkgAiProviderRegistry.get(providerTypeId)?.let { provider -> diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index 4bae16334..b91aa6333 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -183,6 +183,15 @@ class ClaudeProvider( private fun parseAnthropicUsage(usage: JSONObject?): AnthropicUsageCounts? { usage ?: return null + // 评审 P1-5:显式全零 payload 也是“已观察到的 usage”——按字段存在判断, + // 不能按 “>0” 过滤;P2-1:Long 解析,旧 UI 计数边界饱和 Int。 + val hasAny = + usage.has("input_tokens") || usage.has("prompt_tokens") || + usage.has("cache_read_input_tokens") || usage.has("cached_tokens") || + usage.has("cache_creation_input_tokens") || usage.has("cache_creation") || + usage.has("output_tokens") || usage.has("completion_tokens") + if (!hasAny) return null + val cachedInputTokens = when { usage.has("cache_read_input_tokens") -> usage.optLong("cache_read_input_tokens", 0L) usage.optJSONObject("input_tokens_details") != null -> @@ -221,11 +230,17 @@ class ClaudeProvider( ) } + /** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ + private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + private suspend fun applyAnthropicUsage( usage: JSONObject?, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, source: String, - overwriteOutputTokens: Boolean + overwriteOutputTokens: Boolean, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1, + completeSnapshot: Boolean = false ): Boolean { val parsed = parseAnthropicUsage(usage) ?: return false @@ -248,6 +263,15 @@ class ClaudeProvider( parsed.cachedInputTokens, tokenCacheManager.outputTokenCount ) + onUsageReported?.invoke( + // 流式 start/delta 是部分更新(省略字段保留旧值);非流式最终响应是 + // 完整快照(null = 明确未知,覆盖旧值)——见 TokenStatRequestContext 合并 + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.anthropic( + usage, + completeSnapshot, + ) ?: return true, + attemptNumber + ) return true } @@ -1393,8 +1417,10 @@ class ClaudeProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -1569,7 +1595,10 @@ class ClaudeProvider( usage = json.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "non_streaming_json", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isBlank() && !usageApplied) { throw IOException(context.getString(R.string.provider_error_parsing_failed)) @@ -1597,7 +1626,10 @@ class ClaudeProvider( usage = json.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "non_streaming_response", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isNotBlank() && !usageApplied) { onTokensUpdated( @@ -1668,7 +1700,9 @@ class ClaudeProvider( usage = jsonResponse.optJSONObject("message")?.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "message_start", - overwriteOutputTokens = false + overwriteOutputTokens = false, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1 ) } "content_block_start" -> { @@ -1818,7 +1852,9 @@ class ClaudeProvider( usage = jsonResponse.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "message_delta", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, ) } "message_stop" -> { @@ -1878,7 +1914,10 @@ class ClaudeProvider( usage = wholeJson.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "buffered_json_fallback", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isNotBlank() && !usageApplied) { onTokensUpdated( @@ -1965,7 +2004,8 @@ class ClaudeProvider( R.string.openai_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.provider_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) @@ -1985,7 +2025,10 @@ class ClaudeProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { // 通过发送一条短消息来测试完整的连接、认证和API端点。 // 这比getModelsList更可靠,因为它直接命中了聊天API。 @@ -1997,6 +2040,7 @@ class ClaudeProvider( emptyList(), false, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) @@ -2006,6 +2050,9 @@ class ClaudeProvider( stream.collect { _ -> } Result.success(context.getString(R.string.openai_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { AppLogger.e("AIService", "连接测试失败", e) Result.failure(IOException(context.getString(R.string.openai_connection_test_failed, e.message ?: ""), e)) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index 35718f1c5..cab1037eb 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -469,10 +469,12 @@ class DeepseekProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { // 直接调用父类的sendMessage实现 - return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onNonFatalError, enableRetry) + return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onUsageReported, onNonFatalError, enableRetry, statsCategory) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index 4fff01c21..ab9fda9da 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -1069,8 +1069,10 @@ class GeminiProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -1174,10 +1176,10 @@ class GeminiProvider( // 根据stream参数处理响应 if (stream) { // 处理流式响应 - processStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent) + processStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent, onUsageReported, retryCount + 1) } else { // 处理非流式响应并转换为Stream - processNonStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent) + processNonStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent, onUsageReported, retryCount + 1) } } finally { response.close() @@ -1212,7 +1214,8 @@ class GeminiProvider( R.string.gemini_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.provider_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) @@ -1408,6 +1411,7 @@ class GeminiProvider( requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, receivedContent: StringBuilder + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { AppLogger.d(TAG, "开始处理响应流") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1450,7 +1454,7 @@ class GeminiProvider( val json = JSONObject(data) jsonCount++ - val content = extractContentFromJson(context, json, requestId, onTokensUpdated) + val content = extractContentFromJson(context, json, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("提取SSE内容,长度: ${content.length}") @@ -1515,7 +1519,9 @@ class GeminiProvider( context, jsonObject, requestId, - onTokensUpdated + onTokensUpdated, + onUsageReported, + attemptNumber ) if (content.isNotEmpty()) { contentCount++ @@ -1575,7 +1581,7 @@ class GeminiProvider( for (i in 0 until jsonContent.length()) { val jsonObject = jsonContent.optJSONObject(i) ?: continue jsonCount++ - val content = extractContentFromJson(context, jsonObject, requestId, onTokensUpdated) + val content = extractContentFromJson(context, jsonObject, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("从最终JSON数组[$i]提取内容,长度: ${content.length}") @@ -1586,7 +1592,7 @@ class GeminiProvider( } is JSONObject -> { jsonCount++ - val content = extractContentFromJson(context, jsonContent, requestId, onTokensUpdated) + val content = extractContentFromJson(context, jsonContent, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("从最终JSON对象提取内容,长度: ${content.length}") @@ -1630,6 +1636,7 @@ class GeminiProvider( requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, receivedContent: StringBuilder + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { AppLogger.d(TAG, "开始处理非流式响应") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1642,7 +1649,7 @@ class GeminiProvider( val json = JSONObject(responseText) // 提取内容 - val content = extractContentFromJson(context, json, requestId, onTokensUpdated) + val content = extractContentFromJson(context, json, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { receivedContent.append(content) @@ -1676,6 +1683,7 @@ class GeminiProvider( json: JSONObject, requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ): String { val contentBuilder = StringBuilder() val searchSourcesBuilder = StringBuilder() @@ -1906,9 +1914,15 @@ class GeminiProvider( val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) val hasServerUsage = - promptTokenCount > 0 || cachedContentTokenCount > 0 || candidatesTokenCount > 0 + usageMetadata.has("promptTokenCount") || + usageMetadata.has("cachedContentTokenCount") || + usageMetadata.has("candidatesTokenCount") if (hasServerUsage) { // 更新实际的token计数 + val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0).saturateToInt() + val cachedContentTokenCount = + usageMetadata.optLong("cachedContentTokenCount", 0).saturateToInt() + val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0).saturateToInt() val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) tokenCacheManager.setOutputTokens(candidatesTokenCount) @@ -1921,6 +1935,9 @@ class GeminiProvider( tokenCacheManager.cachedInputTokenCount, tokenCacheManager.outputTokenCount ) + onUsageReported?.let { callback -> + ProviderUsageNormalizer.gemini(usageMetadata)?.let { callback(it, attemptNumber) } + } } } @@ -1950,7 +1967,10 @@ class GeminiProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { // 通过发送一条短消息来测试完整的连接、认证和API端点。 // 这比getModelsList更可靠,因为它直接命中了聊天API。 @@ -1964,6 +1984,7 @@ class GeminiProvider( false, null, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) @@ -1978,9 +1999,15 @@ class GeminiProvider( // 某些情况下,即使连接成功,也可能不会返回任何数据(例如,如果模型只处理了提示而没有生成响应)。 // 因此,只要不抛出异常,我们就认为连接成功。 Result.success(context.getString(R.string.gemini_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { logError("连接测试失败", e) Result.failure(IOException(context.getString(R.string.gemini_connection_test_failed, e.message ?: ""), e)) } } } + +/** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ +private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt index 5e4f2a829..8cce902c2 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt @@ -426,8 +426,10 @@ open class KimiProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { return super.sendMessage( context, @@ -438,8 +440,10 @@ open class KimiProvider( availableTools, preserveThinkInHistory, onTokensUpdated, + onUsageReported, onNonFatalError, - enableRetry + enableRetry, + statsCategory ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt index 23980e2c6..f24151ae3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File +import java.io.IOException class LlamaProvider( private val context: Context, @@ -113,7 +114,10 @@ class LlamaProvider( return ModelListFetcher.getLlamaLocalModels(context) } - override suspend fun testConnection(context: Context): Result = withContext(Dispatchers.IO) { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result = withContext(Dispatchers.IO) { if (!LlamaSession.isAvailable()) { return@withContext Result.failure(Exception(LlamaSession.getUnavailableReason())) } @@ -170,20 +174,23 @@ class LlamaProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = stream { isCancelled = false if (!LlamaSession.isAvailable()) { emit("${context.getString(R.string.llama_error_prefix)}: ${LlamaSession.getUnavailableReason()}") - return@stream + // 致命错误:保留用户可见错误文本后以失败终止(统计边界记为 FAILED) + throw IOException("${context.getString(R.string.llama_error_prefix)}: ${LlamaSession.getUnavailableReason()}") } val modelFile = getModelFile(context, modelName) if (!modelFile.exists()) { emit("${context.getString(R.string.llama_error_prefix)}: ${context.getString(R.string.llama_error_model_file_not_exist, modelFile.absolutePath)}") - return@stream + throw IOException("${context.getString(R.string.llama_error_prefix)}: ${context.getString(R.string.llama_error_model_file_not_exist, modelFile.absolutePath)}") } val s = withContext(Dispatchers.IO) { @@ -191,7 +198,7 @@ class LlamaProvider( } if (s == null) { emit(context.getString(R.string.llama_error_session_create_failed)) - return@stream + throw IOException(context.getString(R.string.llama_error_session_create_failed)) } val effectiveEnableToolCall = shouldUseToolCall(availableTools) @@ -218,7 +225,7 @@ class LlamaProvider( } if (prompt.isNullOrBlank()) { emit(context.getString(R.string.llama_error_chat_template_failed)) - return@stream + throw IOException(context.getString(R.string.llama_error_chat_template_failed)) } logLargeString("Final prompt before llama generation: ", prompt) @@ -315,27 +322,41 @@ class LlamaProvider( } } - if (effectiveEnableToolCall) { - val normalizedPayload = withContext(Dispatchers.IO) { + // 结束顺序即契约(评审 P2-3):取消优先判定——先上报已实测 usage 再抛 + // 取消,绝不转换/emit 不完整的工具 XML;未取消才处理工具缓冲 + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + source = com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_LLAMA, + cancelMessage = context.getString(R.string.llama_error_request_cancelled), + onUsageReported = onUsageReported, + emitToolResult = { + if (effectiveEnableToolCall) { + val normalizedPayload = withContext(Dispatchers.IO) { + kotlin.runCatching { + s.parseToolCallResponse(toolCallOutputBuffer.toString()) + }.getOrNull() + } + val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( + normalizedPayload ?: toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } + } + }, + failWith = { + // 推理失败:保留用户可见错误文本后以失败终止(统计边界记为 FAILED) kotlin.runCatching { - s.parseToolCallResponse(toolCallOutputBuffer.toString()) - }.getOrNull() - } - val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( - normalizedPayload ?: toolCallOutputBuffer.toString() - ) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) - } - } - - if (!success && !isCancelled) { - kotlin.runCatching { - onNonFatalError(context.getString(R.string.llama_error_inference_failed)) - } - emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") - } + onNonFatalError(context.getString(R.string.llama_error_inference_failed)) + } + emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") + throw IOException(context.getString(R.string.llama_error_inference_failed)) + }, + ) AppLogger.i(TAG, "llama.cpp推理完成,输出token数: $_outputTokenCount") logFinalOutput(finalOutputBuffer, "Final llama.cpp output summary: ") diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt new file mode 100644 index 000000000..e704fff3c --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt @@ -0,0 +1,71 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.util.exceptions.UserCancellationException + +/** + * 本地 provider(Llama/MNN)生成结束的统一顺序契约(评审 P2-3 修复)。 + * + * 顺序即契约,供两个 provider 共用并单独测试: + * 1. **取消优先**:native 生成返回后,先判定 [cancelled]——取消时先上报已实测的 + * usage,再抛 [UserCancellationException],**绝不**转换/emit 不完整的工具 XML; + * 2. 未取消才由 [emitToolResult] 处理工具缓冲(解析 + emit); + * 3. 成功路径上报 usage;失败路径([success] = false)由 [failWith] 处理 + * (保留用户可见错误文本并以失败异常终止),失败同样先上报 usage。 + * + * 背景:旧实现先转换/emit 工具缓冲再检查 isCancelled,取消时会向调用方发出 + * 半截工具 XML,下游可能按完整工具调用执行导致错误落账。 + */ +internal object LocalGenerationEnd { + + /** + * @param cancelled 用户是否已取消(cancelStreaming 触发 native 停止)。 + * @param success native 生成是否正常结束(false = 失败或取消)。 + * @param inputTokens 已实测输入 token 数(tokenizer 计数)。 + * @param outputTokens 已生成输出 token 数(逐 token 实测)。 + * @param source 来源标签(SOURCE_LLAMA / SOURCE_MNN)。 + * @param cancelMessage 取消异常的用户可见消息。 + * @param onUsageReported usage 上报回调(统计账本通道)。 + * @param emitToolResult 未取消时的工具缓冲处理(解析/转换/emit)。 + * @param failWith 失败时的终止动作(错误文本 + 抛 IOException 等)。 + */ + suspend fun end( + cancelled: Boolean, + success: Boolean, + inputTokens: Int, + outputTokens: Int, + source: String, + cancelMessage: String, + onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + emitToolResult: suspend () -> Unit, + failWith: suspend () -> Unit, + ) { + if (cancelled) { + // 取消优先:先保留已实测 usage,再以取消异常结束——不 emit 工具缓冲 + reportUsage(inputTokens, outputTokens, source, onUsageReported) + throw UserCancellationException(cancelMessage) + } + emitToolResult() + reportUsage(inputTokens, outputTokens, source, onUsageReported) + if (!success) { + failWith() + } + } + + private suspend fun reportUsage( + inputTokens: Int, + outputTokens: Int, + source: String, + onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + ) { + onUsageReported?.invoke( + ProviderUsageNormalizer.local( + uncachedInputTokens = inputTokens, + outputTokens = outputTokens, + source = source, + ), + 1, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt index deb30cee1..167dadecb 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt @@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject @@ -601,8 +603,10 @@ class MNNProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = stream { isCancelled = false @@ -612,14 +616,12 @@ class MNNProvider( // 初始化模型 val initResult = initModel() if (initResult.isFailure) { - emit(context.getString(R.string.mnn_generic_error, initResult.exceptionOrNull()?.message ?: "")) - return@stream + // 致命错误:抛出让统计边界记为 FAILED;用户可见错误文本由下方 + // catch 统一 emit(含具体原因),避免重复格式化 + throw IOException(initResult.exceptionOrNull()?.message ?: "") } - val session = llmSession ?: run { - emit(context.getString(R.string.mnn_session_not_initialized)) - return@stream - } + val session = llmSession ?: throw IOException(context.getString(R.string.mnn_session_not_initialized)) // 应用模型参数(采样参数) applyModelParameters(session, modelParameters) @@ -700,24 +702,46 @@ class MNNProvider( } } - if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { - val converted = StructuredToolCallBridge.convertToolCallPayloadToXml(toolCallOutputBuffer.toString()) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) - } - } - - if (!success && !isCancelled) { - emit(context.getString(R.string.mnn_reasoning_error)) - } + // 结束顺序即契约(评审 P2-3):取消优先判定——先上报已实测 usage 再抛 + // 取消,绝不转换/emit 不完整的工具 XML;未取消才处理工具缓冲 + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + source = com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_MNN, + cancelMessage = context.getString(R.string.mnn_error_request_cancelled), + onUsageReported = onUsageReported, + emitToolResult = { + if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { + val converted = + StructuredToolCallBridge.convertToolCallPayloadToXml( + toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } + } + }, + failWith = { + // 推理失败:先上报已实测的 usage,再以失败终止(用户可见错误 + // 文本由下方 catch 统一 emit) + throw IOException(context.getString(R.string.mnn_reasoning_error)) + }, + ) AppLogger.i(TAG, "MNN LLM推理完成,输出token数: $_outputTokenCount") logFinalOutput(finalOutputBuffer, "Final MNN output summary: ") + } catch (e: CancellationException) { + // 取消原样传播,不 emit 错误文本 + throw e } catch (e: Exception) { AppLogger.e(TAG, "发送消息时出错", e) + // 致命错误:保留用户可见错误文本后继续上抛(统计边界记为 FAILED) emit(context.getString(R.string.mnn_generic_error, e.message ?: "")) + throw e } finally { requestTempFiles.forEach { file -> runCatching { file.delete() } @@ -725,7 +749,10 @@ class MNNProvider( } } - override suspend fun testConnection(context: Context): Result = withContext(Dispatchers.IO) { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result = withContext(Dispatchers.IO) { try { // 检查模型名称 if (modelName.isEmpty()) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt index 582f87637..280b17ea7 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt @@ -93,7 +93,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = "Hi")), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } @@ -134,7 +135,8 @@ object ModelConfigConnectionTester { parameters, stream = false, availableTools = availableTools, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } @@ -161,7 +163,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { ImagePoolManager.removeImage(imageId) @@ -189,7 +192,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { MediaPoolManager.removeMedia(audioId) @@ -217,7 +221,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { MediaPoolManager.removeMedia(videoId) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index d7369319d..749e22e2a 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -142,6 +142,7 @@ open class OpenAIProvider( private suspend fun applyUsageToCounters( usage: JSONObject?, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { val parsed = OpenAIResponsesPayloadAdapter.parseUsageCounts(usage) ?: return tokenCacheManager.updateActualTokens(parsed.actualInputTokens, parsed.cachedInputTokens) @@ -151,6 +152,14 @@ open class OpenAIProvider( parsed.cachedInputTokens, tokenCacheManager.outputTokenCount ) + onUsageReported?.invoke( + if (useResponsesApi) { + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.openAiResponses(usage) + } else { + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.openAiChatCompletions(usage) + } ?: return, + attemptNumber + ) } private fun buildOpenAiErrorDetail(error: JSONObject, fallback: String): String { @@ -232,7 +241,10 @@ open class OpenAIProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { val testHistory = listOf( @@ -246,12 +258,16 @@ open class OpenAIProvider( emptyList(), false, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) stream.collect { _ -> } Result.success(context.getString(R.string.openai_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { AppLogger.e("AIService", "连接测试失败", e) Result.failure(IOException(context.getString(R.string.openai_connection_test_failed, e.message ?: ""), e)) @@ -2008,6 +2024,7 @@ open class OpenAIProvider( state: StreamingState, emitter: StreamEmitter, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { val eventType = jsonResponse.optString("type", "") @@ -2176,7 +2193,7 @@ open class OpenAIProvider( } closeAllOpenToolCalls(state, emitter) - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) } "response.failed", "response.error" -> { @@ -2299,11 +2316,12 @@ open class OpenAIProvider( state: StreamingState, emitter: StreamEmitter, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { val usage = jsonResponse.optJSONObject("usage") val choices = jsonResponse.optJSONArray("choices") if (choices == null || choices.length() == 0) { - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) return } @@ -2358,17 +2376,18 @@ open class OpenAIProvider( } } - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) } /** - * 处理流式响应 + * 处理 OpenAI 流式响应 */ private suspend fun processStreamingResponse( reader: java.io.BufferedReader, emitter: StreamEmitter, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, context: Context + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, ) { val state = StreamingState() @@ -2406,7 +2425,7 @@ open class OpenAIProvider( throwIfOpenAiErrorPayload(context, jsonResponse) if (useResponsesApi) { - processResponsesStreamingEvent(context, jsonResponse, state, emitter, onTokensUpdated) + processResponsesStreamingEvent(context, jsonResponse, state, emitter, onTokensUpdated, onUsageReported, attemptNumber) continue } @@ -2416,7 +2435,7 @@ open class OpenAIProvider( continue } } - processResponseChunk(jsonResponse, state, emitter, onTokensUpdated) + processResponseChunk(jsonResponse, state, emitter, onTokensUpdated, onUsageReported, attemptNumber) } catch (e: IOException) { throw e } catch (e: Exception) { @@ -2464,8 +2483,10 @@ open class OpenAIProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -2588,7 +2609,9 @@ open class OpenAIProvider( reader, emitter, onTokensUpdated, - context + context, + onUsageReported, + attemptNumber ) } else { AppLogger.d("AIService", "[req=$requestTraceId] 【发送消息】开始读取非流式响应") @@ -2672,7 +2695,7 @@ open class OpenAIProvider( } } - applyUsageToCounters(jsonResponse.optJSONObject("usage"), onTokensUpdated) + applyUsageToCounters(jsonResponse.optJSONObject("usage"), onTokensUpdated, onUsageReported, attemptNumber) AppLogger.d("AIService", "[req=$requestTraceId] 【发送消息】非流式响应处理完成") } catch (e: IOException) { @@ -2732,7 +2755,8 @@ open class OpenAIProvider( R.string.openai_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.openai_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt index 45429edde..de589af10 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt @@ -189,10 +189,12 @@ class QwenAIProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { // 直接调用父类的sendMessage实现,它已经包含了续写逻辑和stream参数处理 - return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onNonFatalError, enableRetry) + return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onUsageReported, onNonFatalError, enableRetry, statsCategory) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt index 919acde4e..13980e3bd 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt @@ -22,8 +22,10 @@ class RateLimitedAIService( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = com.ai.assistance.operit.util.stream.stream { rateLimiter?.acquire() concurrencySemaphore?.acquire() @@ -38,8 +40,10 @@ class RateLimitedAIService( availableTools = availableTools, preserveThinkInHistory = preserveThinkInHistory, onTokensUpdated = onTokensUpdated, + onUsageReported = onUsageReported, onNonFatalError = onNonFatalError, - enableRetry = enableRetry + enableRetry = enableRetry, + statsCategory = statsCategory ).collect { chunk -> emit(chunk) } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt new file mode 100644 index 000000000..f0ed5e791 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -0,0 +1,453 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.data.model.ModelOption +import com.ai.assistance.operit.data.model.ModelParameter +import com.ai.assistance.operit.data.model.ToolPrompt +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import com.ai.assistance.operit.data.stats.TokenStatRequestContext +import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsLedger +import com.ai.assistance.operit.util.AppLogger +import com.ai.assistance.operit.util.stream.RevisableTextStream +import com.ai.assistance.operit.util.stream.SharedStream +import com.ai.assistance.operit.util.stream.Stream +import com.ai.assistance.operit.util.stream.StreamCollector +import com.ai.assistance.operit.util.stream.TextStreamEvent +import com.ai.assistance.operit.util.stream.TextStreamEventCarrier +import com.ai.assistance.operit.util.stream.TimeoutException +import java.io.InterruptedIOException +import java.net.SocketTimeoutException +import java.util.UUID +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException as JavaTimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking + +/** A successful model call cannot hide loss of its accepted usage event. */ +class TokenStatsPersistenceException(message: String, cause: Throwable? = null) : + java.io.IOException(message, cause) + +/** + * 统一 usage 记录边界(阶段 2):包装任意 [AIService],在逻辑请求级别记录统计事件, + * 覆盖普通响应、流式响应、正常结束、用户取消、超时与失败。 + * + * 边界语义(稳定且可解释): + * - 一次 sendMessage 调用 = 一个事件。provider 内部重试不产生独立事件;不同 attempt + * 的 usage 按分量累加(同一 attempt 重复上报按“最新非空字段”合并),最终只落一个 + * 事件,状态为最终结果。eventId 在请求开始时生成一次,配合 DAO 的 IGNORE 插入 + * 幂等防重。 + * - 首个真实内容 chunk(非空,含仅空白 chunk)到达时设置首 token 时间; + * 空字符串 chunk 不记录;无内容响应保持 null。 + * - 取消([CancellationException])不吞掉:先落 CANCELLED 事件再原样重抛。 + * - 超时按异常类型识别:coroutine [TimeoutCancellationException] / + * [TimeoutException] / [SocketTimeoutException] / java timeout → TIMEOUT; + * [InterruptedIOException] 只有消息明确含 "timeout"(OkHttp 整调用超时)才算, + * 线程中断等普通中断不算。明确的非超时取消(用户取消/协程取消)优先于 cause + * 链中的 timeout 信号,避免 UserCancellationException(cause=InterruptedIOException) + * 被误判为 TIMEOUT。 + * - 请求收尾同步解析价格并 fsync 完整事件到 TokenStatSpool,后台 writer 只做 Room + * insert。价格读取失败写 UNKNOWN;磁盘 append 失败会使成功调用明确失败。模型本身 + * 已失败/取消时,持久化故障作为 suppressed 异常保留,不覆盖原异常。 + * - 调用者 usage observer(外部回调)与 provider 业务隔离:非取消异常只记录日志, + * 不改变账本/请求结果;取消遵循协程取消语义向上传播。 + */ +class TokenTrackingAIService( + private val delegate: AIService, + private val context: Context, + private val configId: String, +) : AIService { + + private val appContext: Context = context.applicationContext + + override val inputTokenCount: Int get() = delegate.inputTokenCount + override val cachedInputTokenCount: Int get() = delegate.cachedInputTokenCount + override val outputTokenCount: Int get() = delegate.outputTokenCount + override val providerModel: String get() = delegate.providerModel + + override fun resetTokenCounts() = delegate.resetTokenCounts() + override fun cancelStreaming() = delegate.cancelStreaming() + override suspend fun getModelsList(context: Context): Result> = + delegate.getModelsList(context) + override suspend fun calculateInputTokens( + chatHistory: List, + availableTools: List?, + ): Int = delegate.calculateInputTokens(chatHistory, availableTools) + + override fun release() = delegate.release() + + override suspend fun sendMessage( + context: Context, + chatHistory: List, + modelParameters: List>, + enableThinking: Boolean, + stream: Boolean, + availableTools: List?, + preserveThinkInHistory: Boolean, + onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, + onNonFatalError: suspend (error: String) -> Unit, + enableRetry: Boolean, + statsCategory: TokenStatCategory?, + ): Stream { + val request = newRequest(statsCategory) + val delegateStream = + delegate.sendMessage( + context = context, + chatHistory = chatHistory, + modelParameters = modelParameters, + enableThinking = enableThinking, + stream = stream, + availableTools = availableTools, + preserveThinkInHistory = preserveThinkInHistory, + onTokensUpdated = onTokensUpdated, + // 组合内部记录与调用者 callback:内部按 attempt 记账,调用者回调 + // 原样转发(每次上报都转发,不吞不重);外部 observer 的异常与 + // provider 业务隔离(非取消只日志,取消仍传播)。 + onUsageReported = { usage, attempt -> + request.onUsage(usage, attempt) + forwardUsageObserver(onUsageReported, usage, attempt) + }, + onNonFatalError = onNonFatalError, + enableRetry = enableRetry, + statsCategory = statsCategory, + ) + return wrapStream(delegateStream, request) + } + + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, + ): Result { + val request = newRequest(TokenStatCategory.CONNECTION_TEST) + return try { + val result = + delegate.testConnection(context) { usage, attempt -> + request.onUsage(usage, attempt) + forwardUsageObserver(onUsageReported, usage, attempt) + } + // 失败 Result 也按统一 cause 分类(timeout/取消语义不丢), + // 与抛出的异常走同一 classify。 + request.finish( + result.exceptionOrNull()?.let { classify(it) } ?: TokenStatStatus.COMPLETED + ) + val persistenceFailure = persistAndCapture(appContext, request, result.exceptionOrNull()) + when { + result.isFailure -> result + persistenceFailure != null -> Result.failure(persistenceFailure) + else -> result + } + } catch (e: CancellationException) { + request.finish(TokenStatStatus.CANCELLED) + persistAndCapture(appContext, request, e) + throw e + } catch (e: Exception) { + request.finish(classify(e)) + persistAndCapture(appContext, request, e) + Result.failure(e) + } + } + + /** + * 转发外部 usage observer 并隔离异常:调用者 callback 的非取消异常只记录日志, + * 不进入 provider 解析/重试控制流,也不改变账本与请求结果;取消(调用者协程 + * 取消)原样向上传播,遵循协程取消语义。 + */ + private suspend fun forwardUsageObserver( + observer: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + usage: ProviderUsageSnapshot, + attempt: Int, + ) { + val callback = observer ?: return + try { + callback(usage, attempt) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "调用者 usage observer 异常,不影响请求与账本", e) + } + } + + private suspend fun newRequest(category: TokenStatCategory?): TokenStatRequestContext { + // P1 终审:restore 替换开始后本进程不再接受新的统计请求(直到进程重启,UI 允许 + // 稍后重启)——在此明确拒绝开始新跟踪请求,绝不等到收尾才失败,也绝不写入已恢复 + // 替换的数据库。替换前失败的 restore 不置位该标志,新请求照常继续。 + if (!TokenStatSpool.isAcceptingEvents()) { + throw TokenStatsPersistenceException( + "Token statistics are not accepting new events until the app restarts after a restore", + ) + } + val (provider, model) = TokenStatIdentityResolver.splitProviderModel(delegate.providerModel) + val acceptedGeneration = TokenStatsLedger.currentResetGeneration(appContext) + return TokenStatRequestContext( + eventId = "evt_${UUID.randomUUID().toString().replace("-", "")}", + category = category ?: TokenStatCategory.OTHER, + configId = configId, + provider = provider, + model = model, + startedAtMs = System.currentTimeMillis(), + acceptedGeneration = acceptedGeneration, + // P1 终审:请求开始时同步捕获 restore epoch(纯内存、无 Room),收尾 append + // 时验证——restore 屏障开始即递增 epoch,旧请求被明确拒绝,不写新 DB。 + sessionEpoch = TokenStatSpool.captureRestoreEpoch(), + ) + } + + /** 保持修订流语义:内部流带 eventChannel 时返回同接口的包装流。 */ + private fun wrapStream( + delegateStream: Stream, + request: TokenStatRequestContext, + ): Stream { + return if (delegateStream is TextStreamEventCarrier) { + TrackingRevisableStream( + inner = delegateStream, + eventChannel = delegateStream.eventChannel, + request = request, + appContext = appContext, + ) + } else { + TrackingStream(inner = delegateStream, request = request, appContext = appContext) + } + } + + private class TrackingStream( + private val inner: Stream, + private val request: TokenStatRequestContext, + private val appContext: Context, + ) : Stream { + override val isLocked: Boolean get() = inner.isLocked + override val bufferedCount: Int get() = inner.bufferedCount + override suspend fun lock() = inner.lock() + override suspend fun unlock() = inner.unlock() + override fun clearBuffer() = inner.clearBuffer() + + override suspend fun collect(collector: StreamCollector) { + var sawFirstToken = false + try { + inner.collect { value -> + // 仅空白 chunk 也是真实输出内容(首空格/换行 token), + // 只有空字符串 chunk 不记录首 token。 + if (!sawFirstToken && value.isNotEmpty()) { + sawFirstToken = true + request.onFirstToken() + } + collector.emit(value) + } + } catch (t: Throwable) { + request.finish(classify(t)) + persistAndCapture(appContext, request, t) + throw t + } + request.finish(TokenStatStatus.COMPLETED) + recordSafely(appContext, request) + } + } + + private class TrackingRevisableStream( + private val inner: Stream, + override val eventChannel: SharedStream, + private val request: TokenStatRequestContext, + private val appContext: Context, + ) : RevisableTextStream { + override val isLocked: Boolean get() = inner.isLocked + override val bufferedCount: Int get() = inner.bufferedCount + override suspend fun lock() = inner.lock() + override suspend fun unlock() = inner.unlock() + override fun clearBuffer() = inner.clearBuffer() + + override suspend fun collect(collector: StreamCollector) { + var sawFirstToken = false + try { + inner.collect { value -> + if (!sawFirstToken && value.isNotEmpty()) { + sawFirstToken = true + request.onFirstToken() + } + collector.emit(value) + } + } catch (t: Throwable) { + request.finish(classify(t)) + persistAndCapture(appContext, request, t) + throw t + } + request.finish(TokenStatStatus.COMPLETED) + recordSafely(appContext, request) + } + } + + companion object { + private const val TAG = "TokenTrackingAIService" + + /** 单次统计落账的有界等待时长;测试可缩短以验证超时只日志不阻塞业务。 */ + internal var recordTimeoutMs: Long = 5_000L + + /** + * 单次落账结果(评审 P1-1/P1-4:进程死亡边界必须向调用方暴露统计失败 + * 状态,不能伪装已记录): + * [DURABLE] means the complete event has been fsynced. Non-durable outcomes throw. + */ + internal enum class RecordOutcome { + DURABLE, + } + + /** + * 可靠、独立、持久落账(companion 版本,供嵌套流类使用): + * - 在请求收尾边界有界解析并冻结价格,随后同步 fsync 完整事件; + * - 价格超时/失败形成 UNKNOWN 事件,append 失败抛明确持久化故障; + * - [recordTimeoutMs] 只等待可选的 Room 可见性,不参与 durable 判定; + * - 进程重启后由 OperitApplication 主动 [com.ai.assistance.operit.data.stats.TokenStatSpool.replay] + * 重放(幂等 eventId IGNORE)。 + */ + internal suspend fun recordSafely( + appContext: Context, + request: TokenStatRequestContext, + ): RecordOutcome { + return withContext(NonCancellable) { + val baseJson = request.toSpoolBaseJson() + val line = + try { + prepareLineBounded(appContext, request) + } catch (e: JavaTimeoutException) { + TokenStatsLedger.prepareUnresolvedEventLine( + request, + baseJson, + "pricing_read_timeout", + ) + } catch (e: Exception) { + TokenStatsLedger.prepareUnresolvedEventLine( + request, + baseJson, + "pricing_read_failed:${e.javaClass.simpleName}", + ) + } + if (!TokenStatSpool.append(appContext, line, request.eventId, request.sessionEpoch)) { + throw TokenStatsPersistenceException( + "Token statistics could not be durably persisted for ${request.eventId}", + ) + } + TokenStatSpool.awaitRoomVisibility(request.eventId, recordTimeoutMs) + RecordOutcome.DURABLE + } + } + + /** + * Bounded pricing worker (P2-1): one daemon thread plus one queue slot. A wedged price + * resolution cannot spawn unbounded threads; queue saturation immediately reports UNKNOWN + * instead of starting more work. + */ + private var pricingExecutor: ThreadPoolExecutor = newPricingExecutor() + + private fun newPricingExecutor() = + ThreadPoolExecutor( + 1, + 1, + 60L, + TimeUnit.SECONDS, + LinkedBlockingQueue(1), + ) { runnable -> Thread(runnable, "operit-token-stats-price").apply { isDaemon = true } } + + /** Discard a wedged pricing worker (interrupt-ignoring resolution) between tests. */ + internal fun resetPricingExecutorForTest() { + pricingExecutor.shutdownNow() + pricingExecutor = newPricingExecutor() + } + + /** + * Pricing resolution with a genuinely bounded lifecycle. The worker builds its own line + * from a fresh base JSON via [com.ai.assistance.operit.data.stats.TokenStatsLedger.prepareEventLineDetached] + * and never mutates [request] or any shared serialization object, so a timed-out task can + * never race the caller's UNKNOWN fallback on the same objects (P2-1). + */ + private fun prepareLineBounded( + appContext: Context, + request: TokenStatRequestContext, + ): String { + val task = FutureTask { + runBlocking { TokenStatsLedger.prepareEventLineDetached(appContext, request) } + } + try { + pricingExecutor.execute(task) + } catch (e: RejectedExecutionException) { + // Saturation (a previous resolution still wedged): report UNKNOWN immediately, + // never start additional threads or queue unbounded work. + throw JavaTimeoutException("pricing executor saturated") + } + return try { + val result = task.get(TokenStatSpool.prepareTimeoutMs, TimeUnit.MILLISECONDS) + // Apply the frozen snapshot on the caller thread only after success; the worker + // never touches request, so these assignments cannot race a discarded task. + request.frozenPricing = result.frozenPricing + request.pricingResolutionDiagnostic = result.diagnostic + result.line + } catch (e: JavaTimeoutException) { + task.cancel(true) + throw e + } catch (e: ExecutionException) { + throw (e.cause ?: e) + } + } + + /** Keep the model error primary; persistence failure remains observable as suppressed. */ + private suspend fun persistAndCapture( + appContext: Context, + request: TokenStatRequestContext, + original: Throwable?, + ): TokenStatsPersistenceException? = + try { + recordSafely(appContext, request) + null + } catch (e: TokenStatsPersistenceException) { + original?.addSuppressed(e) + e + } + + /** + * 结束状态分类。明确的非超时取消(用户取消/协程取消)优先:其 cause 链里 + * 可能带 InterruptedIOException(如 OkHttp 中断),不能误判为超时;只有 + * [TimeoutCancellationException] 才是超时。随后沿 cause chain 识别 provider + * 重试耗尽时把超时包装成 IOException 的情况;[InterruptedIOException] + * 只有消息明确含 "timeout"(OkHttp 整调用超时)才算超时,线程中断等不算。 + */ + internal fun classify(t: Throwable): TokenStatStatus = when { + isExplicitCancellation(t) -> TokenStatStatus.CANCELLED + isTimeout(t) -> TokenStatStatus.TIMEOUT + else -> TokenStatStatus.FAILED + } + + private fun isExplicitCancellation(t: Throwable): Boolean = + t is CancellationException && t !is TimeoutCancellationException + + private fun isTimeout(t: Throwable): Boolean { + var current: Throwable? = t + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + when { + current is TimeoutCancellationException || + current is TimeoutException || + current is java.util.concurrent.TimeoutException || + current is SocketTimeoutException || + (current is InterruptedIOException && + current.message?.contains("timeout", ignoreCase = true) == true) + -> return true + } + current = current.cause + depth++ + } + return false + } + + private const val MAX_CAUSE_DEPTH = 8 + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 0fa121546..3654f93e3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -28,7 +28,7 @@ internal class ToolPkgJsAiProviderService( private val config: ModelConfigData, private val provider: ToolPkgAiProviderRegistration ) : AIService { - private sealed interface ProviderHookValue { + internal sealed interface ProviderHookValue { data object NullValue : ProviderHookValue data class TextValue( @@ -89,6 +89,13 @@ internal class ToolPkgJsAiProviderService( toolPkgPackageManager().cancelToolPkgExecutionsForChat(executionChatId) } + /** + * 测试缝:替换真实包管理器 hook 调用,使 sendMessage 的真实 hook 编排层 + * (intermediate channel、解码、usage 提取、chunk 发射、attempt 语义) + * 可在 JVM 测试中验证;生产为 null(走真实 [PackageManager])。 + */ + internal var mainHookRunnerOverride: ToolPkgMainHookRunner? = null + override suspend fun getModelsList(context: Context): Result> { return runCatching { val decoded = @@ -112,8 +119,10 @@ internal class ToolPkgJsAiProviderService( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = com.ai.assistance.operit.util.stream.stream { var hasIntermediateTextChunk = false val decoded = @@ -135,14 +144,11 @@ internal class ToolPkgJsAiProviderService( put("enableRetry", enableRetry) }, onIntermediateResult = { intermediateDecoded -> - extractUsage(intermediateDecoded)?.let { usage -> - applyUsage(usage) - onTokensUpdated( - currentInputTokenCount, - currentCachedInputTokenCount, - currentOutputTokenCount - ) - } + applyAndForwardUsage( + intermediateDecoded, + onTokensUpdated, + onUsageReported, + ) extractNonFatalError(intermediateDecoded)?.let { error -> onNonFatalError(error) } @@ -154,14 +160,7 @@ internal class ToolPkgJsAiProviderService( ) ensureNoFatalError(decoded) - extractUsage(decoded)?.let { usage -> - applyUsage(usage) - onTokensUpdated( - currentInputTokenCount, - currentCachedInputTokenCount, - currentOutputTokenCount - ) - } + applyAndForwardUsage(decoded, onTokensUpdated, onUsageReported) extractNonFatalError(decoded)?.let { error -> onNonFatalError(error) } @@ -172,15 +171,28 @@ internal class ToolPkgJsAiProviderService( } } - override suspend fun testConnection(context: Context): Result { - return runCatching { - val decoded = + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { + // 评审 P1-7:中间结果 + 最终结果与普通请求走同一 usage 提取/attempt 转发; + // 取消必须原样传播,不能被 runCatching 吞成 Result.failure + val decoded = + try { invokeProviderFunction( functionName = provider.testConnectionFunctionName, functionSource = provider.testConnectionFunctionSource, event = TOOLPKG_EVENT_AI_PROVIDER_TEST_CONNECTION, - eventPayload = buildBasePayload(context) + eventPayload = buildBasePayload(context), + onIntermediateResult = { intermediateDecoded -> + forwardUsage(intermediateDecoded, onUsageReported) + } ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } + forwardUsage(decoded, onUsageReported) + return runCatching { ensureNoFatalError(decoded) parseConnectionMessage(decoded) } @@ -219,7 +231,6 @@ internal class ToolPkgJsAiProviderService( eventPayload: JSONObject, onIntermediateResult: (suspend (ProviderHookValue) -> Unit)? = null ): ProviderHookValue = coroutineScope { - val manager = toolPkgPackageManager() val intermediateChannel = if (onIntermediateResult == null) { null @@ -240,26 +251,50 @@ internal class ToolPkgJsAiProviderService( try { val result = withContext(Dispatchers.IO) { - manager.runToolPkgMainHook( - containerPackageName = provider.containerPackageName, - functionName = functionName, - event = event, - pluginId = "${provider.providerId}:$event", - inlineFunctionSource = functionSource, - eventPayload = - jsonObjectToMap( - JSONObject(eventPayload.toString()).put("chatId", executionChatId) - ), - executionContextKey = providerRuntimeContextKey, - runtimeKind = "provider", - dispatchIntermediateOnMain = false, - onIntermediateResult = - intermediateChannel?.let { channel -> - { raw -> - channel.trySend(raw) + val override = mainHookRunnerOverride + if (override != null) { + override.run( + containerPackageName = provider.containerPackageName, + functionName = functionName, + event = event, + pluginId = "${provider.providerId}:$event", + inlineFunctionSource = functionSource, + eventPayload = + jsonObjectToMap( + JSONObject(eventPayload.toString()).put("chatId", executionChatId) + ), + executionContextKey = providerRuntimeContextKey, + runtimeKind = "provider", + onIntermediateResult = + intermediateChannel?.let { channel -> + { raw -> + channel.trySend(raw) + } } - } - ) + ) + } else { + val manager = toolPkgPackageManager() + manager.runToolPkgMainHook( + containerPackageName = provider.containerPackageName, + functionName = functionName, + event = event, + pluginId = "${provider.providerId}:$event", + inlineFunctionSource = functionSource, + eventPayload = + jsonObjectToMap( + JSONObject(eventPayload.toString()).put("chatId", executionChatId) + ), + executionContextKey = providerRuntimeContextKey, + runtimeKind = "provider", + dispatchIntermediateOnMain = false, + onIntermediateResult = + intermediateChannel?.let { channel -> + { raw -> + channel.trySend(raw) + } + } + ) + } } decodeProviderHookValue( result.getOrElse { error -> throw error }?.let { raw -> decodeToolPkgHookResult(raw) } @@ -467,6 +502,26 @@ internal class ToolPkgJsAiProviderService( return null } + /** + * 账本路径的 Long 读取(评审 P2-1):全程 Long,绝不 Int 截断/回绕; + * 负值拒绝为未知(null)。 + */ + private fun JSONObject.optTokenCountLong(vararg keys: String): Long? { + for (key in keys) { + if (!has(key) || isNull(key)) continue + val parsed = when (val raw = opt(key)) { + is Number -> raw.toLong() + is String -> raw.trim().toBigDecimalOrNull()?.toLong() + else -> null + } + if (parsed != null) return parsed.takeIf { it >= 0 } + } + return null + } + + /** 旧 UI 计数边界(评审 P2-1):Long 饱和为 Int,绝不回绕为负。 */ + private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + private fun ensureNoFatalError(decoded: ProviderHookValue) { when (decoded) { is ProviderHookValue.ObjectValue -> { @@ -494,7 +549,19 @@ internal class ToolPkgJsAiProviderService( } } - private fun extractUsage(decoded: ProviderHookValue): TokenUsage? { + /** + * 提取 usage。usage 协议(评审 P1-6/P2-1,**不猜测 attempt、不继承全局计数**): + * - **新协议**:usage 对象(或顶层)携带 `attempt` / `attemptNumber` + * (provider 内部第几次尝试,从 1 开始)。同 attempt 的多次上报是流式 + * 部分更新(省略字段保留旧值);不同 attempt 分别记账,聚合时累加。 + * - **旧协议**:不携带 attempt 字段。语义为**整个逻辑请求的累计快照** + * (跨内部重试累计的最终数字),固定按 attempt 1 完整快照记账(后报覆盖 + * 先报,绝不把多个无 attempt 上报误累加)。内部按 attempt 逐次上报的 + * 插件必须迁移到新协议。 + * - 账本字段可空:缺省字段 = 未知,**绝不**用全局 current 计数填充(避免 + * 跨 attempt 继承造成虚假累计);负值拒绝为未知。 + */ + internal fun extractUsage(decoded: ProviderHookValue): TokenUsage? { return when (decoded) { is ProviderHookValue.ObjectValue -> extractUsageFromJson(decoded.value) else -> null @@ -504,19 +571,65 @@ internal class ToolPkgJsAiProviderService( private fun extractUsageFromJson(json: JSONObject): TokenUsage? { val usageObject = json.optJSONObject("usage") val source = usageObject ?: json - val input = source.optTokenCount("input", "inputTokens") - val cachedInput = source.optTokenCount("cachedInput", "cachedInputTokens") - val output = source.optTokenCount("output", "outputTokens") + val input = source.optTokenCountLong("input", "inputTokens") + val cachedInput = source.optTokenCountLong("cachedInput", "cachedInputTokens") + val output = source.optTokenCountLong("output", "outputTokens") if (input == null && cachedInput == null && output == null) { return null } + val attemptPresent = source.has("attempt") || source.has("attemptNumber") + val attempt = + source.optTokenCountLong("attempt", "attemptNumber")?.coerceAtLeast(1)?.toInt() ?: 1 return TokenUsage( - input = input ?: currentInputTokenCount, - cachedInput = cachedInput ?: currentCachedInputTokenCount, - output = output ?: currentOutputTokenCount + input = input, + cachedInput = cachedInput, + output = output, + attempt = attempt, + attemptPresent = attemptPresent, ) } + /** + * sendMessage 通道:提取 → 更新 UI 累计计数 → 转发规范化 usage。 + * UI 计数器与账本快照分离(评审 P1-6):缺省字段只保留 UI 侧全局累计值, + * 账本快照保持未知(由 [TokenStatRequestContext] 按 attempt 合并)。 + */ + private suspend fun applyAndForwardUsage( + decoded: ProviderHookValue, + onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, + ) { + extractUsage(decoded)?.let { usage -> + applyUsage(usage) + onTokensUpdated( + currentInputTokenCount, + currentCachedInputTokenCount, + currentOutputTokenCount + ) + forwardUsage(decoded, onUsageReported) + } + } + + /** 只转发规范化 usage(testConnection 等无 UI 计数通道的场景共用)。 */ + private suspend fun forwardUsage( + decoded: ProviderHookValue, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, + ) { + extractUsage(decoded)?.let { usage -> + onUsageReported?.invoke( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.toolPkg( + input = usage.input, + cachedInput = usage.cachedInput, + output = usage.output, + // 协议语义:attempt 在场 = 同 attempt 部分更新;缺省 = 整个 + // 逻辑请求的累计完整快照 + completeSnapshot = !usage.attemptPresent, + ), + usage.attempt + ) + } + } + private fun applyUsage(usage: TokenUsage) { currentInputTokenCount = usage.input.coerceAtLeast(0L) currentCachedInputTokenCount = usage.cachedInput.coerceAtLeast(0L) @@ -576,3 +689,22 @@ internal class ToolPkgJsAiProviderService( val output: Long ) } + +/** + * ToolPkg hook 调用抽象(测试缝):与 [PackageManager.runToolPkgMainHook] 相同的 + * 调用面。生产路径由 [ToolPkgJsAiProviderService.mainHookRunnerOverride] 为 null + * 时走真实包管理器;测试注入假 runner 驱动真实 hook 编排层。 + */ +internal fun interface ToolPkgMainHookRunner { + suspend fun run( + containerPackageName: String, + functionName: String, + event: String, + pluginId: String?, + inlineFunctionSource: String?, + eventPayload: Map, + executionContextKey: String?, + runtimeKind: String?, + onIntermediateResult: ((Any?) -> Unit)?, + ): Result +} diff --git a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt index 8da572077..1bc1c7e0e 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt @@ -46,6 +46,7 @@ import com.ai.assistance.operit.data.preferences.initUserPreferencesManager import com.ai.assistance.operit.data.preferences.preferencesManager import com.ai.assistance.operit.data.repository.CustomEmojiRepository import com.ai.assistance.operit.data.stats.TokenBaselineImportRunner +import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.ui.features.chat.webview.LocalWebServer import com.ai.assistance.operit.ui.features.chat.webview.workspace.editor.language.LanguageFactory import com.ai.assistance.operit.util.GlobalExceptionHandler @@ -294,9 +295,12 @@ class OperitApplication : Application(), ImageLoaderFactory, WorkConfiguration.P } // 旧 DataStore 累计统计 → baseline 幂等导入(一次性;已存在 baseline 冻结)。 + // 最后主动重放统计 spool(P1-2):生产冷启动不依赖新请求,失败段由退避 + // 定时重试持续恢复。 applicationScope.launch { val statsStartTime = System.currentTimeMillis() TokenBaselineImportRunner.ensureMigrated(applicationContext) + TokenStatSpool.replay(applicationContext) AppLogger.d( TAG, "【启动计时】旧累计统计 baseline 导入完成(异步) - " + diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt deleted file mode 100644 index 962f79b3a..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ /dev/null @@ -1,653 +0,0 @@ -package com.ai.assistance.operit.data.backup - -import android.content.Context -import android.net.Uri -import android.os.Handler -import android.os.Looper -import android.util.AtomicFile -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.db.ObjectBoxManager -import com.ai.assistance.operit.util.AppLogger -import com.ai.assistance.operit.util.OperitPaths -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream -import kotlin.system.measureTimeMillis -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json - -object RawSnapshotBackupManager { - - private const val TAG = "RawSnapshotBackup" - private const val FORMAT_VERSION = 1 - - private const val ZIP_PREFIX = "operit_raw_snapshot_" - - private const val ENTRY_MANIFEST = "manifest.json" - private const val ENTRY_PAYLOAD_PREFIX = "payload/" - - private const val ENTRY_FILES = "payload/files/" - private const val ENTRY_EXTERNAL_FILES = "payload/external_files/" - private const val ENTRY_SHARED_PREFS = "payload/shared_prefs/" - private const val ENTRY_DATASTORE = "payload/datastore/" - private const val ENTRY_DATABASES = "payload/databases/" - - private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") - - private val mutex = Mutex() - private val mainHandler = Handler(Looper.getMainLooper()) - - @Serializable - data class Manifest( - val formatVersion: Int, - val packageName: String, - val createdAt: Long, - val includes: List, - val includeTerminalData: Boolean = true - ) - - data class SnapshotOptions( - val includeTerminalData: Boolean = false - ) - - enum class ExportProgress { - PREPARING, - SCANNING_FILES, - ZIPPING_FILES, - ZIPPING_EXTERNAL_FILES, - ZIPPING_SHARED_PREFS, - ZIPPING_DATASTORE, - ZIPPING_DATABASES, - FINALIZING - } - - data class ExportProgressInfo( - val stage: ExportProgress, - val percent: Int? = null, - val scannedFiles: Int? = null - ) - - enum class RestoreProgress { - PREPARING, - READING_ZIP, - EXTRACTING, - REPLACING_FILES, - REPLACING_EXTERNAL_FILES, - REPLACING_SHARED_PREFS, - REPLACING_DATASTORE, - REPLACING_DATABASES, - FINALIZING - } - - private val json = Json { - prettyPrint = true - encodeDefaults = true - ignoreUnknownKeys = true - isLenient = true - } - - suspend fun exportToBackupDir( - context: Context, - options: SnapshotOptions = SnapshotOptions(), - onProgress: ((ExportProgressInfo) -> Unit)? = null - ): File = withContext(Dispatchers.IO) { - mutex.withLock { - AppLogger.i(TAG, "export start (includeTerminalData=${options.includeTerminalData})") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.PREPARING)) } - val exportDir = OperitBackupDirs.rawSnapshotDir() - val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) - val outFile = File(exportDir, "$ZIP_PREFIX$timestamp.zip") - val tmpFile = File(exportDir, "${outFile.name}.tmp") - - if (tmpFile.exists()) { - tmpFile.delete() - } - - val dataDir = context.dataDir - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" - } - val sharedPrefsDir = File(dataDir, "shared_prefs") - val datastoreDir = File(dataDir, "datastore") - val databasesDir = File(dataDir, "databases") - - try { - val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - - val includes = listOf( - ENTRY_FILES, - ENTRY_EXTERNAL_FILES, - ENTRY_SHARED_PREFS, - ENTRY_DATASTORE, - ENTRY_DATABASES - ) - val manifest = Manifest( - formatVersion = FORMAT_VERSION, - packageName = context.packageName, - createdAt = System.currentTimeMillis(), - includes = includes, - includeTerminalData = options.includeTerminalData - ) - - ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> - zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) - zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) - zos.closeEntry() - - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val excludedNames = if (options.includeTerminalData) { - alwaysExcluded - } else { - alwaysExcluded + terminalTopLevelDirNames - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) - } - val filesTotalCount = totalFilesForZip( - dir = context.filesDir, - entryPrefix = ENTRY_FILES, - excludedTopLevelDirNames = excludedNames, - onScannedCountChanged = { scanned -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) - ) - } - } - } - ) - withContext(Dispatchers.Main) { - onProgress?.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) - ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } - val filesMs = measureTimeMillis { - addDirToZip( - zos = zos, - dir = context.filesDir, - entryPrefix = ENTRY_FILES, - excludedTopLevelDirNames = excludedNames, - totalFiles = filesTotalCount, - onPercentChanged = { percent -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) - } - } - } - ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } - AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") - - val externalFilesTotalCount = totalFilesForZip( - dir = externalFilesDir, - entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet() - ) - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) - } - val externalFilesMs = measureTimeMillis { - addDirToZip( - zos = zos, - dir = externalFilesDir, - entryPrefix = ENTRY_EXTERNAL_FILES, - totalFiles = externalFilesTotalCount, - onPercentChanged = { percent -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke( - ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) - ) - } - } - } - ) - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) - } - AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") - - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } - val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } - AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") - - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } - val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } - AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") - - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } - val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } - AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") - } - - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.FINALIZING)) } - if (outFile.exists()) { - outFile.delete() - } - - if (!tmpFile.renameTo(outFile)) { - tmpFile.copyTo(outFile, overwrite = true) - tmpFile.delete() - } - - AppLogger.i(TAG, "export done: ${outFile.absolutePath} (${outFile.length()} bytes)") - outFile - } - } - - suspend fun restoreFromBackupUri( - context: Context, - uri: Uri, - onProgress: ((RestoreProgress) -> Unit)? = null - ) = withContext(Dispatchers.IO) { - mutex.withLock { - val cacheZip = File.createTempFile("raw_snapshot_restore_", ".zip", context.cacheDir) - val workDir = File(context.cacheDir, "raw_snapshot_restore_work").apply { - if (exists()) deleteRecursively() - mkdirs() - } - - try { - AppLogger.i(TAG, "restore start uri=$uri") - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.PREPARING) } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.READING_ZIP) } - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(cacheZip).use { output -> - input.copyTo(output) - } - } ?: throw IllegalStateException("Failed to open uri") - - AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") - - AppDatabase.closeDatabase() - ObjectBoxManager.closeAll() - - AppLogger.i(TAG, "restore closed databases (room + objectbox)") - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } - val manifest = extractZipToWorkDir(cacheZip, workDir, expectedPackageName = context.packageName) - - val payloadDir = File(workDir, "payload") - val externalFilesPayloadDir = File(payloadDir, "external_files") - - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - - val preserveTerminal = !manifest.includeTerminalData - val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() - val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> - File(payloadDir, "files/$dirName").exists() - }.toSet() - val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames - - AppLogger.i( - TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" - ) - - AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } - replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) - if (externalFilesPayloadDir.exists()) { - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } - replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } - replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } - replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } - AppLogger.i(TAG, "restore done: ${manifest.packageName}") - } catch (e: Exception) { - AppLogger.e(TAG, "restore failed", e) - throw e - } finally { - try { - cacheZip.delete() - } catch (_: Exception) { - } - try { - workDir.deleteRecursively() - } catch (_: Exception) { - } - } - } - } - - private fun extractZipToWorkDir(zipFile: File, workDir: File, expectedPackageName: String): Manifest { - val payloadRoot = File(workDir, "payload") - payloadRoot.mkdirs() - - var manifestText: String? = null - var extractedPayloadFiles = 0 - - val buffer = ByteArray(64 * 1024) - val extractMs = measureTimeMillis { - ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> - while (true) { - val entry = zis.nextEntry ?: break - val name = entry.name - - if (entry.isDirectory) { - zis.closeEntry() - continue - } - - if (name == ENTRY_MANIFEST) { - val bytes = zis.readBytesSafely(maxBytes = 512 * 1024) - manifestText = bytes.toString(Charsets.UTF_8) - zis.closeEntry() - continue - } - - if (!name.startsWith(ENTRY_PAYLOAD_PREFIX)) { - zis.closeEntry() - continue - } - - val target = File(workDir, name) - val workCanonical = workDir.canonicalFile - val targetCanonical = target.canonicalFile - if (!targetCanonical.path.startsWith(workCanonical.path + File.separator)) { - zis.closeEntry() - throw IllegalArgumentException("Invalid zip entry path: $name") - } - - target.parentFile?.mkdirs() - BufferedOutputStream(FileOutputStream(target)).use { output -> - while (true) { - val read = zis.read(buffer) - if (read <= 0) break - output.write(buffer, 0, read) - } - } - - extractedPayloadFiles++ - - zis.closeEntry() - } - } - } - - AppLogger.i(TAG, "restore extract done in ${extractMs}ms (payloadFiles=$extractedPayloadFiles)") - - val manifest = manifestText?.let { json.decodeFromString(Manifest.serializer(), it) } - ?: throw IllegalArgumentException("Invalid backup zip: missing $ENTRY_MANIFEST") - - if (manifest.formatVersion != FORMAT_VERSION) { - throw IllegalArgumentException("Unsupported backup version: ${manifest.formatVersion}") - } - - if (manifest.packageName != expectedPackageName) { - throw IllegalArgumentException("Backup package mismatch: ${manifest.packageName}") - } - - return manifest - } - - private fun addDirToZip( - zos: ZipOutputStream, - dir: File, - entryPrefix: String, - excludedTopLevelDirNames: Set = emptySet(), - totalFiles: Int = 0, - onPercentChanged: ((Int) -> Unit)? = null - ) { - if (!dir.exists() || !dir.isDirectory) return - - val baseCanonical = dir.canonicalFile - val buffer = ByteArray(64 * 1024) - val writtenEntryNames = HashSet() - - var processedFiles = 0 - var lastPercent = -1 - - dir.walkTopDown().onEnter { currentDir -> - !shouldPruneDirForZip(currentDir, dir, entryPrefix, excludedTopLevelDirNames) - }.forEach { f -> - if (!f.isFile) return@forEach - - val canonical = f.canonicalFile - if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) { - if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { - AppLogger.w(TAG, "export skip objectbox lock file: ${canonical.absolutePath}") - } - return@forEach - } - - val rel = canonical.path.substring(baseCanonical.path.length + 1) - val entryName = entryPrefix + rel.replace(File.separatorChar, '/') - - if (!writtenEntryNames.add(entryName)) { - AppLogger.w(TAG, "export skip duplicate entry: $entryName") - return@forEach - } - - zos.putNextEntry(ZipEntry(entryName)) - BufferedInputStream(FileInputStream(canonical)).use { input -> - while (true) { - val read = input.read(buffer) - if (read <= 0) break - zos.write(buffer, 0, read) - } - } - zos.closeEntry() - - if (totalFiles > 0 && onPercentChanged != null) { - processedFiles++ - val percent = ((processedFiles * 100) / totalFiles).coerceIn(0, 100) - if (percent != lastPercent) { - lastPercent = percent - onPercentChanged(percent) - } - } - } - } - - private fun shouldPruneDirForZip( - currentDir: File, - baseDir: File, - entryPrefix: String, - excludedTopLevelDirNames: Set - ): Boolean { - if (currentDir == baseDir) return false - val parent = currentDir.parentFile ?: return false - if (parent != baseDir) return false - - val name = currentDir.name - if (excludedTopLevelDirNames.contains(name)) return true - - if (entryPrefix == ENTRY_FILES) { - if (name.startsWith("sherpa-ncnn-")) return true - } - - return false - } - - private fun shouldSkipForZip( - canonical: File, - baseCanonical: File, - entryPrefix: String, - excludedTopLevelDirNames: Set - ): Boolean { - if (!canonical.path.startsWith(baseCanonical.path + File.separator)) return true - - if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { - return true - } - - val rel = canonical.path.substring(baseCanonical.path.length + 1) - val relNormalized = rel.replace(File.separatorChar, '/') - val top = relNormalized.substringBefore('/', missingDelimiterValue = relNormalized) - if (excludedTopLevelDirNames.isNotEmpty() && excludedTopLevelDirNames.contains(top)) { - return true - } - - if (entryPrefix == ENTRY_FILES) { - if (top.startsWith("sherpa-ncnn-")) { - return true - } - - // Exclude Ubuntu rootfs package (very large). Stored as a top-level file in filesDir. - if (!relNormalized.contains('/')) { - val name = relNormalized - if (name.startsWith("ubuntu-", ignoreCase = true) && name.endsWith(".tar.xz", ignoreCase = true)) { - return true - } - } - - if (!relNormalized.contains('/')) { - if (relNormalized.startsWith("memory_hnsw_") && relNormalized.endsWith(".idx")) { - return true - } - if (relNormalized.startsWith("doc_index_") && relNormalized.endsWith(".hnsw")) { - return true - } - } - } - - return false - } - - private fun totalFilesForZip( - dir: File, - entryPrefix: String, - excludedTopLevelDirNames: Set, - onScannedCountChanged: ((Int) -> Unit)? = null - ): Int { - if (!dir.exists() || !dir.isDirectory) return 0 - val baseCanonical = dir.canonicalFile - var total = 0 - - var lastReported = 0 - var lastReportAtMs = 0L - dir.walkTopDown().onEnter { currentDir -> - !shouldPruneDirForZip(currentDir, dir, entryPrefix, excludedTopLevelDirNames) - }.forEach { f -> - if (!f.isFile) return@forEach - val canonical = f.canonicalFile - if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) return@forEach - total++ - - if (onScannedCountChanged != null) { - val now = System.currentTimeMillis() - if (total == 1 || total - lastReported >= 200 || now - lastReportAtMs >= 250L) { - lastReported = total - lastReportAtMs = now - onScannedCountChanged(total) - } - } - } - return total - } - - private fun replaceDirContents( - fromDir: File, - toDir: File, - preservedTopLevelDirNames: Set = emptySet() - ) { - if (!toDir.exists()) { - toDir.mkdirs() - } - - // A raw snapshot is a complete restore point. Keeping entries that are absent from the - // snapshot leaves newer migration markers behind and changes how restored data is read. - toDir.listFiles()?.forEach { existing -> - if (!preservedTopLevelDirNames.contains(existing.name)) { - check(existing.deleteRecursively()) { - "Failed to remove stale snapshot entry: ${existing.absolutePath}" - } - } - } - - if (!fromDir.exists() || !fromDir.isDirectory) return - copyDir(fromDir, toDir, preservedTopLevelDirNames) - } - - private fun copyDir( - fromDir: File, - toDir: File, - preservedTopLevelDirNames: Set - ) { - val baseCanonical = fromDir.canonicalFile - fromDir.walkTopDown().forEach { f -> - val canonical = f.canonicalFile - if (!canonical.path.startsWith(baseCanonical.path + File.separator) && canonical != baseCanonical) { - return@forEach - } - - if (canonical == baseCanonical) return@forEach - - val rel = canonical.path.substring(baseCanonical.path.length + 1) - if (preservedTopLevelDirNames.isNotEmpty()) { - val relNormalized = rel.replace(File.separatorChar, '/') - val top = relNormalized.substringBefore('/', missingDelimiterValue = relNormalized) - if (preservedTopLevelDirNames.contains(top)) { - return@forEach - } - } - val target = File(toDir, rel) - - if (canonical.isDirectory) { - target.mkdirs() - } else if (canonical.isFile) { - target.parentFile?.mkdirs() - // DataStore observes this directory. Replacing an active preferences file by - // truncating it exposes a transient empty payload that can be persisted again. - val atomicFile = AtomicFile(target) - var output: FileOutputStream? = null - try { - output = atomicFile.startWrite() - canonical.inputStream().use { input -> input.copyTo(output) } - atomicFile.finishWrite(output) - } catch (error: Throwable) { - output?.let(atomicFile::failWrite) - throw error - } - } - } - } - - private fun ZipInputStream.readBytesSafely(maxBytes: Int): ByteArray { - val out = ByteArrayOutputStream() - val buffer = ByteArray(16 * 1024) - while (true) { - val read = read(buffer) - if (read <= 0) break - if (out.size() + read > maxBytes) { - throw IllegalArgumentException("Zip entry too large") - } - out.write(buffer, 0, read) - } - return out.toByteArray() - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index bbaeeb237..78e704a5d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -10,6 +10,9 @@ import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity +import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow /** @@ -38,6 +41,9 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_events WHERE eventId = :eventId") abstract suspend fun getEvent(eventId: String): TokenStatEventEntity? + @Query("SELECT * FROM token_stat_events") + abstract suspend fun getAllEvents(): List + @Query("SELECT COUNT(*) FROM token_stat_events") abstract suspend fun countEvents(): Int @@ -194,4 +200,97 @@ abstract class TokenStatsDao { @Query("DELETE FROM token_stat_baselines") abstract suspend fun deleteAllBaselines(): Int + + + // ==== 重置 tombstone(reset cutoff) ==== + // reset 与 spool 排空的一致同步边界:tombstone 与删除在同一事务提交, + // 排空插入在同一事务内检查,SQLite 事务串行化杜绝并发复活(P1-3)。 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun upsertResetCutoff(cutoff: TokenStatResetCutoffEntity) + + @Query( + "SELECT * FROM token_stat_reset_cutoffs " + + "WHERE kind = 'FULL' LIMIT 1" + ) + abstract suspend fun fullResetCutoff(): TokenStatResetCutoffEntity? + + @Query("SELECT * FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") + abstract suspend fun modelResetCutoffs(): List + + @Query("DELETE FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") + protected abstract suspend fun deleteModelResetCutoffs() + + @Query("SELECT COALESCE(MAX(generation), 0) FROM token_stat_reset_cutoffs") + abstract suspend fun currentResetGeneration(): Long + + /** + * 全量重置:写入 FULL tombstone 并与删除(事件按 startedAtMs 过滤、baseline + * 全清)同一事务提交。排空并发插入要么在本事务前(被本事务删除),要么在 + * 本事务后(被 tombstone 在插入事务内跳过),不可能复活。 + */ + @Transaction + open suspend fun resetAllStatisticsTx() { + val generation = Math.addExact(currentResetGeneration(), 1L) + upsertResetCutoff( + TokenStatResetCutoffEntity( + kind = TokenStatResetCutoffEntity.KIND_FULL, + provider = "", + model = "", + generation = generation, + ) + ) + deleteModelResetCutoffs() + deleteAllEvents() + deleteAllBaselines() + } + + /** + * 按模型重置:写入 MODEL tombstone(每 provider/model REPLACE 覆盖,取最近 + * 时刻)并与删除同一事务提交;覆盖该模型下所有配置实例身份。 + */ + @Transaction + open suspend fun resetModelTx(provider: String, model: String) { + val generation = Math.addExact(currentResetGeneration(), 1L) + upsertResetCutoff( + TokenStatResetCutoffEntity( + kind = TokenStatResetCutoffEntity.KIND_MODEL, + provider = provider, + model = model, + generation = generation, + ) + ) + deleteEventsByProviderModel(provider, model) + deleteBaselinesByProviderModel(provider, model) + } + + /** + * 排空路径的事件插入入口:tombstone 检查与插入在同一事务内。 + * @return false = 事件被 reset tombstone 覆盖(跳过;调用方视为已处理, + * 段可删除,不重放);true = 已插入。 + */ + @Transaction + open suspend fun insertEventIfNotResetCovered(event: TokenStatEventEntity): Boolean { + val full = fullResetCutoff() + if (full != null && event.acceptedGeneration < full.generation) return false + val identity = + getIdentity(event.statIdentityId) + ?: error("identity missing for event ${event.eventId} (ensureIdentity must run first)") + val models = modelResetCutoffs() + for (cutoff in models) { + if (event.acceptedGeneration < cutoff.generation && + TokenStatIdentityResolver.normalizeProvider(cutoff.provider) == + TokenStatIdentityResolver.normalizeProvider(identity.provider) && + TokenStatIdentityResolver.normalizeModelName(cutoff.model) == + TokenStatIdentityResolver.normalizeModelName(identity.model) + ) { + return false + } + } + insertEvent(event) + return true + } +} + + } diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 797b27f6f..43e2601e6 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -19,6 +19,7 @@ import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity /** 应用数据库,包含聊天表和消息表 */ @Database( entities = [ @@ -30,6 +31,7 @@ import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity TokenStatPriceOverrideEntity::class, TokenStatEventEntity::class, TokenStatBaselineEntity::class, + TokenStatResetCutoffEntity::class, ], version = 21, exportSchema = false @@ -370,9 +372,62 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) + // 事件表增加脱敏诊断列与费用计算所需的结构化列: + // - `acceptedGeneration`:reset tombstone 一致性边界(排空事务检查); + // - `totalInputTokens`:provider 明确上报的总输入(拆分未知时重估直接读取); + // - `cacheWriteSeparateBilling`:缓存写入是否独立计费; + // - `diagnosticsJson`:来源标签、usageObserved、usageReportCount 等诊断元数据。 + // 另新增 `token_stat_reset_cutoffs` 表(reset tombstone)。全部为纯新增, + // 幂等可重入(重复执行时列/表已存在即跳过)。 + try { + db.execSQL( + "ALTER TABLE `token_stat_events` ADD COLUMN " + + "`acceptedGeneration` INTEGER NOT NULL DEFAULT 0" + ) + } catch (_: Exception) { + // 列已存在(幂等重放),忽略 + } + try { + db.execSQL( + "ALTER TABLE `token_stat_events` ADD COLUMN `totalInputTokens` INTEGER" + ) + } catch (_: Exception) { + // 列已存在(幂等重放),忽略 + } + try { + db.execSQL( + "ALTER TABLE `token_stat_events` ADD COLUMN " + + "`cacheWriteSeparateBilling` INTEGER" + ) + } catch (_: Exception) { + // 列已存在(幂等重放),忽略 + } + try { + db.execSQL( + "ALTER TABLE `token_stat_events` ADD COLUMN `diagnosticsJson` TEXT" + ) + } catch (_: Exception) { + // 列已存在(幂等重放),忽略 + } + try { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_reset_cutoffs` ( + `kind` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + `generation` INTEGER NOT NULL, + PRIMARY KEY(`kind`, `provider`, `model`) + ) + """.trimIndent() + ) + } catch (_: Exception) { + // 表已存在(幂等重放),忽略 + } } } + // 定义从版本2到3的迁移 private val MIGRATION_2_3 = object : Migration(2, 3) { diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt index 8383cc4e6..ec5bf8d5b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt @@ -12,12 +12,21 @@ import androidx.room.PrimaryKey * - 一行代表一次真实请求/尝试;[eventId] 由记录链路提供稳定标识,用于防止重复入账。 * - 数值型 token/价格/成本字段为 null 表示“未知”,禁止静默当作 0; * 0 表示 provider 确认该分量(如无缓存读取、无缓存写入)为 0。 + * token 列使用 [Long]:provider 原值可能超过 Int 范围,聚合与费用计算全程 + * Long 运算,绝不因 Int 溢出产生负数落账(与 baseline 表一致)。 * - 事件保存发生时的原币价格快照与原币成本([pricingCurrency]/[costInPricingCurrency]), * 不冻结汇率;跨币种展示换算始终使用当前手动汇率。 * - [reasoningIncludedInOutput] 是 provider 适配层规范化后的边界声明: * true = provider 的 output 计数已包含推理 token(计费时不得再加推理); * false = 推理 token 独立计数,需按输出单价补算; * null = provider 未声明,计费时按“已包含”处理以避免重复收费。 + * - [totalInputTokens]:provider 明确上报的总输入(含缓存命中/写入),在 + * cached/uncached 拆分未知时仍可表达输入量;费用计算与当前价格重估直接读取, + * 无需解析 [diagnosticsJson]。 + * - [cacheWriteSeparateBilling]:provider 的缓存写入计费模型(结构化保存,供 + * 当前价格重估直接读取;null = 旧行未声明,重估时按 provider/来源推断)。 + * - [diagnosticsJson] 保存必要且脱敏的原始诊断字段(usage 来源标签、是否观察到 + * usage、usage 上报次数等),**不**保存正文、API key、Cookie 或 endpoint 凭据。 * - 不保存提示词/回复正文,也不保存任何凭据。 */ @Entity( @@ -41,15 +50,19 @@ data class TokenStatEventEntity( @ColumnInfo(name = "statIdentityId") val statIdentityId: String, @ColumnInfo(name = "category") val category: String, @ColumnInfo(name = "status") val status: String, + /** 请求开始时捕获的持久单调 generation;reset 不依赖墙钟判断先后。 */ + @ColumnInfo(name = "acceptedGeneration") val acceptedGeneration: Long = 0L, @ColumnInfo(name = "startedAtMs") val startedAtMs: Long, @ColumnInfo(name = "endedAtMs") val endedAtMs: Long, @ColumnInfo(name = "firstTokenAtMs") val firstTokenAtMs: Long? = null, - @ColumnInfo(name = "uncachedInputTokens") val uncachedInputTokens: Int? = null, - @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Int? = null, - @ColumnInfo(name = "cacheWriteTokens") val cacheWriteTokens: Int? = null, - @ColumnInfo(name = "outputTokens") val outputTokens: Int? = null, - @ColumnInfo(name = "reasoningTokens") val reasoningTokens: Int? = null, + @ColumnInfo(name = "uncachedInputTokens") val uncachedInputTokens: Long? = null, + @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Long? = null, + @ColumnInfo(name = "cacheWriteTokens") val cacheWriteTokens: Long? = null, + @ColumnInfo(name = "totalInputTokens") val totalInputTokens: Long? = null, + @ColumnInfo(name = "outputTokens") val outputTokens: Long? = null, + @ColumnInfo(name = "reasoningTokens") val reasoningTokens: Long? = null, @ColumnInfo(name = "reasoningIncludedInOutput") val reasoningIncludedInOutput: Boolean? = null, + @ColumnInfo(name = "cacheWriteSeparateBilling") val cacheWriteSeparateBilling: Boolean? = null, @ColumnInfo(name = "billingMode") val billingMode: String, @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, @ColumnInfo(name = "inputPricePerMillion") val inputPricePerMillion: Double? = null, @@ -59,4 +72,5 @@ data class TokenStatEventEntity( @ColumnInfo(name = "pricePerRequest") val pricePerRequest: Double? = null, @ColumnInfo(name = "pricingSource") val pricingSource: String, @ColumnInfo(name = "costInPricingCurrency") val costInPricingCurrency: Double? = null, + @ColumnInfo(name = "diagnosticsJson") val diagnosticsJson: String? = null, ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt new file mode 100644 index 000000000..6a98bccb0 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt @@ -0,0 +1,36 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity + +/** + * 统计重置 tombstone(reset cutoff)—— reset 与 spool 排空的一致同步边界(P1-3)。 + * + * - FULL 行(kind = "FULL",provider/model 为空串):最近一次全量重置 generation。 + * 排空插入事件时,事件 acceptedGeneration < FULL.generation 则跳过(不复活)。 + * - MODEL 行(kind = "MODEL"):最近一次按模型重置时刻,每 (provider, model) + * 至多一行(REPLACE 覆盖)。 + * - 写入与删除在同一 Room 事务提交(见 + * [com.ai.assistance.operit.data.dao.TokenStatsDao.resetAllStatisticsTx] / + * [com.ai.assistance.operit.data.dao.TokenStatsDao.resetModelTx]);排空插入在 + * 同一事务内检查 tombstone([com.ai.assistance.operit.data.dao.TokenStatsDao.insertEventIfNotResetCovered])。 + * SQLite 事务串行化保证“检查-插入”与“写 tombstone-删除”不交错:并发中已接受 + * 但未入 Room 的事件在 reset 后不会复活。 + * generation 由 Room 事务从所有 tombstone 的最大值递增产生,跨重启持久且不受 + * 同毫秒事件或设备时间回拨影响。 + */ +@Entity( + tableName = "token_stat_reset_cutoffs", + primaryKeys = ["kind", "provider", "model"], +) +data class TokenStatResetCutoffEntity( + @ColumnInfo(name = "kind") val kind: String, + @ColumnInfo(name = "provider") val provider: String, + @ColumnInfo(name = "model") val model: String, + @ColumnInfo(name = "generation") val generation: Long, +) { + companion object { + const val KIND_FULL = "FULL" + const val KIND_MODEL = "MODEL" + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 6cebb0341..12ed94572 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -723,6 +723,31 @@ class ApiPreferences private constructor(private val context: Context) { ) } + /** + * 读取指定 provider:model 的旧系统用户价格设置(阶段 2 事件记录用)。 + * 旧约定:价格键缺失或为 0 视为未设置(0 与“未设置”不可区分), + * 只有 > 0 的值才算用户设置;无任何设置时返回 null。 + */ + suspend fun legacyPriceSettingsFor( + providerModel: String + ): com.ai.assistance.operit.data.stats.LegacyPriceSettings? { + val preferences = context.apiDataStore.data.first() + val billingRaw = preferences[getBillingModeKey(providerModel)] + val settings = + com.ai.assistance.operit.data.stats.LegacyPriceSettings( + billingMode = billingRaw?.let { com.ai.assistance.operit.data.model.BillingMode.fromString(it) }, + inputPricePerMillion = + preferences[getModelInputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, + cachedInputPricePerMillion = + preferences[getModelCachedInputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, + outputPricePerMillion = + preferences[getModelOutputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, + pricePerRequest = + preferences[getPricePerRequestKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, + ) + return settings.takeIf { it.hasAnyUserSetting() } + } + private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) { val names = keyNames.toSet() preferences.asMap().keys diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt new file mode 100644 index 000000000..054787778 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt @@ -0,0 +1,360 @@ +package com.ai.assistance.operit.data.stats + +import org.json.JSONObject + +/** + * provider 适配层规范化后的 usage 快照(阶段 1 契约 + 诊断标签)。 + * + * - null 字段表示“未知”(provider 未提供),0 表示 provider 确认该分量为 0 + * (例如确认无缓存读取/无缓存写入);任何字段都不得静默把“未知”当作 0; + * token 字段使用 [Long] 承载,聚合以 Long 运算避免 Int 溢出,负值一律拒绝为未知。 + * - [completeSnapshot]:本次上报的语义。true = 完整快照(null 字段 = 明确未知, + * 同一 attempt 合并时覆盖旧值,即“撤销”旧值);false = 部分更新(null = 字段 + * 省略,保留旧值)。流式增量上报(Anthropic message_start/message_delta、 + * ToolPkg 新协议 attempt 内的流式更新)是部分更新;最终响应 usage(OpenAI、 + * Anthropic 非流式、本地实测、ToolPkg 旧协议请求级累计)是完整快照。 + * - [cacheWriteSeparateBilling]:provider 的计费模型是否把“缓存写入”作为独立 + * 计费分量。false = 无独立缓存写入计费概念(OpenAI 兼容系/Gemini/本地/ToolPkg: + * 缓存写入成本已包含在输入单价内,字段缺失不阻碍费用计算);true = 缓存写入 + * 独立计费(Anthropic),此时该分量未知会导致费用未知。默认 true 保持保守: + * 未声明时缺失缓存写入字段仍按未知处理。 + * - [reasoningIncludedInOutput]:true = provider 的 output 计数已包含推理 token + * (计费时不得再加推理);false = 推理独立计数;null = provider 未声明, + * 计费按“已包含”处理以避免重复收费。 + * - [totalInputTokens]:provider 明确上报的**总输入**(含缓存命中/缓存写入)。 + * 当 cached/uncached 拆分未知(如 OpenAI 兼容端点缺 prompt_tokens_details、 + * Gemini 缺 cachedContentTokenCount)时,总输入仍可表达“至少这么多输入”; + * 费用只有在 cached/uncached 单价相同(拆分不影响计费)时才可按总输入计算, + * 否则仍保持未知。拆分已知时 [uncachedInputTokens] + [cachedInputTokens] + * 即总输入,本字段为冗余可空冗余(用于拆分未知场景,绝不伪造 uncached)。 + * - [source] 是诊断用的来源标签(哪个解析路径),不含任何凭据或正文。 + */ +data class ProviderUsageSnapshot( + val uncachedInputTokens: Long? = null, + val cachedInputTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val totalInputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val reasoningIncludedInOutput: Boolean? = null, + val cacheWriteSeparateBilling: Boolean = true, + /** true = 完整快照(null 覆盖旧值);false = 部分更新(null 保留旧值)。 */ + val completeSnapshot: Boolean = false, + val source: String, +) { + /** 转换为阶段 1 费用计算输入。 */ + fun toTokenUsageInput(): TokenUsageInput = + TokenUsageInput( + uncachedInputTokens = uncachedInputTokens, + cachedInputTokens = cachedInputTokens, + cacheWriteTokens = cacheWriteTokens, + totalInputTokens = totalInputTokens, + outputTokens = outputTokens, + reasoningTokens = reasoningTokens, + reasoningIncludedInOutput = reasoningIncludedInOutput, + cacheWriteSeparateBilling = cacheWriteSeparateBilling, + ) + + /** 是否有任何已知用量分量(含明确 0;完全无已知字段才为 false)。 */ + fun hasKnownFields(): Boolean = + uncachedInputTokens != null || + cachedInputTokens != null || + cacheWriteTokens != null || + totalInputTokens != null || + outputTokens != null || + reasoningTokens != null +} + +/** + * provider 原始 usage → 阶段 1 契约的归一化(provider 适配层)。 + * + * 语义事实: + * - OpenAI 兼容系:`prompt_tokens`/`completion_tokens` 已包含缓存命中与推理 token, + * 因此 [ProviderUsageSnapshot.cachedInputTokens] 从 `prompt_tokens_details.cached_tokens` + * 提取、[ProviderUsageSnapshot.uncachedInputTokens] 为差值;`completion_tokens` 包含 + * 推理 → [ProviderUsageSnapshot.reasoningIncludedInOutput] = true。 + * - Anthropic:文档明确 `input_tokens` **不含** `cache_read_input_tokens` 与 + * `cache_creation_input_tokens`(总量 = 三者之和),因此三个分量各自独立保留, + * 缓存写入单独计费;`output_tokens` 包含 thinking → 推理已包含在输出。 + * - Gemini:`candidatesTokenCount` 包含 thought token;`thoughtsTokenCount` 有值时 + * 单独保留(仍计入输出)。 + * - 本地模型(llama/MNN):没有 provider usage 对象,token 为本地实测计数 + * (tokenizer 计数 + 逐 token 生成计数),缓存分量明确为 0。 + * + * 不保存正文、API key、Cookie 或 endpoint 凭据。 + */ +object ProviderUsageNormalizer { + + const val SOURCE_OPENAI_CHAT_COMPLETIONS = "openai_chat_completions" + const val SOURCE_OPENAI_RESPONSES = "openai_responses" + const val SOURCE_ANTHROPIC = "anthropic" + const val SOURCE_GEMINI = "gemini" + const val SOURCE_LLAMA = "llama_cpp" + const val SOURCE_MNN = "mnn" + const val SOURCE_TOOLPKG = "toolpkg_js" + + /** OpenAI chat/completions 系(含 DeepSeek、Kimi、Qwen、Mistral 等兼容端点)。 + * 单次上报即该 attempt 的完整最终 usage → [completeSnapshot] = true。 */ + fun openAiChatCompletions( + usage: JSONObject?, + completeSnapshot: Boolean = true, + ): ProviderUsageSnapshot? { + usage ?: return null + val totalInput = usage.optLong("prompt_tokens", usage.optLong("input_tokens", -1)) + val output = usage.optLong("completion_tokens", usage.optLong("output_tokens", -1)) + val cached = + usage.optJSONObject("prompt_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1).takeIf { it >= 0 } + val cacheWrite = + usage.optJSONObject("prompt_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + val reasoning = + usage.optJSONObject("output_tokens_details") + ?.optLong("reasoning_tokens", -1) + ?.takeIf { it >= 0 } + + val uncached = if (totalInput >= 0 && cached != null) { + (totalInput - cached).coerceAtLeast(0) + } else { + // cached 拆分未知时不得把总输入确定为 uncached(分类确定性) + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = totalInput.takeIf { it >= 0 }, + outputTokens = output.takeIf { it >= 0 }, + reasoningTokens = reasoning, + reasoningIncludedInOutput = true, + // OpenAI 兼容系缓存写入成本已包含在输入单价内,无独立计费概念 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_OPENAI_CHAT_COMPLETIONS, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** OpenAI Responses API:`input_tokens_details.cached_tokens` + `output_tokens_details.reasoning_tokens`。 + * 单次上报即完整最终 usage → [completeSnapshot] = true。 */ + fun openAiResponses( + usage: JSONObject?, + completeSnapshot: Boolean = true, + ): ProviderUsageSnapshot? { + usage ?: return null + val totalInput = usage.optLong("input_tokens", -1) + val output = usage.optLong("output_tokens", -1) + val cached = + usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + val reasoning = + usage.optJSONObject("output_tokens_details") + ?.optLong("reasoning_tokens", -1) + ?.takeIf { it >= 0 } + val cacheWrite = + usage.optJSONObject("input_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + + val uncached = if (totalInput >= 0 && cached != null) { + (totalInput - cached).coerceAtLeast(0) + } else { + // cached 拆分未知时不得把总输入确定为 uncached(分类确定性) + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = totalInput.takeIf { it >= 0 }, + outputTokens = output.takeIf { it >= 0 }, + reasoningTokens = reasoning, + reasoningIncludedInOutput = true, + // OpenAI Responses 与 chat/completions 一致:无独立缓存写入计费 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_OPENAI_RESPONSES, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** + * Anthropic Messages API。`input_tokens` 不含缓存分量(官方文档:总量 = + * input_tokens + cache_read_input_tokens + cache_creation_input_tokens), + * 因此 uncached/cached/cacheWrite 直接取各自字段,缓存写入独立计费。 + * + * 流式 message_start/message_delta 是**部分更新**([completeSnapshot] = false, + * 省略字段保留旧值);非流式最终响应是完整快照(true,null 覆盖旧值)。 + */ + fun anthropic( + usage: JSONObject?, + completeSnapshot: Boolean = false, + ): ProviderUsageSnapshot? { + usage ?: return null + val input = usage.optLong("input_tokens", -1).takeIf { it >= 0 } + val cached = + usage.optLong("cache_read_input_tokens", -1).takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1).takeIf { it >= 0 } + val cacheWrite = + usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + ?: usage.optJSONObject("cache_creation") + ?.let { sumNumericFields(it) } + ?.takeIf { it >= 0 } + val output = usage.optLong("output_tokens", -1).takeIf { it >= 0 } + val fallbackInput = usage.optLong("prompt_tokens", -1).takeIf { it >= 0 } + + val uncached = input ?: fallbackInput?.let { total -> + if (cached != null) (total - cached).coerceAtLeast(0) else total + } + // 总输入 = input + cache_read + cache_creation(官方文档语义); + // 全部已知才确定总量;无任何缓存分量时总输入即 input_tokens。 + val totalInput = + when { + cached != null && cacheWrite != null && uncached != null -> + uncached + cached + cacheWrite + cached == null && cacheWrite == null -> uncached + else -> null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + totalInputTokens = totalInput, + outputTokens = output, + reasoningTokens = null, + reasoningIncludedInOutput = true, + // Anthropic:缓存创建独立计费;字段缺失即该分量未知 + cacheWriteSeparateBilling = true, + completeSnapshot = completeSnapshot, + source = SOURCE_ANTHROPIC, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** Gemini:`usageMetadata`,`candidatesTokenCount` 包含 thought token。 + * 流式逐 chunk 上报的是服务器累计快照,省略字段不代表撤销 → 保持部分更新。 */ + fun gemini( + usageMetadata: JSONObject?, + completeSnapshot: Boolean = false, + ): ProviderUsageSnapshot? { + usageMetadata ?: return null + val prompt = usageMetadata.optLong("promptTokenCount", -1).takeIf { it >= 0 } + val cached = usageMetadata.optLong("cachedContentTokenCount", -1).takeIf { it >= 0 } + val output = usageMetadata.optLong("candidatesTokenCount", -1).takeIf { it >= 0 } + val thoughts = + if (usageMetadata.has("thoughtsTokenCount")) { + usageMetadata.optLong("thoughtsTokenCount", 0) + } else { + null + } + + val uncached = if (prompt != null && cached != null) { + (prompt - cached).coerceAtLeast(0) + } else { + // cachedContentTokenCount 缺失(cached 拆分未知)时不得把总输入确定为 uncached + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = null, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = prompt, + outputTokens = output, + reasoningTokens = thoughts, + reasoningIncludedInOutput = true, + // Gemini 无独立缓存写入计费概念 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_GEMINI, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** 本地模型(llama.cpp/MNN):本地实测计数,缓存分量明确为 0;单次完整上报。 */ + fun local( + uncachedInputTokens: Int, + outputTokens: Int, + source: String, + ): ProviderUsageSnapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncachedInputTokens.coerceAtLeast(0).toLong(), + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = uncachedInputTokens.coerceAtLeast(0).toLong(), + outputTokens = outputTokens.coerceAtLeast(0).toLong(), + reasoningTokens = null, + reasoningIncludedInOutput = null, + cacheWriteSeparateBilling = false, + completeSnapshot = true, + source = source, + ) + + /** + * ToolPkg JS provider:`input` 视为总量(含缓存命中),uncached 为差值。 + * [completeSnapshot] 由协议版本决定:新协议(携带 attempt)为同 attempt 内 + * 的部分更新;旧协议(无 attempt)为整个逻辑请求的累计完整快照。 + * 字段可空(评审 P1-6):缺省字段 = 未知,绝不继承全局累计计数;跨 attempt + * 聚合时缺失分量保持未知(不猜测)。Long 语义(评审 P2-1),负值拒绝为未知。 + */ + fun toolPkg( + input: Long?, + cachedInput: Long?, + output: Long?, + completeSnapshot: Boolean, + ): ProviderUsageSnapshot { + val uncached = + when { + input == null || cachedInput == null -> null + input >= cachedInput -> (input - cachedInput).coerceAtLeast(0) + else -> input + } + return ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cachedInput?.coerceAtLeast(0), + cacheWriteTokens = null, + totalInputTokens = input?.coerceAtLeast(0), + outputTokens = output?.coerceAtLeast(0), + reasoningTokens = null, + reasoningIncludedInOutput = null, + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_TOOLPKG, + ) + } + + private fun sumNumericFields(jsonObject: JSONObject): Long { + var total = 0L + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + when (val value = jsonObject.opt(key)) { + is Number -> total += value.toLong() + is JSONObject -> total += sumNumericFields(value) + else -> {} + } + } + return total + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt index e712b72ed..62beda157 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt @@ -164,36 +164,36 @@ object TokenBaselineImportRunner { /** * 统计重置接线:把新账本(事件 + baseline)接入仓库现有“全量重置/按模型重置”机制。 * - * - 全量重置:清空全部事件与 baseline(不删除身份、展示分组与价格覆盖, + * - 全量重置:递增 durable FULL generation 并在同一 Room 事务内无条件删除事件 + * 与全部 baseline(不删除身份、展示分组与价格覆盖, * 与旧系统“重置只清计数、保留配置”语义一致)。 - * - 按模型重置:在单个数据库事务中删除该 provider/model 下**所有配置实例** - * 身份的事件与 baseline;旧 DataStore 无配置实例区分,其 baseline 身份的 - * configId 为空串,同样被覆盖。 + * - 按模型重置:写 MODEL generation tombstone(每 provider/model REPLACE) + * 并在同一事务内删除该 provider/model 下**所有配置实例**身份的事件与 baseline; + * 旧 DataStore 无配置实例区分,其 baseline 身份的 configId 为空串,同样被覆盖。 + * - spool 一致性(P1-3):排空插入在同一 Room 事务内检查 tombstone + * ([TokenStatsDao.insertEventIfNotResetCovered]),并发中已接受但未入 Room 的 + * 事件不会复活;重置后触发 [TokenStatSpool.replay] 让排空丢弃被覆盖的行。 * * [daoProvider] 为测试注入缝:生产代码始终为 null,走 [AppDatabase] 的真实事务; - * 测试注入时由测试自行验证调用语义(生产原子性由 withTransaction 保证)。 + * 测试注入时由测试自行验证调用语义(生产原子性由 DAO @Transaction 保证)。 */ object TokenStatsResetCoordinator { internal var daoProvider: ((Context) -> TokenStatsDao)? = null suspend fun resetAllStatistics(context: Context) { - withTransaction(context) { dao -> - dao.deleteAllEvents() - dao.deleteAllBaselines() - } + withDao(context) { dao -> dao.resetAllStatisticsTx() } + TokenStatSpool.replay(context.applicationContext) } suspend fun resetStatisticsForProviderModel(context: Context, providerModel: String) { val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) if (model.isBlank()) return - withTransaction(context) { dao -> - dao.deleteEventsByProviderModel(provider, model) - dao.deleteBaselinesByProviderModel(provider, model) - } + withDao(context) { dao -> dao.resetModelTx(provider, model) } + TokenStatSpool.replay(context.applicationContext) } - private suspend fun withTransaction( + private suspend fun withDao( context: Context, block: suspend (TokenStatsDao) -> Unit, ) { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt index 204c52318..5bcd15d35 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -8,55 +8,68 @@ import com.ai.assistance.operit.data.model.BillingMode * * - null 字段表示“未知”,不允许静默当作 0;0 表示 provider 确认该分量为 0 * (例如确认无缓存读取/无缓存写入)。任一未知分量导致 TOKEN 模式成本未知。 + * - token 字段为 [Long]:provider 原值可能超过 Int 范围,聚合与计费全程 Long + * 运算,绝不因 Int 溢出产生负数落账;负值在适配层已被拒绝为未知。 + * - [totalInputTokens]:provider 明确上报的总输入(含缓存命中/写入)。当 + * cached/uncached 拆分未知([uncachedInputTokens]/[cachedInputTokens] 为 null) + * 时,**只有**在输入与缓存输入单价相同(拆分不影响计费)的前提下才允许按 + * 总输入计费;单价不同则成本仍保持未知,绝不伪造 uncached。 + * - [cacheWriteSeparateBilling]:false = provider 无独立缓存写入计费概念 + * (OpenAI 兼容系/Gemini/本地/ToolPkg),cacheWriteTokens 缺失或为 0 都不阻碍 + * 费用计算(写入成本已包含在输入单价内);true = 缓存写入独立计费(Anthropic), + * 此时该分量未知会导致费用未知。 * - [reasoningIncludedInOutput] 是推理 token 归一化边界: * true = provider 的 output 计数已包含推理 token; * false = 推理 token 独立计数,计费时按输出单价补算; * null = provider 未声明,按“已包含”处理,避免重复收费。 */ data class TokenUsageInput( - val uncachedInputTokens: Int? = null, - val cachedInputTokens: Int? = null, - val cacheWriteTokens: Int? = null, - val outputTokens: Int? = null, - val reasoningTokens: Int? = null, + val uncachedInputTokens: Long? = null, + val cachedInputTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val totalInputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, val reasoningIncludedInOutput: Boolean? = null, + val cacheWriteSeparateBilling: Boolean = true, ) /** 单次请求的原币成本计算结果;[amount] 为 null 表示未知(非 0)。 */ data class TokenCostResult( val amount: Double?, val currency: PricingCurrency, - val billedInputTokens: Int? = null, - val billedCacheWriteTokens: Int? = null, - val billedOutputTokens: Int? = null, + val billedInputTokens: Long? = null, + val billedCacheWriteTokens: Long? = null, + val billedOutputTokens: Long? = null, ) /** * 原币费用计算。 * * - TOKEN 模式:计费输入 = uncached + cached(两者都必须已知,null 即未知→成本 null); - * 缓存写入按 cacheWriteTokens 独立计费(未知→成本 null;0 跳过;>0 需要缓存写入单价, - * 该单价未解析到时成本 null);计费输出 = output + 独立计数的 reasoning。 + * 缓存写入在 [TokenUsageInput.cacheWriteSeparateBilling] 为 true 时独立计费 + * (未知→成本 null;0 跳过;>0 需要缓存写入单价,缺失则成本 null); + * 为 false(无独立缓存写入概念)时不单独计费,字段缺失不影响成本。 * - COUNT 模式:成本 = 单次价格(每事件一次请求)。 * - 价格为“每百万 token”原币单价;cached 单价缺省已由 [TokenPriceResolver] 回填。 */ object TokenCostCalculator { - fun billedOutputTokens(usage: TokenUsageInput): Int? { + fun billedOutputTokens(usage: TokenUsageInput): Long? { val output = usage.outputTokens ?: return null val separateReasoning = if (usage.reasoningIncludedInOutput == false && usage.reasoningTokens != null) { usage.reasoningTokens } else { - 0 + 0L } - return output + separateReasoning + return saturatedAdd(output, separateReasoning) } - fun billedInputTokens(usage: TokenUsageInput): Int? { + fun billedInputTokens(usage: TokenUsageInput): Long? { val uncached = usage.uncachedInputTokens ?: return null val cached = usage.cachedInputTokens ?: return null - return uncached + cached + return saturatedAdd(uncached, cached) } fun computeCost(usage: TokenUsageInput, pricing: ResolvedPricing): TokenCostResult { @@ -68,9 +81,8 @@ object TokenCostCalculator { ) } - val billedInput = billedInputTokens(usage) val billedOutput = billedOutputTokens(usage) - if (billedInput == null || billedOutput == null) { + if (billedOutput == null) { return TokenCostResult(amount = null, currency = pricing.currency) } val inputPrice = pricing.inputPricePerMillion @@ -80,16 +92,37 @@ object TokenCostCalculator { return TokenCostResult(amount = null, currency = pricing.currency) } - val cachedTokens = usage.cachedInputTokens ?: 0 - val uncachedTokens = billedInput - cachedTokens - var amount = - uncachedTokens / 1_000_000.0 * inputPrice + - cachedTokens / 1_000_000.0 * cachedPrice + - billedOutput / 1_000_000.0 * outputPrice + // 输入计费: + // - 拆分已知 → 按 uncached/cached 分量各自计价; + // - 拆分未知(cached details 缺失)但总输入已知 → 仅当输入与缓存输入 + // 单价相同(拆分不影响计费)时按总输入计价;单价不同则成本保持未知, + // 绝不把总输入伪装成 uncached。 + val cachedTokens = usage.cachedInputTokens + val uncachedTokens = usage.uncachedInputTokens + val billedInput: Long + val inputAmount: Double + if (cachedTokens != null && uncachedTokens != null) { + billedInput = saturatedAdd(uncachedTokens, cachedTokens) + inputAmount = + uncachedTokens / 1_000_000.0 * inputPrice + + cachedTokens / 1_000_000.0 * cachedPrice + } else { + val total = usage.totalInputTokens + if (total == null || inputPrice != cachedPrice) { + return TokenCostResult(amount = null, currency = pricing.currency) + } + billedInput = total + inputAmount = total / 1_000_000.0 * inputPrice + } + var amount = inputAmount + billedOutput / 1_000_000.0 * outputPrice - // 缓存写入:未知 → 成本未知;确认 0 → 不参与;> 0 → 需要缓存写入单价 + // 缓存写入: + // - 独立计费概念下未知 → 成本未知(不静默当作 0); + // - 确认 0 → 不参与; + // - > 0 且独立计费 → 需要缓存写入单价,缺失则成本未知; + // - 非独立计费(OpenAI 兼容系等)→ 写入成本已包含在输入单价内,不单独计费。 val cacheWriteTokens = usage.cacheWriteTokens - if (cacheWriteTokens == null) { + if (cacheWriteTokens == null && usage.cacheWriteSeparateBilling) { return TokenCostResult( amount = null, currency = pricing.currency, @@ -98,10 +131,16 @@ object TokenCostCalculator { billedOutputTokens = billedOutput, ) } - if (cacheWriteTokens > 0) { + if (cacheWriteTokens != null && cacheWriteTokens > 0 && usage.cacheWriteSeparateBilling) { val cacheWritePrice = pricing.cacheWritePricePerMillion if (cacheWritePrice == null) { - return TokenCostResult(amount = null, currency = pricing.currency) + return TokenCostResult( + amount = null, + currency = pricing.currency, + billedInputTokens = billedInput, + billedCacheWriteTokens = cacheWriteTokens, + billedOutputTokens = billedOutput, + ) } amount += cacheWriteTokens / 1_000_000.0 * cacheWritePrice } @@ -114,6 +153,13 @@ object TokenCostCalculator { billedOutputTokens = billedOutput, ) } + + /** + * 饱和加法:溢出时钳制到 [Long.MAX_VALUE],绝不出现负数或回绕;调用方 + * 只接受非负分量,负数视为异常数据在适配层已拒绝,这里做最终防线。 + */ + internal fun saturatedAdd(left: Long, right: Long): Long = + if (right > 0 && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right } /** diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt new file mode 100644 index 000000000..76459a91a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt @@ -0,0 +1,352 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import org.json.JSONObject + +/** + * 一次统计请求/尝试的上下文(阶段 2 记录链路核心)。 + * + * - 一个上下文对应一次**逻辑请求**(一次 [com.ai.assistance.operit.api.chat.llmprovider.AIService.sendMessage])。 + * - [eventId] 在请求开始时生成一次,全生命周期稳定:同一请求的重试、重复回调、 + * 重复落账都复用同一标识,配合 DAO 的 IGNORE 插入实现幂等(不会重复入账)。 + * - 重试边界:provider 内部重试**不**产生独立事件;无论内部尝试多少次, + * 最终只落一个事件,状态为最终结果(成功 / 取消 / 超时 / 失败)。 + * - [onUsage] 可被 provider 多次回调,每次携带 attempt(provider 内部第几次尝试, + * 从 1 开始)。同一 attempt 的多次上报按 [ProviderUsageSnapshot.completeSnapshot] + * 合并:部分更新(false,流式增量如 Anthropic message_start/message_delta)按 + * “最新非空字段优先”合并,output 等累计字段直接取最新值、绝不相加;完整快照 + * (true,如最终响应 usage)整份覆盖,null 字段 = 明确未知,覆盖旧值(撤销)。 + * **不同 attempt 的用量按分量累加**,避免重试后漏掉已计费 attempt 的 token/费用。 + * - [aggregatedUsage] 返回按 attempt 聚合后的快照:分量只在所有 attempt 都已知时 + * 求和(Long 饱和加法,绝不 Int 溢出为负),任一 attempt 未知则该分量保持未知。 + * - 负数分量在任何入口都被拒绝为未知(防御:适配层已拒绝,这里做最终防线)。 + * - [onFirstToken] 只在真实首个响应 token/chunk(首个非空内容)到达时设置一次。 + * + * 不保存正文、API key、Cookie 或 endpoint 凭据。 + */ +class TokenStatRequestContext( + val eventId: String, + val category: TokenStatCategory, + val configId: String, + val provider: String, + val model: String, + val startedAtMs: Long, + /** 请求开始时从 Room 捕获;reset 后完成的旧请求仍属于 reset 前统计。 */ + val acceptedGeneration: Long = 0L, + /** + * 请求开始时同步捕获的 [TokenStatSpool.captureRestoreEpoch](P1 终审):纯内存、无 + * Room。收尾 [TokenStatSpool.append] 时验证其仍等于当前 restore epoch——恢复屏障开始 + * 即原子递增使所有旧请求失效,绝不写入可能已被恢复替换的 spool/Room。spool 重放 + * ([fromSpoolLine])不经过 append 的请求 fence,保持默认值即可。 + */ + val sessionEpoch: Long = 0L, +) { + /** 首个真实响应 token/chunk 时间;无内容响应保持 null。 */ + var firstTokenAtMs: Long? = null + private set + + /** 最后一次 provider 上报的规范化 usage;未上报保持 null(未知)。 */ + var lastUsage: ProviderUsageSnapshot? = null + private set + + /** provider 上报 usage 的总次数(诊断:重复回调防重证据)。 */ + var usageReportCount: Int = 0 + private set + + /** 观察到的最大的 attempt 序号(诊断:内部重试边界证据)。 */ + var attemptCount: Int = 0 + private set + + /** 最终状态;未结束时为 null。 */ + var status: TokenStatStatus? = null + private set + + /** 结束时间;未结束时为 [startedAtMs]。 */ + var endedAtMs: Long = startedAtMs + private set + + /** + * 冻结的“发生时”价格快照(P1-1):由 [TokenStatsLedger.prepareEventLine] 在 + * 请求收尾解析并随 spool 行持久化;排空([TokenStatsLedger.recordWith])只使用 + * 该快照,绝不重读当前价格。直接路径([TokenStatsLedger.record])不经 spool, + * 解析即落账,无需冻结。 + */ + internal var frozenPricing: FrozenEventPricing? = null + + /** 价格无法读取时的结构化、脱敏原因;不得用默认价格冒充历史快照。 */ + internal var pricingResolutionDiagnostic: String? = null + + /** attempt -> 该 attempt 最后一次上报的快照(同一 attempt 重复上报取最后)。 */ + private val attemptUsages = LinkedHashMap() + + /** 仅在首个真实内容 chunk 到达时记录一次。 */ + fun onFirstToken(nowMs: Long = System.currentTimeMillis()) { + if (firstTokenAtMs == null) { + firstTokenAtMs = nowMs + } + } + + /** + * provider 上报 usage。同一 attempt 的重复上报按 + * [ProviderUsageSnapshot.completeSnapshot] 合并:部分更新按“最新非空字段优先” + * (流式增量快照,如 Anthropic message_start + message_delta:后一次只带 + * 累计 output,input/cache 保留前一次的值;output 等累计字段取最新值,绝不 + * 相加);完整快照整份覆盖(null = 明确未知,撤销旧值)。不同 attempt 分别 + * 记账,聚合时累加。 + */ + fun onUsage(usage: ProviderUsageSnapshot, attempt: Int = 1) { + val normalizedAttempt = attempt.coerceAtLeast(1) + val sanitized = sanitizeUsage(usage) + usageReportCount += 1 + lastUsage = sanitized + attemptUsages[normalizedAttempt] = + mergeSameAttemptSnapshot(attemptUsages[normalizedAttempt], sanitized) + if (normalizedAttempt > attemptCount) { + attemptCount = normalizedAttempt + } + } + + /** 防御:负值分量一律拒绝为未知(真实负值只会来自异常 provider 数据)。 */ + private fun sanitizeUsage(usage: ProviderUsageSnapshot): ProviderUsageSnapshot { + fun nonNegative(value: Long?): Long? = value?.takeIf { it >= 0 } + return ProviderUsageSnapshot( + uncachedInputTokens = nonNegative(usage.uncachedInputTokens), + cachedInputTokens = nonNegative(usage.cachedInputTokens), + cacheWriteTokens = nonNegative(usage.cacheWriteTokens), + totalInputTokens = nonNegative(usage.totalInputTokens), + outputTokens = nonNegative(usage.outputTokens), + reasoningTokens = nonNegative(usage.reasoningTokens), + reasoningIncludedInOutput = usage.reasoningIncludedInOutput, + cacheWriteSeparateBilling = usage.cacheWriteSeparateBilling, + completeSnapshot = usage.completeSnapshot, + source = usage.source, + ) + } + + /** + * 同一 attempt 的快照合并: + * - 完整快照([ProviderUsageSnapshot.completeSnapshot] = true):整份覆盖, + * null 字段 = 明确未知(撤销旧值); + * - 部分更新(false):最新上报的非空字段优先;新快照缺失的字段保留旧值。 + * 累计字段(output 等)直接取最新值,不能 start/delta 相加。 + */ + private fun mergeSameAttemptSnapshot( + previous: ProviderUsageSnapshot?, + latest: ProviderUsageSnapshot, + ): ProviderUsageSnapshot { + if (previous == null) return latest + if (latest.completeSnapshot) return latest + return ProviderUsageSnapshot( + uncachedInputTokens = latest.uncachedInputTokens ?: previous.uncachedInputTokens, + cachedInputTokens = latest.cachedInputTokens ?: previous.cachedInputTokens, + cacheWriteTokens = latest.cacheWriteTokens ?: previous.cacheWriteTokens, + totalInputTokens = latest.totalInputTokens ?: previous.totalInputTokens, + outputTokens = latest.outputTokens ?: previous.outputTokens, + reasoningTokens = latest.reasoningTokens ?: previous.reasoningTokens, + reasoningIncludedInOutput = + latest.reasoningIncludedInOutput ?: previous.reasoningIncludedInOutput, + cacheWriteSeparateBilling = latest.cacheWriteSeparateBilling, + completeSnapshot = false, + source = latest.source, + ) + } + + /** + * 按 attempt 聚合后的 usage:分量在所有上报 attempt 中都已知时才求和 + * (Long 饱和加法,绝不溢出为负),任一 attempt 该分量未知则聚合值保持未知; + * 来源/包含推理声明取最后一次。 + */ + fun aggregatedUsage(): ProviderUsageSnapshot? { + val snapshots = attemptUsages.values.toList() + if (snapshots.isEmpty()) return null + return ProviderUsageSnapshot( + uncachedInputTokens = sumComponent(snapshots) { it.uncachedInputTokens }, + cachedInputTokens = sumComponent(snapshots) { it.cachedInputTokens }, + cacheWriteTokens = sumComponent(snapshots) { it.cacheWriteTokens }, + totalInputTokens = sumComponent(snapshots) { it.totalInputTokens }, + outputTokens = sumComponent(snapshots) { it.outputTokens }, + reasoningTokens = sumComponent(snapshots) { it.reasoningTokens }, + reasoningIncludedInOutput = snapshots.lastOrNull()?.reasoningIncludedInOutput, + cacheWriteSeparateBilling = snapshots.lastOrNull()?.cacheWriteSeparateBilling ?: true, + completeSnapshot = true, + source = snapshots.lastOrNull()?.source ?: "unknown", + ) + } + + private fun sumComponent( + snapshots: List, + pick: (ProviderUsageSnapshot) -> Long?, + ): Long? { + val values = snapshots.mapNotNull(pick) + if (values.size != snapshots.size) return null + return values.fold(0L) { acc, value -> TokenCostCalculator.saturatedAdd(acc, value) } + } + + /** 结束请求:只能设置一次,后续调用被忽略。 */ + fun finish(status: TokenStatStatus, nowMs: Long = System.currentTimeMillis()) { + if (this.status == null) { + this.status = status + endedAtMs = nowMs + } + } + + // ==== 磁盘 spool 序列化(TokenStatSpool 写入前日志重放) ==== + // 只保存完整脱敏事件(无正文/凭据);聚合已完成,重放结果与原始请求一致。 + // v2 起行内携带“发生时”价格/成本快照(P1-1),排空只按快照落账。 + + /** 不可变基础 JSON(usage/状态/时间):调用方在请求收尾同步生成,冻结快照。 */ + internal fun toSpoolBaseJson(): JSONObject = + JSONObject().apply { + put("v", SPOOL_FORMAT_VERSION) + put("eventId", eventId) + put("category", category.name) + put("configId", configId) + put("provider", provider) + put("model", model) + put("startedAtMs", startedAtMs) + put("acceptedGeneration", acceptedGeneration) + put("endedAtMs", endedAtMs) + firstTokenAtMs?.let { put("firstTokenAtMs", it) } + status?.let { put("status", it.name) } + put("usageReportCount", usageReportCount) + put("attemptCount", attemptCount) + aggregatedUsage()?.let { usage -> + put("usage", usageToJson(usage)) + } + } + + /** 完整 v2 行:基础 JSON + 发生时价格/成本快照。 */ + internal fun toSpoolLine(pricing: ResolvedPricing, cost: Double?): String = + toSpoolBaseJson().apply { + put("pricing", pricingToJson(pricing, cost)) + }.toString() + + internal companion object { + private const val SPOOL_FORMAT_VERSION = 2 + + /** + * 从 spool 行恢复上下文(重放时直接使用聚合结果与冻结价格快照,语义与 + * 原始请求一致)。v2 严格解析:缺价格快照(v1 未发布格式)视为损坏行, + * 由 spool 整段隔离(保留证据),绝不静默用当前价格重放。 + */ + internal fun fromSpoolLine(line: String): TokenStatRequestContext { + val json = JSONObject(line) + if (json.optInt("v", 0) != SPOOL_FORMAT_VERSION) { + throw IllegalStateException("unsupported spool format version") + } + val context = + TokenStatRequestContext( + eventId = json.getString("eventId"), + category = TokenStatCategory.fromName(json.optString("category")), + configId = json.getString("configId"), + provider = json.getString("provider"), + model = json.getString("model"), + startedAtMs = json.getLong("startedAtMs"), + acceptedGeneration = json.getLong("acceptedGeneration"), + ) + json.opt("firstTokenAtMs")?.let { firstTokenAtMs -> + context.firstTokenAtMs = (firstTokenAtMs as Number).toLong() + } + context.endedAtMs = json.getLong("endedAtMs") + context.status = TokenStatStatus.fromName(json.optString("status")) + context.usageReportCount = json.optInt("usageReportCount", 0) + context.attemptCount = json.optInt("attemptCount", 0) + json.optJSONObject("usage")?.let { usageJson -> + val usage = usageFromJson(usageJson) + context.lastUsage = usage + context.attemptUsages[1] = usage + } + val pricingJson = + json.optJSONObject("pricing") + ?: throw IllegalStateException("spool line missing pricing snapshot") + context.frozenPricing = pricingFromJson(pricingJson) + context.pricingResolutionDiagnostic = + pricingJson.optString("resolutionDiagnostic").takeIf { it.isNotBlank() } + return context + } + + internal fun pricingToJson( + pricing: ResolvedPricing, + cost: Double?, + resolutionDiagnostic: String? = null, + ): JSONObject = + JSONObject().apply { + put("billingMode", pricing.billingMode.name) + put("currency", pricing.currency.name) + pricing.inputPricePerMillion?.let { put("inputPricePerMillion", it) } + pricing.cachedInputPricePerMillion?.let { put("cachedInputPricePerMillion", it) } + pricing.cacheWritePricePerMillion?.let { put("cacheWritePricePerMillion", it) } + pricing.outputPricePerMillion?.let { put("outputPricePerMillion", it) } + pricing.pricePerRequest?.let { put("pricePerRequest", it) } + put("source", pricing.source.name) + put("known", pricing.known) + cost?.let { put("cost", it) } + resolutionDiagnostic?.let { put("resolutionDiagnostic", it) } + } + + private fun pricingFromJson(json: JSONObject): FrozenEventPricing { + val pricing = + ResolvedPricing( + billingMode = BillingMode.fromString(json.getString("billingMode")), + currency = + if (json.optString("currency").equals("CNY", ignoreCase = true)) { + PricingCurrency.CNY + } else { + PricingCurrency.USD + }, + inputPricePerMillion = json.optDoubleOrNull("inputPricePerMillion"), + cachedInputPricePerMillion = json.optDoubleOrNull("cachedInputPricePerMillion"), + cacheWritePricePerMillion = json.optDoubleOrNull("cacheWritePricePerMillion"), + outputPricePerMillion = json.optDoubleOrNull("outputPricePerMillion"), + pricePerRequest = json.optDoubleOrNull("pricePerRequest"), + source = PricingSource.fromName(json.optString("source")), + known = json.optBoolean("known", false), + ) + return FrozenEventPricing(pricing, json.optDoubleOrNull("cost")) + } + + private fun usageToJson(usage: ProviderUsageSnapshot): JSONObject = + JSONObject().apply { + usage.uncachedInputTokens?.let { put("uncachedInputTokens", it) } + usage.cachedInputTokens?.let { put("cachedInputTokens", it) } + usage.cacheWriteTokens?.let { put("cacheWriteTokens", it) } + usage.totalInputTokens?.let { put("totalInputTokens", it) } + usage.outputTokens?.let { put("outputTokens", it) } + usage.reasoningTokens?.let { put("reasoningTokens", it) } + usage.reasoningIncludedInOutput?.let { put("reasoningIncludedInOutput", it) } + put("cacheWriteSeparateBilling", usage.cacheWriteSeparateBilling) + put("completeSnapshot", usage.completeSnapshot) + put("source", usage.source) + } + + private fun usageFromJson(json: JSONObject): ProviderUsageSnapshot = + ProviderUsageSnapshot( + uncachedInputTokens = json.optLongOrNull("uncachedInputTokens"), + cachedInputTokens = json.optLongOrNull("cachedInputTokens"), + cacheWriteTokens = json.optLongOrNull("cacheWriteTokens"), + totalInputTokens = json.optLongOrNull("totalInputTokens"), + outputTokens = json.optLongOrNull("outputTokens"), + reasoningTokens = json.optLongOrNull("reasoningTokens"), + reasoningIncludedInOutput = + if (json.has("reasoningIncludedInOutput")) { + json.optBoolean("reasoningIncludedInOutput") + } else { + null + }, + cacheWriteSeparateBilling = json.optBoolean("cacheWriteSeparateBilling", true), + completeSnapshot = json.optBoolean("completeSnapshot", false), + source = json.optString("source", "unknown"), + ) + + private fun JSONObject.optLongOrNull(key: String): Long? = + if (has(key) && !isNull(key)) optLong(key, -1).takeIf { it >= 0 } else null + + private fun JSONObject.optDoubleOrNull(key: String): Double? = + if (has(key) && !isNull(key)) { + optDouble(key, Double.NaN).takeIf { !it.isNaN() } + } else { + null + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt new file mode 100644 index 000000000..27b083fc1 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -0,0 +1,3506 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** + * Durable token-stat outbox. + * + * The acceptance boundary is a synchronous append plus fsync of a complete event line. Price + * resolution happens before this object is called; the background worker only inserts immutable + * rows into Room. There is deliberately no memory overflow queue: a disk failure is returned to + * the model-call boundary and cannot be mistaken for a durable statistic. + * + * [lifecycleMutex] is also the raw-snapshot barrier. A snapshot drains every pending line before + * checkpointing; restore invalidates queued workers before replacing files and verifies cleanup + * before reporting success. + * + * Room insert isolation across the snapshot/restore boundary is enforced by an active-insert + * registry plus generation fencing (P1-2): an insert task atomically re-checks the session + * generation and the exclusive flag, and only then registers itself before touching Room. A + * snapshot/restore bumps the generation, blocks new registrations, and waits a hard-bounded + * [exclusiveQuiesceTimeoutMs] for the registry to empty; on timeout it fails explicitly BEFORE + * any file replacement (or checkpoint), so an old insert that is wedged inside Room can never + * overlap a replaced database. Lock order is always `lifecycleMutex -> stateLock` and never + * reversed, so a normal drain/append can never deadlock on the registry wait. + * + * In-flight provider/stream requests are NOT visible to the insert registry (they have not + * reached [append] yet), so the registry alone cannot stop an old request from writing into a + * restored database. Request/session fencing (P1 终审) closes that gap: + * - Every request captures [restoreEpoch] synchronously at its start via [captureRestoreEpoch] + * (pure in-memory, no Room) and carries it in its [TokenStatRequestContext.sessionEpoch]. + * - [append] validates the captured epoch against the current [restoreEpoch] AND + * [acceptingEventsThisProcess] inside [lifecycleMutex] before any spool write. A restore + * barrier (clearAfter=true) atomically increments [restoreEpoch] at its start, so every old + * request fails the epoch check and is rejected with a clear persistence failure — it never + * writes the (replaced) spool/Room, and the epoch is never round-tripped through JSON/Room. + * - Once a restore replacement actually starts (right before [block] runs), the process stops + * accepting ALL statistics events ([acceptingEventsThisProcess] = false) until it restarts: + * the UI allows restarting later, so same-process new requests must be explicitly rejected + * ([isAcceptingEvents], used by the tracking boundary) and can never pollute the new DB. + * A restore failure BEFORE replacement leaves accepting enabled (new requests continue); + * a failure after replacement has started keeps it disabled and requires a restart. + */ +internal object TokenStatSpool { + private const val TAG = "TokenStatSpool" + internal const val SPOOL_DIR_NAME = "token_stats_spool" + private const val ACTIVE_FILE_NAME = "active.jsonl" + private const val SEALED_PREFIX = "sealed_" + private const val SEALED_SUFFIX = ".jsonl" + private const val QUARANTINE_PREFIX = "quarantine_" + + /** + * P2:seal copy 回退中途失败的部分目标隔离前缀(`seal_failed_`)。隔离文件 scanner + * 忽略(不匹配 [SEALED_PREFIX])、计入递归总 cap(占用可见)、由维护入口 [retryPendingCleanup] + * 清理(active 保留完整内容,隔离副本删除安全,无数据损失)。P2 终审:同时作为**受管失败 + * 发布证据**计入 [quarantineAreaFiles]——长期删除失败时可见(quarantineEvidence/info 字节)、 + * 可导出、可确认删除(ack 按 NOFOLLOW/path 根校验删除并释放容量),绝不无限隐藏占用。 + */ + private const val SEAL_FAILED_PREFIX = "seal_failed_" + + /** + * ack 删除的事务化暂存目录前缀(reviewer P1):ack 先把全部待删文件 rename 进本轮唯一 + * 的 trash 目录(同 filesystem、可回滚),全部成功后才重写 manifest。P1-2:trash 目录内 + * 先原子写入状态文件 [ACK_TRASH_STATE_FILE_NAME](首行 UNCOMMITTED/COMMITTED + mapping + * 行),维护入口 [retryPendingCleanup] 按持久状态处置:**只有显式 COMMITTED 才允许后台 + * 补删**;UNCOMMITTED 一律按 mapping+identity 回滚(P1-1 修复:绝不根据主 manifest 缺失 + * 推断已提交——普通 quarantine 证据从未进入 manifest,缺失恒成立,旧推断会把未确认的 + * 证据误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚后的损坏 sealed 会被扫描器 + * 重新隔离,ack 视失败但不丢证据)。状态缺失/损坏或回滚长期失败的 trash 作为 + * StuckAckEvidence 由 UI 管理(见 [stuckAckTrashEvidence]),绝不自动删除。trash 目录及 + * 其内容计入递归总容量(P1-1),占用绝不隐藏。目录名不可与任何证据/元数据文件前缀冲突。 + */ + private const val ACK_TRASH_PREFIX = "quarantine_ack_trash_" + + /** ack trash 内的原子状态文件(P1-2):首行 = [ACK_STATE_UNCOMMITTED]/[ACK_STATE_COMMITTED],后续行 = mapping(原名 → trash 名 + bytes + sha256)。 */ + internal const val ACK_TRASH_STATE_FILE_NAME = "ack_state.jsonl" + internal const val ACK_STATE_UNCOMMITTED = "UNCOMMITTED" + internal const val ACK_STATE_COMMITTED = "COMMITTED" + internal const val MAX_LINE_BYTES = 8 * 1024 + + /** 单段封顶字节(P1-2 测试可注入更小值,端到端验证 seal 与总容量边界)。 */ + internal var MAX_SEGMENT_BYTES = 4L * 1024 * 1024 + + /** + * 总 spool 硬上限(P1-1/P1-2):active/sealed/pending-delete/quarantine/summary/manifest + * 及 sidecar/tmp 与 ack trash 等**全部管理文件**的实际字节总和(递归,见 + * [totalSpoolBytes]),任意时刻恒 ≤ 该值。 + * + * 数据准入上限 = 总上限 − [METADATA_RESERVE_BYTES](见 [dataAdmissionMaxBytes]):append + * 准入时投影(当前总量 + 本次行字节)超过即明确抛 [TokenStatsPersistenceException], + * 绝不发布新文件——Room 长期失败时 sealed 段因此也有界(256MiB / 4MiB 段 ≈ 64 段)。 + * 元数据(summary/manifest 及其 sidecar/tmp、ack trash 状态/mapping)写在发布前另行投影 + * `totalSpoolBytes + worstCaseAdditional ≤ 总上限`(见 [metadataWriteBudgetExceeded]), + * 因此 drain/ack 的元数据发布同样不可能把实际总量推过总上限。测试可注入更小值。 + */ + internal const val TOTAL_SPOOL_MAX_BYTES = 256L * 1024 * 1024 + internal var totalSpoolMaxBytesForTest: Long? = null + + /** + * Hard retention cap for full corrupt-segment evidence. Within the cap the complete evidence + * is preserved; a NEW corrupt segment that would exceed the cap is replaced by a bounded + * rolling summary (count/hash/bytes/time, never content) and removed, so disk usage stays + * bounded while healthy drains continue. + */ + internal const val MAX_QUARANTINE_BYTES = 16L * 1024 * 1024 + + /** Fixed-size rolling summary of over-cap corrupt segments (atomic update, never grows unbounded). */ + private const val QUARANTINE_SUMMARY_NAME = "quarantine_summary.jsonl" + internal const val MAX_QUARANTINE_SUMMARY_BYTES = 64L * 1024 + internal const val MAX_QUARANTINE_SUMMARY_LINES = 256 + + /** + * 有界 skip/tombstone manifest(P1-2/P1-1):既不能删除也不能重命名出 sealed 队列的段身份 + * 记入此处(时间/文件名/字节/SHA-256/是否超限,不含正文),扫描器按稳定 identity + * (file+bytes+sha256)跳过该具体文件并继续后续健康段;维护入口(drain 重试)会再次尝试 + * 处置并移除记录。 + * + * 这是**不滚动**的活跃受管失败集合:条目只在对应文件物理消失或身份变化后移除(P1-2), + * 绝不能像历史摘要那样滚动丢弃仍存在文件的身份(P1-1:滚动会让旧段重新进入扫描队列, + * 造成无界重扫循环)。硬上限 [MAX_TOMBSTONE_ENTRIES]/[MAX_TOMBSTONE_MANIFEST_BYTES]/ + * [MAX_MANAGED_BYTES] 到达后停止接受新的统计 append(明确抛 + * [TokenStatsPersistenceException],不产生更多段),drain 有界跳过并继续健康段。 + */ + private const val TOMBSTONE_MANIFEST_NAME = "quarantine_skip_manifest.jsonl" + + /** 活跃受管失败集合的身份硬上限:超过后新统计 append 明确失败(不能继续产生更多段)。 */ + internal const val MAX_TOMBSTONE_ENTRIES = 64 + + /** 受管失败集合的 manifest 文件字节硬上限(64 条约 200B,纯 ASCII,正常远达不到)。 */ + private const val MAX_TOMBSTONE_MANIFEST_BYTES = 64L * 1024 + + // ── P1-1 元数据预留(总 cap 证明)──────────────────────────────────────────── + // 有界元数据文件(quarantine summary / tombstone manifest)各有 4 个磁盘槽位: + // canonical、`.new`、`.bak`、`.tmp<随机>`。原子替换或回退协议的任意中断窗口下四个 + // 槽位都可能同时各持一份完整副本(read 恢复/清理前),因此单份元数据的最坏磁盘 + // 占用 = 4 × 内容硬上限。所有 spool 元数据读写都持 lifecycleMutex(至多一个写进行 + // 中,Atomic tmp 唯一文件并发数 = 1),预留按单写者计算即可覆盖。 + /** 单份有界元数据的磁盘槽位数(canonical + .new + .bak + tmp)。 */ + internal const val METADATA_COPY_COUNT = 4 + + /** 有界元数据文件数:quarantine summary + tombstone manifest。 */ + private const val METADATA_FILE_COUNT = 2 + + /** 单份有界元数据内容的字节硬上限(summary/manifest 中较大者,均为纯 ASCII 有界)。 */ + internal val MAX_METADATA_FILE_BYTES: Long = + maxOf(MAX_QUARANTINE_SUMMARY_BYTES, MAX_TOMBSTONE_MANIFEST_BYTES) + + /** + * 元数据预留(P1-1):数据准入上限 = 总上限 − 本预留。预留 = 2 个元数据文件 × 4 个 + * 槽位 × 内容硬上限,覆盖 summary 与 manifest 各 canonical/.new/.bak/tmp 最坏副本、 + * overflow summary(quarantine_summary 本身)与固定大小临时文件(tmp 内容同 bound)。 + * 生产值 512KiB = 2 × 4 × 64KiB,远小于 256MiB 总上限([init] 有 require 证明)。 + */ + internal val METADATA_RESERVE_BYTES: Long = + METADATA_FILE_COUNT * METADATA_COPY_COUNT * MAX_METADATA_FILE_BYTES + + init { + require(METADATA_RESERVE_BYTES < TOTAL_SPOOL_MAX_BYTES) { + "metadata reserve must be strictly smaller than the total spool cap: " + + "$METADATA_RESERVE_BYTES >= $TOTAL_SPOOL_MAX_BYTES" + } + } + + /** 受管失败集合中仍占用磁盘的段的总字节硬上限(64 × 单段 4MiB 封顶;段大小可注入时实时计算)。 */ + private val MAX_MANAGED_BYTES get() = MAX_TOMBSTONE_ENTRIES * MAX_SEGMENT_BYTES + + /** 删除失败的 over-cap 段的诊断去向:完整证据(计入硬 cap,可导出/确认删除)。 */ + private const val PENDING_DELETE_PREFIX = "quarantine_pending_delete_" + private const val RETRY_BACKOFF_BASE_MS = 1_000L + private const val RETRY_BACKOFF_CAP_MS = 30_000L + internal var insertTimeoutMs: Long = 5_000L + internal var prepareTimeoutMs: Long = 5_000L + + /** 排他快照/恢复等待已登记 insert 全部结束的硬超时;超时则操作明确失败,绝不替换文件。 */ + internal var exclusiveQuiesceTimeoutMs: Long = 5_000L + private const val QUIESCE_POLL_INTERVAL_MS = 50L + + /** 文件 I/O 调度缝(P2-2):导出/确认删除的复制、fsync、扫描绝不运行在调用方(Main)线程。 */ + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + + private val lifecycleMutex = Mutex() + private val stateLock = Any() + private var drainScheduled = false + private var sessionGeneration = 0L + private var retryDelayMs = RETRY_BACKOFF_BASE_MS + private var writerExecutor = newWriterExecutor() + + /** + * 排他快照/恢复进行中:阻止新 insert 登记(与 insert 的登记在同一 critical section 原子判定)。 + */ + private var exclusiveBarrierActive = false + + /** 已通过 fence 且正在 Room 内写入的 insert(eventId -> 提交时 generation)。 */ + private val activeInserts = HashMap() + + /** + * Request/session fencing epoch(P1 终审):只在恢复屏障(clearAfter=true)开始时原子递增, + * 使所有在屏障开始前开始(已捕获旧 epoch)的 in-flight provider/stream 请求在收尾 + * [append] 时被明确拒绝——绝不写入可能已被恢复替换的 spool/Room。导出/快照屏障 + * (clearAfter=false)不递增:进行中的请求在导出期间正常收尾。进程内单调递增, + * 不随普通 [withExclusiveSnapshotAccess] 变化;测试经 [clearPendingStateForTest] 复位。 + */ + private var restoreEpoch = 0L + + /** + * 本进程是否仍接受统计事件(P1 终审):恢复屏障的替换开始([block] 即将执行)时置 false, + * 直到进程重启(UI 允许稍后重启——此后所有 [append] 与新的跟踪请求被明确拒绝,绝不污染 + * 恢复后的新 DB)。替换前失败(drain/quiesce 阶段抛错)保持 true,新请求可继续。 + * 进程重启(含测试模拟)经 [resetExecutorsForTest]/[clearPendingStateForTest] 复位。 + */ + @Volatile + private var acceptingEventsThisProcess = true + + /** + * Dedicated bounded insert worker. Room/SQLite writes can ignore thread interrupts, so the + * drain never joins this worker; a timed-out task is detached and the durable segment is + * retried later. The single daemon thread plus one queue slot is the hard bound (P2-1), so a + * permanently wedged database cannot leak threads or hold the lifecycle lock. + */ + private var insertExecutor = newInsertExecutor() + + /** Dedicated bounded database-preparation worker with single-flight semantics (P2-1). */ + private var databaseExecutor = newDatabaseExecutor() + private var pendingDaoTask: FutureTask? = null + private val insertionWaiters = HashMap>() + + /** 测试注入缝:返回 null 走真实删除;返回 false 模拟删除失败(P1-2 分支注入)。 */ + internal var segmentDeleteForTest: ((File) -> Boolean?)? = null + + /** 测试注入缝:返回 null 走真实 renameTo;返回 false 模拟段处置重命名失败。 */ + internal var segmentRenameForTest: ((File, File) -> Boolean?)? = null + internal var afterSegmentReadForTest: (() -> Unit)? = null + internal var spoolDeleteForTest: ((File) -> Boolean)? = null + + /** 测试注入缝:返回 false 强制摘要/manifest 原子替换不支持(走 old/new/backup 回退)。 */ + internal var quarantineAtomicMoveForTest: ((File, File) -> Boolean)? = null + + /** 测试注入缝:返回 true 时对应元数据文件(summary/manifest)读取抛 IOException(P1-2)。 */ + internal var metadataReadErrorForTest: ((File) -> Boolean)? = null + + /** 测试注入缝:返回 true 时对应元数据文件发布抛 IOException。 */ + internal var metadataWriteErrorForTest: ((File) -> Boolean)? = null + + /** + * 可控 seal publication seam(P1-8):候选编号选定后、实际发布前以目标文件调用。 + * 返回 false 模拟发布前失败(seal 明确失败);测试可在回调里创建同名不同内容的目标 + * 文件模拟冲突(返回 true),真实发布路径必须检测到占用并选下一编号,目标原字节 + * 保持不变。返回 null 表示无操作。 + */ + internal var beforeSealPublishForTest: ((File) -> Boolean?)? = null + + /** 测试注入缝:返回 false 强制模拟硬链接不受支持(走 copy 回退发布);其余走真实 createLink。 */ + internal var sealHardLinkForTest: ((File, File) -> Boolean?)? = null + + /** 测试注入缝:返回 false 模拟 seal 发布成功后删除 active 失败(硬链接崩溃窗口)。 */ + internal var sealActiveDeleteForTest: ((File) -> Boolean?)? = null + + /** + * 测试注入缝(P1 终审):返回 null 走真实 fsync;返回 false 模拟 seal 目标文件 fsync + * 失败(调用方必须保留 active、处置目标并返回 FAILED,绝不声称 PUBLISHED)。 + */ + internal var fileSyncForTest: ((File) -> Boolean?)? = null + + /** + * 测试注入缝(P1 终审):返回 null 走真实目录 fsync;返回 [DirSyncResult.OK] 模拟目录 + * fsync 成功;[DirSyncResult.FAILED] 模拟真实失败(发布路径 fail-closed); + * [DirSyncResult.UNSUPPORTED] 模拟平台明确不支持(发布路径同样 fail-closed——绝不当作 + * 成功继续删除唯一 fsynced active 或声称 PUBLISHED)。生产平台(Android/Linux)支持 + * 目录 fd fsync;Windows 仅 JVM 测试环境,测试统一注入 OK 运行正常路径,UNSUPPORTED + * 只用于显式 fail-closed 测试。不存在“原地排空”平台模式:UNSUPPORTED 与 FAILED 一样 + * 只让调用方 fail-closed 保留数据。 + */ + internal var dirSyncForTest: ((File) -> DirSyncResult?)? = null + + /** + * 每进程 spool 目录项持久确认标记(P1-1 终审):进程内首次成功完成 bootstrap gate + * ([ensureDirectoryDurabilityConfirmed])后为 true;初始 false,进程重启即清零 + * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟)。P1-1 修复: + * 任一 spool 目录项变更(新建/rename/delete,含元数据严格发布)后的目录 sync 非 OK, + * 或删除开始/重建 mkdir 失败,都**立即**重新置 false(统一经 [requireSpoolDirSync]), + * 下一次使用前必须先重新确认——绝不带着“已确认”内存标记声称 durable。**仅进程内 + * 有效**:上一进程可见但未确认的目录项在本进程重新提交,未声明 durable 的事件允许 + * 丢失,属 append 契约内。 + */ + @Volatile + private var directoryDurabilityConfirmedThisProcess = false + + /** + * 测试注入缝(P2):返回 null 走真实 `Files.copy`;返回 false 模拟 copy 中途失败(seam + * 可在回调内先写入部分目标字节,发布路径必须按 identity 确认后隔离/删除/tombstone); + * 返回 true 模拟 copy 成功(seam 自行写入目标内容)。 + */ + internal var sealCopyForTest: ((File, File) -> Boolean?)? = null + + /** 测试注入缝:返回 null 走真实原子 move;返回 false 模拟 ack prepare/rollback 失败。 */ + internal var ackAtomicMoveForTest: ((File, File) -> Boolean?)? = null + + /** 测试注入缝:返回 true 时对应段原始字节读取失败 → 身份校验 UNREADABLE / drain 中止(P1-2)。 */ + internal var segmentReadErrorForTest: ((File) -> Boolean)? = null + + /** + * 测试注入缝:安全关键路径的目录枚举(默认行为与 [File.listFiles] 完全一致)。测试可按 + * 目录返回 null 模拟枚举失败——依赖枚举完整性的安全判定(mapping 全有或全无、trash 空 + * 目录判定、UNCOMMITTED 身份扫描、sealed 队列/待处理判定、seal 编号选择)据此显式 + * fail-closed,绝不把失败当空目录、空队列或编号 1。仅用于这些安全路径,不影响普通目录 + * 枚举(容量扫描等)。 + */ + internal var directoryListingForTest: ((File) -> Array?)? = null + + /** 活跃受管失败集合的稳定身份(P1-2):file+bytes+sha256,三者全匹配才算同一段。 */ + private data class TombstoneEntry( + val file: String, + val bytes: Long, + val sha256: String, + val overCap: Boolean, + ) + + /** tombstone 写入结果:容量满 ≠ 写失败(容量满时跳过该段继续健康,写失败才退避重试)。 */ + private enum class TombstoneResult { RECORDED, CAPACITY_FULL, FAILED } + + /** + * 身份判定(P1-1)只允许使用**实时**从原始字节计算的 SHA-256:length+mtime 不足以 + * 区分同名同长同 mtime 的不同内容,任何身份缓存复用旧 SHA 都会让 cleanup/ack 误删或 + * 隔离健康段。因此这里没有任何身份哈希缓存——每个破坏性决策(skip/delete/rename/ack) + * 都现场 hash 文件原始字节(单段 ≤4MiB,成本可接受)。 + */ + + private fun newWriterExecutor() = + ScheduledThreadPoolExecutor(1) { runnable -> + Thread(runnable, "operit-token-stats-writer").apply { isDaemon = true } + } + + private fun newInsertExecutor() = + ThreadPoolExecutor( + 1, + 1, + 60L, + TimeUnit.SECONDS, + LinkedBlockingQueue(1), + ) { runnable -> Thread(runnable, "operit-token-stats-insert").apply { isDaemon = true } } + + private fun newDatabaseExecutor() = + ThreadPoolExecutor( + 1, + 1, + 60L, + TimeUnit.SECONDS, + LinkedBlockingQueue(1), + ) { runnable -> Thread(runnable, "operit-token-stats-database").apply { isDaemon = true } } + + /** + * Append a complete immutable event. true means an fsync-backed durable copy exists. + * 受管失败集合(tombstone entries)或证据区到达硬上限时,在持锁下明确抛 + * [TokenStatsPersistenceException]:不能继续产生更多段,绝不返回伪 durable。 + * + * P1 终审:目录 fsync([syncDir])的 OK 是返回 durable 的唯一前提。首次创建 spool + * 目录时先同步父目录(filesDir)目录项、再同步新目录本身;首次创建 active 文件时写 + * +fd.sync 后必须同步 spool 目录确认目录项。任一非 OK 都 fail-closed(返回 false, + * 内容保留、可重试,但绝不声明 durable)。 + * + * P1-1 终审:每进程首次使用 spool 前先过 durable bootstrap gate + * ([ensureDirectoryDurabilityConfirmed])——若 spool 目录已存在(无论本进程还是 + * **上一进程**创建),先 sync filesDir 确认 spool 目录项、再 sync spool 目录确认 + * active/metadata 等可见目录项;两者 OK 前绝不写新行或返回 durable。上一进程已可见 + * 但未确认的目录项在本进程重新提交,绝不把两个事件混为一次成功。 + * + * P1 终审(request/session fencing):写 spool 前先验证请求开始捕获的 + * [sessionEpoch] 仍等于当前 [restoreEpoch] 且 [acceptingEventsThisProcess] 为 true + * (恢复屏障开始时原子递增 epoch 使所有旧请求失效;恢复替换开始后本进程不再接受任何 + * 事件直至重启)。任一不满足即明确失败(返回 false,调用方抛 + * [com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException]), + * 绝不写入可能已被恢复替换的 spool。默认 [sessionEpoch] 为调用时刻捕获(直接 spool + * 写入的请求边界即调用本身);生产请求边界经 [TokenStatRequestContext.sessionEpoch] + * 在请求开始时显式捕获。 + */ + suspend fun append( + context: Context, + line: String, + eventId: String, + sessionEpoch: Long = captureRestoreEpoch(), + ): Boolean = + lifecycleMutex.withLock { + val appContext = context.applicationContext + // P1 终审:restore fencing 必须在任何目录创建/容量检查/写入之前判定——旧请求 + // 绝不能碰(可能已被恢复替换的)spool,也绝不能把事件写进新 DB。 + if (!fenceAcceptsRestore(sessionEpoch)) { + logE( + "statistics append rejected after restore barrier: eventId=$eventId, " + + "epoch=$sessionEpoch, accepting=$acceptingEventsThisProcess", + ) + return@withLock false + } + try { + val bytes = (line + "\n").toByteArray(Charsets.UTF_8) + if (bytes.size > MAX_LINE_BYTES) { + logE("statistics event exceeds durable line limit: eventId=$eventId, bytes=${bytes.size}") + return@withLock false + } + val dir = spoolDir(appContext) + // P1-1 终审:bootstrap gate——本进程首次使用前必须先确认既有 spool 目录项 + // 持久(上进程可见未确认的目录项在此重新提交);失败 fail-closed。 + if (!ensureDirectoryDurabilityConfirmed(appContext, dir)) { + logE( + "statistics spool directory durability unconfirmed; refusing append: eventId=$eventId", + ) + return@withLock false + } + if (managedFailureCapacityExceeded(appContext)) { + logE("statistics managed-failure capacity exhausted; refusing new durable event: eventId=$eventId") + throw TokenStatsPersistenceException( + "token statistics persistence capacity exhausted: managed failure set is full", + ) + } + // P1 终审:首次创建 spool 目录必须确认目录项持久才允许声称 durable。 + // 创建成功后先同步父目录(filesDir,系统预存)的目录项,再同步新目录本身; + // 任一非 OK 本次 append 明确失败(已创建的目录可保留供重试,但未声明 durable, + // 且 [directoryDurabilityConfirmedThisProcess] 保持 false,下一次 append 由 + // bootstrap gate 重新确认)。 + val dirCreated = !dir.isDirectory + if (dirCreated) { + if (!dir.mkdirs()) { + // P1-1 终审修复:创建失败立即失效 gate——已确认的目录项状态不再 + // 可靠,下一次使用必须先重新确认,绝不带着“已确认”标记继续。 + directoryDurabilityConfirmedThisProcess = false + logE("statistics spool directory cannot be created: ${dir.absolutePath}") + return@withLock false + } + val parent = dir.parentFile + if (parent == null || !requireSpoolDirSync(parent, dir)) { + logE( + "statistics spool directory creation is not durable; " + + "directory retained for retry: ${dir.absolutePath}", + ) + return@withLock false + } + directoryDurabilityConfirmedThisProcess = true + } + val active = File(dir, ACTIVE_FILE_NAME) + // P1-8:active 非空时先恢复可能的 seal 崩溃窗口重复(硬链接或 copy 回退的 + // copy+delete 窗口)。不消除同 inode 重复就追加会把已 seal 段一起改写; + // 不消除 copy 窗口重复会让同一内容被排空两次。恢复失败 fail-closed—— + // 绝不带着“可能还有重复”的状态写入。 + if (active.isFile && active.length() > 0L && !recoverSealDuplicates(dir, active)) { + logE("statistics spool seal recovery failed; refusing append: eventId=$eventId") + return@withLock false + } + // P1-1 总硬上限:数据准入上限 = 总上限 − 元数据预留。全部管理文件实际字节 + // (递归含 ack trash 子目录,含元数据 canonical 与 sidecar/tmp 残留)+ 本次行 + // 超过即明确拒绝且不发布任何新文件(seal 只是同目录改名,不新增字节;空 active + // 计入 0 字节)。持有 lifecycleMutex,与并发 seal/drain 互斥,扫描结果即一致性快照。 + // P1 终审:不尝试任何“排空换容量”回退——容量超限就是明确拒绝。 + val cap = totalSpoolMaxBytesForTest ?: TOTAL_SPOOL_MAX_BYTES + if (totalSpoolBytes(dir, cap) + bytes.size > dataAdmissionMaxBytes(cap)) { + logE("statistics spool total size cap exceeded; refusing new durable event: eventId=$eventId") + throw TokenStatsPersistenceException( + "token statistics persistence capacity exhausted: total spool size cap reached", + ) + } + if (active.isFile && active.length() > 0L && !activeEndsWithLineBreak(active)) { + // A crash mid-write leaves a partial line without a trailing newline. Appending + // here would splice the healthy event onto the broken tail and quarantine the + // combined segment, losing a durable healthy event. Seal the incomplete tail as + // evidence first (the drain quarantines the partial line), then write clean. + if (!sealActive(appContext, dir)) return@withLock false + } + if (active.isFile && active.length() + bytes.size > MAX_SEGMENT_BYTES) { + // 段超限:先封段(发布持久化协议见 [sealActive]),失败则明确持久化失败 + // 但 active 保留。 + if (!sealActive(appContext, dir)) return@withLock false + } + // P1 终审:append 前记录 active 是否已存在。首次创建时写+fd.sync 之后必须 + // 同步 spool 目录确认目录项;非 OK 则本次不返回 durable(内容保留,且 + // [directoryDurabilityConfirmedThisProcess] 复位——下一次 append 由 bootstrap + // gate 重新确认目录项后才写入新行,绝不把两事件混为一次成功)。 + val activeExisted = active.isFile + FileOutputStream(File(dir, ACTIVE_FILE_NAME), true).use { output -> + output.write(bytes) + output.fd.sync() + } + if (!activeExisted && !requireSpoolDirSync(dir)) { + logE( + "statistics spool first active creation is not durable; " + + "content retained: eventId=$eventId", + ) + return@withLock false + } + synchronized(stateLock) { + insertionWaiters[eventId] = CompletableDeferred() + } + scheduleDrain(appContext) + true + } catch (e: TokenStatsPersistenceException) { + throw e + } catch (e: Exception) { + logE("statistics durable append failed: eventId=$eventId", e) + false + } + } + + /** + * Optional visibility wait; durability already succeeded before this is called. + * + * A restore may invalidate this waiter while the caller's own coroutine is still active + * (callers run under [kotlinx.coroutines.NonCancellable]). That is internal invalidation, not + * a persistence failure and not a coroutine cancellation: it must never escape over the + * caller's primary model outcome. Only a cancellation of the caller coroutine itself is + * rethrown. + */ + suspend fun awaitRoomVisibility(eventId: String, timeoutMs: Long) { + val waiter = synchronized(stateLock) { insertionWaiters[eventId] } ?: return + try { + withTimeoutOrNull(timeoutMs) { waiter.await() } + } catch (e: CancellationException) { + // Throws only if the caller coroutine itself was cancelled; under NonCancellable this + // is a no-op, so a restore-invalidated waiter simply returns (durability was already + // fsynced and restore semantics supersede visibility). + currentCoroutineContext().ensureActive() + } finally { + synchronized(stateLock) { + if (insertionWaiters[eventId] === waiter) insertionWaiters.remove(eventId) + } + } + } + + fun replay(context: Context) = scheduleDrain(context.applicationContext) + + /** + * P1-1 终审:每进程首次使用 spool 前的 durable bootstrap gate(调用方持 lifecycleMutex)。 + * + * 内存标记 [directoryDurabilityConfirmedThisProcess] 初始为 false,进程重启即清零 + * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟进程重启)。若 spool + * 目录**已存在**——无论它是本进程创建还是**上一进程**创建——都必须先 sync filesDir + * (确认 spool 目录项持久)再 sync spool 目录(确认 active/metadata 等可见目录项持久); + * 两者都 OK 之前不得写新行/返回 durable/做任何目录变更。这样上一进程已可见但未确认的 + * 目录项在本进程重新提交(崩溃后文件可能消失的窗口被关闭)。 + * + * 目录尚不存在时没有可确认的目录项,放行(首次创建协议在 [append] 中负责创建后同步 + * 父目录与新目录本身;其任一 sync 失败会把本标记保持/复位为 false,下一次使用重新走 + * 本 gate)。任一非 OK 均 fail-closed:不置位标记、返回 false,由调用方明确失败 + * (append 返回 false / drain 退避 / snapshot 抛 IOException)。 + */ + private fun ensureDirectoryDurabilityConfirmed(context: Context, dir: File): Boolean { + if (directoryDurabilityConfirmedThisProcess) return true + if (!dir.isDirectory) return true + val parent = dir.parentFile + // P1-1 终审:bootstrap 只在 filesDir 与 spool 目录两者都 OK 时才置位;任一非 OK + // 由 [requireSpoolDirSync] 保持/置回 false(本处进入时 flag 必为 false),绝不置位。 + if (parent == null || !requireSpoolDirSync(parent, dir)) { + logE( + "statistics spool directory durability unconfirmed; refusing writes " + + "until directory entries are re-confirmed: ${dir.absolutePath}", + ) + return false + } + directoryDurabilityConfirmedThisProcess = true + return true + } + + /** + * Raw snapshot barrier. Export uses [drainBefore] and checkpoints inside [block]; restore uses + * [clearAfter] so old workers are invalidated before replacement and old files are verified + * gone before the restore can succeed. + * + * Isolation guarantee (P1-2): after the drain phase the barrier enters an exclusive state that + * atomically rejects any new insert registration, then waits a hard-bounded time for every + * already-registered (in-flight, inside Room) insert to finish. On timeout it throws + * [IOException] BEFORE [block] runs, so no checkpoint or file replacement can overlap a live + * old insert; the durable spool is untouched and a later process restart can retry. [block] + * itself is only reached once the registry is provably empty. + * + * Request/session fencing (P1 终审): a restore barrier (clearAfter=true) additionally + * atomically increments [restoreEpoch] at its start, invalidating every in-flight + * provider/stream request that captured the previous epoch (their [append] is explicitly + * rejected, never writing the replaced spool/Room). Right before [block] runs (replacement + * starts) the process stops accepting ALL statistics events ([acceptingEventsThisProcess] = + * false) until restart. State machine: failure BEFORE [block] (drain/quiesce/bootstrap) + * leaves accepting enabled — new requests continue; failure after [block] started keeps it + * disabled — the process must restart. + */ + suspend fun withExclusiveSnapshotAccess( + context: Context, + drainBefore: Boolean, + clearAfter: Boolean = false, + block: suspend () -> T, + ): T = lifecycleMutex.withLock { + val appContext = context.applicationContext + // P1-1 终审:快照/恢复前必须先确认 spool 目录项持久(上一进程可见未确认的目录项 + // 在本进程重新提交);失败明确中止,绝不带着未确认状态做 drain/替换/清理。 + if (!ensureDirectoryDurabilityConfirmed(appContext, spoolDir(appContext))) { + throw IOException( + "statistics spool directory durability could not be confirmed for snapshot", + ) + } + val generation = synchronized(stateLock) { + sessionGeneration += 1L + drainScheduled = false + if (clearAfter) { + // P1 终审:恢复屏障开始即原子递增 restore epoch——所有在屏障前开始的请求 + // 收尾 append 时 epoch 不匹配而被明确拒绝;导出/快照(clearAfter=false) + // 不递增,进行中的请求在导出期间正常收尾。 + restoreEpoch += 1L + } + sessionGeneration + } + if (drainBefore && !drainCore(appContext, generation)) { + throw IOException("statistics spool could not be drained for snapshot") + } + if (drainBefore && hasPendingSegments(appContext)) { + throw IOException("statistics spool still contains pending events after drain") + } + // 排他状态必须在 drain 阶段之后设置:drainBefore 自己的 insert 需要登记。 + // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 + synchronized(stateLock) { exclusiveBarrierActive = true } + try { + if (!awaitActiveInsertsEmpty()) { + val live = synchronized(stateLock) { activeInserts.size } + throw IOException( + "statistics Room insert still active ($live); " + + "snapshot/restore aborted before any file replacement", + ) + } + if (clearAfter) { + // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 + // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 + // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 + // 可继续(替换前失败可恢复)。 + synchronized(stateLock) { acceptingEventsThisProcess = false } + } + val result = block() + if (clearAfter) clearForRestoreLocked(appContext) + result + } finally { + synchronized(stateLock) { exclusiveBarrierActive = false } + } + } + + /** + * Request/session fencing 判定(P1 终审,调用方持 lifecycleMutex):请求开始捕获的 + * [sessionEpoch] 必须等于当前 [restoreEpoch](恢复屏障开始时原子递增使旧请求失效), + * 且本进程仍接受事件(恢复替换开始后为 false 直至重启)。任一不满足 → 明确拒绝, + * 绝不写入可能已被恢复替换的 spool。 + */ + private fun fenceAcceptsRestore(sessionEpoch: Long): Boolean = + synchronized(stateLock) { acceptingEventsThisProcess && sessionEpoch == restoreEpoch } + + /** + * 请求开始时同步捕获当前 restore epoch(P1 终审):纯内存读取,无需 Room。请求在 + * 收尾 [append] 时按该值验证未被恢复屏障取代。 + */ + fun captureRestoreEpoch(): Long = synchronized(stateLock) { restoreEpoch } + + /** 本进程是否仍接受新的统计事件/请求(恢复替换开始后为 false,直到进程重启)。 */ + fun isAcceptingEvents(): Boolean = synchronized(stateLock) { acceptingEventsThisProcess } + + /** + * 硬超时等待已登记 insert 全部结束。等待期间不持有 [stateLock](轮询只短暂取快照), + * 因此绝不阻塞普通 drain/append;[delay] 可被协程取消,超时由调用方转换为明确失败。 + */ + private suspend fun awaitActiveInsertsEmpty(): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) + while (true) { + if (synchronized(stateLock) { activeInserts.isEmpty() }) return true + if (System.nanoTime() >= deadline) return false + delay(QUIESCE_POLL_INTERVAL_MS) + } + } + + private fun clearForRestoreLocked(context: Context) { + val dir = spoolDir(context) + // P1-1 终审修复:删除开始前立即失效 bootstrap gate——删除本身是目录项变更,删除后 + // 任何 sync 失败都不得让“已确认”内存标记继续生效,下一次使用必须重新确认(或重新 + // 走首次创建协议)。 + directoryDurabilityConfirmedThisProcess = false + if (dir.exists()) { + val deleted = spoolDeleteForTest?.invoke(dir) ?: dir.deleteRecursively() + if (!deleted || dir.exists()) { + throw IOException("statistics spool cleanup failed: ${dir.absolutePath}") + } + } + // P1-3 终审:spool 目录项删除(可能刚发生且可见)必须确认持久,否则 restore 失败并 + // 保留恢复状态;目录删除可见但 sync 失败时重试幂等(目录已不存在则跳过删除,本处 + // 仍 sync filesDir 确认“删除/不存在”持久后才放行)。P1-1:sync 非 OK 由 + // [requireSpoolDirSync] 同步失效 gate(本函数开头已失效,保持 false 供下次重新确认)。 + val parent = dir.parentFile + if (parent == null || !requireSpoolDirSync(parent)) { + throw IOException( + "statistics spool cleanup not durable; restore state retained: ${dir.absolutePath}", + ) + } + synchronized(stateLock) { + insertionWaiters.values.forEach { it.cancel() } + insertionWaiters.clear() + } + } + + /** + * Evidence management is explicit: callers can export these files, then acknowledge deletion. + * 摘要与 tombstone manifest 是有界元数据(不计入完整证据硬 cap,导出时单独带上); + * 两者的 `.new`/`.bak`/`.tmp*` 崩溃安全 sidecar 也不算证据,避免误导出与重复计容。 + * 受管失败段(P1-3):manifest 中仍存在且 identity 匹配的原损坏 sealed 文件必须作为 + * managed evidence 参与容量、UI 计数/字节、导出与 ack 删除,绝不隐藏。 + * + * P1-3:非空 ack trash 目录(未完成删除事务)也作为受管证据返回(追加在文件列表之后)—— + * 有效 UNCOMMITTED 的 trash 会被维护优先自动回滚、不常驻;状态缺失/损坏或回滚长期失败 + * 的 trash 必须可被 UI 计数/导出/确认删除,绝不隐藏。字节计数需配合 [stuckAckTrashBytes] + * (目录的 [File.length] 恒为 0)。 + * + * P1-1:公开入口统一持 [lifecycleMutex]——sidecar 恢复(read 会把 `.new`/`.bak` 改名回 + * canonical)与 append/drain 的容量扫描共享同一把锁,绝不与容量投影竞态。内部调用 + * (已在锁内)必须使用 [quarantineEvidenceLocked],避免重入。 + * + * P1-6:证据枚举失败(quarantine 区或 stuck ack trash)时抛 [IOException](fail-closed, + * 见 [stuckAckTrashEvidenceLocked]/[quarantineAreaFiles])——调用方必须按失败处理, + * 绝不能当作“0 证据”诱导删除。 + */ + suspend fun quarantineEvidence(context: Context): List = + lifecycleMutex.withLock { + quarantineEvidenceLocked(context) + stuckAckTrashEvidenceLocked(context) + } + + /** + * P1-3:非空 ack trash 目录(未完成删除事务)——维护无法(或尚未)自动解决的 stuck + * 证据,等待用户在 UI 中导出/确认删除。空目录不算(stage 前的崩溃窗口,维护会删除)。 + * 只接受真实普通目录,排除符号链接。 + * + * P1-6:枚举失败即抛 [IOException](fail-closed):根目录或任一 trash 子目录的枚举 + * 失败绝不能当作“没有 trash”/“空目录”返回——否则 export 会在遗漏全部 stuck 证据时 + * 仍报告成功,UI 也会收到误导性的空列表。只有枚举**成功**且目录为空才忽略。 + */ + suspend fun stuckAckTrashEvidence(context: Context): List = + lifecycleMutex.withLock { stuckAckTrashEvidenceLocked(context) } + + /** P1-3:全部 stuck ack trash 目录的实际字节总和(递归、NOFOLLOW、只计普通文件)。 */ + suspend fun stuckAckTrashBytes(context: Context): Long = + lifecycleMutex.withLock { + stuckAckTrashEvidenceLocked(context).sumOf { trash -> + totalSpoolBytes(trash, Long.MAX_VALUE) + } + } + + private fun stuckAckTrashEvidenceLocked(context: Context): List { + val dir = spoolDir(context.applicationContext) + if (!dir.isDirectory) return emptyList() + // P1-6:根目录枚举统一走 [listDir](测试可注入失败);失败(null)即明确抛错, + // 绝不当作“没有 trash”返回(否则 export 会成功遗漏全部 stuck 证据)。 + val rootFiles = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for ack trash: ${dir.absolutePath}") + // 先完整枚举结果,再对每个候选做 NOFOLLOW 目录验证(File.isDirectory 会跟随符号链接) + return rootFiles + .filter { f -> + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) && + Files.isDirectory(f.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) + } + .mapNotNull { trash -> + // P1-6:trash 枚举失败(null)是明确失败,绝不当作空目录跳过该证据 + val files = listDir(trash) + ?: throw IOException("cannot enumerate ack trash directory: ${trash.name}") + files.takeIf { it.isNotEmpty() }?.let { trash } + } + .sortedBy { it.name } + } + + private suspend fun quarantineEvidenceLocked(context: Context): List { + val dir = spoolDir(context.applicationContext) + val managed = readTombstoneLines(context).mapNotNull { line -> + val entry = parseTombstoneLine(line) ?: return@mapNotNull null + val file = File(dir, entry.file) + // P1-2:只有身份可校验(MATCH)的受管段才作为 evidence 暴露——UNREADABLE 绝不 + // 出现在可导出/可 ack 的列表里(身份不可校验时 ack 无法安全删除),保留 manifest。 + file.takeIf { it.isFile && entry.identityCheck(file) == IdentityCheck.MATCH } + } + return (quarantineAreaFiles(dir) + managed).sortedBy { it.name } + } + + /** + * 完整证据区(quarantine_* 前缀 + seal 发布失败隔离的 seal_failed_*,不含受管失败段与 + * 有界元数据/sidecar)。P1-6:证据区是 export/ack/info 的证据来源,根目录枚举失败(null) + * 时抛 [IOException](fail-closed)——绝不把失败当作空证据区返回,否则 export 会在遗漏 + * 完整证据时仍成功。P2 终审:seal_failed_*(受管失败发布证据)与 quarantine_* 同等参与 + * 可见/计数/导出/ack——长期删除失败时用户可确认删除并释放容量,绝不无限隐藏占用。 + * 注意:这里**不走** [directoryListingForTest] seam(seam 只覆盖 sealed 队列/待处理判定 + * 与 ack/trash 安全路径,见 [listDir]),生产路径的原始枚举失败同样按 null 显式失败处理。 + */ + private fun quarantineAreaFiles(dir: File): List { + // 目录不存在 = 证据区尚未创建,空集是真实状态(append 准入在创建目录之前检查); + // 目录**存在**但枚举失败(null)才是 fail-closed 抛错场景。 + if (!dir.isDirectory) return emptyList() + val files = dir.listFiles() + ?: throw IOException("cannot enumerate spool directory for quarantine evidence: ${dir.absolutePath}") + return files + .filter { + it.isFile && + (it.name.startsWith(QUARANTINE_PREFIX) || it.name.startsWith(SEAL_FAILED_PREFIX)) && + it.name != QUARANTINE_SUMMARY_NAME && + it.name != TOMBSTONE_MANIFEST_NAME && + !it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") && + !it.name.startsWith("$TOMBSTONE_MANIFEST_NAME.") + } + .sortedBy { it.name } + } + + /** + * P1-1 append 准入检查(调用方持 lifecycleMutex):受管失败集合 + * (entry 数/受管段字节)或完整证据区字节任一到达硬上限即拒绝新统计——此时新损坏段 + * 将无处可去(不能删除/重命名、受管集合已满),继续接收只会让磁盘/重扫无界。 + */ + private suspend fun managedFailureCapacityExceeded(context: Context): Boolean { + val dir = spoolDir(context) + val entries = readTombstoneLines(context).mapNotNull(::parseTombstoneLine) + if (entries.isEmpty() && quarantineAreaFiles(dir).isEmpty()) return false + return entries.size >= MAX_TOMBSTONE_ENTRIES || + entries.sumOf { it.bytes.coerceAtLeast(0L) } >= MAX_MANAGED_BYTES || + quarantineAreaFiles(dir).sumOf { it.length() } >= MAX_QUARANTINE_BYTES + } + + /** + * Structured summary of over-cap corrupt segments (P2-1). Within [MAX_QUARANTINE_BYTES] the + * full evidence is preserved; each new segment that would exceed the cap is replaced by one + * bounded rolling summary record (count/hash/bytes/time, never content) and removed, so the + * quarantine area has a hard disk bound while healthy drains continue. + */ + data class QuarantineSummaryInfo( + val recordCount: Int, + val summaryBytes: Long, + ) + + /** + * 崩溃安全读取摘要(P1-1):经 [AtomicRestoreMarkerStore] 恢复旧/新完整值后统计, + * 任意中断后得到的都是完整旧或完整新内容,绝不截断。公开入口持 [lifecycleMutex] + * (sidecar 恢复与容量扫描互斥,P1-1),内部调用使用 [quarantineSummaryInfoLocked]。 + * 读取失败(非测试注入的异常路径)返回 null:纯展示信息,不参与容量/维护判定。 + */ + suspend fun quarantineSummaryInfo(context: Context): QuarantineSummaryInfo? = + lifecycleMutex.withLock { quarantineSummaryInfoLocked(context) } + + private suspend fun quarantineSummaryInfoLocked(context: Context): QuarantineSummaryInfo? { + val file = File(spoolDir(context.applicationContext), QUARANTINE_SUMMARY_NAME) + val content = try { + readMetadata(summaryStore(file), file) + } catch (e: Exception) { + logE("statistics quarantine summary read failed", e) + null + } ?: return null + val lines = content.lineSequence().filter { it.isNotEmpty() }.toList() + if (lines.isEmpty()) return null + return QuarantineSummaryInfo( + recordCount = lines.size, + summaryBytes = file.length(), + ) + } + + private fun summaryStore(file: File) = + AtomicRestoreMarkerStore( + file, + quarantineAtomicMoveForTest ?: ::atomicMoveReplacing, + // P1-3 终审:spool 的 summary/manifest/ack state 统一走严格目录同步——write 只有 + // 目录项确认持久([syncDir] == OK)才成功;read 的 sidecar 恢复 rename 同样严格。 + // P1-1 终审修复:任一非 OK 同时失效 bootstrap gate([requireSpoolDirSync])—— + // 元数据目录项未确认后下一次使用必须重新确认,绝不带着“已确认”标记继续。 + strictDirectorySync = { dir -> requireSpoolDirSync(dir) }, + ) + + /** + * 有界元数据读取(P1-2,调用方持 lifecycleMutex):测试注入缝模拟读取失败(抛明确 + * IOException,调用方据此 fail-closed),生产路径委托 [AtomicRestoreMarkerStore.read] + * (崩溃安全恢复 canonical/.new/.bak/tmp 完整值)。 + */ + private suspend fun readMetadata(store: AtomicRestoreMarkerStore, file: File): String? { + if (metadataReadErrorForTest?.invoke(file) == true) { + throw IOException("statistics metadata read failed (injected): ${file.name}") + } + return store.read() + } + + /** 首选同目录原子替换(Windows MoveFileEx / POSIX rename);不支持或失败返回 false 走回退。 */ + private fun atomicMoveReplacing(from: File, to: File): Boolean = try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + true + } catch (e: AtomicMoveNotSupportedException) { + false + } catch (e: IOException) { + false + } + + /** + * Copy evidence (and the bounded over-cap summary and tombstone manifest) for support/export. + * Deletion still requires a separate acknowledged call. File I/O always runs on [ioDispatcher] (P2-2). + * 摘要/manifest 先经 [AtomicRestoreMarkerStore.read] 恢复 canonical(P2-2:崩溃窗口里 + * canonical 可能缺失、内容只在 `.new`/`.bak` sidecar),导出内容绝不遗漏元数据; + * sidecar 本身不直接导出。受管失败段(P1-3)以原文件名导出并附 manifest 供身份核对。 + */ + suspend fun exportQuarantineEvidence(context: Context, destinationDir: File): List = + lifecycleMutex.withLock { + withContext(ioDispatcher) { + // P2:导出目标必须是专用空目录——拒绝写入非空目录(上一次导出的残留 + // 绝不混入/冒充本次结果,也绝不删除目录中用户自己的文件)。UI 每次导出 + // 创建唯一子目录,并在失败/取消时只清理该唯一目录。 + if (destinationDir.isFile) { + throw IOException("quarantine export destination is not a directory: ${destinationDir.absolutePath}") + } + if (destinationDir.isDirectory) { + // P1-6 fail-closed:无法枚举目标目录内容时绝不能当作“空目录”继续导出—— + // 未知内容可能与本次结果混入,也无法验证“专用空目录”前提。 + val existing = destinationDir.listFiles() + if (existing == null) { + throw IOException( + "cannot enumerate quarantine export destination: ${destinationDir.absolutePath}", + ) + } + if (existing.isNotEmpty()) { + throw IOException("quarantine export destination is not empty: ${destinationDir.absolutePath}") + } + } + if (!destinationDir.exists() && !destinationDir.mkdirs()) { + throw IOException("cannot create quarantine export directory: ${destinationDir.absolutePath}") + } + val spool = spoolDir(context.applicationContext) + val summaryContent = try { + readMetadata(summaryStore(File(spool, QUARANTINE_SUMMARY_NAME)), File(spool, QUARANTINE_SUMMARY_NAME)) + } catch (e: Exception) { + logE("statistics quarantine summary read failed", e) + null + } + // P1-2 fail-closed:manifest 不可读则整个导出失败——受管证据必须以可追溯身份 + // 随导出提供,绝不静默导出缺失 manifest 的证据集。 + val manifestContent = + readMetadata(summaryStore(File(spool, TOMBSTONE_MANIFEST_NAME)), File(spool, TOMBSTONE_MANIFEST_NAME)) + val exported = mutableListOf() + quarantineEvidenceLocked(context).forEach { evidence -> + val target = File(destinationDir, evidence.name) + evidence.inputStream().use { input -> + FileOutputStream(target, false).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + exported += target + } + summaryContent?.let { content -> + val target = File(destinationDir, QUARANTINE_SUMMARY_NAME) + FileOutputStream(target, false).use { output -> + output.write(content.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + exported += target + } + manifestContent?.let { content -> + val target = File(destinationDir, TOMBSTONE_MANIFEST_NAME) + FileOutputStream(target, false).use { output -> + output.write(content.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + exported += target + } + // P1-3:未完成删除事务(非空 ack trash)复制到唯一子目录,包含状态文件与 + // sidecar。文件名做单层路径校验(防穿越),只接受普通文件、拒绝符号链接; + // 任一文件不合法/复制失败 → 整个导出失败(fail-closed,绝不部分导出冒充完整)。 + stuckAckTrashEvidenceLocked(context).forEach { trash -> + val targetDir = File(destinationDir, trash.name) + if (!targetDir.mkdir()) { + throw IOException( + "cannot create ack trash export directory: ${trash.name}", + ) + } + // P1-5:trash 枚举失败 → 整个导出明确失败(绝不部分导出冒充完整)。 + // P1-6:枚举走统一 seam([listDir]),与 stuck 证据筛选一致。 + val trashFiles = listDir(trash) + ?: throw IOException( + "cannot enumerate ack trash during export: ${trash.name}", + ) + trashFiles.forEach { file -> + if (Files.isSymbolicLink(file.toPath()) || + !Files.isRegularFile( + file.toPath(), + java.nio.file.LinkOption.NOFOLLOW_LINKS, + ) || + !isSafeTrashName(trash, file.name) + ) { + throw IOException( + "unsafe file inside ack trash during export: ${file.name}", + ) + } + val target = File(targetDir, file.name) + file.inputStream().use { input -> + FileOutputStream(target, false).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + } + exported += targetDir + } + exported + } + } + + /** + * File I/O always runs on [ioDispatcher] (P2-2); the summary file is intentionally kept as + * the bounded rolling record of already-discarded over-cap evidence. 受管失败段(P1-3): + * 按 identity 删除原损坏文件并移除对应 manifest 记录;文件已消失只移除记录;身份不匹配 + * 视为陈旧记录绝不删新身份文件。删除失败保留记录并抛错(UI 反馈失败,不声称全部成功)。 + * + * 严格两阶段(reviewer P1):Phase 1 在持锁下只读预检——先恢复/读取 manifest 并枚举本次 + * 全部目标(quarantine area + managed original),对所有 managed identity 现场 fresh 验证; + * 任一读/UNREADABLE/元数据错误都使整个操作失败:零删除、零 manifest 写。MISMATCH 只视为 + * 陈旧记录并计划移除 manifest 条目,绝不删除同名新文件。所有目标名先做路径归属预检 + * (防目录穿越)。Phase 2 预检全部通过后才动手:先把全部待删文件原子 rename 进本轮唯一 + * trash 目录(同 filesystem 的可回滚 prepare),任一 rename 失败则把已 rename 文件移回 + * 原位、manifest 不改并报错;全部 rename 成功后原子写入 trash 状态文件(P1-2: + * UNCOMMITTED + mapping:原名 → trash 名 + bytes + sha256,只含已 stage 文件;P1-1: + * 写入预算先投影,超限有界失败并回滚),再原子重写 manifest 移除对应条目,然后原子 + * 改状态为 COMMITTED,最后删除 trash(删除失败只记录——证据逻辑已 ack,维护入口按状态 + * 补删/回滚:只有显式 COMMITTED 才补删,UNCOMMITTED 一律回滚,绝不误删未提交的证据)。 + * trash 占用计入递归总容量(P1-1),占用绝不隐藏。 + * + * P1-3:`names` 也可包含非空 ack trash 目录名(StuckAckEvidence)——用户显式确认后删除 + * 整个 trash(无需 mapping 完整);仅接受 spool 根内匹配 [ACK_TRASH_PREFIX] 的真实普通 + * 目录(NOFOLLOW、拒绝符号链接)。全部名字都是 trash 目录时跳过 manifest 读取(其完整性 + * 与 trash 删除无关)。trash 删除放在文件事务完成后,文件侧失败时 trash 保持原样。 + */ + suspend fun acknowledgeAndDeleteQuarantine(context: Context, names: Set) = + lifecycleMutex.withLock { + withContext(ioDispatcher) { + val dir = spoolDir(context.applicationContext) + // ── Phase 1:只读预检(任何失败 → 零删除、零 manifest 写)──────────────── + names.forEach { requireSafeEvidenceName(dir, it) } + // P1-3:stuck ack trash 目录(未完成删除事务)的显式确认删除。名字必须是 + // spool 根内匹配 ACK_TRASH_PREFIX 的真实普通目录(NOFOLLOW、拒绝符号链接), + // 全部名字都是 trash 目录时无需依赖 manifest(用户已显式授权删除)。 + val trashDirs = names + .filter { it.startsWith(ACK_TRASH_PREFIX) && File(dir, it).isDirectory } + .map { it to File(dir, it) } + trashDirs.forEach { (_, trash) -> requireAckTrashDirForDelete(dir, trash) } + if (trashDirs.size == names.size) { + trashDirs.forEach { (_, trash) -> + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + throw IOException( + "cannot delete acknowledged stuck trash: ${trash.name}", + ) + } + // P1-3 终审:删除是目录项变更——sync 非 OK 绝不报告成功(保留状态、 + // 失败;重试幂等:trash 已可见删除则下次无操作,未删则由状态机/直接 + // 删除再处置)。P1-1:非 OK 同时失效 bootstrap gate。 + if (!requireSpoolDirSync(dir)) { + throw IOException( + "acknowledged stuck trash deletion not durable: ${trash.name}", + ) + } + } + return@withContext + } + val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) + // P1-3:不设 canonical isFile 前置——仅 `.new`/`.bak` sidecar 存在时 + // read() 会先恢复 canonical 再返回,ack 才能按身份删除受管段。 + // P1-2 fail-closed:manifest 不可读则 ack 明确失败(不删任何文件)。 + val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) + ?.lineSequence()?.filter { it.isNotBlank() }?.toList() + ?: emptyList() + val entries = rawLines.map { rawLine -> + parseTombstoneLine(rawLine) + ?: throw IOException("invalid tombstone manifest entry; refusing ack") + } + entries.forEach { requireSafeEvidenceName(dir, it.file) } + val quarantineFiles = quarantineAreaFiles(dir).filter { it.name in names } + quarantineFiles.forEach { requireManageableEvidenceFile(dir, it) } + val removeRawLines = mutableListOf() + val managedFiles = mutableListOf() + for ((index, rawLine) in rawLines.withIndex()) { + val entry = entries[index] + if (entry.file !in names) continue + val file = File(dir, entry.file) + when { + !file.exists() -> { + // 物理消失:只计划移除陈旧记录 + removeRawLines += rawLine + } + else -> when (entry.identityCheck(file)) { + // P1-2:身份不可校验(UNREADABLE)时 ack 绝不能成功——既不能删 + // 也不能当陈旧记录移除,保留 manifest,让维护/用户稍后重试。 + IdentityCheck.UNREADABLE -> { + throw IOException( + "cannot verify identity of acknowledged managed evidence; " + + "refusing ack: ${entry.file}", + ) + } + IdentityCheck.MISMATCH -> { + // 陈旧记录:计划移除 manifest 条目,但绝不删除同名新文件 + removeRawLines += rawLine + } + IdentityCheck.MATCH -> { + requireManageableEvidenceFile(dir, file) + removeRawLines += rawLine + managedFiles += file + } + } + } + } + // 同一文件同时被 quarantine area 与 managed 枚举命中时只处理一次 + val allFiles = (quarantineFiles + managedFiles).distinct() + if (allFiles.isEmpty() && removeRawLines.isEmpty() && trashDirs.isEmpty()) { + return@withContext + } + + // ── Phase 2:预检全通过后的事务化删除────────────────────────────────── + // 文件删除中途失败无法事务化,因此先全部 rename 进本轮唯一 trash 目录(同卷 + // 原子、可回滚 prepare);全部成功后才重写 manifest,最后删除 trash。 + if (allFiles.isNotEmpty()) { + val trashDir = + File(dir, "$ACK_TRASH_PREFIX${UUID.randomUUID().toString().replace("-", "")}") + if (!trashDir.mkdir()) { + throw IOException( + "cannot create acknowledged-deletion trash directory: ${trashDir.name}", + ) + } + // P1-3 终审:trash 目录创建是目录项变更——sync 非 OK 明确失败(尚未 + // stage 任何证据,空 trash 由维护入口按“无状态空目录”清理,无证据损失)。 + if (!requireSpoolDirSync(dir)) { + throw IOException( + "ack trash directory creation not durable: ${trashDir.name}", + ) + } + val staged = mutableListOf>() + try { + for (file in allFiles) { + val target = File(trashDir, file.name) + if (target.exists() || !atomicMoveForAck(file, target)) { + throw IOException( + "cannot stage acknowledged evidence for deletion: ${file.name}", + ) + } + staged += file to target + // P1-3 终审:证据移动跨 spool 根与 trash 两个目录——两者目录项 + // 都必须确认持久;非 OK 走回滚/状态补写协议(见 catch),绝不 + // 带着未确认状态继续 stage 或声称成功。P1-1:非 OK 同时失效 gate。 + if (!requireSpoolDirSync(dir, trashDir)) { + throw IOException( + "ack staging not durable: ${file.name}", + ) + } + } + } catch (e: Exception) { + val rollback = rollbackStagedRenames(staged, trashDir) + if (rollback.syncFailed) { + logE( + "statistics ack rollback directory entries unconfirmed; " + + "keeping UNCOMMITTED state for maintenance: ${trashDir.name}", + ) + } + // P1-2:回滚失败(trash 仍持有已 stage 证据)时尽力补写状态 + // (UNCOMMITTED + mapping 已 stage 文件),使维护入口能按身份回滚; + // 回滚成功时 trash 已删除,无需补写。P2:回滚目录项未确认持久 + // (syncFailed)同样保留 trash 并补写状态,上层失败绝不静默。 + if (trashDir.exists()) { + writeUncommittedTrashState(context, trashDir, staged) + } + throw e + } + // P1-2:stage 全部成功后才写状态文件(UNCOMMITTED + mapping,只含已 stage + // 文件:原名 → trash 名 + 稳定身份 bytes+sha256)——回滚只需处理真正进过 + // trash 的文件。P1-1:写入预算先投影(4 槽位最坏副本),超限有界失败并 + // 回滚已 stage 文件,不写任何正式文件。 + if (!writeUncommittedTrashState(context, trashDir, staged)) { + val rollback = rollbackStagedRenames(staged, trashDir) + // P2:回滚目录项未确认持久(syncFailed)时 trash 保留证据——尽力再 + // 补写一次 UNCOMMITTED 状态(预算仍拒绝时仅记录,trash 由维护/UI 作为 + // StuckAckEvidence 管理),上层失败绝不静默。 + if (trashDir.exists()) { + writeUncommittedTrashState(context, trashDir, staged) + } + throw IOException( + "ack trash state publish refused: metadata budget over the total cap", + ) + } + if (removeRawLines.isNotEmpty()) { + val remaining = rawLines.filterNot { it in removeRawLines } + val newContent = + remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" + try { + rewriteAckManifestLocked(context, manifestFile, newContent) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + rollbackStagedRenames(staged, trashDir) + throw IOException( + "tombstone manifest rewrite failed after acknowledgment: ${e.message}", + ) + } + } + // 主 manifest 已发布 → 原子改 COMMITTED。翻转只是同目录内替换小内容(比 + // UNCOMMITTED 状态文件更小),最坏瞬态 ≤ 当前总量(已在预算内),无需另行 + // 投影。P1-3 终审:状态翻转经严格目录同步 store——write 只有目录项确认 + // 持久才成功;失败绝不报告成功(抛 IOException),trash 保持 UNCOMMITTED + // + mapping 由维护按状态机回滚(证据回到原路径后被扫描器重新隔离,ack + // 视失败但不丢证据)。 + try { + summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)) + .write(ACK_STATE_COMMITTED + "\n") + } catch (e: Exception) { + logE( + "statistics ack trash commit flip failed; retaining trash for rollback", + e, + ) + throw IOException("ack trash commit failed: ${e.message}") + } + // 证据逻辑已 ack:trash 删除失败只记录(COMMITTED 状态保留,下次维护/ack + // 有界补删);P1-3 终审:删除**成功**后目录项必须确认持久,非 OK 绝不报告 + // 成功(失败;重试幂等——trash 已可见删除则下次 ack 无操作)。 + if (!(spoolDeleteForTest?.invoke(trashDir) ?: deleteAckTrashDirNoFollow(trashDir))) { + logE( + "statistics ack trash cleanup deferred; acknowledged evidence is already " + + "removed from the manifest: ${trashDir.name}", + ) + } else if (!requireSpoolDirSync(dir)) { + throw IOException( + "ack trash deletion not durable: ${trashDir.name}", + ) + } + } else if (removeRawLines.isNotEmpty()) { + // 只有陈旧 manifest 记录要移除:没有任何文件需要 stage/删除,直接重写 + // manifest(P1-1 预算投影 + 注入检查),失败抛错;无 trash 参与。 + val remaining = rawLines.filterNot { it in removeRawLines } + val newContent = + remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" + try { + rewriteAckManifestLocked(context, manifestFile, newContent) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + throw IOException( + "tombstone manifest rewrite failed after acknowledgment: ${e.message}", + ) + } + } + // P1-3:stuck trash 的确认删除放在文件事务完成之后——文件侧失败时 trash 保持 + // 原样(一致失败态,用户可重试);删除使用 NOFOLLOW 遍历,绝不跟随符号链接。 + // P1-3 终审:删除成功后目录项必须确认持久,非 OK 失败(重试幂等)。P1-1: + // 非 OK 同时失效 gate。 + for ((_, trash) in trashDirs) { + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + throw IOException("cannot delete acknowledged stuck trash: ${trash.name}") + } + if (!requireSpoolDirSync(dir)) { + throw IOException( + "acknowledged stuck trash deletion not durable: ${trash.name}", + ) + } + } + } + } + + /** + * ack 的主 manifest 重写(P1-1,调用方持 lifecycleMutex):发布前投影实际总量 + 最坏 + * sidecar 增量,超限有界失败不写文件;测试注入缝照常生效。失败抛 [IOException]。 + */ + private suspend fun rewriteAckManifestLocked(context: Context, manifestFile: File, newContent: String) { + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + throw IOException("tombstone manifest rewrite refused: metadata budget over the total cap") + } + if (metadataWriteErrorForTest?.invoke(manifestFile) == true) { + throw IOException("tombstone manifest write failed (injected)") + } + summaryStore(manifestFile).write(newContent) + } + + /** + * 写入 ack trash 状态文件(P1-2,调用方持 lifecycleMutex):UNCOMMITTED + mapping(只含 + * 已 stage 文件)。P1-1:发布前投影预算,超限返回 false。写失败返回 false(调用方保留 + * 无状态 trash,维护 fail-closed 保留,绝不误删)。原子崩溃安全写入(sidecar 可恢复)。 + * + * P2 终审:mapping 身份(bytes+sha256)必须从**实际当前所在文件**捕获——回滚 move 已 + * 可见但目录项 sync 失败时,文件可能已回到原路径(trash 内已无此文件),此时从已移走 + * 的 target 盲读会得到 0 字节/空哈希的伪身份,甚至使整个状态写入失败;因此 target + * 存在读 target,否则读 original(两者内容同一,身份一致)。两者都不存在(文件消失, + * 不可能的正常路径)→ 返回 false fail-closed,绝不写残缺 mapping。 + */ + private suspend fun writeUncommittedTrashState( + context: Context, + trashDir: File, + staged: List>, + ): Boolean { + return try { + val mappingEntries = staged.map { (original, target) -> + // P2 终审:身份从实际所在位置捕获(trash 或 original),绝不盲读已移走的 target。 + val location = + when { + target.exists() -> target + original.exists() -> original + else -> null + } + if (location == null) { + logE( + "statistics ack trash state identity unavailable; refusing to write mapping: " + + "${original.name}", + ) + return false + } + AckMappingEntry( + original = original.name, + trashName = target.name, + bytes = location.length(), + sha256 = sha256Hex(location.readBytes()), + ) + } + val stateContent = buildAckStateContent(ACK_STATE_UNCOMMITTED, mappingEntries) + if (metadataWriteBudgetExceeded(context, stateContent.toByteArray(Charsets.UTF_8).size)) { + logE("statistics ack trash state publish refused: metadata budget over the total cap") + return false + } + summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)).write(stateContent) + true + } catch (e: Exception) { + logE("statistics ack trash state write failed", e) + false + } + } + + /** P2 终审:ack 回滚的结构化结果。success=false = 有文件未能移回原位(trash 保留为可 + * 重试记录,由维护按 mapping 处置);syncFailed=true = 存在目录项未确认持久的变更 + * (上层必须失败并保留 UNCOMMITTED/stuck 状态,绝不静默推进)。 */ + private data class RollbackStagedResult( + val success: Boolean, + val syncFailed: Boolean, + ) + + /** + * ack 的 rename 回滚(reviewer P1 + P2 终审):把已 stage 进 trash 的文件按逆序移回原位; + * 全部成功且目录项全部确认持久才删除本轮 trash 目录(含状态/mapping 文件)。P2:每个 + * 移动/删除都是目录项变更——move 后必须严格 sync([requireSpoolDirSync],跨 spool 根与 + * trash 两个目录),任一非 OK 置 [RollbackStagedResult.syncFailed]:上层保留 + * UNCOMMITTED 状态并失败,绝不带着未确认状态声称回滚完成(变更可见时下一轮按 + * mapping+identity 幂等完成)。某个回滚失败时保留 trash 及其证据(状态仍为 UNCOMMITTED + * + mapping,维护入口按状态机判定/回滚,绝不误删),同样报告失败。 + */ + private fun rollbackStagedRenames( + staged: List>, + trashDir: File, + ): RollbackStagedResult { + val dir = trashDir.parentFile ?: return RollbackStagedResult(false, false) + var allRolledBack = true + var syncFailed = false + for ((original, target) in staged.asReversed()) { + if (!target.exists()) continue + if (original.exists() || !atomicMoveForAck(target, original)) { + allRolledBack = false + logE( + "statistics ack rollback failed for ${original.name}; evidence stays in ${trashDir.name}", + ) + continue + } + // P2 终审:回滚 move 跨 spool 根与 trash 两个目录——两者目录项都必须确认持久; + // 非 OK 置 syncFailed(调用方保留 UNCOMMITTED 状态并失败)。 + if (!requireSpoolDirSync(dir, trashDir)) { + logE("statistics ack rollback move not durable: ${original.name}") + syncFailed = true + } + } + if (allRolledBack && !syncFailed) { + if (!deleteAckTrashDirNoFollow(trashDir)) { + logE("statistics ack trash directory cleanup failed after rollback: ${trashDir.name}") + allRolledBack = false + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable after rollback: ${trashDir.name}") + syncFailed = true + } + } + return RollbackStagedResult(allRolledBack, syncFailed) + } + + /** + * 目标路径归属预检(reviewer P1,防目录穿越):ack 只接受 spool 根目录下的单层相对 + * 文件名——非空、不含路径分隔符、不是 "."/"..",且解析后父目录仍是 spool 根目录。 + */ + private fun requireSafeEvidenceName(dir: File, name: String) { + if (!isSafeEvidenceName(dir, name)) { + throw IOException("unsafe acknowledged evidence name: $name") + } + } + + /** 单层相对名检查(reviewer P1,防目录穿越):非空、不含分隔符、解析后父目录是 dir。 */ + private fun isSafeEvidenceName(dir: File, name: String): Boolean = + name.isNotBlank() && + name != "." && + name != ".." && + !name.contains('/') && + !name.contains('\\') && + File(dir, name).parentFile?.canonicalFile == dir.canonicalFile + + /** trash 内文件名检查(P1-2):同 [isSafeEvidenceName],父目录必须是 trash 目录本身。 */ + private fun isSafeTrashName(trash: File, name: String): Boolean = + name.isNotBlank() && + name != "." && + name != ".." && + !name.contains('/') && + !name.contains('\\') && + File(trash, name).parentFile?.canonicalFile == trash.canonicalFile + + /** ack trash mapping 条目(P1-2):spool 根原名 → trash 内名 + 稳定身份(bytes+sha256)。 */ + private data class AckMappingEntry( + val original: String, + val trashName: String, + val bytes: Long, + val sha256: String, + ) + + private fun ackMappingLine(entry: AckMappingEntry): String = + JSONObject() + .put("o", entry.original) + .put("t", entry.trashName) + .put("b", entry.bytes) + .put("s", entry.sha256) + .toString() + + private fun parseAckMappingLine(line: String): AckMappingEntry? = try { + val obj = JSONObject(line) + val original = obj.optString("o").takeIf { it.isNotEmpty() } ?: return null + val trashName = obj.optString("t").takeIf { it.isNotEmpty() } ?: return null + AckMappingEntry( + original = original, + trashName = trashName, + bytes = obj.optLong("b", -1L), + sha256 = obj.optString("s", ""), + ) + } catch (_: Exception) { + null + } + + /** + * P1-2 修复:状态 mapping 的**全有或全无**解析(调用方持 lifecycleMutex)。任一条件失败 + * 返回 null,调用方对整个 trash fail-closed 保留(不执行 delete/rollback/manifest 改动): + * - header 之后的每一行都必须解析成功(mapNotNull 静默丢弃损坏行会漏掉未回滚的证据, + * 导致 trash 被整体删除); + * - bytes/sha256 必须完整(缺失即身份不可校验); + * - 原名/trash 名必须单层安全(防穿越),且无重复(同名两份证据无法可靠处置); + * - trash 内所有普通文件(排除状态文件及其 `.new`/`.bak`/`.tmp*` sidecar)都必须被 + * mapping 覆盖(mapping 数量与证据文件集合完整对应);存在符号链接/特殊文件也 fail-closed。 + * + * mapping 条目引用的 trash 文件**缺失**是允许的(该文件可能已在先前一次回滚中移回原槽位, + * 由 [rollbackUncommittedTrash] 按原槽位身份判定),因此这里只校验“trash 里的每个文件都 + * 有 mapping”,不要求反向一一对应。 + */ + private fun parseAckMappingStrict( + dir: File, + trash: File, + lines: List, + ): List? { + val entries = mutableListOf() + val originals = HashSet() + val trashNames = HashSet() + for (raw in lines.drop(1)) { + val entry = parseAckMappingLine(raw) ?: return null + if (entry.bytes < 0L || entry.sha256.isEmpty()) return null + if (!isSafeEvidenceName(dir, entry.original)) return null + if (!isSafeTrashName(trash, entry.trashName)) return null + if (!originals.add(entry.original)) return null + if (!trashNames.add(entry.trashName)) return null + entries += entry + } + val stateBase = ACK_TRASH_STATE_FILE_NAME + // P1-5 fail-closed:trash 目录枚举失败(null)时内部证据集合不可知——mapping 无法 + // 证明覆盖了全部证据文件,任何 rollback 后对 trash 的整体删除都会丢失未枚举的证据, + // 立即返回 null 使整个 trash 被保留。 + val trashFiles = listDir(trash) + if (trashFiles == null) { + logE("statistics ack trash directory enumeration failed; retaining trash: ${trash.name}") + return null + } + val unaccounted = trashFiles.any { file -> + val name = file.name + val isStateSidecar = + name == stateBase || + name == "$stateBase.new" || + name == "$stateBase.bak" || + name.startsWith("$stateBase.tmp") + if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + // 符号链接/特殊文件:无法按身份管理,fail-closed + true + } else if (isStateSidecar) { + false + } else { + name !in trashNames + } + } + return if (unaccounted) null else entries + } + + /** 状态文件内容:首行状态 + 每行一条 mapping(状态与 mapping 一次原子写入,无半写窗口)。 */ + private fun buildAckStateContent(state: String, entries: List): String = + state + "\n" + entries.joinToString("\n") { ackMappingLine(it) } + "\n" + + /** ack 只管理 spool 根目录中的普通文件,不跟随符号链接或其他特殊路径。 */ + private fun requireManageableEvidenceFile(dir: File, file: File) { + val dirPath = dir.canonicalFile.toPath() + val filePath = file.toPath() + if (file.parentFile?.canonicalFile?.toPath() != dirPath || + Files.isSymbolicLink(filePath) || + !Files.isRegularFile(filePath, java.nio.file.LinkOption.NOFOLLOW_LINKS) || + file.canonicalFile.parentFile?.toPath() != dirPath + ) { + throw IOException("unmanageable acknowledged evidence path: ${file.name}") + } + } + + /** + * P1-3:stuck ack trash 目录的删除前校验(防目录穿越/符号链接):只接受 spool 根内匹配 + * [ACK_TRASH_PREFIX] 的真实普通目录(NOFOLLOW_LINKS),拒绝符号链接与特殊路径。 + */ + private fun requireAckTrashDirForDelete(dir: File, trash: File) { + if (!trash.name.startsWith(ACK_TRASH_PREFIX)) { + throw IOException("not an ack trash directory: ${trash.name}") + } + if (Files.isSymbolicLink(trash.toPath())) { + throw IOException("ack trash must not be a symbolic link: ${trash.name}") + } + if (!Files.isDirectory(trash.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw IOException("ack trash must be a real directory: ${trash.name}") + } + if (trash.canonicalFile.parentFile?.toPath() != dir.canonicalFile.toPath()) { + throw IOException("ack trash escapes the spool root: ${trash.name}") + } + } + + /** + * P1-3:递归删除 ack trash 目录(NOFOLLOW):不跟随符号链接(链接本身被删除,绝不触及 + * 其目标),只删除普通文件与空目录;任何遍历/删除失败返回 false(调用方保留并报错)。 + */ + private fun deleteAckTrashDirNoFollow(trash: File): Boolean { + return try { + Files.walkFileTree( + trash.toPath(), + EnumSet.noneOf(FileVisitOption::class.java), + Int.MAX_VALUE, + object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) throw exc + Files.delete(dir) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + throw exc + } + }, + ) + true + } catch (e: Exception) { + logE("statistics ack trash no-follow deletion failed: ${trash.name}", e) + false + } + } + + /** prepare/rollback 必须是同 filesystem 的原子 move,且绝不覆盖同名目标。 */ + private fun atomicMoveForAck(from: File, to: File): Boolean { + ackAtomicMoveForTest?.invoke(from, to)?.let { return it } + // 兼容现有故障注入缝;生产为 null 时仍走真正的 ATOMIC_MOVE。 + segmentRenameForTest?.invoke(from, to)?.let { return it } + if (to.exists()) return false + return try { + Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) + true + } catch (e: AtomicMoveNotSupportedException) { + false + } catch (e: IOException) { + false + } + } + + private fun scheduleDrain(context: Context, delayMs: Long = 0L) { + val generation: Long + synchronized(stateLock) { + if (drainScheduled) return + if (writerExecutor.isShutdown) writerExecutor = newWriterExecutor() + drainScheduled = true + generation = sessionGeneration + } + try { + val task = Runnable { runDrain(context, generation) } + if (delayMs == 0L) writerExecutor.execute(task) + else writerExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS) + } catch (e: RejectedExecutionException) { + synchronized(stateLock) { drainScheduled = false } + logE("statistics drain scheduling failed", e) + } + } + + private fun runDrain(context: Context, generation: Long) { + var success = false + try { + success = runBlocking { + lifecycleMutex.withLock { + if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true + drainCore(context, generation) + } + } + } catch (e: Throwable) { + logE("statistics spool drain failed", e) + } finally { + var retry = 0L + synchronized(stateLock) { + drainScheduled = false + if (sessionGeneration == generation) { + if (success) retryDelayMs = RETRY_BACKOFF_BASE_MS + else { + retry = retryDelayMs + retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) + } + } + } + if (retry > 0L) scheduleDrain(context, retry) + } + } + + /** Called with lifecycleMutex held. */ + private suspend fun drainCore(context: Context, generation: Long): Boolean { + // P1-1 终审:replay/维护/快照 drain 前必须先确认 spool 目录项持久(上一进程可见 + // 未确认的目录项在本进程重新提交);失败退避重试,绝不带着未确认状态做任何目录变更。 + if (!ensureDirectoryDurabilityConfirmed(context, spoolDir(context))) { + logE("statistics spool drain deferred: directory durability unconfirmed") + return false + } + val dao = resolveDaoSafely(context) ?: return false + // P1-2 维护/后台重试:先清理删除失败被隔离的残留(pending-delete 与 tombstoned 段)。 + // 返回 false 表示本轮存在目录项未确认持久的变更——drain 退避重试,绝不推进状态。 + if (!retryPendingCleanup(context)) { + logE("statistics spool maintenance deferred: directory changes unconfirmed") + return false + } + var lastRound: List = emptyList() + while (synchronized(stateLock) { sessionGeneration == generation }) { + val segments = sealAndList(context) ?: return false + if (segments.isEmpty()) return true + // P1-1 有界推进:受管集合已满且仍无法处置的段会停留在队列(未入受管集合)。 + // 连续两轮同一集合说明无进展——跳过并返回,健康段已排空,绝不无限重扫/持锁。 + if (segments == lastRound) { + logE( + "statistics spool drain cannot make progress (managed-failure set full?); " + + "leaving ${segments.size} unmanageable segment(s): ${segments.joinToString { it.name }}", + ) + return true + } + lastRound = segments + for (segment in segments) if (!drainSegment(context, dao, segment)) return false + } + return true + } + + private suspend fun sealAndList(context: Context): List? { + val dir = spoolDir(context) + if (!dir.isDirectory) return emptyList() + val active = File(dir, ACTIVE_FILE_NAME) + if (active.isFile && active.length() > 0L && !sealActive(context, dir)) { + return null + } + // P1-2:tombstone 按稳定 identity(file+bytes+sha256)跳过,绝不只信文件名 + return sealedFilesToProcess(context, dir, readTombstoneLines(context)) + } + + private suspend fun hasPendingSegments(context: Context): Boolean { + val dir = spoolDir(context) + val processableNames = + sealedFilesToProcess(context, dir, readTombstoneLines(context)).mapTo(HashSet()) { it.name } + // P1-7 fail-closed:待处理判定依赖枚举完整性——根枚举失败(null)时绝不能当作 + // “没有 pending”返回(那会让快照在仍有待处理事件时误成功)。抛 IOException 由 + // [withExclusiveSnapshotAccess] 传播,[block] 绝不执行,文件保持原样。 + val files = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for pending segments: ${dir.absolutePath}") + return files.any { + it.isFile && + it.length() > 0L && + (it.name == ACTIVE_FILE_NAME || + (it.name.startsWith(SEALED_PREFIX) && + it.name.endsWith(SEALED_SUFFIX) && + it.name in processableNames)) + } + } + + /** + * P1-2:对 sealed 队列应用受管失败集合。身份匹配的段跳过(受管);身份不匹配或文件已 + * 消失的条目是陈旧 tombstone(旧文件已删但 manifest 未更新,随后同名不同 hash 的健康 + * 段复用)——移除陈旧记录并正常处理新文件,绝不删/跳过健康。例外(P1-2):条目身份仍 + * 停留在未提交 ack trash 中时绝不按陈旧移除——该身份的证据还存在于 trash,移除会让崩溃 + * 窗口判定把未确认的证据误判为已提交而删除。身份**不可校验**(读取失败)的段本轮跳过 + * 且保留 manifest 条目:无法证明当前文件仍是记录中的段时,既不处理也不清理,绝不基于 + * 失败猜测破坏性决策。陈旧记录移除在持锁下崩溃安全重写 manifest。 + */ + private suspend fun sealedFilesToProcess(context: Context, dir: File, rawLines: List): List { + val trashHold = scanUncommittedTrashHolds(context) + val heldInTrash = trashHold.known + val entries = rawLines.mapNotNull(::parseTombstoneLine) + val stale = mutableListOf() + // P1-7 fail-closed:sealed 队列枚举失败(null)时抛 IOException——drain 据此退避 + // 重试、快照/恢复中止;绝不把失败当作空队列(否则 drain 会在仍有待处理段时误成功, + // 快照 barrier 也随之误判“无 pending”)。 + val allFiles = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for sealed segments: ${dir.absolutePath}") + val files = allFiles + .filter { + it.isFile && + it.name.startsWith(SEALED_PREFIX) && + it.name.endsWith(SEALED_SUFFIX) + } + .sortedBy { it.sealIndex() } + .filter { file -> + val entry = entries.firstOrNull { it.file == file.name } + when { + entry == null -> true + else -> when (entry.identityCheck(file)) { + IdentityCheck.MATCH -> false + IdentityCheck.MISMATCH -> { + // P1-2:旧身份仍停留在未提交 trash 中 → 保留条目(证据未 + // 消失),但仍正常处理同名新文件(旧文件在 trash 中不可能 + // 与当前文件同名共存,身份判定互不干扰)。 + // P1-4:存在无法完整严格解析的 UNCOMMITTED trash 时同样 + // 保留条目——旧身份可能正被其持有,scanner 绝不把可能受 + // trash 持有的身份当无保护而按 stale 移除。 + if (!trashHold.hasUnknown && + heldInTrash[entry.file] != (entry.bytes to entry.sha256) + ) { + stale += entry + } + true + } + IdentityCheck.UNREADABLE -> { + logE( + "statistics tombstone identity unreadable; keeping manifest " + + "entry and skipping the segment this round: ${file.name}", + ) + false + } + } + } + } + if (stale.isNotEmpty()) { + logE( + "statistics tombstone manifest has stale identities (vanished or reused-name files); " + + "removing: ${stale.joinToString { it.file }}", + ) + rewriteTombstoneManifest( + context = context, + remainingRawLines = rawLines.filterNot { line -> parseTombstoneLine(line)?.let { it in stale } == true }, + ) + } + return files + } + + /** + * Seal 采用文件系统级原子“不替换”发布(P1-8 + P1 终审持久化协议): + * + * 1. 首选 `Files.createLink(target, active)`:同目录硬链接,目标创建原子且已存在时抛 + * [FileAlreadyExistsException](绝不替换既有 sealed 段);链接建立后按 P1 终审顺序 + * 持久化:sync 目录(链接目录项)→ 删除 active → sync 目录(删除持久化)。崩溃窗口 + * (链接已建、active 删除未发生或未持久化)两个名字指向同一 inode,由 + * [recoverSealDuplicates] 在下次 append/drain 时识别并删除 sealed 副本(内容保留在 + * active,绝不重复拼接——向 active 追加会连带改写已 seal 段)。 + * 2. 硬链接不受支持(FAT/exFAT 等)或临时失败时回退 copy 发布([publishSealedByCopy]): + * Android/Linux 的 Unix provider 以 O_CREAT|O_EXCL 原子创建目标(已存在即抛 + * [FileAlreadyExistsException]),Windows 以 CREATE_NEW 同样原子不替换。这比不带 + * REPLACE 的 `Files.move` 更强:Android 的普通 move 先做存在性预检再 rename(2) + * (rename 会静默替换预检之后出现的目标),保留 TOCTOU,不可单独依赖;ATOMIC_MOVE + * 在目标已存在时语义实现相关,同样不可依赖。copy 回退的崩溃窗口(复制完成、active + * 未删)产生两个内容相同的独立文件,同样由 [recoverSealDuplicates] 按内容识别去重。 + * + * 枚举失败(null)或恢复无法确认无重复时 seal 明确失败(fail-closed),绝不发布。 + */ + private suspend fun sealActive(context: Context, dir: File): Boolean { + val active = File(dir, ACTIVE_FILE_NAME) + if (!active.isFile || active.length() == 0L) return true + if (!recoverSealDuplicates(dir, active)) { + logE("statistics spool segment seal failed: seal recovery could not confirm no duplicates") + return false + } + // P1-7 fail-closed:seal 编号枚举失败(null)→ seal 明确失败,绝不回退到编号 1 + // (枚举失败时回退 1 会重名覆盖 sealed_1 等既有段,销毁其证据)。枚举成功但目标 + // 已被占用(异常残留)→ 递增到下一个安全编号,找不到则失败,绝不覆盖任何既有段。 + val index = nextSealIndex(dir) + if (index == null) { + logE("statistics spool segment seal failed: cannot enumerate spool directory: ${dir.absolutePath}") + return false + } + var candidate = index + while (true) { + val target = File(dir, "$SEALED_PREFIX$candidate$SEALED_SUFFIX") + // 可控 publication seam(P1-8):测试可在此创建同名不同内容的目标文件模拟冲突, + // 真实发布路径必须检测到占用并选择下一编号,目标原字节保持不变。 + if (beforeSealPublishForTest?.invoke(target) == false) { + logE("statistics spool segment seal failed: pre-publish hook refused: ${target.name}") + return false + } + when (publishSealedNoReplace(context, dir, active, target)) { + SealPublishResult.PUBLISHED -> return true + SealPublishResult.EXISTS -> { + candidate += 1L + if (candidate <= 0L) { + // Long 溢出防御:不再有可用编号 → 失败(绝不覆盖) + logE("statistics spool segment seal failed: no free sealed index: ${dir.absolutePath}") + return false + } + } + SealPublishResult.FAILED -> { + logE("statistics spool segment seal failed: ${target.name}") + return false + } + } + } + } + + /** seal 原子发布结果:成功 / 目标已存在(调用方换下一编号)/ 其他失败(终止本轮)。 */ + private enum class SealPublishResult { PUBLISHED, EXISTS, FAILED } + + /** + * 原子“不替换”发布 active → target(调用方持 lifecycleMutex,契约见 [sealActive]): + * 首选硬链接;不受支持时回退 copy 发布([publishSealedByCopy])。目标已存在只返回 + * [SealPublishResult.EXISTS],绝不修改、替换或删除既有目标。 + * + * 持久化契约(P1 终审):两种路径都保证“target 的 data + 目录项(创建/链接/删除)已 + * fsync 确认后才可能返回 PUBLISHED”;任何前置失败保留 active(数据持有者)并返回 + * FAILED;删除 active 后的目录同步失败返回 FAILED 但保留已 durable 的 target,由 + * [recoverSealDuplicates] 恢复。 + */ + private suspend fun publishSealedNoReplace( + context: Context, + dir: File, + active: File, + target: File, + ): SealPublishResult { + val linked = if (sealHardLinkForTest?.invoke(active, target) != false) { + try { + Files.createLink(target.toPath(), active.toPath()) + true + } catch (e: FileAlreadyExistsException) { + return SealPublishResult.EXISTS + } catch (e: Exception) { + // 平台/文件系统不支持硬链接或临时失败 → 回退 copy 发布 + false + } + } else { + // 测试注入:强制模拟硬链接不受支持 + false + } + if (linked) return publishSealedAfterHardLink(dir, active, target) + return publishSealedByCopy(context, dir, active, target) + } + + /** + * 硬链接发布后置持久化(P1 终审):createLink 已原子建立同 inode 链接(active 数据在 + * append 时已 fsync)。顺序:sync 目录(持久化链接目录项)→ 删除 active → sync 目录 + * (持久化删除)。 + * + * - 删除 active 之前的任何失败:active 是唯一数据持有者,保留 active、回滚链接并返回 + * FAILED,绝不声称 PUBLISHED(否则崩溃窗口里 append 可能写进已 seal 段)。 + * - 删除 active 之后的目录同步失败:链接已 data+creation durable,active 删除可能未 + * 持久化——保留明确恢复状态(崩溃后 active 以同 inode 重现时由 [recoverSealDuplicates] + * 去重;未重现则 target 正常排空),返回 FAILED 阻止本轮后续 append 写入,绝不回滚 + * 已 durable 的 target。 + * + * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 + * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 + */ + private fun publishSealedAfterHardLink( + dir: File, + active: File, + target: File, + ): SealPublishResult { + if (!requireSpoolDirSync(dir)) { + rollbackSealTarget(dir, target, "hardlink") + return SealPublishResult.FAILED + } + if (!deleteActiveAfterPublish(active)) { + // 链接已建但 active 删除失败:同 inode 重复。先尝试回滚链接;回滚也失败时 + // 保留给 [recoverSealDuplicates] 下次识别(内容仍在 active)。绝不可带着 + // active 返回成功——否则后续 drain 会把同一内容排空两次。 + rollbackSealTarget(dir, target, "hardlink") + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal hardlink: dir sync after active removal failed; " + + "durable link will be recovered: ${target.name}", + ) + return SealPublishResult.FAILED + } + return SealPublishResult.PUBLISHED + } + + /** + * copy 回退发布(P1 终审 + P2):O_CREAT|O_EXCL / CREATE_NEW 原子创建目标(绝不替换 + * 既有目标;[FileAlreadyExistsException] → EXISTS 让调用方选下一编号)。 + * + * 持久化顺序:copy 目标 → fsync 目标数据([syncFile])→ sync 目录(目标创建持久)→ + * 删除 active → sync 目录(删除持久)。 + * - 删除 active 之前的任何失败:active 是完整内容持有者,保留 active,并按 P2 处置本次 + * 目标([disposeFailedCopyTarget]:identity 确认后隔离到 seal_failed_ 或安全 + * 删除;两者都失败则 tombstone skip,绝不当 normal sealed 排空),返回 FAILED。 + * - 删除 active 之后的目录同步失败:目标已 data+creation durable,active 删除可能未 + * 持久化(崩溃后 active 以原内容重现 → [recoverSealDuplicates] 按内容去重;未重现则 + * target 正常排空)——保留该明确恢复状态并返回 FAILED,阻止本轮后续 append 污染, + * 绝不回滚已 durable 的 target。 + * + * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 + * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 + */ + private suspend fun publishSealedByCopy( + context: Context, + dir: File, + active: File, + target: File, + ): SealPublishResult { + val injected = sealCopyForTest?.invoke(active, target) + if (injected != null) { + if (!injected) { + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + } else { + try { + Files.copy(active.toPath(), target.toPath()) + } catch (e: FileAlreadyExistsException) { + return SealPublishResult.EXISTS + } catch (e: Exception) { + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + } + if (!syncFile(target)) { + // 目标数据未确认 durable:保留 active,处置本次目标 + disposeFailedCopyTarget(context, dir, target, active) + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + // 目标创建未确认持久:保留 active,处置本次目标 + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + if (!deleteActiveAfterPublish(active)) { + // 复制完成、active 未删:两个独立文件同内容。目标已 durable(data+creation), + // 删除目标放弃 sealed 副本(active 仍是完整内容持有者,无数据损失);回滚失败 + // 留给 [recoverSealDuplicates] 按内容去重。 + rollbackSealTarget(dir, target, "copy") + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy: dir sync after active removal failed; " + + "durable target will be recovered: ${target.name}", + ) + return SealPublishResult.FAILED + } + return SealPublishResult.PUBLISHED + } + + private fun deleteActiveAfterPublish(active: File): Boolean = + sealActiveDeleteForTest?.invoke(active) ?: active.delete() + + /** + * seal 前置失败回滚(P2 终审):删除刚发布的 target(active 仍是完整内容持有者,删除 + * 安全无数据损失),删除后必须经 [requireSpoolDirSync] 确认目录项持久——删除是目录项 + * 变更,未确认持久绝不视为回滚完成(P1-1:非 OK 同时失效 bootstrap gate,下一次使用 + * 重新确认)。返回 false 表示回滚未完成/未确认(target 删除失败或目录项未确认持久), + * 调用方保持失败状态;残留由 [recoverSealDuplicates] 按 inode/内容去重兜底。 + */ + private fun rollbackSealTarget(dir: File, target: File, kind: String): Boolean { + if (!target.delete()) { + logE("statistics spool seal rollback failed ($kind); duplicate will be recovered: ${target.name}") + return false + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal rollback deletion not durable ($kind); " + + "gate invalidated, duplicate will be recovered: ${target.name}", + ) + return false + } + return true + } + + /** + * P2 终审修复:seal copy 失败后的部分目标处置(调用方持 lifecycleMutex)。身份前提:候选 + * 编号在 copy 前由 [nextSealIndex] 确认不存在、copy 无 REPLACE 语义、lifecycleMutex 内无本 + * 进程并发——异常后目标若存在只可能是本次 copy 的部分写入;[isPrefixOf] 前缀校验防御外部 + * 进程并发占用该名字时的误隔离(identity 确认)。处置顺序: + * 1. 原子 rename 到 `seal_failed_`(scanner 忽略该前缀、计入递归总 cap、维护清理、 + * 作为受管失败发布证据可见/导出/ack);rename 后目录项 sync 非 OK——隔离文件本身即受管 + * 证据,另按候选 sealed 身份写 tombstone(崩溃后该名字以同内容重现时 scanner 跳过,绝不 + * 普通排空),返回 false(调用方失败,绝不静默)。 + * 2. rename 失败 → 安全删除(active 保留完整内容,删除部分副本无数据损失);删除后目录项 + * sync 非 OK——删除可见但未确认:按候选 sealed 身份写 tombstone 保护崩溃后可能重现的 + * 名字,返回 false。 + * 3. rename/delete 都失败 → tombstone skip(记录稳定身份,scanner 跳过该具体文件,绝不当 + * normal sealed 排空);tombstone 写失败返回 false——drain 退避重试,不做任何破坏性决策。 + * + * @return true = 已留下受管证据(seal_failed 隔离文件/tombstone 条目)或已安全删除且目录项 + * 确认持久;false = 存在目录项未确认持久的变更(tombstone 已尽力写入受管证据),调用方 + * 必须失败,绝不只记录日志。 + */ + private suspend fun disposeFailedCopyTarget( + context: Context, + dir: File, + target: File, + active: File, + ): Boolean { + if (!target.exists()) return true + if (!isPrefixOf(target, active)) { + logE( + "statistics spool seal copy failure target identity mismatch; " + + "leaving file untouched: ${target.name}", + ) + return true + } + val isolated = File(dir, "$SEAL_FAILED_PREFIX${UUID.randomUUID().toString().replace("-", "")}") + if (renameForTest(target, isolated)) { + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy failed; isolated target rename not durable: ${isolated.name}", + ) + // P2:隔离文件本身已是受管证据(seal_failed_*,可 UI/导出/ack/维护);再按 + // 候选 sealed 身份写 tombstone,崩溃后该名字以同内容重现时 scanner 跳过。 + tombstonePartialTarget(context, target, isolated) + return false + } + logE("statistics spool seal copy failed; partial target isolated: ${isolated.name}") + return true + } + // rename 失败:先取原始字节(删除成功后将无法再读取),再尝试安全删除 + val rawBytes = try { + target.readBytes() + } catch (e: Exception) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", + e, + ) + null + } + if (segmentDeleteForTest?.invoke(target) ?: target.delete()) { + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy failed; partial target deletion not durable: ${target.name}", + ) + // P2:删除可见但未确认——按候选 sealed 身份写 tombstone,崩溃后该名字 + // 以同内容重现时 scanner 跳过,绝不普通排空。 + if (rawBytes != null) { + tombstoneSegment(context, target, rawBytes, overCap = false) + } + return false + } + logE("statistics spool seal copy failed; partial target deleted: ${target.name}") + return true + } + if (rawBytes == null) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", + ) + return false + } + return when (tombstoneSegment(context, target, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> { + logE( + "statistics spool seal copy failed; partial target tombstoned, scanner will skip: ${target.name}", + ) + true + } + TombstoneResult.FAILED -> { + logE( + "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${target.name}", + ) + false + } + } + } + + /** + * P2:seal copy 失败目标的 tombstone 写入(调用方持 lifecycleMutex)。目标可能已被改名/ + * 删除,[bytesSource] 提供其原始字节;[tombstoneSegment] 按稳定身份(bytes+sha256)记录 + * [nameFile](候选 sealed 名),崩溃后该名字以同内容重现时 scanner 跳过。写失败仅记录—— + * 调用方本就返回失败,drain 退避重试。 + */ + private suspend fun tombstonePartialTarget( + context: Context, + nameFile: File, + bytesSource: File, + ) { + val rawBytes = try { + bytesSource.readBytes() + } catch (e: Exception) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${nameFile.name}", + e, + ) + return + } + when (tombstoneSegment(context, nameFile, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> Unit + TombstoneResult.FAILED -> { + logE( + "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${nameFile.name}", + ) + } + } + } + + /** + * 部分目标 identity 确认(P2):目标必须是 source(active)的前缀(长度 ≤ 且逐字节 + * 相等)才允许处置;读取失败返回 false(fail-closed,绝不隔离不可确认的文件)。 + */ + private fun isPrefixOf(partial: File, source: File): Boolean { + if (partial.length() > source.length()) return false + if (partial.length() == 0L) return true + return try { + partial.inputStream().use { pIn -> + source.inputStream().use { sIn -> + val bufP = ByteArray(64 * 1024) + val bufS = ByteArray(64 * 1024) + var remaining = partial.length() + while (remaining > 0L) { + val want = minOf(bufP.size.toLong(), remaining).toInt() + val nP = pIn.read(bufP, 0, want) + if (nP <= 0) return false + val nS = sIn.read(bufS, 0, nP) + if (nS != nP) return false + if (!bufP.copyOfRange(0, nP).contentEquals(bufS.copyOfRange(0, nS))) { + return false + } + remaining -= nP + } + true + } + } + } catch (e: Exception) { + logE("statistics spool seal copy failure identity check failed", e) + false + } + } + + /** + * 目录同步结果(P1 终审):OK 已持久;FAILED 真实失败;UNSUPPORTED 平台明确不支持目录 + * fsync——与 FAILED 一样 fail-closed(**绝不**当作成功继续删除唯一 fsynced active 或 + * 声称 PUBLISHED)。不存在“原地排空”平台模式:生产平台(Android/Linux)支持目录 + * fd fsync,Windows 仅 JVM 测试环境。 + */ + internal enum class DirSyncResult { OK, FAILED, UNSUPPORTED } + + /** + * 目录 fsync(P1 终审,调用方持 lifecycleMutex)。Android/Linux:`FileChannel.open(dir, + * READ)` + `force(true)` 即 fsync(2) 目录 fd,持久化目录项(新建/硬链接/删除)。 + * Windows:JDK 无法打开目录句柄(CreateFile 拒绝目录,实测抛 [AccessDeniedException]) + * ——平台明确不支持,返回 [DirSyncResult.UNSUPPORTED]。 + * + * 调用方契约(P1 终审):只有 [DirSyncResult.OK] 才能继续发布/删除;FAILED 与 + * UNSUPPORTED 一律 fail-closed——目录项未确认持久时绝不删除唯一 fsynced active、绝不 + * 声称 durable/PUBLISHED。生产路径不缓存平台能力、不进入任何特殊模式:每次目录项 + * 变更(新建/rename/link/delete)都调用本方法确认。 + */ + private fun syncDir(dir: File): DirSyncResult { + val seam = dirSyncForTest + if (seam != null) return seam(dir) ?: realSyncDir(dir) + return realSyncDir(dir) + } + + private fun realSyncDir(dir: File): DirSyncResult { + return try { + FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } + DirSyncResult.OK + } catch (e: AccessDeniedException) { + logE( + "statistics spool directory fsync unsupported on this platform; " + + "fail-closed: no directory entry is confirmed durable", + e, + ) + DirSyncResult.UNSUPPORTED + } catch (e: Exception) { + logE("statistics spool directory fsync failed: ${dir.absolutePath}", e) + DirSyncResult.FAILED + } + } + + /** + * P1-1 终审修复:spool 目录项持久确认的统一入口(调用方持 lifecycleMutex)。任一目录 + * sync 非 OK 立即把 bootstrap gate 标记 [directoryDurabilityConfirmedThisProcess] 置 false + * ——此后任何声称 durable 前都必须重新确认目录项,绝不带着“已确认”内存标记继续。 + * 所有 spool 目录项变更(新建/rename/delete,含 ack 跨 spool 根与 trash 两个目录)后的 + * 目录 sync 都必须经本入口确认。 + */ + private fun requireSpoolDirSync(vararg dirs: File): Boolean { + val ok = dirs.all { syncDir(it) == DirSyncResult.OK } + if (!ok) directoryDurabilityConfirmedThisProcess = false + return ok + } + + /** + * 文件 fsync(P1 终审):`FileChannel.force(true)` 持久化数据与元数据;失败返回 false + * (调用方保留 active、处置目标、返回 FAILED,绝不声称 PUBLISHED)。 + */ + private fun syncFile(file: File): Boolean { + val seam = fileSyncForTest + if (seam != null) return seam(file) ?: realSyncFile(file) + return realSyncFile(file) + } + + private fun realSyncFile(file: File): Boolean = try { + FileChannel.open(file.toPath(), StandardOpenOption.WRITE).use { it.force(true) } + true + } catch (e: Exception) { + logE("statistics spool file fsync failed: ${file.name}", e) + false + } + + /** + * 恢复 seal 崩溃窗口的重复(P1-8,调用方持 lifecycleMutex):active 与某个 sealed 段 + * 指向同一 inode(硬链接窗口:createLink 成功但 active 删除失败/崩溃)或内容完全相同 + * (copy 回退窗口:目标复制完成、active 删除未发生,两个独立 inode 同内容)时,删除 + * sealed 副本、保留 active 为唯一内容持有者——后续 append 才不会被连带写进已 seal 段, + * 同一内容也只会被排空一次。 + * + * 合法内容重复不可能发生(事件行含唯一 eventId,active 内容严格单调增长),因此 + * 内容相等只可能来自上述崩溃窗口;即使病理情况下误删副本,内容仍从 active 重新 + * seal 并排空,不丢数据。 + * + * fail-closed:spool 根枚举失败(null)、任一 sealed 候选无法 stat/读取或删除失败时 + * 返回 false——调用方(append/seal)拒绝继续,绝不带着“可能还有重复”的状态写入或发布。 + */ + private fun recoverSealDuplicates(dir: File, active: File): Boolean { + val files = listDir(dir) ?: return false + val activeKey = try { + Files.readAttributes( + active.toPath(), + BasicFileAttributes::class.java, + java.nio.file.LinkOption.NOFOLLOW_LINKS, + ).fileKey() + } catch (e: Exception) { + logE("statistics spool cannot stat active for seal recovery", e) + return false + } + var ok = true + var deletedAny = false + for (file in files) { + if (!file.isFile || !file.name.startsWith(SEALED_PREFIX) || !file.name.endsWith(SEALED_SUFFIX)) { + continue + } + val key = try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + java.nio.file.LinkOption.NOFOLLOW_LINKS, + ).fileKey() + } catch (e: Exception) { + logE("statistics spool cannot stat sealed segment for seal recovery: ${file.name}", e) + ok = false + continue + } + val sameInode = key != null && key == activeKey + val contentDuplicate = if (sameInode) null else contentsEqual(file, active) + when { + sameInode || contentDuplicate == true -> { + if (!file.delete()) { + logE("statistics spool seal duplicate removal failed: ${file.name}") + ok = false + } else { + deletedAny = true + } + } + contentDuplicate == null -> { + // 无法确认是否重复(读取失败)→ fail-closed,绝不带着未知状态继续 + logE("statistics spool cannot compare sealed segment for seal recovery: ${file.name}") + ok = false + } + } + } + // P1-3 终审:重复副本删除是目录项变更——未确认持久绝不报告恢复完成(append/seal + // 据此 fail-closed)。active 仍是完整内容持有者,删除可见但未确认时崩溃后副本重现, + // 由下次恢复按 inode/内容幂等重删,绝不丢数据。P1-1:非 OK 同时失效 gate。 + if (deletedAny && !requireSpoolDirSync(dir)) { + logE("statistics spool seal duplicate removal not durable; recovery unconfirmed") + ok = false + } + return ok + } + + /** 逐字节比较(长度先短路;读失败返回 null,调用方按 fail-closed 处理)。 */ + private fun contentsEqual(a: File, b: File): Boolean? { + if (a.length() != b.length()) return false + if (a.length() == 0L) return true + return try { + a.inputStream().use { aIn -> + b.inputStream().use { bIn -> + var equal = true + val bufA = ByteArray(64 * 1024) + val bufB = ByteArray(64 * 1024) + while (true) { + val nA = aIn.read(bufA) + val nB = bIn.read(bufB) + if (nA != nB) { + equal = false + break + } + if (nA < 0) break + if (!bufA.copyOfRange(0, nA).contentEquals(bufB.copyOfRange(0, nB))) { + equal = false + break + } + } + equal + } + } + } catch (e: Exception) { + logE("statistics spool seal duplicate content compare failed", e) + null + } + } + + /** + * A complete line always ends with '\n'; every append writes whole lines, so a file ending + * with '\n' has no partial tail. Only the final write of a crash can leave a tail without one. + */ + private fun activeEndsWithLineBreak(active: File): Boolean = + RandomAccessFile(active, "r").use { raf -> + raf.seek(raf.length() - 1L) + raf.read() == '\n'.code + } + + /** 下一个建议的 sealed 编号;目录枚举失败(null)返回 null → seal 必须失败(P1-7)。 */ + private fun nextSealIndex(dir: File): Long? { + val files = listDir(dir) ?: return null + return files.mapNotNull { file -> + Regex("(?:quarantine_[^_]+_)?sealed_(\\d+)\\.jsonl").matchEntire(file.name) + ?.groupValues?.get(1)?.toLongOrNull() + }.maxOrNull()?.plus(1L) ?: 1L + } + + private fun File.sealIndex(): Long = + name.removePrefix(SEALED_PREFIX).removeSuffix(SEALED_SUFFIX).toLongOrNull() ?: Long.MAX_VALUE + + private suspend fun drainSegment( + context: Context, + dao: TokenStatsDao, + segment: File, + ): Boolean { + val rawBytes = try { + if (segmentReadErrorForTest?.invoke(segment) == true) { + throw IOException("statistics spool segment read failed (injected): ${segment.name}") + } + segment.readBytes() + } catch (e: Exception) { + logE("statistics spool segment read failed: ${segment.name}", e) + return false + } + afterSegmentReadForTest?.invoke() + // 身份哈希一律基于原始字节(readText 会对非法 UTF-8 做替换再编码,与文件字节 + // 不一致会让 tombstone 身份永远无法匹配损坏段,造成反复重扫/重复条目)。 + val text = String(rawBytes, Charsets.UTF_8) + var corrupt = false + var corruptLineCount = 0 + for (line in text.lineSequence().filter { it.isNotEmpty() }) { + val request = try { + TokenStatRequestContext.fromSpoolLine(line) + } catch (e: Exception) { + corrupt = true + corruptLineCount += 1 + logE("statistics spool line corrupt; preserving segment evidence: ${segment.name}", e) + continue + } + if (!insertSafely(context, dao, request)) return false + synchronized(stateLock) { + insertionWaiters.remove(request.eventId)?.complete(Unit) + } + } + if (corrupt) { + val existingBytes = quarantineEvidenceLocked(context).sumOf { it.length() } + if (existingBytes + segment.length() > MAX_QUARANTINE_BYTES) { + // 硬边界(P2-1):容量内保留完整证据;超限的新损坏段只保留固定大小滚动 + // 摘要(计数/hash/字节/时间,不含正文),并移除原段,磁盘占用有界。 + // 健康排空不受影响:本段处理完立即继续后续 segment。 + if (!summarizeOverCapSegment(context, segment, rawBytes, text, corruptLineCount)) { + logE("statistics quarantine hard cap: over-cap segment retained: ${segment.name}") + return false + } + return true + } + val target = File( + segment.parentFile, + "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (!renameForTest(segment, target)) { + // P1-2:证据重命名失败也不能阻塞健康排空——容量内预算允许时先移入有界 + // pending-delete 诊断区(完整证据,维护入口会移回证据区),再失败才 tombstone + val pending = File( + segment.parentFile, + "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (renameForTest(segment, pending)) { + // P1 终审:rename 后目录项必须确认持久,非 OK fail-closed(段内容在任一 + // 名字下保留,绝不丢原始证据;drain 退避重试下一轮)。P1-1:非 OK 同时 + // 失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE( + "statistics corrupt segment pending-delete rename not durable; " + + "deferring round: ${pending.name}", + ) + return false + } + logE("statistics corrupt segment quarantine rename failed; retained as pending-delete evidence: ${pending.name}") + return true + } + logE("statistics corrupt segment quarantine rename failed; tombstoning: ${segment.name}") + // P1-1:受管集合满时跳过该段继续健康(有界重扫),写失败才退避重试 + return when (tombstoneSegment(context, segment, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> true + TombstoneResult.FAILED -> false + } + } + // P1 终审:证据 rename 后目录项必须确认持久,非 OK fail-closed——证据内容在 + // quarantine 名下保留(绝不丢原始),本轮退避由 drain 重试。P1-1:非 OK 同时 + // 失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE( + "statistics corrupt segment quarantine rename not durable; " + + "deferring round: ${target.name}", + ) + return false + } + return true + } + if (!(segmentDeleteForTest?.invoke(segment) ?: segment.delete())) { + logE("statistics spool segment deletion failed: ${segment.name}") + return false + } + // P1 终审:删除后目录项必须确认持久,非 OK fail-closed(行已入 Room,崩溃后文件 + // 复活会被 INSERT IGNORE 幂等重放,绝不丢数据;本轮退避下一轮继续)。P1-1:非 OK + // 同时失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics spool segment deletion not durable: ${segment.name}") + return false + } + return true + } + + private fun renameForTest(from: File, to: File): Boolean = + segmentRenameForTest?.invoke(from, to) ?: from.renameTo(to) + /** + * 超限损坏段的硬边界替换(P1-1/P1-2):崩溃安全地发布“已裁剪到双上限”的新完整摘要 + * (旧完整或新完整,绝不截断),随后把段移出 sealed 扫描队列;段删除失败绝不阻塞健康 + * 排空(改为 pending-delete 证据或 tombstone 跳过)。摘要发布失败抛异常 → 保留旧摘要 + * 与待处理段,返回 false,绝不声称成功。 + */ + private suspend fun summarizeOverCapSegment( + context: Context, + segment: File, + rawBytes: ByteArray, + text: String, + corruptLineCount: Int, + ): Boolean { + val summaryFile = File(spoolDir(context), QUARANTINE_SUMMARY_NAME) + val store = summaryStore(summaryFile) + return try { + val sha = sha256Hex(rawBytes) + // 崩溃安全读取旧完整摘要(中断残留会被恢复),绝不基于半写内容裁剪 + val oldContent = readMetadata(store, summaryFile) + val oldLines = + oldContent?.lineSequence()?.filter { it.isNotEmpty() }?.toList() ?: emptyList() + val record = + JSONObject() + .put("ts", System.currentTimeMillis()) + .put("file", segment.name) + .put("bytes", segment.length()) + .put("sha256", sha) + .put("lineCount", text.lineSequence().filter { it.isNotEmpty() }.count()) + .put("corruptLines", corruptLineCount) + .toString() + // 崩溃重试幂等:同一段已有记录且未超限则不再追加;超限旧摘要仍会被裁剪自愈 + val alreadyRecorded = + oldLines.any { line -> + try { + val obj = JSONObject(line) + obj.optString("file") == segment.name && obj.optString("sha256") == sha + } catch (_: Exception) { + false + } + } + val withinCaps = + oldLines.size <= MAX_QUARANTINE_SUMMARY_LINES && + oldLines.sumOf { utf8RecordBytes(it) } <= MAX_QUARANTINE_SUMMARY_BYTES + if (!alreadyRecorded || !withinCaps) { + val newContent = buildTrimmedSummary(oldLines, record) + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量(canonical/.new/.bak/tmp 四 + // 槽位),超限有界失败:保留旧摘要与待处理段,返回 false 让 drain 退避重试, + // 绝不发布任何正式文件。 + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE( + "statistics quarantine summary publish refused: metadata budget over the " + + "total cap; keeping old summary and pending segment: ${segment.name}", + ) + return false + } + store.write(newContent) + } + if (disposeOverCapSegment(context, segment, rawBytes) == TombstoneResult.FAILED) { + return false + } + logE("statistics quarantine hard cap: over-cap corrupt segment summarized and removed: ${segment.name}") + true + } catch (e: Exception) { + logE("statistics quarantine summary write failed: ${segment.name}", e) + false + } + } + + /** + * 构建“已裁剪到双上限”的新完整摘要内容(行数与 UTF-8 字节总数都满足上限,保留最新记录)。 + * 真实记录约 200 字节,单行不可能超过字节上限;循环只保证至少保留最新一行。 + */ + private fun buildTrimmedSummary(oldLines: List, record: String): String { + var keep = oldLines.map(::normalizeOversizedSummaryLine) + record + while (keep.size > 1 && + (keep.size > MAX_QUARANTINE_SUMMARY_LINES || + keep.sumOf { utf8RecordBytes(it) } > MAX_QUARANTINE_SUMMARY_BYTES) + ) { + keep = keep.drop(1) + } + return keep.joinToString("\n") + "\n" + } + + /** + * P2-1:摘要上限按 UTF-8 实际字节计(Kotlin String.length 是 UTF-16 code unit,非 ASCII + * 字符会低估);单行 UTF-8 字节超上限时替换为固定 ASCII 缩略记录(hash/bytes,不含正文), + * 输出恒 ≤ [MAX_QUARANTINE_SUMMARY_BYTES]。 + */ + private fun utf8RecordBytes(line: String): Int = + line.toByteArray(Charsets.UTF_8).size + 1 + + private fun normalizeOversizedSummaryLine(line: String): String { + if (utf8RecordBytes(line) <= MAX_QUARANTINE_SUMMARY_BYTES) return line + val bytes = line.toByteArray(Charsets.UTF_8) + return JSONObject() + .put("truncated", true) + .put("bytes", bytes.size) + .put("sha256", sha256Hex(bytes)) + .toString() + } + + private suspend fun disposeOverCapSegment( + context: Context, + segment: File, + rawBytes: ByteArray, + ): TombstoneResult { + if (segmentDeleteForTest?.invoke(segment) ?: segment.delete()) { + // P1-2 终审:删除是目录项变更——sync 非 OK 绝不返回 RECORDED(否则本轮声称成功 + // 而崩溃后段可能复活;下一轮按摘要身份幂等重删)。P1-1:非 OK 同时失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap segment deletion not durable: ${segment.name}") + return TombstoneResult.FAILED + } + return TombstoneResult.RECORDED + } + if (!segment.exists()) { + // 段已消失(上一轮可见删除):先确认删除持久才允许推进队列,绝不基于未确认 + // 状态返回 RECORDED。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap segment absence not durable: ${segment.name}") + return TombstoneResult.FAILED + } + return TombstoneResult.RECORDED + } + val fitsBudget = + quarantineEvidenceLocked(context).sumOf { it.length() } + segment.length() <= MAX_QUARANTINE_BYTES + if (fitsBudget) { + val pending = File( + segment.parentFile, + "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (renameForTest(segment, pending)) { + // P1-2 终审:rename 后目录项必须确认持久,非 OK 返回 FAILED(本轮退避重试; + // rename 可见时下一轮直接跳过/按身份幂等处置,崩溃后 pending 名重现由维护重放) + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap pending-delete rename not durable: ${pending.name}") + return TombstoneResult.FAILED + } + logE("statistics over-cap segment deletion failed; retained as pending-delete evidence: ${pending.name}") + return TombstoneResult.RECORDED + } + } else { + logE("statistics over-cap segment deletion failed and full evidence exceeds the hard cap; summary retains hash/bytes: ${segment.name}") + } + logE("statistics over-cap segment pending-delete rename failed; tombstoning: ${segment.name}") + return tombstoneSegment(context, segment, rawBytes, overCap = true) + } + + /** + * 读取 tombstone manifest(崩溃安全恢复)得到原始行;解析交给 [parseTombstoneLine]。 + * P1-3:不设 canonical isFile 前置——canonical 缺失而内容只在 `.new`/`.bak` sidecar + * 时也必须先经 [AtomicRestoreMarkerStore.read] 恢复完整值再返回;否则仅 sidecar 存在 + * 时 info/ack/容量/扫描会误判为空。 + * P1-2 fail-closed:读取失败必须抛明确 [IOException](不返回 empty)——调用方(append + * 容量检查、scanner、快照、维护)据此中止并退避;返回空只允许出现在“manifest 不存在 + * (无受管记录)”这一真实状态。 + */ + private suspend fun readTombstoneLines(context: Context): List { + val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) + val content = readMetadata(summaryStore(manifestFile), manifestFile) ?: return emptyList() + return content.lineSequence().filter { it.isNotBlank() }.toList() + } + + private fun parseTombstoneLine(line: String): TombstoneEntry? = try { + val obj = JSONObject(line) + val file = obj.optString("file").takeIf { it.isNotEmpty() } ?: return null + TombstoneEntry( + file = file, + bytes = obj.optLong("bytes", -1L), + sha256 = obj.optString("sha256", ""), + overCap = obj.optBoolean("overCap", false), + ) + } catch (_: Exception) { + null + } + + /** 段身份校验结果(P1-2):读取失败 = UNREADABLE,绝不误判为陈旧而删/隔离/清理。 */ + private enum class IdentityCheck { MATCH, MISMATCH, UNREADABLE } + + /** + * P1-2:稳定身份校验——文件名相同且字节数相同且原始字节 SHA-256 相同才是同一段 + * (MATCH)。bytes/sha256 缺失的旧条目(无身份)永不匹配 → MISMATCH(陈旧记录被清理)。 + * 原始字节读取失败 → UNREADABLE(保留 manifest 条目,调用方跳过或失败,不做破坏性决策)。 + * P1-1:SHA 永远现场从原始字节计算,绝不复用 length+mtime 缓存——同名同长同 mtime + * 的替换内容必须被识别为不同身份(陈旧记录被清理,健康段绝不删/跳/隔离)。 + */ + private fun TombstoneEntry.identityCheck(file: File): IdentityCheck { + if (sha256.isEmpty() || !file.isFile || file.length() != bytes) return IdentityCheck.MISMATCH + if (segmentReadErrorForTest?.invoke(file) == true) return IdentityCheck.UNREADABLE + return try { + if (sha256Hex(file.readBytes()) == sha256) IdentityCheck.MATCH else IdentityCheck.MISMATCH + } catch (e: Exception) { + IdentityCheck.UNREADABLE + } + } + + /** + * 有界 skip/tombstone manifest 更新(P1-1/P1-2):**不滚动**的活跃受管失败集合,条目 + * 只在文件物理消失/身份变化后由维护入口移除;达到 entry/字节硬上限时返回 + * [TombstoneResult.CAPACITY_FULL](调用方跳过该段继续健康,新统计 append 随后被拒绝), + * 写失败返回 [TombstoneResult.FAILED](drain 退避重试),绝不静默放行。 + */ + private suspend fun tombstoneSegment( + context: Context, + segment: File, + rawBytes: ByteArray, + overCap: Boolean, + ): TombstoneResult { + val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) + val store = summaryStore(manifestFile) + return try { + val oldLines = + readMetadata(store, manifestFile)?.lineSequence()?.filter { it.isNotEmpty() }?.toList() + ?: emptyList() + val sha = sha256Hex(rawBytes) + val entry = + JSONObject() + .put("ts", System.currentTimeMillis()) + .put("file", segment.name) + // P2 终审:字节数按原始字节计算——候选文件可能已被隔离/删除(目录项 sync + // 失败路径),File.length() 对不存在的文件恒为 0,会让崩溃后重现文件的 + // 身份判定失效;rawBytes 是调用方现场读取的稳定身份。 + .put("bytes", rawBytes.size.toLong()) + .put("sha256", sha) + .put("overCap", overCap) + .toString() + // 崩溃重试幂等:同一身份已记录则不再追加(同一文件同一内容) + val alreadyRecorded = + oldLines.any { line -> + val existing = parseTombstoneLine(line) + existing?.file == segment.name && existing?.sha256 == sha + } + if (alreadyRecorded) return TombstoneResult.RECORDED + val wouldBeEntries = oldLines.size + 1 + val wouldBeBytes = (oldLines + entry).sumOf { utf8RecordBytes(it) } + if (wouldBeEntries > MAX_TOMBSTONE_ENTRIES || wouldBeBytes > MAX_TOMBSTONE_MANIFEST_BYTES) { + logE( + "statistics tombstone capacity full; segment stays in queue for a later retry: ${segment.name}", + ) + return TombstoneResult.CAPACITY_FULL + } + val newContent = (oldLines + entry).joinToString("\n") + "\n" + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(FAILED → drain + // 退避重试,绝不发布任何正式文件)。 + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE( + "statistics tombstone manifest publish refused: metadata budget over the " + + "total cap; segment stays in queue: ${segment.name}", + ) + return TombstoneResult.FAILED + } + store.write(newContent) + TombstoneResult.RECORDED + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest write failed: ${segment.name}", e) + TombstoneResult.FAILED + } + } + + /** + * 移除/裁剪后崩溃安全重写 manifest;写失败仅记录(下一次 drain 会再尝试)。 + * P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件)。 + */ + private suspend fun rewriteTombstoneManifest(context: Context, remainingRawLines: List) { + try { + val content = remainingRawLines.joinToString("\n") + if (remainingRawLines.isEmpty()) "" else "\n" + if (metadataWriteBudgetExceeded(context, content.toByteArray(Charsets.UTF_8).size)) { + logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") + return + } + summaryStore(File(spoolDir(context), TOMBSTONE_MANIFEST_NAME)).write(content) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + } + } + + /** + * P1-2 维护/后台重试:清理删除失败被隔离的残留。ack trash 按持久状态机处置(**只有显式 + * COMMITTED 有界补删**;UNCOMMITTED 一律按 mapping+identity 回滚——P1-1:绝不根据主 + * manifest 缺失推断已提交;状态缺失/损坏或回滚长期失败的 trash 保留为 StuckAckEvidence + * 由 UI 管理,绝不自动删除);pending-delete 证据(容量内来源)移回完整证据区;tombstoned + * 段按记录的处置动作重试(over-cap → 删除,容量内 → 移回完整证据区),处置前必须按稳定 + * identity(file+bytes+sha256)校验当前文件仍是记录的段(P1-2:同名不同 hash 的健康新段 + * 绝不删/移,只移除陈旧记录)。成功后从 manifest 移除记录;文件已物理消失也移除记录 + * (除非身份仍停留在未提交 trash 中——P1-2:绝不让崩溃窗口判定把未确认的证据误判为已提交 + * 而删除)。P1-4:存在无法完整严格解析/读取的 UNCOMMITTED ack trash( + * [scanUncommittedTrashHolds] 的 hasUnknown)时,本轮**整轮跳过** stale 判定与 manifest + * 重写(缺失/不匹配条目的身份可能正被其持有),记录日志并有界返回。 + * + * P1-2 终审:**任何目录项变更(rename/delete)后 syncDir 非 OK 都不推进状态**—— + * 不返回 RECORDED、不移除 manifest 条目、不把变更视为完成:pending-delete 恢复 rename + * 非 OK 时尽力把文件移回 pending-delete 名(重建明确可重试记录)、seal_failed 删除与 + * tombstone 处置非 OK 时保留 manifest 条目,并返回 false 让 drain 退避重试。由于文件 + * 操作可能已可见,下一轮 bootstrap gate([ensureDirectoryDurabilityConfirmed])sync OK + * 后按 identity 幂等完成,绝不丢证据。失败仅记录(tombstone 本身就是有界可见错误证据), + * 绝不阻塞健康排空——本函数返回 true 时 drain 继续处理健康段。 + * + * @return false 表示本轮存在目录项未确认持久的变更(调用方 [drainCore] 退避重试); + * 其它失败(rename/delete 返回 false、状态无效、枚举失败)保留对应可重试记录并返回 true。 + */ + private suspend fun retryPendingCleanup(context: Context): Boolean { + val dir = spoolDir(context) + if (!dir.isDirectory) return true + var roundOk = true + // P1-3:不设 canonical isFile 前置——仅 sidecar 存在时也必须先恢复再处置受管段。 + // P1-2 fail-closed:manifest 不可读则维护中止并抛明确 IOException(drain 退避重试), + // 绝不当作“无受管记录”继续——那会让扫描器把受管段当健康段处理;ack trash 的“已提交” + // 判定也依赖主 manifest,不能拿空集冒充。manifest 不存在(从未有受管记录)视为空集。 + val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) + val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) + val lines = rawLines?.lineSequence()?.filter { it.isNotBlank() }?.toList() ?: emptyList() + // reviewer P1:ack trash 状态机(只有显式 COMMITTED 有界补删;UNCOMMITTED 一律按 + // mapping+identity 回滚——P1-1:绝不根据主 manifest 缺失推断已提交,普通 quarantine + // 证据从不在 manifest 中,缺失恒成立,旧推断会误删未确认的证据)。符号链接目录绝不 + // 进入处置(跟随链接可能删除链接目标的内容)。 + // P1-5:spool 根枚举失败(null)时本轮跳过 trash 处置并记录——枚举失败绝不是 + // “没有 trash 目录”,绝不静默放行;身份持有判定由 [scanUncommittedTrashHolds] 的 + // hasUnknown fail-closed 另行兜底(stale 清理整轮跳过)。 + val ackTrashCandidates = listDir(dir) + if (ackTrashCandidates == null) { + logE("statistics spool directory enumeration failed; deferring ack trash disposal") + } else { + ackTrashCandidates + .filter { f -> + f.isDirectory && + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) + } + .forEach { trash -> + if (!handleAckTrashDir(dir, trash)) roundOk = false + } + } + // P1-2/P1-4:处置后仍停留在未提交 trash 中的身份 → manifest 条目绝不按 stale 移除; + // 存在无法完整严格解析的 UNCOMMITTED trash 时(hasUnknown)本轮保守跳过 stale 处置 + val trashHold = scanUncommittedTrashHolds(context) + val heldInTrash = trashHold.known + dir.listFiles { f -> f.isFile && f.name.startsWith(PENDING_DELETE_PREFIX) } + ?.forEach { file -> + val target = File( + dir, + "$QUARANTINE_PREFIX${file.name.removePrefix(PENDING_DELETE_PREFIX)}", + ) + if (renameForTest(file, target)) { + if (!requireSpoolDirSync(dir)) { + // P1-2 终审:rename 已可见但目录项未确认持久——不推进状态:尽力把文件 + // 移回 pending-delete 名(重建明确可重试记录),并让本轮失败退避;崩溃 + // 后文件在任一名字下都保留证据,下一轮按名字/身份幂等完成。P1-1: + // 非 OK 同时失效 gate。 + logE( + "statistics pending-delete evidence restore rename not durable; " + + "restoring retryable record: ${target.name}", + ) + val reverseRenamed = target.exists() && renameForTest(target, file) + if (reverseRenamed) { + // P2 终审:反向 rename 同样是目录项变更——未确认持久绝不算 + // “已重建可重试记录”(变更可见时下一轮 bootstrap 重新确认后按 + // 名字幂等完成);失败保留 pending 记录并退避,绝不静默。 + if (!requireSpoolDirSync(dir)) { + logE( + "statistics pending-delete evidence restore reverse rename " + + "not durable; keeping retryable record: ${file.name}", + ) + } + } else if (target.exists()) { + logE( + "statistics pending-delete evidence restore reverse rename failed: ${file.name}", + ) + } + roundOk = false + } else { + logE("statistics pending-delete evidence restored to quarantine: ${target.name}") + } + } + } + // P2:seal copy 失败隔离的部分目标(seal_failed_*,scanner 忽略):active 保留完整 + // 内容,删除隔离副本安全无数据损失;删除失败只记录(文件作为受管失败发布证据计入 + // 证据区,占用可见且有界——quarantineEvidence/导出/ack 可管理),下一轮维护再试。 + // P1-2 终审:删除成功但目录项未确认持久 → 本轮不推进(roundOk=false,退避重试)。 + dir.listFiles { f -> f.isFile && f.name.startsWith(SEAL_FAILED_PREFIX) } + ?.forEach { file -> + if (!(segmentDeleteForTest?.invoke(file) ?: file.delete())) { + logE( + "statistics spool seal-failed target cleanup deferred; " + + "visible as managed failed-publication evidence: ${file.name}", + ) + } else if (!requireSpoolDirSync(dir)) { + logE("statistics spool seal-failed target deletion not durable: ${file.name}") + roundOk = false + } + } + if (lines.isEmpty()) return roundOk + // P1-4 fail-closed:hasUnknown 时,缺失(!file.exists())或不匹配(MISMATCH)条目 + // 的身份可能正被无法解析的 ack trash 持有——本轮绝不移除任何这类 manifest 条目。 + // 整轮跳过 MATCH 处置与 manifest 重写(简单正确),记录日志并有界返回(不持锁 + // 等待);trash 状态恢复后下一轮维护再清理。 + if (trashHold.hasUnknown) { + logE( + "statistics ack trash state partially unknown; deferring tombstone stale " + + "cleanup and manifest rewrite this round", + ) + return roundOk + } + val remaining = lines.filterNot { line -> + val entry = parseTombstoneLine(line) ?: return@filterNot false + val file = File(dir, entry.file) + when { + // 物理消失:P1-2 先查未提交 trash——身份在其中时条目必须保留(证据仍存在, + // 等待回滚或提交判定),绝不按 stale 移除。P1-2 终审:删除/移动可能在上轮 + // 可见但未确认持久——本轮先 sync 确认“消失”持久才允许移除条目。P1-1: + // 非 OK 同时失效 gate。 + !file.exists() -> { + val confirmed = requireSpoolDirSync(dir) + if (!confirmed) { + logE( + "statistics tombstone entry absence not durable; retaining entry: ${entry.file}", + ) + roundOk = false + } + confirmed && heldInTrash[entry.file] != (entry.bytes to entry.sha256) + } + else -> when (entry.identityCheck(file)) { + // P1-2:身份不可校验(UNREADABLE)→ 保留记录与文件,本轮不处置 + IdentityCheck.UNREADABLE -> false + // 身份不匹配:同名新文件 ≠ 陈旧记录的前提是旧身份已无处可寻;旧身份仍 + // 停留在未提交 trash 中时条目必须保留(崩溃窗口判定依赖它),只处置新文件 + IdentityCheck.MISMATCH -> heldInTrash[entry.file] != (entry.bytes to entry.sha256) + IdentityCheck.MATCH -> + if (entry.overCap) { + val deleted = + (segmentDeleteForTest?.invoke(file) ?: file.delete()) || !file.exists() + // P1-2 终审:删除成功但目录项未确认持久 → 保留 manifest 条目 + // (可重试记录)并让本轮失败退避;绝不带着未确认删除推进状态。 + if (!deleted) { + false + } else if (!requireSpoolDirSync(dir)) { + logE( + "statistics tombstone over-cap segment deletion not durable; " + + "keeping entry: ${file.name}", + ) + roundOk = false + false + } else { + true + } + } else { + // 容量内:重试移回完整证据区(不超硬 cap 才允许) + val target = + File(dir, "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${entry.file}") + val fits = + quarantineEvidenceLocked(context).sumOf { it.length() } + file.length() <= MAX_QUARANTINE_BYTES + val renamed = fits && renameForTest(file, target) + // P1-2 终审:rename 成功但目录项未确认持久 → 保留 manifest 条目 + // (可重试记录)并让本轮失败退避;rename 可见时下一轮按消失条目 + // 路径 sync 确认后幂等移除。 + if (!renamed) { + false + } else if (!requireSpoolDirSync(dir)) { + logE( + "statistics tombstone evidence restore rename not durable; " + + "keeping entry: ${target.name}", + ) + roundOk = false + false + } else { + true + } + } + } + } + } + if (remaining.size == lines.size) return roundOk + try { + val newContent = remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件) + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") + return roundOk + } + // P1-2 终审:manifest 重写是目录项变更(严格 store)——写失败即保留全部条目 + // (不移除 manifest),并让本轮失败退避重试。 + summaryStore(manifestFile).write(newContent) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + roundOk = false + } + return roundOk + } + + /** + * ack trash 状态机处置(P1-2,调用方持 lifecycleMutex)。trash 目录内的原子状态文件 + * [ACK_TRASH_STATE_FILE_NAME] 首行为 UNCOMMITTED/COMMITTED,后续为 mapping 行 + * (原名 → trash 名 + bytes + sha256)。规则(P1-1 修复): + * - 状态文件尚未写入(无 canonical 与 sidecar)且目录为空:staging 严格发生在状态写入 + * 成功之后,此时不可能有已 stage 的证据 → 空目录直接删除(无证据损失)。 + * - COMMITTED:唯一允许后台删除的状态——有界补删,失败下次再试。 + * - UNCOMMITTED:**一律**按 mapping+identity 回滚到原路径,绝不根据主 manifest 缺失推断 + * 已提交(普通 quarantine 证据从不在 manifest 中,缺失恒成立,旧推断会把未确认的证据 + * 误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚的损坏 sealed 会被扫描器重新 + * 隔离,ack 视失败但不丢证据)。mapping 必须全有或全无有效(P1-2:逐行解析、无重复、 + * 无穿越、与 trash 内证据文件集合完整对应),任一失败 → 整个 trash fail-closed 保留 + * (由 UI 作为 StuckAckEvidence 管理),绝不执行 delete/rollback/manifest 改动。 + * - 状态文件缺失/不可读/无效:非空 trash 保留并报告(fail-closed),绝不删除。 + * + * P1-2 终审:删除成功(空目录/COMMITTED)或回滚移动后目录项必须确认持久—— + * [syncDir] 非 OK 返回 false(调用方 [retryPendingCleanup] 令本轮退避重试,绝不推进); + * 删除返回 false 只记录(trash 本身就是可重试记录,下一轮再试)。 + * + * @return false 表示本轮存在目录项未确认持久的变更;其余情形(含删除失败、状态无效) + * 返回 true(保留可重试记录,不阻塞健康排空)。 + */ + private suspend fun handleAckTrashDir(dir: File, trash: File): Boolean { + val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) + val store = summaryStore(stateFile) + val content: String? = + if (stateFile.exists() || + File(trash, "$ACK_TRASH_STATE_FILE_NAME.new").exists() || + File(trash, "$ACK_TRASH_STATE_FILE_NAME.bak").exists() + ) { + try { + readMetadata(store, stateFile) + } catch (e: Exception) { + logE("statistics ack trash state unreadable; retaining trash: ${trash.name}", e) + null + } + } else if (listDir(trash)?.isEmpty() == true) { + // 崩溃于 stage 开始之前:trash 内没有任何证据文件(stage 是原子移动,空目录 + // = 无证据可保护),删除空目录无损失。枚举失败(listDir 返回 null)绝不当空 + // 目录——内容不可知时走下方 fail-closed 保留分支,绝不删除。stage 已开始或 + // 完成后崩溃(目录非空、状态未写)→ 同样走到保留分支。 + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics empty ack trash cleanup deferred: ${trash.name}") + } else if (!requireSpoolDirSync(dir)) { + logE("statistics empty ack trash deletion not durable: ${trash.name}") + return false + } + return true + } else { + null + } + if (content == null) { + logE("statistics ack trash state missing; retaining trash: ${trash.name}") + return true + } + val lines = content.lineSequence().filter { it.isNotBlank() }.toList() + return when (lines.firstOrNull()) { + ACK_STATE_COMMITTED -> { + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics ack trash cleanup deferred: ${trash.name}") + true + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable: ${trash.name}") + false + } else { + true + } + } + ACK_STATE_UNCOMMITTED -> { + // P1-2:全有或全无解析——任一损坏/重复/穿越/对应缺失都使整个 trash + // fail-closed 保留,绝不基于部分 mapping 做破坏性决策。 + val entries = parseAckMappingStrict(dir, trash, lines) + ?: run { + logE("statistics ack trash state mapping invalid; retaining trash: ${trash.name}") + return true + } + // P1-1:UNCOMMITTED 永远尝试回滚(identity 验证,目标被不同内容占用绝不覆盖)。 + val result = rollbackUncommittedTrash(dir, trash, entries) + if (!result.allResolved) { + logE( + "statistics ack trash rollback not fully resolved; " + + "retaining retryable trash: ${trash.name}", + ) + } + // P1-2 终审:回滚移动/删除的目录项未确认持久 → 本轮失败退避(trash 保留为 + // 可重试记录,下一轮按 identity 幂等完成);普通回滚失败(移动失败、槽位被 + // 占用等)保留记录并继续本轮,绝不阻塞健康排空。 + !result.syncFailed + } + else -> { + logE("statistics ack trash state invalid (${lines.firstOrNull() ?: ""}); retaining trash: ${trash.name}") + true + } + } + } + + /** + * 未提交 trash 回滚结果(P1-2 终审):allResolved=false 表示有文件无法恢复(trash 保留 + * 为可重试记录);syncFailed=true 表示存在目录项未确认持久的变更(本轮必须退避重试)。 + */ + private data class TrashRollbackResult( + val allResolved: Boolean, + val syncFailed: Boolean, + ) + + /** + * 未提交 trash 回滚(P1-2,调用方持 lifecycleMutex):按 mapping 逐条 identity 验证后 + * 恢复。trash 内文件必须仍与 mapping 身份(bytes+sha256)一致才允许移动;原槽位被不同 + * 内容占用时绝不覆盖(保留 trash 证据并 fail-closed);全部恢复成功才删除 trash 目录, + * 否则保留(递归容量统计计入占用)并报告。无法恢复的文件绝不删除。 + * + * P1-2 终审:每个移动/删除都是目录项变更——成功后 [syncDir] 非 OK 置 syncFailed + * (调用方本轮退避;变更可见时下一轮按 identity 幂等完成,崩溃后 trash 重现由状态机 + * 重放),绝不基于未确认状态声称已恢复。 + */ + private fun rollbackUncommittedTrash( + dir: File, + trash: File, + entries: List, + ): TrashRollbackResult { + var allResolved = true + var syncFailed = false + for (entry in entries) { + // 防御:mapping 名字必须是 spool 根/trash 内的合法单层文件名 + if (!isSafeEvidenceName(dir, entry.original) || !isSafeTrashName(trash, entry.trashName)) { + allResolved = false + logE("statistics ack trash mapping has unsafe names; retaining trash: ${trash.name}") + continue + } + val trashFile = File(trash, entry.trashName) + val original = File(dir, entry.original) + if (!trashFile.exists()) { + // mapping 有记录但 trash 中无此文件:文件从未被 stage(状态/映射写于 staging + // 之前)。原槽位同身份即视为已恢复;否则无法验证 → 保留。 + if (!(original.exists() && identityMatches(original, entry))) { + allResolved = false + logE("statistics ack trash rollback cannot verify ${entry.original}; retaining trash: ${trash.name}") + } + continue + } + if (!identityMatches(trashFile, entry)) { + allResolved = false + logE("statistics ack trash file identity mismatch; retaining evidence: ${entry.original}") + continue + } + when { + !original.exists() -> { + if (!atomicMoveForAck(trashFile, original)) { + allResolved = false + logE("statistics ack trash rollback move failed for ${entry.original}; evidence stays in ${trash.name}") + } else if (!requireSpoolDirSync(dir, trash)) { + logE("statistics ack trash rollback move not durable: ${entry.original}") + syncFailed = true + } + } + identityMatches(original, entry) -> { + // 原槽位已是同身份内容:trash 副本冗余,删除副本即可 + if (!(segmentDeleteForTest?.invoke(trashFile) ?: trashFile.delete())) { + allResolved = false + logE("statistics ack trash redundant copy deletion failed: ${entry.original}") + } else if (!requireSpoolDirSync(trash)) { + logE("statistics ack trash redundant copy deletion not durable: ${entry.original}") + syncFailed = true + } + } + else -> { + // 原槽位被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed + allResolved = false + logE("statistics ack trash rollback target occupied by different content; retaining evidence: ${entry.original}") + } + } + } + if (allResolved) { + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics ack trash deletion failed after successful rollback: ${trash.name}") + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable after rollback: ${trash.name}") + syncFailed = true + } + } + return TrashRollbackResult(allResolved, syncFailed) + } + + /** P1-2:文件与 mapping 身份比对(bytes + 原始字节 SHA-256,绝不跟随符号链接)。 */ + private fun identityMatches(file: File, entry: AckMappingEntry): Boolean { + if (!file.isFile || file.length() != entry.bytes) return false + if (Files.isSymbolicLink(file.toPath())) return false + if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false + if (segmentReadErrorForTest?.invoke(file) == true) return false + return try { + sha256Hex(file.readBytes()) == entry.sha256 + } catch (e: Exception) { + false + } + } + + /** P1-4:未提交 ack trash 扫描结果:已知身份 + 是否存在无法完整严格解析/读取的 trash。 */ + private data class UncommittedTrashScan( + val known: Map>, + val hasUnknown: Boolean, + ) + + /** + * P1-2:仍在未提交 trash 中的身份集合(原名 → bytes+sha256),供 manifest 条目的 stale + * 判定使用——身份仍在 trash 中时条目绝不能移除,否则会把未确认的证据误判为已提交而删除。 + * 符号链接目录不读取(不跟随)。调用方持 lifecycleMutex。 + * + * P1-4 fail-closed:返回结构化结果。任何**非空**、非明确 COMMITTED、无法完整严格解析/ + * 读取的 ack trash(状态缺失/不可读、首行非法、mapping 任一损坏/缺身份/不安全/重复、 + * trash 内存在 mapping 未覆盖的证据文件)都会让 [UncommittedTrashScan.hasUnknown] = true, + * 调用方据此保守处置(stale 清理整轮跳过、scanner 不按 MISMATCH 移除条目)——该 trash + * 可能正持有已知集合之外的证据身份,绝不基于残缺信息做破坏性决策。mapping 完整严格 + * 解析成功时按全有或全无计入 [UncommittedTrashScan.known](与 [parseAckMappingStrict] + * 一致)。空目录不可能持有证据(stage 是原子移动,见 [handleAckTrashDir]),不贡献 + * 身份也不置 unknown。 + */ + private suspend fun scanUncommittedTrashHolds(context: Context): UncommittedTrashScan { + val dir = spoolDir(context) + if (!dir.isDirectory) return UncommittedTrashScan(emptyMap(), false) + val result = HashMap>() + var hasUnknown = false + // P1-5 fail-closed:spool 根枚举失败(null)时,任何 ack trash 目录都可能存在但 + // 不可见——身份持有情况完全不可知,置 unknown 阻止调用方做 stale/删除类决策。 + val rootFiles = listDir(dir) + if (rootFiles == null) { + logE( + "statistics spool directory enumeration failed; treating uncommitted ack trash " + + "state as unknown", + ) + return UncommittedTrashScan(emptyMap(), true) + } + rootFiles + .filter { f -> + f.isDirectory && + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) + } + .forEach { trash -> + val files = listDir(trash) + if (files == null) { + // 目录枚举失败:内部证据状态不可知 → fail-closed + hasUnknown = true + return@forEach + } + if (files.isEmpty()) return@forEach + val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) + val content = try { + readMetadata(summaryStore(stateFile), stateFile) + } catch (e: Exception) { + logE("statistics ack trash state unreadable during scan; treating as unknown", e) + null + } ?: run { + // 状态缺失/不可读:非空 trash 中的证据身份不可知 + hasUnknown = true + return@forEach + } + val lines = content.lineSequence().filter { it.isNotBlank() }.toList() + when (lines.firstOrNull()) { + // 显式 COMMITTED:删除已授权,无回滚保护义务,不贡献身份也不置 unknown + ACK_STATE_COMMITTED -> Unit + ACK_STATE_UNCOMMITTED -> { + val entries = parseAckMappingStrict(dir, trash, lines) + if (entries == null) { + // 全有或全无解析失败:该 trash 可能持有任意身份的证据 + hasUnknown = true + } else { + entries.forEach { result[it.original] = it.bytes to it.sha256 } + } + } + // 首行非法/内容为空:无法判定状态 → fail-closed + else -> hasUnknown = true + } + } + return UncommittedTrashScan(result, hasUnknown) + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + } + + /** + * Room insert with a genuinely bounded lifecycle. The write runs on the dedicated single-thread + * insert worker; the drain waits at most [insertTimeoutMs] and on timeout releases the lifecycle + * lock WITHOUT joining the worker (SQLite can ignore interrupts forever, and an unbounded join + * under the lock would freeze every append/snapshot/replay). + * + * Safety across the snapshot/restore barrier (P1-2) has two halves: + * - Generation fencing: the task captures the session generation at submission and atomically + * re-checks it together with [exclusiveBarrierActive] before touching Room. A task that runs + * after a restore bumped the generation skips entirely (its durable segment belongs to the + * pre-restore state that restore replaces). + * - Active-insert registry: the same atomic section registers the insert BEFORE Room is + * entered and the `finally` unregisters it. A snapshot/restore therefore provably waits (or + * bounded-fails) for every insert that already passed the fence, instead of merely relying on + * a check-then-act race that could let an old DAO write into replaced files. + */ + private suspend fun insertSafely( + context: Context, + dao: TokenStatsDao, + request: TokenStatRequestContext, + ): Boolean { + val generation = synchronized(stateLock) { sessionGeneration } + val task = FutureTask { + val registered = synchronized(stateLock) { + if (sessionGeneration != generation || exclusiveBarrierActive) { + false + } else { + activeInserts[request.eventId] = generation + true + } + } + if (!registered) return@FutureTask + try { + runBlocking { TokenStatsLedger.recordWith(context, dao, request) } + synchronized(stateLock) { + insertionWaiters.remove(request.eventId)?.complete(Unit) + } + } finally { + synchronized(stateLock) { activeInserts.remove(request.eventId) } + } + } + try { + insertExecutor.execute(task) + } catch (e: RejectedExecutionException) { + logE("statistics insert worker saturated; durable segment retained: ${request.eventId}", e) + return false + } + return try { + task.get(insertTimeoutMs, TimeUnit.MILLISECONDS) + true + } catch (e: TimeoutException) { + // The worker may legitimately outlive this wait; the durable segment stays for a later + // drain and the generation fence keeps a late write out of a restored database. + task.cancel(true) + logE("statistics Room insert timed out; durable segment retained: ${request.eventId}", e) + false + } catch (e: ExecutionException) { + logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e.cause ?: e) + false + } catch (e: CancellationException) { + logE("statistics Room insert cancelled; durable segment retained: ${request.eventId}", e) + false + } catch (e: Throwable) { + logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e) + false + } + } + + /** + * Database preparation with bounded single-flight semantics: at most one resolution runs at a + * time, and a timed-out resolution is reused by later drain cycles instead of spawning another + * thread (P2-1). A permanently wedged open cannot recover without a restart, but it can never + * block the lifecycle lock or leak threads. + */ + private fun resolveDaoSafely(context: Context): TokenStatsDao? { + val task = synchronized(stateLock) { + pendingDaoTask?.takeIf { !it.isDone } + ?: FutureTask { + (TokenStatsLedger.databaseProvider?.invoke(context) ?: AppDatabase.getDatabase(context)) + .tokenStatsDao() + }.also { created -> + pendingDaoTask = created + try { + databaseExecutor.execute(created) + } catch (e: RejectedExecutionException) { + pendingDaoTask = null + throw e + } + } + } + return try { + task.get(prepareTimeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + task.cancel(true) + logE("statistics database preparation timed out; durable segments retained", e) + null + } catch (e: RejectedExecutionException) { + logE("statistics database preparation rejected; durable segments retained", e) + null + } catch (e: ExecutionException) { + logE("statistics database preparation failed; durable segments retained", e.cause ?: e) + null + } catch (e: Throwable) { + logE("statistics database preparation failed; durable segments retained", e) + null + } + } + + private fun spoolDir(context: Context) = File(context.filesDir, SPOOL_DIR_NAME) + + /** + * 安全关键路径的目录枚举统一入口(调用方持 lifecycleMutex):注入缝为 null 时与 + * [File.listFiles] 完全一致(不影响普通目录枚举);返回 null 表示枚举失败,调用方必须 + * fail-closed(保留/视为 unknown/中止),绝不把失败当作空目录、空队列或编号 1。 + * 覆盖 sealed 队列/待处理判定、seal 编号选择与 ack/trash 安全路径。 + * + * 注意:不能用 `seam?.invoke(dir) ?: dir.listFiles()`——注入缝**返回 null** 表示“枚举 + * 失败”,必须原样传递,绝不能回退到真实枚举(否则 fail-closed 注入失效,测试无法覆盖 + * 该失败分支)。 + */ + private fun listDir(dir: File): Array? { + val seam = directoryListingForTest + return if (seam != null) seam(dir) else dir.listFiles() + } + + /** + * 全部 spool 管理文件的实际字节总和(P1-1 修复:递归):覆盖 spool 根下所有子目录 + * (ack trash 等),只计 regular file,绝不跟随符号链接(NOFOLLOW_LINKS:链接按链接 + * 本身处理,符号链接目录不进入遍历)。总和超过 [cap] 或 Long 溢出时饱和返回 cap+1—— + * 调用方投影必拒绝,无需精确值;目录不存在返回 0;遍历失败按超限处理(fail-closed, + * 绝不因扫描失败而低估容量)。文件数受总 cap 约束有界,无需维护缓存。 + */ + private fun totalSpoolBytes(dir: File, cap: Long): Long { + if (!dir.isDirectory) return 0L + val saturated = if (cap == Long.MAX_VALUE) cap else cap + 1L + var total = 0L + val visitor = object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (attrs.isRegularFile) { + total += attrs.size() + if (total < 0L || total > cap) { + total = saturated + return FileVisitResult.TERMINATE + } + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + total = saturated + return FileVisitResult.TERMINATE + } + } + return try { + Files.walkFileTree( + dir.toPath(), + EnumSet.noneOf(FileVisitOption::class.java), + Int.MAX_VALUE, + visitor, + ) + total + } catch (e: IOException) { + saturated + } + } + + /** + * 数据准入上限(P1-1)= 总上限 − 元数据预留。测试注入更小的总上限时预留同步收缩 + * (至少为数据保留一条完整行 [MAX_LINE_BYTES] 的空间,避免准入区间为负),生产值 + * 恒等于 [METADATA_RESERVE_BYTES]。 + */ + private fun dataAdmissionMaxBytes(cap: Long): Long { + val reserve = minOf(METADATA_RESERVE_BYTES, cap - MAX_LINE_BYTES).coerceAtLeast(0L) + return (cap - reserve).coerceAtLeast(0L) + } + + /** + * 元数据发布预算(P1-1,调用方持 lifecycleMutex):发布 contentBytes 元数据时,最坏 + * 瞬时增量 = [METADATA_COPY_COUNT] × contentBytes(canonical/.new/.bak/tmp 四个槽位可能 + * 短暂同时各持一份完整副本)。投影“实际 [totalSpoolBytes](递归含 ack trash)+ 该增量” + * 仍 ≤ 总上限才允许发布,否则调用方有界失败且不写任何正式文件(sidecar 也不写)。spool + * 内所有元数据读写都持 lifecycleMutex,任意时刻至多一个 AtomicRestoreMarkerStore 写进行中 + * (Atomic tmp 唯一文件并发数 = 1),因此按单写者投影即可证明全部实际字节恒 ≤ 总上限。 + */ + private fun metadataWriteBudgetExceeded(context: Context, contentBytes: Int): Boolean { + val cap = totalSpoolMaxBytesForTest ?: TOTAL_SPOOL_MAX_BYTES + return totalSpoolBytes(spoolDir(context), cap) + contentBytes.toLong() * METADATA_COPY_COUNT > cap + } + + internal fun shutdownWriterForTest() = synchronized(stateLock) { + drainScheduled = false + writerExecutor.shutdownNow() + } + + /** Discard wedged worker executors (e.g. an interrupt-ignoring insert) so later tests start clean. + * Simulates a process restart: the active-insert registry, visibility waiters, the exclusive + * flag, the event-acceptance fence and the bootstrap durability marker are all reset (a wedged + * task can never unregister itself; directory durability must be re-confirmed on the next use). */ + internal fun resetExecutorsForTest() = synchronized(stateLock) { + insertExecutor.shutdownNow() + databaseExecutor.shutdownNow() + pendingDaoTask = null + insertExecutor = newInsertExecutor() + databaseExecutor = newDatabaseExecutor() + activeInserts.clear() + insertionWaiters.values.forEach { it.cancel() } + insertionWaiters.clear() + exclusiveBarrierActive = false + // P1 终审:模拟进程重启——restore 后本进程拒绝事件的状态随重启清除,重新接受 + acceptingEventsThisProcess = true + // P1-1 终审:模拟进程重启——bootstrap gate 标记清零,下一次使用重新确认目录项 + directoryDurabilityConfirmedThisProcess = false + } + + internal fun clearPendingStateForTest() = synchronized(stateLock) { + sessionGeneration += 1L + drainScheduled = false + retryDelayMs = RETRY_BACKOFF_BASE_MS + // P1 终审:逐测试复位 restore fencing 状态(进程内标记绝不跨测试泄漏) + restoreEpoch = 0L + acceptingEventsThisProcess = true + // P1 终审:bootstrap gate 标记逐测试复位(进程内标记绝不跨测试泄漏) + directoryDurabilityConfirmedThisProcess = false + resetExecutorsForTest() + } + + internal fun emergencyQueueSizeForTest(): Int = 0 + internal fun pendingLatchCountForTest(): Int = synchronized(stateLock) { insertionWaiters.size } + internal fun activeInsertCountForTest(): Int = synchronized(stateLock) { activeInserts.size } + + private fun logE(message: String, error: Throwable? = null) { + try { + if (error == null) AppLogger.e(TAG, message) else AppLogger.e(TAG, message, error) + } catch (_: Throwable) { + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt new file mode 100644 index 000000000..aae778d56 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt @@ -0,0 +1,303 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.CancellationException +import org.json.JSONObject + +/** + * 冻结的“发生时”价格/成本快照(P1-1):请求收尾解析一次,随 spool 行持久化; + * 排空与重放只使用该快照,绝不重读当前价格。成本在冻结时计算。 + */ +data class FrozenEventPricing( + val pricing: ResolvedPricing, + val cost: Double?, +) + +/** + * 统一 usage 记录器(阶段 2):把 [TokenStatRequestContext] 落成 + * [TokenStatEventEntity] 账本事件。 + * + * - **幂等**:事件以 [TokenStatEventEntity.eventId] 为主键 IGNORE 插入;同一 + * eventId 重复落账不会重复入账。 + * - **失败不破坏业务**:数据库/价格解析失败只记录日志并返回,绝不向上抛出 + * ([CancellationException] 除外——协程取消必须向上传播,不能当作写入失败吞掉)。 + * - 身份(configId+provider+model)不存在时自动 INSERT IGNORE 创建;展示模型分组 + * 缺失时自动补齐(默认规范化模型名分组)。 + * - 价格:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧 DataStore 用户价格 > 内置默认价 + * ([TokenPriceResolver] 层级);成本用事件发生时的原币价格快照计算。 + * - 诊断字段 [TokenStatEventEntity.diagnosticsJson] 只保存脱敏来源标签与计数, + * 不保存正文、API key、Cookie 或 endpoint 凭据。 + * + * [databaseProvider] / [legacyPriceProvider] 为测试注入缝:生产代码始终为 null + * (真实 [AppDatabase] 与真实 DataStore 读取);测试注入真实 Room 数据库与 + * 桩价格来源验证语义。 + */ +object TokenStatsLedger { + + private const val TAG = "TokenStatsLedger" + + internal var databaseProvider: ((Context) -> AppDatabase)? = null + + internal var legacyPriceProvider: (suspend (Context, String) -> LegacyPriceSettings?)? = null + + /** Linearization token captured before a model invocation starts. Failure aborts that call. */ + suspend fun currentResetGeneration(context: Context): Long { + val appContext = context.applicationContext + val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + return database.tokenStatsDao().currentResetGeneration() + } + + /** + * 记录一个请求事件。写入失败(非取消)只记录日志,不影响原响应/取消传播。 + * @throws CancellationException 协程取消时向上传播,不吞掉。 + */ + suspend fun record(context: Context, request: TokenStatRequestContext) { + try { + val appContext = context.applicationContext + val injected = databaseProvider + val database = + injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + recordWith(appContext, database.tokenStatsDao(), request) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e( + TAG, + "统计事件写入失败(不影响业务): eventId=${request.eventId}, " + + "category=${request.category}, status=${request.status}, " + + "provider=${request.provider}, model=${request.model}", + e, + ) + } + } + + /** + * 请求收尾:解析并冻结“发生时”价格/成本快照,生成 spool v2 行(P1-1)。 + * 数据库/DataStore 不可用时生成明确 UNKNOWN 快照,价格和成本保持 null;默认价 + * 不能冒充当时可能存在但未能读取的用户覆盖。 + * @throws CancellationException 协程取消向上传播。 + */ + internal suspend fun prepareEventLine( + appContext: Context, + request: TokenStatRequestContext, + baseJson: JSONObject, + ): String { + val detached = prepareEventLineDetached(appContext, request) + request.frozenPricing = detached.frozenPricing + request.pricingResolutionDiagnostic = detached.diagnostic + return detached.line + } + + /** + * Detached 版本的收尾行生成:worker 线程在完全独立的 base JSON 上构建行,绝不读写 + * 调用方的 [TokenStatRequestContext] 或共享 JSON,因此超时后被弃置的任务不可能与 + * 调用方的 UNKNOWN 回退路径竞争同一组可变对象(P2-1)。 + */ + internal data class DetachedEventLine( + val line: String, + val frozenPricing: FrozenEventPricing, + val diagnostic: String?, + ) + + internal suspend fun prepareEventLineDetached( + appContext: Context, + request: TokenStatRequestContext, + ): DetachedEventLine { + val frozen = resolveFrozenPricing(appContext, request) + val line = + request.toSpoolBaseJson() + .apply { + put( + "pricing", + TokenStatRequestContext.pricingToJson( + frozen.pricing, + frozen.cost, + request.pricingResolutionDiagnostic, + ), + ) + } + .toString() + return DetachedEventLine(line, frozen, request.pricingResolutionDiagnostic) + } + + private suspend fun resolveFrozenPricing( + appContext: Context, + request: TokenStatRequestContext, + ): FrozenEventPricing { + val pricing = resolvePricing(appContext, request) + val usage = request.aggregatedUsage() + val cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } + return FrozenEventPricing(pricing, cost) + } + + /** 价格读取失败/超时:完整 usage 仍持久化,但价格与成本明确 unresolved。 */ + internal fun prepareUnresolvedEventLine( + request: TokenStatRequestContext, + baseJson: JSONObject, + diagnostic: String, + ): String { + val pricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + source = PricingSource.UNKNOWN, + known = false, + ) + request.pricingResolutionDiagnostic = diagnostic + request.frozenPricing = FrozenEventPricing(pricing, null) + return baseJson + .apply { + put( + "pricing", + TokenStatRequestContext.pricingToJson(pricing, null, diagnostic), + ) + } + .toString() + } + + private suspend fun resolvePricing( + appContext: Context, + request: TokenStatRequestContext, + ): ResolvedPricing { + val injected = databaseProvider + val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + val dao = database.tokenStatsDao() + val providerModel = "${request.provider}:${request.model}" + val overrides = dao.getAllPriceOverrides() + val legacyOverride = + legacyPriceProvider?.invoke(appContext, providerModel) + ?: ApiPreferences.getInstance(appContext).legacyPriceSettingsFor(providerModel) + return resolvePricingFrom(overrides, legacyOverride, request) + } + + private fun resolvePricingFrom( + overrides: List, + legacyOverride: LegacyPriceSettings?, + request: TokenStatRequestContext, + ): ResolvedPricing { + val providerModel = "${request.provider}:${request.model}" + return TokenPriceResolver.resolve( + provider = request.provider, + model = request.model, + configId = request.configId, + overrides = overrides, + legacyOverride = legacyOverride, + defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), + ) + } + + /** + * 实际落账(含身份创建;错误直接向上抛,由调用方决定重试边界)。 + * 事件携带冻结价格快照(spool 重放)时直接使用,否则现场解析(直接路径)。 + * 插入经 [TokenStatsDao.insertEventIfNotResetCovered] 与 reset tombstone 同 + * 事务检查:被 reset 覆盖的事件跳过(视为已处理),不会复活(P1-3)。 + */ + internal suspend fun recordWith( + appContext: Context, + dao: TokenStatsDao, + request: TokenStatRequestContext, + ) { + val identity = ensureIdentity(dao, request) + + val frozen = request.frozenPricing + val pricing: ResolvedPricing + val cost: Double? + if (frozen != null) { + pricing = frozen.pricing + cost = frozen.cost + } else { + pricing = resolvePricing(appContext, request) + val usage = request.aggregatedUsage() + cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } + } + val usage = request.aggregatedUsage() + + dao.insertEventIfNotResetCovered( + TokenStatEventEntity( + eventId = request.eventId, + statIdentityId = identity.identityId, + category = request.category.name, + status = (request.status ?: TokenStatStatus.FAILED).name, + acceptedGeneration = request.acceptedGeneration, + startedAtMs = request.startedAtMs, + endedAtMs = request.endedAtMs, + firstTokenAtMs = request.firstTokenAtMs, + uncachedInputTokens = usage?.uncachedInputTokens, + cachedInputTokens = usage?.cachedInputTokens, + cacheWriteTokens = usage?.cacheWriteTokens, + totalInputTokens = usage?.totalInputTokens, + outputTokens = usage?.outputTokens, + reasoningTokens = usage?.reasoningTokens, + reasoningIncludedInOutput = usage?.reasoningIncludedInOutput, + // 结构化保存缓存写入计费模型:当前价格重估直接读取,不解析 JSON + cacheWriteSeparateBilling = usage?.cacheWriteSeparateBilling, + billingMode = pricing.billingMode.name, + pricingCurrency = pricing.currency.name, + inputPricePerMillion = pricing.inputPricePerMillion, + cachedInputPricePerMillion = pricing.cachedInputPricePerMillion, + cacheWritePricePerMillion = pricing.cacheWritePricePerMillion, + outputPricePerMillion = pricing.outputPricePerMillion, + pricePerRequest = pricing.pricePerRequest, + pricingSource = pricing.source.name, + costInPricingCurrency = cost, + diagnosticsJson = buildDiagnosticsJson(request), + ) + ) + } + + /** 身份不存在时创建(INSERT IGNORE,绝不 REPLACE),并补齐默认展示分组。 */ + private suspend fun ensureIdentity( + dao: TokenStatsDao, + request: TokenStatRequestContext, + ): TokenStatIdentityEntity { + val identity = + TokenStatIdentityEntity( + identityId = + TokenStatIdentityResolver.identityId( + request.configId, + request.provider, + request.model, + ), + configId = request.configId, + provider = request.provider, + model = request.model, + displayModelId = + TokenStatIdentityResolver.displayModelIdFor(request.model), + ) + dao.insertIdentityIfAbsent(identity) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = identity.displayModelId, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(request.model), + displayName = request.model, + ) + ) + return identity + } + + /** 脱敏诊断字段:来源标签、是否观察到 usage、上报次数、attempt 数;无正文/凭据。 */ + private fun buildDiagnosticsJson(request: TokenStatRequestContext): String? { + val usage = request.aggregatedUsage() + return JSONObject().apply { + if (usage != null) { + put("source", usage.source) + put("reasoningIncludedInOutput", usage.reasoningIncludedInOutput) + put("cacheWriteSeparateBilling", usage.cacheWriteSeparateBilling) + } + put("usageObserved", usage != null) + put("usageReportCount", request.usageReportCount) + put("attemptCount", request.attemptCount) + request.pricingResolutionDiagnostic?.let { put("pricingResolution", it) } + }.toString() + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt index e17159bee..870af8002 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt @@ -1058,7 +1058,8 @@ class MessageCoordinationDelegate( modelParameters = modelParameters, enableThinking = false, stream = false, - preserveThinkInHistory = false + preserveThinkInHistory = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { chunk -> contentBuilder.append(chunk) } }.onFailure { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 40f5bc3de..a603cc43b 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -55,6 +55,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -75,6 +77,8 @@ import com.ai.assistance.operit.data.backup.RoomDatabaseBackupManager import com.ai.assistance.operit.data.backup.RoomDatabaseBackupPreferences import com.ai.assistance.operit.data.backup.RoomDatabaseBackupScheduler import com.ai.assistance.operit.data.backup.RoomDatabaseRestoreManager +import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.UserPreferencesManager import com.ai.assistance.operit.data.preferences.ModelConfigManager @@ -106,11 +110,18 @@ import com.ai.assistance.operit.ui.features.settings.components.SectionHeader import com.ai.assistance.operit.ui.features.settings.components.CharacterCardOperation import com.ai.assistance.operit.ui.main.MainActivity import java.io.File +import java.io.IOException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.UUID import kotlin.system.exitProcess +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -137,6 +148,65 @@ enum class RawSnapshotOperation { FAILED } +private fun formatBytes(bytes: Long): String = when { + bytes >= 1024L * 1024L -> + String.format(Locale.getDefault(), "%.1f MB", bytes / 1024.0 / 1024.0) + bytes >= 1024L -> + String.format(Locale.getDefault(), "%.1f KB", bytes / 1024.0) + else -> "$bytes B" +} + +/** 隔离证据区信息快照(P1-3:含未完成删除事务的计数与字节)。 */ +private data class QuarantineInfoSnapshot( + val evidenceCount: Int, + val evidenceBytes: Long, + val stuckTrashCount: Int, + val stuckTrashBytes: Long, + val summaryRecordCount: Int, + val summaryBytes: Long, +) + +/** + * P2:导出失败/取消时目录清理的调度缝。生产路径在 [ioDispatcher](默认 [Dispatchers.IO]) + * 执行真实递归删除,绝不运行在 Main;测试注入 dispatcher 与删除动作以覆盖失败/取消分支并 + * 断言删除不在 Main。取消分支额外使用 NonCancellable 完成本轮独占目录的有界清理,之后由 + * 调用方重抛取消。 + */ +internal object QuarantineExportCleanup { + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + internal var deleteRecursivelyForTest: ((File) -> Boolean?)? = null + + internal suspend fun deleteRecursively(destination: File, nonCancellable: Boolean = false): Boolean = + if (nonCancellable) { + // The outer context prevents prompt cancellation while returning from a different + // dispatcher, so the caller can reliably log the cleanup result before rethrowing. + withContext(NonCancellable) { + withContext(NonCancellable + ioDispatcher) { + deleteRecursivelyForTest?.invoke(destination) ?: destination.deleteRecursively() + } + } + } else { + withContext(ioDispatcher) { + deleteRecursivelyForTest?.invoke(destination) ?: destination.deleteRecursively() + } + } +} + +/** + * P2:导出失败/取消时的目录清理。只删除**本轮成功独占创建**的目录(本应用刚创建、 + * 不含用户文件),绝不触碰他人目录或上一轮残留;返回清理是否成功,调用方据此向用户 + * 反馈清理失败(而非静默假装已清理)。删除在 [QuarantineExportCleanup] 指定的 IO 线程 + * 执行;调用方协程已取消时仍完成有界清理(NonCancellable),随后调用方重抛取消。 + */ +private suspend fun cleanupQuarantineExportDirectory( + destination: File, + createdByThisRun: Boolean, + nonCancellable: Boolean = false, +): Boolean { + if (!createdByThisRun) return true + return QuarantineExportCleanup.deleteRecursively(destination, nonCancellable) +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun ChatBackupSettingsScreen() { @@ -175,6 +245,67 @@ fun ChatBackupSettingsScreen() { var rawSnapshotOperationMessage by remember { mutableStateOf("") } var pendingRawSnapshotRestoreUri by remember { mutableStateOf(null) } var showRawSnapshotRestoreConfirmDialog by remember { mutableStateOf(false) } + var showRawSnapshotRestoreRestartDialog by remember { mutableStateOf(false) } + var quarantineEvidenceCount by remember { mutableIntStateOf(0) } + var quarantineEvidenceBytes by remember { mutableLongStateOf(0L) } + var quarantineSummaryCount by remember { mutableIntStateOf(0) } + var quarantineSummaryBytes by remember { mutableLongStateOf(0L) } + // P1-3:未完成删除事务(非空 ack trash)计数与字节,单独显示并计入总量 + var quarantineStuckTrashCount by remember { mutableIntStateOf(0) } + var quarantineStuckTrashBytes by remember { mutableLongStateOf(0L) } + var quarantineOperationMessage by remember { mutableStateOf("") } + var quarantineOperationFailed by remember { mutableStateOf(false) } + var quarantineBusy by remember { mutableStateOf(false) } + var showQuarantineDeleteConfirmDialog by remember { mutableStateOf(false) } + // 文件扫描/统计全部在 IO 线程执行(P2-2):Main 只更新 state,避免大证据卡死 UI。 + // P1-6:证据枚举失败(如 ack trash 目录不可枚举)→ fail-closed:保留上次显示值并提示 + // 信息不可用,绝不回传“0 证据”误导用户(也不会因此关闭确认删除入口的计数)。 + val refreshQuarantineInfo: suspend () -> Unit = remember { + { + try { + val snapshot = withContext(Dispatchers.IO) { + // P1-3:quarantineEvidence 已含非空 ack trash 目录(目录 length 恒为 0, + // 字节需用 stuckAckTrashBytes 补足) + val evidence = TokenStatSpool.quarantineEvidence(context) + val stuck = TokenStatSpool.stuckAckTrashEvidence(context) + val stuckBytes = TokenStatSpool.stuckAckTrashBytes(context) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + QuarantineInfoSnapshot( + evidenceCount = evidence.size, + evidenceBytes = evidence.sumOf { it.length() } + stuckBytes, + stuckTrashCount = stuck.size, + stuckTrashBytes = stuckBytes, + summaryRecordCount = summary?.recordCount ?: 0, + summaryBytes = summary?.summaryBytes ?: 0L, + ) + } + quarantineEvidenceCount = snapshot.evidenceCount + quarantineEvidenceBytes = snapshot.evidenceBytes + quarantineStuckTrashCount = snapshot.stuckTrashCount + quarantineStuckTrashBytes = snapshot.stuckTrashBytes + quarantineSummaryCount = snapshot.summaryRecordCount + quarantineSummaryBytes = snapshot.summaryBytes + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e( + "ChatBackupSettings", + "quarantine evidence info refresh failed; keeping previous values", + e, + ) + quarantineOperationFailed = true + quarantineOperationMessage = + context.getString( + R.string.stats_quarantine_info_error, + e.localizedMessage ?: e.toString() + ) + } + } + } + LaunchedEffect(Unit) { refreshQuarantineInfo() } + } + var pendingOfficialOperitMigrationUri by remember { mutableStateOf(null) } + var showOfficialOperitMigrationConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showMemoryImportStrategyDialog by remember { mutableStateOf(false) } var pendingMemoryImportUri by remember { mutableStateOf(null) } @@ -1074,6 +1205,260 @@ fun ChatBackupSettingsScreen() { } } } + + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + SectionHeader( + title = stringResource(R.string.stats_quarantine_title), + subtitle = stringResource(R.string.stats_quarantine_subtitle), + icon = Icons.Default.Info + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + R.string.stats_quarantine_desc, + formatBytes(TokenStatSpool.MAX_QUARANTINE_BYTES) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + val quarantineInfoText = buildString { + if (quarantineEvidenceCount > 0) { + append( + stringResource( + R.string.stats_quarantine_info, + quarantineEvidenceCount, + formatBytes(quarantineEvidenceBytes) + ) + ) + } + // P1-3:未完成删除事务(stuck ack trash)单独显示,计数已计入总量; + // 确认删除全部证据时会一并清除 + if (quarantineStuckTrashCount > 0) { + if (isNotEmpty()) append("\n") + append( + stringResource( + R.string.stats_quarantine_stuck_trash_info, + quarantineStuckTrashCount, + formatBytes(quarantineStuckTrashBytes) + ) + ) + } + if (quarantineSummaryCount > 0) { + if (isNotEmpty()) append("\n") + append( + stringResource( + R.string.stats_quarantine_summary_info, + quarantineSummaryCount, + formatBytes(quarantineSummaryBytes) + ) + ) + } + if (isEmpty()) { + append(stringResource(R.string.stats_quarantine_info_empty)) + } + } + Text( + modifier = Modifier.fillMaxWidth(), + text = quarantineInfoText, + style = MaterialTheme.typography.bodyMedium + ) + if (quarantineOperationMessage.isNotBlank()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = quarantineOperationMessage, + style = MaterialTheme.typography.bodySmall, + color = + if (quarantineOperationFailed) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + } + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + ManagementButton( + text = stringResource(R.string.stats_quarantine_export), + icon = Icons.Default.CloudDownload, + onClick = { + // P2:主线程立即置 busy(防双击,状态写先于协程启动); + // 目录名 = 可读时间戳前缀 + UUID(并发导出各自唯一,毫秒级 + // 碰撞不可能撞目录);目录用 Files.createDirectory 独占创建 + // (已存在即失败,绝不复用旧导出目录);失败/取消只清理本轮 + // 成功创建的独占目录。 + if (quarantineBusy) return@ManagementButton + quarantineBusy = true + quarantineOperationFailed = false + val baseDir = OperitBackupDirs.rawSnapshotDir() + val destination = + File( + baseDir, + "token_stats_quarantine_" + + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + .format(Date()) + + "_" + UUID.randomUUID().toString().replace("-", "") + ) + var directoryCreatedByThisRun = false + scope.launch { + try { + try { + Files.createDirectory(destination.toPath()) + } catch (e: FileAlreadyExistsException) { + throw IOException( + "quarantine export directory already exists: " + + destination.absolutePath, + e, + ) + } + directoryCreatedByThisRun = true + // 文件复制/fsync 在 spool 内部切到 IO 线程(P2-2) + TokenStatSpool.exportQuarantineEvidence(context, destination) + quarantineOperationMessage = + context.getString( + R.string.stats_quarantine_export_success, + destination.absolutePath + ) + } catch (e: CancellationException) { + // P2:已取消也要完成有界清理(NonCancellable+IO), + // 清理失败/异常记录日志,随后重抛取消 + val cleaned = try { + cleanupQuarantineExportDirectory( + destination, + directoryCreatedByThisRun, + nonCancellable = true, + ) + } catch (cleanupError: Exception) { + AppLogger.e( + "ChatBackupSettings", + "quarantine export cancelled; cleanup failed: " + + destination.absolutePath, + cleanupError, + ) + false + } + if (!cleaned) { + AppLogger.e( + "ChatBackupSettings", + "quarantine export cancelled; cleanup failed: " + + destination.absolutePath, + ) + } + throw e + } catch (e: Exception) { + quarantineOperationFailed = true + val cleaned = try { + cleanupQuarantineExportDirectory( + destination, + directoryCreatedByThisRun, + ) + } catch (cleanupError: Exception) { + AppLogger.e( + "ChatBackupSettings", + "quarantine export cleanup failed: " + + destination.absolutePath, + cleanupError, + ) + false + } + val reason = e.localizedMessage ?: e.toString() + quarantineOperationMessage = + if (cleaned) { + context.getString( + R.string.stats_quarantine_export_failed, + reason, + ) + } else { + context.getString( + R.string.stats_quarantine_export_failed_cleanup, + reason, + destination.absolutePath, + ) + } + } finally { + quarantineBusy = false + refreshQuarantineInfo() + } + } + }, + modifier = Modifier.weight(1f, fill = false), + enabled = !quarantineBusy + ) + ManagementButton( + text = stringResource(R.string.stats_quarantine_delete), + icon = Icons.Default.Delete, + onClick = { showQuarantineDeleteConfirmDialog = true }, + modifier = Modifier.weight(1f, fill = false), + isDestructive = true, + enabled = !quarantineBusy && quarantineEvidenceCount > 0 + ) + } + } + } + } + } + + if (showQuarantineDeleteConfirmDialog) { + AlertDialog( + onDismissRequest = { showQuarantineDeleteConfirmDialog = false }, + title = { Text(stringResource(R.string.stats_quarantine_delete_confirm_title)) }, + text = { + Text( + stringResource( + R.string.stats_quarantine_delete_confirm_message, + quarantineEvidenceCount, + formatBytes(quarantineEvidenceBytes) + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + showQuarantineDeleteConfirmDialog = false + scope.launch { + quarantineBusy = true + quarantineOperationFailed = false + try { + // 扫描/删除在 spool 内部切到 IO 线程(P2-2) + val names = + withContext(Dispatchers.IO) { + TokenStatSpool.quarantineEvidence(context).map { it.name }.toSet() + } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, names) + quarantineOperationMessage = + context.getString(R.string.stats_quarantine_delete_success, names.size) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + quarantineOperationFailed = true + quarantineOperationMessage = + context.getString( + R.string.stats_quarantine_delete_failed, + e.localizedMessage ?: e.toString() + ) + } finally { + quarantineBusy = false + refreshQuarantineInfo() + } + } + } + ) { + Text(stringResource(R.string.stats_quarantine_delete_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = { showQuarantineDeleteConfirmDialog = false }) { + Text(stringResource(R.string.stats_quarantine_delete_cancel_action)) + } + } + ) } if (showDeleteConfirmDialog) { @@ -1431,6 +1816,24 @@ fun ChatBackupSettingsScreen() { if (showRawSnapshotRestoreConfirmDialog) { val targetName = pendingRawSnapshotRestoreUri?.lastPathSegment ?: "-" + val replacingDatabasesProgressText = + stringResource(R.string.backup_raw_snapshot_progress_replacing_databases) + val finalizingProgressText = + stringResource(R.string.backup_raw_snapshot_progress_finalizing) + val preparingProgressText = + stringResource(R.string.backup_raw_snapshot_progress_preparing) + val readingZipProgressText = + stringResource(R.string.backup_raw_snapshot_progress_reading_zip) + val extractingProgressText = + stringResource(R.string.backup_raw_snapshot_progress_extracting) + val replacingFilesProgressText = + stringResource(R.string.backup_raw_snapshot_progress_replacing_files) + val replacingExternalFilesProgressText = + stringResource(R.string.backup_raw_snapshot_progress_replacing_external_files) + val replacingSharedPrefsProgressText = + stringResource(R.string.backup_raw_snapshot_progress_replacing_shared_prefs) + val replacingDatastoreProgressText = + stringResource(R.string.backup_raw_snapshot_progress_replacing_datastore) AlertDialog( onDismissRequest = { @@ -1448,7 +1851,7 @@ fun ChatBackupSettingsScreen() { if (uri != null) { scope.launch { rawSnapshotOperationState = RawSnapshotOperation.RESTORING - rawSnapshotOperationMessage = context.getString(R.string.backup_raw_snapshot_progress_preparing) + rawSnapshotOperationMessage = preparingProgressText try { try { context.contentResolver.takePersistableUriPermission( @@ -1463,25 +1866,25 @@ fun ChatBackupSettingsScreen() { onProgress = { progress -> rawSnapshotOperationMessage = when (progress) { RawSnapshotBackupManager.RestoreProgress.PREPARING -> - context.getString(R.string.backup_raw_snapshot_progress_preparing) + preparingProgressText RawSnapshotBackupManager.RestoreProgress.READING_ZIP -> - context.getString(R.string.backup_raw_snapshot_progress_reading_zip) + readingZipProgressText RawSnapshotBackupManager.RestoreProgress.EXTRACTING -> - context.getString(R.string.backup_raw_snapshot_progress_extracting) + extractingProgressText RawSnapshotBackupManager.RestoreProgress.REPLACING_FILES -> - context.getString(R.string.backup_raw_snapshot_progress_replacing_files) + replacingFilesProgressText RawSnapshotBackupManager.RestoreProgress.REPLACING_EXTERNAL_FILES -> - context.getString(R.string.backup_raw_snapshot_progress_replacing_external_files) + replacingExternalFilesProgressText RawSnapshotBackupManager.RestoreProgress.REPLACING_SHARED_PREFS -> - context.getString(R.string.backup_raw_snapshot_progress_replacing_shared_prefs) + replacingSharedPrefsProgressText RawSnapshotBackupManager.RestoreProgress.REPLACING_DATASTORE -> - context.getString(R.string.backup_raw_snapshot_progress_replacing_datastore) + replacingDatastoreProgressText RawSnapshotBackupManager.RestoreProgress.REPLACING_DATABASES -> context.getString(R.string.backup_raw_snapshot_progress_replacing_databases) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt index 6ae898fc8..449e4c88c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt @@ -525,7 +525,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -554,7 +555,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -583,7 +585,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -605,7 +608,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -625,7 +629,8 @@ fun FunctionConfigCard( ), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { chunk -> buffer.append(chunk) } buffer.toString() } @@ -655,7 +660,8 @@ fun FunctionConfigCard( ), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { chunk -> buffer.append(chunk) } buffer.toString() } @@ -668,7 +674,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = "Hi")), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -684,7 +691,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt index 25396a557..63bf65c39 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt @@ -378,7 +378,8 @@ fun PersonaCardGenerationScreen( val stream = aiService.sendMessage( context = context, - chatHistory = (fullHistory + ("user" to prompt)).toPromptTurns() + chatHistory = (fullHistory + ("user" to prompt)).toPromptTurns(), + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CHARACTER_GENERATION ) Pair(stream, aiService) } @@ -515,18 +516,6 @@ fun PersonaCardGenerationScreen( } } - // Update token and request count statistics - withContext(Dispatchers.IO) { - val apiPreferences = ApiPreferences.getInstance(context) - apiPreferences.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - apiPreferences.incrementRequestCountForProviderModel(aiService.providerModel) - } - // 流结束后解析并执行工具 withContext(Dispatchers.IO) { processToolInvocations(rawBuffer.toString(), assistantIndex) diff --git a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt index 1260ea877..e08ac19ec 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt @@ -114,7 +114,10 @@ object OperitPaths { VECTOR_INDEX_DIR_NAME, IMAGE_POOL_DIR_NAME, MEDIA_POOL_DIR_NAME, - SKILL_REPO_ZIP_POOL_DIR_NAME + SKILL_REPO_ZIP_POOL_DIR_NAME, + // Raw snapshot 在全局门闩内先排空并 checkpoint,因此不重复打包 spool; + // restore 在同一门闩内验证清理,避免旧事件污染恢复 DB。 + com.ai.assistance.operit.data.stats.TokenStatSpool.SPOOL_DIR_NAME, ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b12edc604..df6db9935 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -5220,6 +5220,25 @@ This will overwrite current app data with the following backup:\n%1$s\n\nThis action cannot be undone. It is recommended to perform a backup before restoring. Confirm restore Cancel + Statistics quarantine evidence + Preserve and maintain corrupt statistics evidence + Event segments that cannot be parsed are preserved as quarantine evidence and never block healthy statistics recording. Full evidence is retained up to the %1$s cap; beyond it only count, hash, byte and time summaries are kept, never content. Export the evidence first, then acknowledge deletion explicitly. + No quarantine evidence + %1$d file(s) quarantined, %2$s total + %1$d incomplete deletion transaction(s) (%2$s), cleared together on delete confirmation + %1$d summary record(s) (%2$s) for the over-cap portion, without content + Export evidence + Evidence exported to: %1$s + Evidence export failed: %1$s + Evidence export failed: %1$s (export directory cleanup also failed, please remove it manually: %2$s) + Delete evidence + Delete statistics quarantine evidence? + This permanently deletes %1$d quarantine evidence file(s) (%2$s total). This cannot be undone; exporting the evidence first is recommended. + Delete + Cancel + Deleted %1$d quarantine evidence file(s) + Deletion failed: %1$s + Quarantine evidence info unavailable: %1$s (keeping the last shown values) Chat History Cross-format backup, export and recovery @@ -6771,6 +6790,7 @@ Error: %1$s Error: LLM session not initialized \n\n[Reasoning process error] + Request cancelled by user Error: %1$s Model name not configured Model directory does not exist: %1$s\nPlease download the model first @@ -7041,6 +7061,7 @@ Error: Cannot apply model chat template (llama_model_chat_template/llama_chat_apply_template) llama.cpp inference process error [Inference error occurred] + Request cancelled by user Task execution failed: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e019721a..a8337674e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5665,6 +5665,25 @@ 将使用以下备份文件覆盖当前应用数据:\n%1$s\n\n此操作不可撤销。建议先执行一次备份再恢复。 确认恢复 取消 + 统计隔离证据 + 损坏的统计事件证据保留与维护 + 统计落盘中无法解析的事件段会保留为隔离证据,不会阻断后续正常事件的记录。完整证据保留到 %1$s 上限;超出上限的部分只保留计数、哈希、字节与时间摘要,不保留正文。请先导出证据,再明确确认删除。 + 暂无隔离证据 + 已隔离 %1$d 个文件,共 %2$s + 其中未完成删除事务 %1$d 个(共 %2$s),确认删除时一并清除 + 其中超出上限的部分仅保留 %1$d 条摘要(%2$s),不含正文 + 导出证据 + 证据已导出到:%1$s + 证据导出失败:%1$s + 证据导出失败:%1$s(导出目录清理也失败,请手动删除:%2$s) + 确认删除 + 确认删除统计隔离证据 + 将永久删除 %1$d 个隔离证据文件(共 %2$s)。此操作不可撤销,建议先导出证据。 + 确认删除 + 取消 + 已删除 %1$d 个隔离证据文件 + 删除失败:%1$s + 隔离证据信息读取失败:%1$s(保留上次显示,请稍后重试) 聊天记录 跨格式备份、导出与恢复 @@ -7245,6 +7264,7 @@ 错误: %1$s 错误: LLM会话未初始化 \n\n[推理过程出现错误] + 请求已被用户取消 错误: %1$s 未配置模型名称 模型目录不存在: %1$s\n请先下载模型 @@ -7573,6 +7593,7 @@ 错误: 无法应用模型对话模板(llama_model_chat_template/llama_chat_apply_template) llama.cpp 推理过程出现错误 [推理过程出现错误] + 请求已被用户取消 任务执行失败: %1$s diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt new file mode 100644 index 000000000..bb21f9438 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt @@ -0,0 +1,99 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.util.exceptions.UserCancellationException +import java.io.IOException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * 本地 provider 生成结束顺序契约测试(评审 P2-3): + * 取消必须在工具缓冲转换/emit 之前判定——取消时无工具结果 emit、 + * 已实测 usage 保留、CANCELLED(UserCancellationException)传播。 + */ +class LocalGenerationEndTest { + + @Test + fun `cancel reports usage then throws without emitting tool result`() = runBlocking { + val usageReports = mutableListOf() + var toolEmitted = false + try { + LocalGenerationEnd.end( + cancelled = true, + success = false, + inputTokens = 300, + outputTokens = 12, + source = ProviderUsageNormalizer.SOURCE_LLAMA, + cancelMessage = "cancelled by user", + onUsageReported = { usage, attempt -> + assertEquals(1, attempt) + usageReports.add(usage) + }, + emitToolResult = { toolEmitted = true }, + failWith = { fail("cancel path must not reach failWith") }, + ) + fail("cancellation must propagate") + } catch (e: UserCancellationException) { + assertEquals("cancelled by user", e.message) + } + // 取消时绝不 emit 不完整的工具 XML + assertFalse("tool buffer must not be emitted on cancel", toolEmitted) + // 已实测 usage 先上报 + assertEquals(1, usageReports.size) + assertEquals(300L, usageReports[0].uncachedInputTokens) + assertEquals(12L, usageReports[0].outputTokens) + } + + @Test + fun `success emits tool result and reports usage without throwing`() = runBlocking { + val usageReports = mutableListOf() + var toolEmitted = false + LocalGenerationEnd.end( + cancelled = false, + success = true, + inputTokens = 100, + outputTokens = 30, + source = ProviderUsageNormalizer.SOURCE_MNN, + cancelMessage = "cancelled", + onUsageReported = { usage, _ -> usageReports.add(usage) }, + emitToolResult = { toolEmitted = true }, + failWith = { fail("success path must not fail") }, + ) + assertTrue("tool result must be emitted when not cancelled", toolEmitted) + assertEquals(1, usageReports.size) + assertEquals(100L, usageReports[0].uncachedInputTokens) + assertEquals(30L, usageReports[0].outputTokens) + } + + @Test + fun `failure emits tool result reports usage then fails`() = runBlocking { + val usageReports = mutableListOf() + var toolEmitted = false + try { + LocalGenerationEnd.end( + cancelled = false, + success = false, + inputTokens = 200, + outputTokens = 5, + source = ProviderUsageNormalizer.SOURCE_LLAMA, + cancelMessage = "cancelled", + onUsageReported = { usage, _ -> usageReports.add(usage) }, + emitToolResult = { toolEmitted = true }, + failWith = { throw IOException("inference failed") }, + ) + fail("failure must propagate") + } catch (e: IOException) { + assertEquals("inference failed", e.message) + } + assertTrue(toolEmitted) + // 失败前已实测 usage 必须落账 + assertEquals(1, usageReports.size) + assertEquals(200L, usageReports[0].uncachedInputTokens) + assertEquals(5L, usageReports[0].outputTokens) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt new file mode 100644 index 000000000..5e2c400e6 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt @@ -0,0 +1,66 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * OpenAI Responses/chat 兼容解析的 usage 计数门测试(评审 P1-5/P2-1): + * - 按字段存在判断:显式全零 payload 也是“已观察到的 usage”(返回非 null, + * 字段为 0),不按 “>0” 过滤; + * - 全程 Long 解析,只在旧 UI 计数边界饱和 Int(不回绕为负); + * - usage 对象完全缺失/无任何相关字段 → null(未观察到)。 + */ +class OpenAIResponsesPayloadAdapterTest { + + @Test + fun `explicit zero payload is observed usage with zero fields`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject("""{"prompt_tokens": 0, "completion_tokens": 0}""") + )!! + assertEquals(0, counts.totalInputTokens) + assertEquals(0, counts.outputTokens) + assertEquals(0, counts.cachedInputTokens) + assertEquals(0, counts.actualInputTokens) + } + + @Test + fun `zero cached split with non-zero totals is parsed`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject( + """{"prompt_tokens": 100, "completion_tokens": 50, "prompt_tokens_details": {"cached_tokens": 0}}""" + ) + )!! + assertEquals(100, counts.totalInputTokens) + assertEquals(100, counts.actualInputTokens) + assertEquals(0, counts.cachedInputTokens) + assertEquals(50, counts.outputTokens) + } + + @Test + fun `values beyond int range saturate at the ui boundary instead of wrapping`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject( + """{"prompt_tokens": 5000000000, "completion_tokens": 4000000000}""" + ) + )!! + // 旧 UI 计数边界(P2-1):饱和为 Int.MAX,绝不回绕为负 + assertEquals(Int.MAX_VALUE, counts.totalInputTokens) + assertEquals(Int.MAX_VALUE, counts.outputTokens) + } + + @Test + fun `usage absent or without any token fields returns null`() { + assertNull(OpenAIResponsesPayloadAdapter.parseUsageCounts(null)) + assertNull(OpenAIResponsesPayloadAdapter.parseUsageCounts(JSONObject("{}"))) + assertNull( + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject("""{"other": "x"}""") + ) + ) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt new file mode 100644 index 000000000..3657ac0c1 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -0,0 +1,2164 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.ModelOption +import com.ai.assistance.operit.data.model.ModelParameter +import com.ai.assistance.operit.data.model.ToolPrompt +import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver +import com.ai.assistance.operit.data.stats.PricingSource +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer +import com.ai.assistance.operit.data.stats.TokenPriceResolver +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsLedger +import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator +import com.ai.assistance.operit.data.stats.TokenStatRequestContext +import com.ai.assistance.operit.util.exceptions.UserCancellationException +import com.ai.assistance.operit.util.stream.MutableSharedStream +import com.ai.assistance.operit.util.stream.Stream +import com.ai.assistance.operit.util.stream.TextStreamEvent +import com.ai.assistance.operit.util.stream.TextStreamEventCarrier +import com.ai.assistance.operit.util.stream.TextStreamEventType +import com.ai.assistance.operit.util.stream.stream +import com.ai.assistance.operit.util.stream.streamOf +import com.ai.assistance.operit.util.stream.timeout +import com.ai.assistance.operit.util.stream.withEventChannel +import java.io.File +import java.io.IOException +import java.io.InterruptedIOException +import java.net.SocketTimeoutException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * 统一记录边界(TokenTrackingAIService)测试: + * 正常/流式/取消/超时/失败、真实 Job 取消与 withTimeout、有 usage/无 usage、 + * 防重、首 token(含仅空白 chunk)、内部重试的 attempt 聚合、调用者 callback 转发、 + * 业务分类上下文、修订流接口保持、连接测试 usage 与取消传播、 + * 有界落账(存储挂起不阻塞业务)。 + */ +class TokenTrackingAIServiceTest { + + private lateinit var tempDir: File + private lateinit var database: AppDatabase + private lateinit var context: Context + + @Before + fun setUp() { + // 流框架日志走 android.util.Log,JVM 测试不可用:关闭避免 Stub! 异常 + com.ai.assistance.operit.util.stream.StreamLogger.setEnabled(false) + com.ai.assistance.operit.util.stream.StreamLogger.setVerboseEnabled(false) + tempDir = kotlin.io.path.createTempDirectory("tracking-test").toFile() + context = mockContext(tempDir) + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + TokenStatsLedger.databaseProvider = { database } + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + // 每个测试使用独立的 spool 目录(context.filesDir 指向独立 tempDir), + // 落账 writer 是生产默认单例:清空调度状态并递增会话代次,使任何残留 + // drain/resolver 立即中止,避免跨测试污染(评审 P2-4) + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.segmentDeleteForTest = null + // P1 终审:测试夹具默认“目录 fsync 支持且成功”(平台无关)——本类断言 seal 排空后 + // spool 无残留文件;Windows 生产会走原地排空模式(active 保留为空文件),与夹具无关 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenTrackingAIService.resetPricingExecutorForTest() + } + + @After + fun tearDown() { + runBlocking { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } + } + TokenStatsLedger.databaseProvider = null + TokenStatsLedger.legacyPriceProvider = null + TokenTrackingAIService.recordTimeoutMs = 5_000L + TokenStatSpool.insertTimeoutMs = 5_000L + TokenStatSpool.dirSyncForTest = null + database.close() + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + /** + * 可编程的假 provider:按给定行为产出流,并像真实 provider 一样上报 usage + * (携带 attempt 序号);testConnection 支持上报 usage / 抛取消,模拟真实 + * provider 内部经 sendMessage 发起探测的形态。 + */ + private class FakeAiService( + var testConnectionResult: Result = Result.success("ok"), + val eventCarrier: Boolean = false, + var behavior: suspend ( + onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + ) -> Stream = { _ -> streamOf("hello") }, + ) : AIService { + val reportCount = AtomicInteger(0) + + /** 非空时 testConnection 会像真实 provider 一样上报 usage。 */ + var testConnectionUsage: ProviderUsageSnapshot? = null + + /** 非空时 testConnection 抛出的取消(模拟 provider 内部取消传播)。 */ + var testConnectionCancellation: CancellationException? = null + + /** 非空时 testConnection 直接抛出该异常(模拟 provider 内部超时/失败)。 */ + var testConnectionThrowable: Throwable? = null + + /** cancelStreaming 的行为(模拟本地 provider 的 isCancelled 通知)。 */ + var cancelHandler: (() -> Unit)? = null + + override val inputTokenCount: Int = 100 + override val cachedInputTokenCount: Int = 0 + override val outputTokenCount: Int = 50 + override val providerModel: String = "DEEPSEEK:deepseek-chat" + + override fun resetTokenCounts() {} + override fun cancelStreaming() { + cancelHandler?.invoke() + } + + override suspend fun getModelsList(context: Context): Result> = + Result.success(emptyList()) + + override suspend fun sendMessage( + context: Context, + chatHistory: List, + modelParameters: List>, + enableThinking: Boolean, + stream: Boolean, + availableTools: List?, + preserveThinkInHistory: Boolean, + onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + onNonFatalError: suspend (error: String) -> Unit, + enableRetry: Boolean, + statsCategory: TokenStatCategory?, + ): Stream { + val inner = behavior(onUsageReported) + if (!eventCarrier) return inner + val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) + return inner.withEventChannel(eventChannel) + } + + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + ): Result { + testConnectionCancellation?.let { throw it } + testConnectionThrowable?.let { throw it } + testConnectionUsage?.let { onUsageReported?.invoke(it, 1) } + return testConnectionResult + } + + override suspend fun calculateInputTokens( + chatHistory: List, + availableTools: List?, + ): Int = 100 + } + + private fun tracked(fake: FakeAiService): TokenTrackingAIService = + TokenTrackingAIService(delegate = fake, context = context, configId = "cfg-1") + + private fun usage(): ProviderUsageSnapshot = + ProviderUsageSnapshot( + uncachedInputTokens = 800L, + cachedInputTokens = 200L, + outputTokens = 500L, + reasoningIncludedInOutput = true, + source = ProviderUsageNormalizer.SOURCE_OPENAI_CHAT_COMPLETIONS, + ) + + /** 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 + * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 */ + private fun gateIgnoringInterrupts(gate: CountDownLatch) { + while (true) { + try { + if (gate.await(1, TimeUnit.SECONDS)) return + } catch (_: InterruptedException) { + } + } + } + + /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ + private fun awaitNoSpoolWorkerThreads() { + fun live(): List = + Thread.getAllStackTraces().entries + .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } + .map { it.key.name } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + if (live().isEmpty()) return + Thread.sleep(20) + } + fail("spool worker threads leaked: ${live()}") + } + + @Test + fun `restore waiter invalidation never overrides model failure or cancellation`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousInsert = TokenStatSpool.insertTimeoutMs + val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs + val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs + TokenStatSpool.insertTimeoutMs = 50 + // caller 的可见性等待也缩短:restore 有界失败后由 caller 自己的超时返回 + TokenTrackingAIService.recordTimeoutMs = 100 + TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 + try { + // insert 永久挂起(忽略中断但可释放):append 已 durable 但 Room 可见性永远等不到。 + // mock 的 suspend 方法默认返回 null,必须显式 stub 新请求会读取的查询 + val release = CountDownLatch(1) + val hangingDao = mock() + whenever(hangingDao.currentResetGeneration()).thenReturn(0L) + whenever(hangingDao.getAllPriceOverrides()).thenReturn(emptyList()) + whenever(hangingDao.insertIdentityIfAbsent(any())).thenAnswer { + gateIgnoringInterrupts(release) + true + } + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(hangingDao) + TokenStatsLedger.databaseProvider = { proxy } + + // 固定模型异常:restore 门闩遇到仍存活的 wedged insert 必须有界失败(P1-2), + // 绝不作废 waiter、绝不覆盖 primary;caller 自己的可见性等待按超时返回 + val modelFailure = IOException("model failed") + var primary: Throwable? = null + val failureJob = launch { + try { + tracked(FakeAiService { _ -> stream { emit("partial"); throw modelFailure } }) + .sendMessage(context = context).collect { } + fail("model failure must propagate") + } catch (e: Throwable) { + primary = e + if (e is CancellationException) throw e + } + } + val firstDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < firstDeadline) { + delay(10) + } + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } + fail("restore must fail bounded while the caller's insert is still live") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + failureJob.join() + assertTrue("primary must be the model exception, was: $primary", primary === modelFailure) + assertEquals(0, primary!!.suppressed.size) + + // 模型取消同样原样传播,不被 restore 门闩/waiter 覆盖 + val modelCancellation = CancellationException("user cancelled") + var primaryCancel: Throwable? = null + val cancelJob = launch { + try { + tracked(FakeAiService { _ -> stream { throw modelCancellation } }) + .sendMessage(context = context).collect { } + fail("model cancellation must propagate") + } catch (e: Throwable) { + primaryCancel = e + if (e is CancellationException) throw e + } + } + val secondDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < secondDeadline) { + delay(10) + } + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } + fail("restore must fail bounded while the caller's insert is still live") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + cancelJob.join() + assertTrue( + "primary must be the model cancellation, was: $primaryCancel", + primaryCancel === modelCancellation, + ) + + // 模拟重启前必须释放旧 insert 并确认 registry 真正清空:释放门闩 → 任务 + // 完成 → 旧 worker shutdown 后真实终止,绝不遗留线程 + release.countDown() + val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { + delay(10) + } + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.insertTimeoutMs = previousInsert + TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout + TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce + } + } + } + + @Test + fun `pricing timeouts stay bounded without leaking threads and events stay unknown`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousPrepare = TokenStatSpool.prepareTimeoutMs + TokenStatSpool.prepareTimeoutMs = 50 + try { + // 价格解析永久挂起且忽略中断(可释放):每次调用都必须在有界时间内返回 UNKNOWN + val release = CountDownLatch(1) + TokenStatsLedger.legacyPriceProvider = { _, _ -> + gateIgnoringInterrupts(release) + null + } + val startedAt = System.nanoTime() + repeat(8) { index -> + val request = + TokenStatRequestContext( + eventId = "evt-price-hang-$index", + category = TokenStatCategory.OTHER, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.finish(TokenStatStatus.COMPLETED) + TokenTrackingAIService.recordSafely(context, request) + } + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + assertTrue("every record must be bounded: $elapsedMs ms", elapsedMs < 10_000) + + // 反复超时后定价线程数固定在上限内(1 个执行线程 + 1 个队列位) + val priceThreads = Thread.getAllStackTraces().keys.count { + it.isAlive && it.name.startsWith("operit-token-stats-price") + } + assertTrue("pricing worker must stay bounded: $priceThreads", priceThreads <= 2) + + // 事件全部 durable 且价格为明确的 UNKNOWN(绝不静默用默认价冒充) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().countEvents() < 8 && System.nanoTime() < deadline) { + delay(50) + } + assertEquals(8, database.tokenStatsDao().countEvents()) + assertTrue( + "all events must be durable with UNKNOWN pricing", + database.tokenStatsDao().getAllEvents().all { it.pricingSource == PricingSource.UNKNOWN.name }, + ) + + // 释放挂起的定价 worker 并确认其真实终止,绝不遗留线程 + release.countDown() + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + } finally { + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.prepareTimeoutMs = previousPrepare + } + } + } + + @Test + fun `normal stream records one completed event with first token and usage`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("first ") + emit("chunk") + onUsage?.invoke(usage(), 1) + } + } + val collected = StringBuilder() + tracked(fake).sendMessage( + context = context, + chatHistory = emptyList(), + statsCategory = TokenStatCategory.CHAT, + ).collect { collected.append(it) } + + assertEquals("first chunk", collected.toString()) + val events = database.tokenStatsDao().getAllEvents() + assertEquals(1, events.size) + val event = events[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(TokenStatCategory.CHAT.name, event.category) + assertNotNull("first token must be set on real first chunk", event.firstTokenAtMs) + assertTrue(event.firstTokenAtMs!! >= event.startedAtMs) + assertEquals(800L, event.uncachedInputTokens) + assertEquals(200L, event.cachedInputTokens) + assertEquals(500L, event.outputTokens) + assertTrue("endedAt after startedAt", event.endedAtMs >= event.startedAtMs) + } + + @Test + fun `category defaults to OTHER when caller does not declare it`() = runBlocking { + val fake = FakeAiService() + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatCategory.OTHER.name, event.category) + } + + @Test + fun `subagent category is propagated`() = runBlocking { + val fake = FakeAiService() + tracked(fake).sendMessage( + context = context, + statsCategory = TokenStatCategory.SUBAGENT, + ).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatCategory.SUBAGENT.name, event.category) + } + + @Test + fun `real job cancellation records cancelled event with usage and propagates`() = + runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("partial") + onUsage?.invoke(usage(), 1) + delay(60_000) + } + } + var propagated: CancellationException? = null + val job = + launch { + try { + tracked(fake).sendMessage(context = context).collect { } + fail("cancellation must propagate") + } catch (e: CancellationException) { + propagated = e + throw e + } + } + // 等流开始并已上报 usage 后再真实取消 Job + delay(50) + job.cancelAndJoin() + assertNotNull("original cancellation must propagate", propagated) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.CANCELLED.name, event.status) + // 真实取消前拿到的 usage 必须落账 + assertEquals(800L, event.uncachedInputTokens) + assertEquals(500L, event.outputTokens) + } + + @Test + fun `cancellation records cancelled event and rethrows`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("partial") + onUsage?.invoke(usage(), 1) + throw CancellationException("user cancelled") + } + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("cancellation must propagate") + } catch (e: CancellationException) { + assertEquals("user cancelled", e.message) + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.CANCELLED.name, event.status) + // 取消时已拿到的 usage 也要落账 + assertEquals(800L, event.uncachedInputTokens) + } + + @Test + fun `failure records failed event and rethrows`() = runBlocking { + val fake = + FakeAiService { _ -> + stream { throw IOException("boom") } + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("failure must propagate") + } catch (e: IOException) { + assertEquals("boom", e.message) + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.FAILED.name, event.status) + assertNull("no usage on failure", event.uncachedInputTokens) + } + + @Test + fun `local provider fatal error emits user visible text then records failed`() = runBlocking { + // 与 Llama/MNN 修复后的契约一致:致命错误先保留用户可见错误文本, + // 再以异常终止 → 统计边界记为 FAILED,错误文本仍完整送达 + val fake = + FakeAiService { onUsage -> + stream { + emit("[error] 模型文件不存在") + onUsage?.invoke( + usage().copy(uncachedInputTokens = 300L, outputTokens = 12L), + 1, + ) + throw IOException("llama_error_inference_failed") + } + } + val collected = StringBuilder() + try { + tracked(fake).sendMessage(context = context).collect { collected.append(it) } + fail("fatal error must propagate") + } catch (e: IOException) { + assertEquals("llama_error_inference_failed", e.message) + } + assertEquals("[error] 模型文件不存在", collected.toString()) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.FAILED.name, event.status) + // 失败前已实测的 usage 也要落账 + assertEquals(300L, event.uncachedInputTokens) + assertEquals(12L, event.outputTokens) + } + + @Test + fun `withTimeout classifies as timeout and records event with usage`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("partial") + onUsage?.invoke(usage(), 1) + delay(60_000) + } + } + var timeout: TimeoutCancellationException? = null + try { + withTimeout(100) { + tracked(fake).sendMessage(context = context).collect { } + } + fail("withTimeout must fire") + } catch (e: TimeoutCancellationException) { + timeout = e + } + assertNotNull(timeout) + val event = database.tokenStatsDao().getAllEvents()[0] + // coroutine 超时(CancellationException 子类)必须记为 TIMEOUT 而非 CANCELLED + assertEquals(TokenStatStatus.TIMEOUT.name, event.status) + assertEquals(800L, event.uncachedInputTokens) + } + + @Test + fun `stream timeout operator classifies as timeout`() = runBlocking { + val fake = + FakeAiService { _ -> + stream { + emit("a") + delay(200) + emit("b") + }.timeout(50.milliseconds) + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("stream timeout must fire") + } catch (e: com.ai.assistance.operit.util.stream.TimeoutException) { + // expected + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.TIMEOUT.name, event.status) + } + + @Test + fun `socket timeout records timeout event`() = runBlocking { + val fake = + FakeAiService { _ -> + stream { throw SocketTimeoutException("connect timed out") } + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("timeout must propagate") + } catch (e: SocketTimeoutException) { + // expected + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.TIMEOUT.name, event.status) + } + + @Test + fun `network retry exhaustion preserves timeout classification through cause chain`() = + runBlocking { + val fake = + FakeAiService { _ -> + // provider 重试耗尽时把超时包成 IOException:cause chain 必须可识别 + stream { + throw IOException("exhausted", SocketTimeoutException("connect timed out")) + } + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("must throw") + } catch (e: IOException) { + // expected + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.TIMEOUT.name, event.status) + } + + @Test + fun `classify maps timeout cancellation and failure precisely`() = runBlocking { + val coroutineTimeout = captureCoroutineTimeout() + assertEquals(TokenStatStatus.TIMEOUT, TokenTrackingAIService.classify(coroutineTimeout)) + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(java.util.concurrent.TimeoutException("t")), + ) + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(com.ai.assistance.operit.util.stream.TimeoutException("t")), + ) + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(SocketTimeoutException("t")), + ) + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(IOException("wrapped", SocketTimeoutException("t"))), + ) + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(IOException("wrapped", coroutineTimeout)), + ) + assertEquals( + TokenStatStatus.CANCELLED, + TokenTrackingAIService.classify(CancellationException("c")), + ) + assertEquals(TokenStatStatus.FAILED, TokenTrackingAIService.classify(IOException("f"))) + // 明确非超时取消优先于 cause 链:UserCancellationException(cause=InterruptedIOException) + // 必须 CANCELLED,不能被 cause 里的 InterruptedIOException 误判为超时 + assertEquals( + TokenStatStatus.CANCELLED, + TokenTrackingAIService.classify( + UserCancellationException("c", InterruptedIOException("Interrupted")) + ), + ) + // 线程中断等普通 InterruptedIOException 不是超时 + assertEquals( + TokenStatStatus.FAILED, + TokenTrackingAIService.classify( + IOException("wrapped", InterruptedIOException("Interrupted")) + ), + ) + // OkHttp 整调用超时(消息明确 "timeout")仍识别为超时 + assertEquals( + TokenStatStatus.TIMEOUT, + TokenTrackingAIService.classify(IOException("wrapped", InterruptedIOException("timeout"))), + ) + } + + /** TimeoutCancellationException 构造器是 internal:用真实 withTimeout 捕获一个实例。 */ + private suspend fun captureCoroutineTimeout(): TimeoutCancellationException = + try { + withTimeout(1) { delay(10_000) } + error("withTimeout must fire") + } catch (e: TimeoutCancellationException) { + e + } + + @Test + fun `no usage keeps unknown fields null`() = runBlocking { + val fake = FakeAiService { _ -> streamOf("plain answer") } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertNull(event.uncachedInputTokens) + assertNull(event.outputTokens) + assertNull(event.costInPricingCurrency) + assertTrue(event.diagnosticsJson!!.contains("\"usageObserved\":false")) + } + + @Test + fun `internal retry usage accumulates across attempts without double counting`() = + runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + // 模拟 provider 内部重试:attempt 1 上报 usage 后 I/O 失败被 + // provider 内部捕获,attempt 2 上报后成功;同 attempt 的重复 + // 上报只取最后一次。 + onUsage?.invoke( + usage().copy(uncachedInputTokens = 300L, outputTokens = 100L), + 1, + ) + try { + throw IOException("transient failure") + } catch (e: IOException) { + onUsage?.invoke( + usage().copy(uncachedInputTokens = 500L, outputTokens = 400L), + 2, + ) + // 同 attempt 重复上报:不得重复累加,取最后一次 + onUsage?.invoke( + usage().copy(uncachedInputTokens = 999L, outputTokens = 400L), + 2, + ) + } + emit("final answer") + } + } + val collected = StringBuilder() + tracked(fake) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { collected.append(it) } + assertEquals("final answer", collected.toString()) + + // 一次逻辑请求只落一个事件 + val events = database.tokenStatsDao().getAllEvents() + assertEquals(1, events.size) + val event = events[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + // attempt1(300) + attempt2 最后一次(999) = 1299;attempt2 重复上报不累加 + assertEquals(1299L, event.uncachedInputTokens) + // attempt1(100) + attempt2 最后一次(400) = 500 + assertEquals(500L, event.outputTokens) + assertTrue(event.diagnosticsJson!!.contains("\"usageReportCount\":3")) + assertTrue(event.diagnosticsJson!!.contains("\"attemptCount\":2")) + } + + @Test + fun `duplicate usage callbacks never duplicate the event`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("a") + onUsage?.invoke(usage(), 1) + emit("b") + onUsage?.invoke(usage(), 1) + } + } + tracked(fake).sendMessage(context = context).collect { } + val events = database.tokenStatsDao().getAllEvents() + assertEquals(1, events.size) + } + + @Test + fun `caller usage callback is forwarded for every report with attempt`() = runBlocking { + val forwarded = mutableListOf>() + val fake = + FakeAiService { onUsage -> + stream { + emit("a") + onUsage?.invoke(usage(), 1) + emit("b") + onUsage?.invoke(usage().copy(outputTokens = 777L), 1) + } + } + tracked(fake) + .sendMessage( + context = context, + onUsageReported = { u, attempt -> forwarded.add(u to attempt) }, + ) + .collect { } + // 调用者 callback 每次上报都转发(含 attempt) + assertEquals(2, forwarded.size) + assertEquals(1, forwarded[0].second) + assertEquals(777L, forwarded[1].first.outputTokens) + // 内部账本按同 attempt 最后一次记账,不重复 + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(777L, event.outputTokens) + } + + @Test + fun `whitespace-only first chunk records first token`() = runBlocking { + val fake = + FakeAiService { _ -> + stream { emit(" "); emit("\n"); emit("x") } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertNotNull("whitespace chunk is real output and must set first token", event.firstTokenAtMs) + } + + @Test + fun `empty string chunks do not set first token before real content`() = runBlocking { + val fake = + FakeAiService { _ -> + stream { emit(""); emit("x") } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + // 空字符串不记首 token,首个非空 chunk 才记 + assertNotNull(event.firstTokenAtMs) + } + + @Test + fun `test connection records connection test events with result status`() = runBlocking { + val ok = FakeAiService(testConnectionResult = Result.success("ok")) + assertEquals(true, tracked(ok).testConnection(context).isSuccess) + val okEvent = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatCategory.CONNECTION_TEST.name, okEvent.category) + assertEquals(TokenStatStatus.COMPLETED.name, okEvent.status) + + val bad = + FakeAiService(testConnectionResult = Result.failure(IOException("denied"))) + assertEquals(false, tracked(bad).testConnection(context).isSuccess) + val badEvent = database.tokenStatsDao().getAllEvents()[1] + assertEquals(TokenStatCategory.CONNECTION_TEST.name, badEvent.category) + assertEquals(TokenStatStatus.FAILED.name, badEvent.status) + } + + @Test + fun `test connection forwards provider usage into connection test event`() = runBlocking { + val fake = FakeAiService(testConnectionResult = Result.success("ok")) + fake.testConnectionUsage = usage() + assertEquals(true, tracked(fake).testConnection(context).isSuccess) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatCategory.CONNECTION_TEST.name, event.category) + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + // 探测调用拿到的 provider usage 必须进入 CONNECTION_TEST 事件 + assertEquals(800L, event.uncachedInputTokens) + assertEquals(500L, event.outputTokens) + } + + @Test + fun `test connection propagates cancellation and records cancelled`() = runBlocking { + val fake = FakeAiService() + fake.testConnectionCancellation = CancellationException("test cancelled") + try { + tracked(fake).testConnection(context) + fail("cancellation must propagate") + } catch (e: CancellationException) { + assertEquals("test cancelled", e.message) + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatCategory.CONNECTION_TEST.name, event.category) + assertEquals(TokenStatStatus.CANCELLED.name, event.status) + } + + @Test + fun `revision stream interface is preserved for downstream rollback handling`() = runBlocking { + val fake = FakeAiService(eventCarrier = true) + val result = tracked(fake).sendMessage(context = context) + assertTrue("tracked stream must stay a revision carrier", result is TextStreamEventCarrier) + val collected = StringBuilder() + result.collect { collected.append(it) } + assertEquals("hello", collected.toString()) + // 保存点/回滚事件通道仍然可访问(replayCache 是 SharedStream 的公开只读面) + assertNotNull((result as TextStreamEventCarrier).eventChannel.replayCache) + val events = database.tokenStatsDao().getAllEvents() + assertEquals(1, events.size) + } + + @Test + fun `generation read failure aborts before model invocation`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + var invoked = false + val fake = + FakeAiService { onUsage -> + invoked = true + stream { + emit("still delivered") + onUsage?.invoke(usage(), 1) + } + } + TokenStatsLedger.databaseProvider = { throw IOException("generation unavailable") } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("call must not start without a durable reset generation") + } catch (e: IOException) { + assertEquals("generation unavailable", e.message) + } finally { + TokenStatsLedger.databaseProvider = { database } + } + assertFalse("model must not be invoked", invoked) + } + } + + @Test + fun `claude same attempt incremental usage keeps full snapshot and cost`() = runBlocking { + database.tokenStatsDao().upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 3.0, + outputPricePerMillion = 5.0, + ) + val fake = + FakeAiService { onUsage -> + stream { + emit("answer") + // Anthropic 流式:message_start 携带完整 input/cache/cacheWrite + onUsage?.invoke( + ProviderUsageNormalizer.anthropic( + JSONObject( + """ + {"input_tokens": 100, "cache_read_input_tokens": 50, + "cache_creation_input_tokens": 10, "output_tokens": 0} + """.trimIndent() + ) + )!!, + 1, + ) + // message_delta 只携带累计 output:同 attempt 按最新非空字段合并, + // 不得覆盖掉 input/cache(output 等累计字段取最新值,不相加) + onUsage?.invoke( + ProviderUsageNormalizer.anthropic( + JSONObject("""{"output_tokens": 300}""") + )!!, + 1, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(100L, event.uncachedInputTokens) + assertEquals(50L, event.cachedInputTokens) + assertEquals(10L, event.cacheWriteTokens) + assertEquals(300L, event.outputTokens) + // 费用完整:100*2 + 50*0.5 + 10*3 + 300*5 = 1755(每百万) + assertEquals(1755.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) + } + + @Test + fun `complete snapshot revocation overwrites previously known fields`() = runBlocking { + // 评审 P2-2:完整快照(completeSnapshot=true)的 null = 明确未知, + // 必须覆盖旧值(撤销),协议因此可表达“省略”与“明确未知”的区别 + val fake = + FakeAiService { onUsage -> + stream { + emit("a") + onUsage?.invoke( + usage().copy(completeSnapshot = true), + 1, + ) + // 完整快照撤销 output(后续消息确认该分量未知) + onUsage?.invoke( + usage().copy( + uncachedInputTokens = 800L, + cachedInputTokens = 200L, + outputTokens = null, + completeSnapshot = true, + ), + 1, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(800L, event.uncachedInputTokens) + assertNull("complete snapshot null must revoke output", event.outputTokens) + } + + @Test + fun `partial update omission keeps previously known fields`() = runBlocking { + // 评审 P2-2:部分更新(completeSnapshot=false)省略字段保留旧值 + val fake = + FakeAiService { onUsage -> + stream { + emit("a") + onUsage?.invoke( + usage().copy(completeSnapshot = false), + 1, + ) + // 部分更新只带 output,input 省略必须保留 + onUsage?.invoke( + usage().copy( + uncachedInputTokens = null, + cachedInputTokens = null, + outputTokens = 777L, + completeSnapshot = false, + ), + 1, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(800L, event.uncachedInputTokens) + assertEquals(200L, event.cachedInputTokens) + assertEquals(777L, event.outputTokens) + } + + @Test + fun `attempt aggregation never overflows int`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + // 两个 attempt 各 Int.MAX_VALUE:Int 加法必溢出为负, + // Long 聚合必须得到正确的 4294967294 + onUsage?.invoke( + usage().copy( + uncachedInputTokens = Int.MAX_VALUE.toLong(), + outputTokens = 100L, + ), + 1, + ) + onUsage?.invoke( + usage().copy( + uncachedInputTokens = Int.MAX_VALUE.toLong(), + outputTokens = 200L, + ), + 2, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals("no int overflow", 4294967294L, event.uncachedInputTokens) + assertEquals(300L, event.outputTokens) + } + + @Test + fun `negative provider component is rejected as unknown not silently recorded`() = + runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + onUsage?.invoke( + usage().copy(uncachedInputTokens = 500L, outputTokens = 100L), + 1, + ) + // 负值分量(异常 provider 数据)必须拒绝为未知 + onUsage?.invoke( + usage().copy(uncachedInputTokens = -5L, outputTokens = 300L), + 2, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + // attempt2 的 uncached 为负被拒绝为未知 → 该分量整体未知, + // 绝不静默落负数或把未知当作 0 + assertNull("negative must not be recorded", event.uncachedInputTokens) + // 其他分量不受影响:100+300=400 + assertEquals(400L, event.outputTokens) + } + + @Test + fun `cancel streaming only ends with cancelled event preserved usage and propagation`() = + runBlocking { + val cancelled = java.util.concurrent.atomic.AtomicBoolean(false) + val fake = + FakeAiService { onUsage -> + stream { + emit("partial") + onUsage?.invoke(usage(), 1) + // 模拟本地 provider 修复后的契约:cancelStreaming(isCancelled) + // 让 native 停止后,以 UserCancellationException 结束流, + // 不取消 collector Job、不 emit 错误文本 + while (!cancelled.get()) { + delay(10) + } + throw UserCancellationException("cancelled by user") + } + } + fake.cancelHandler = { cancelled.set(true) } + var propagated: CancellationException? = null + val job = + launch { + try { + tracked(fake).sendMessage(context = context).collect { } + fail("cancellation must propagate") + } catch (e: CancellationException) { + propagated = e + throw e + } + } + delay(100) // 等待流开始并已上报 usage + // 只调用 cancelStreaming,不取消 collector Job + tracked(fake).cancelStreaming() + job.join() + assertNotNull("cancelStreaming must end the stream with cancellation", propagated) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.CANCELLED.name, event.status) + // 取消前已实测的 usage 必须保留 + assertEquals(800L, event.uncachedInputTokens) + assertEquals(500L, event.outputTokens) + } + + @Test + fun `test connection forwards external usage callback`() = runBlocking { + val fake = FakeAiService(testConnectionResult = Result.success("ok")) + fake.testConnectionUsage = usage() + val forwarded = mutableListOf>() + tracked(fake).testConnection(context) { u, attempt -> forwarded.add(u to attempt) } + assertEquals(1, forwarded.size) + assertEquals(1, forwarded[0].second) + assertEquals(800L, forwarded[0].first.uncachedInputTokens) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(800L, event.uncachedInputTokens) + } + + @Test + fun `test connection failure result classifies timeout through cause chain`() = runBlocking { + val fake = + FakeAiService( + testConnectionResult = + Result.failure( + IOException("exhausted", SocketTimeoutException("connect timed out")) + ) + ) + assertEquals(false, tracked(fake).testConnection(context).isSuccess) + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.TIMEOUT.name, event.status) + } + + @Test + fun `test connection thrown exception classifies timeout and plain failure`() = runBlocking { + val timeout = FakeAiService() + timeout.testConnectionThrowable = IOException("wrapped", SocketTimeoutException("t")) + assertEquals(false, tracked(timeout).testConnection(context).isSuccess) + assertEquals( + TokenStatStatus.TIMEOUT.name, + database.tokenStatsDao().getAllEvents()[0].status, + ) + + val plain = FakeAiService() + plain.testConnectionThrowable = IOException("denied") + assertEquals(false, tracked(plain).testConnection(context).isSuccess) + assertEquals( + TokenStatStatus.FAILED.name, + database.tokenStatsDao().getAllEvents()[1].status, + ) + } + + @Test + fun `user cancellation with interrupted io cause stays cancelled and interrupt is not timeout`() = + runBlocking { + val fake = + FakeAiService { _ -> + stream { + throw UserCancellationException( + "user cancelled", + InterruptedIOException("Interrupted"), + ) + } + } + try { + tracked(fake).sendMessage(context = context).collect { } + fail("cancellation must propagate") + } catch (e: CancellationException) { + // expected + } + assertEquals( + TokenStatStatus.CANCELLED.name, + database.tokenStatsDao().getAllEvents()[0].status, + ) + + // 线程中断等普通 InterruptedIOException 不是超时 + val interrupted = + FakeAiService { _ -> + stream { throw IOException("interrupted", InterruptedIOException("Interrupted")) } + } + try { + tracked(interrupted).sendMessage(context = context).collect { } + fail("must throw") + } catch (e: IOException) { + // expected + } + assertEquals( + TokenStatStatus.FAILED.name, + database.tokenStatsDao().getAllEvents()[1].status, + ) + + // OkHttp 整调用超时(消息明确 "timeout")仍识别为超时 + val okhttp = + FakeAiService { _ -> + stream { throw IOException("call timeout", InterruptedIOException("timeout")) } + } + try { + tracked(okhttp).sendMessage(context = context).collect { } + fail("must throw") + } catch (e: IOException) { + // expected + } + assertEquals( + TokenStatStatus.TIMEOUT.name, + database.tokenStatsDao().getAllEvents()[2].status, + ) + } + + @Test + fun `caller usage observer non-cancel exception is isolated from request and ledger`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val fake = + FakeAiService { onUsage -> + stream { + emit("still delivered") + onUsage?.invoke(usage(), 1) + } + } + val collected = StringBuilder() + tracked(fake) + .sendMessage( + context = context, + onUsageReported = { _, _ -> throw IllegalStateException("observer bug") }, + ) + .collect { collected.append(it) } + assertEquals("still delivered", collected.toString()) + // 调用者异常不得改变账本/请求结果 + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(800L, event.uncachedInputTokens) + assertEquals(500L, event.outputTokens) + } + } + + @Test + fun `caller usage observer cancellation propagates as request cancellation`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("partial") + onUsage?.invoke(usage(), 1) + } + } + try { + tracked(fake) + .sendMessage( + context = context, + onUsageReported = { _, _ -> throw CancellationException("observer cancel") }, + ) + .collect { } + fail("observer cancellation must propagate") + } catch (e: CancellationException) { + assertEquals("observer cancel", e.message) + } + val event = database.tokenStatsDao().getAllEvents()[0] + assertEquals(TokenStatStatus.CANCELLED.name, event.status) + // 取消前已记录的 usage 仍完整落账 + assertEquals(800L, event.uncachedInputTokens) + } + + @Test + fun `blocked writer never loses events and all are eventually written`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs + TokenTrackingAIService.recordTimeoutMs = 100 + val previousInsertTimeout = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 500 + val previousPrepareTimeout = TokenStatSpool.prepareTimeoutMs + TokenStatSpool.prepareTimeoutMs = 100 + // 可控阻塞:数据库访问是纯同步等待(runBlocking),withTimeout 无法 + // 抢占——必须由独立 resolver/writer + 持久 spool 隔离,业务只做有界等待 + val blocker = CompletableDeferred() + try { + TokenStatsLedger.databaseProvider = { + runBlocking { blocker.await() } + database + } + val startedAt = System.nanoTime() + repeat(20) { index -> + val request = + TokenStatRequestContext( + eventId = "evt-blocked-$index", + category = TokenStatCategory.OTHER, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.finish(TokenStatStatus.COMPLETED) + TokenTrackingAIService.recordSafely(context, request) + } + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + // 每次调用都在有界时间内返回(等待窗口 100ms),业务不被数据库阻塞 + assertTrue("bounded per-call: $elapsedMs ms", elapsedMs < 10_000) + // 未解除阻塞前:任何事件都不应已落账(resolver 全部被阻塞) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 解除阻塞:全部事件必须最终写入,一个都不能丢(评审 P1/P1-4) + blocker.complete(Unit) + TokenStatsLedger.databaseProvider = { database } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) + while (database.tokenStatsDao().countEvents() < 20 && System.nanoTime() < deadline) { + delay(50) + } + assertEquals("all 20 events must be recorded", 20, database.tokenStatsDao().countEvents()) + val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() + assertEquals("no event may be dropped or duplicated", 20, ids.size) + assertTrue(ids.containsAll((0 until 20).map { "evt-blocked-$it" })) + // 排空完成后 spool 必须为空 + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + if (spoolDir.isDirectory) { + val remaining = + spoolDir.listFiles().orEmpty().filter { it.isFile } + assertEquals("spool must be drained", 0, remaining.size) + } + } finally { + TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout + TokenStatSpool.insertTimeoutMs = previousInsertTimeout + TokenStatSpool.prepareTimeoutMs = previousPrepareTimeout + TokenStatsLedger.databaseProvider = { database } + } + } + } + + @Test + fun `task failure is isolated and subsequent events keep recording`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs + TokenTrackingAIService.recordTimeoutMs = 500 + try { + // 让落账失败(数据库不可用):事件保留在 spool,业务不受影响 + TokenStatsLedger.databaseProvider = { throw RuntimeException("db unavailable") } + val first = + TokenStatRequestContext( + eventId = "evt-fail-1", + category = TokenStatCategory.OTHER, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), + ) + first.finish(TokenStatStatus.COMPLETED) + TokenTrackingAIService.recordSafely(context, first) + + // 恢复数据库:后续事件触发排空,失败事件一并重放(IGNORE 幂等) + TokenStatsLedger.databaseProvider = { database } + val second = + TokenStatRequestContext( + eventId = "evt-fail-2", + category = TokenStatCategory.OTHER, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), + ) + second.finish(TokenStatStatus.COMPLETED) + TokenTrackingAIService.recordSafely(context, second) + + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getAllEvents().size < 2 && System.nanoTime() < deadline) { + delay(50) + } + val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() + // 失败任务不阻断后续;失败事件在数据库恢复后成功重放 + assertEquals(setOf("evt-fail-1", "evt-fail-2"), ids) + // P2-4:等待结束/超时后 waiter 引用必须全部解除(大量失败不累积) + assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) + } finally { + TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout + TokenStatsLedger.databaseProvider = { database } + } + } + } + + @Test + fun `spool survives process death and replays on next process start`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousInsertTimeout = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 200 + try { + // 模拟上一进程写入的 spool:直接构造持久化 v2 行(含发生时价格 + // 快照,等价于 enqueue 落盘内容;P1-1:重放只用快照) + val previousProcess = TokenStatRequestContext( + eventId = "evt-old-process-1", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + previousProcess.onUsage( + com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( + uncachedInputTokens = 300L, + cachedInputTokens = 100L, + outputTokens = 50L, + reasoningIncludedInOutput = true, + source = "test", + ), + 1, + ) + previousProcess.finish(TokenStatStatus.COMPLETED) + val line = TokenStatsLedger.prepareEventLine(context, previousProcess, previousProcess.toSpoolBaseJson()) + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") + + // “重启”:清空本进程内存状态,不依赖新 append 触发恢复 + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-old-process-1") == null && + System.nanoTime() < deadline + ) { + delay(50) + } + val event = database.tokenStatsDao().getEvent("evt-old-process-1") + assertNotNull("stale spool must be replayed after process restart", event) + assertEquals(300L, event!!.uncachedInputTokens) + assertEquals(50L, event.outputTokens) + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + // 价格快照来自行内冻结(无覆盖时内置默认价) + assertEquals(PricingSource.DEFAULT.name, event.pricingSource) + } finally { + TokenStatSpool.insertTimeoutMs = previousInsertTimeout + } + } + } + + @Test + fun `shutdown writer self heals and keeps recording`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // 关闭默认 writer(模拟执行器生命周期结束),下一次落账必须自愈重建 + TokenStatSpool.shutdownWriterForTest() + val fake = FakeAiService { _ -> streamOf("still works") } + tracked(fake).sendMessage(context = context).collect { } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getAllEvents().isEmpty() && System.nanoTime() < deadline) { + delay(50) + } + assertEquals(1, database.tokenStatsDao().getAllEvents().size) + } + } + + @Test + fun `hanging record write is bounded and never blocks completion`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs + TokenTrackingAIService.recordTimeoutMs = 300 + val previousInsertTimeout = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 100 + val previousPrepareTimeout = TokenStatSpool.prepareTimeoutMs + TokenStatSpool.prepareTimeoutMs = 100 + try { + // 让价格解析(legacy 价格读取)永久挂起(可释放):suspend 卡死由 + // prepareTimeout 截断 → 回退默认价快照 → 事件仍持久落账(不丢); + // 业务线程只做有界等待,绝不被阻塞 + val release = CountDownLatch(1) + TokenStatsLedger.legacyPriceProvider = { _, _ -> + gateIgnoringInterrupts(release) + null + } + val fake = FakeAiService { _ -> streamOf("still delivered") } + val collected = StringBuilder() + val done = + withTimeoutOrNull(5_000) { + tracked(fake).sendMessage(context = context).collect { + collected.append(it) + } + true + } + assertNotNull("record hang must not block completion", done) + assertEquals("still delivered", collected.toString()) + // 价格解析挂起被截断后:事件带默认价快照持久化并最终落账,不丢失 + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getAllEvents().isEmpty() && + System.nanoTime() < deadline + ) { + delay(50) + } + assertEquals(1, database.tokenStatsDao().getAllEvents().size) + + // 释放挂起的定价 worker 并确认其真实终止,绝不遗留线程 + release.countDown() + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + } finally { + TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout + TokenStatSpool.insertTimeoutMs = previousInsertTimeout + TokenStatSpool.prepareTimeoutMs = previousPrepareTimeout + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + } + } + } + + // ==== P1-1:发生时价格快照 ==== + + @Test + fun `price snapshot is frozen at append and replay never re-reads current prices`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val dao = database.tokenStatsDao() + // 价格 A + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 5.0, + ) + val request = + TokenStatRequestContext( + eventId = "evt-frozen-price", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis() - 1_000, + ) + request.onUsage(usage().copy(cacheWriteTokens = 0L), 1) + request.finish(TokenStatStatus.COMPLETED) + // 请求收尾:解析并冻结价 A(durable append 前) + val line = + TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + + // writer 阻塞/重启期间用户改价为 B + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 9.0, + cachedInputPricePerMillion = 8.0, + outputPricePerMillion = 7.0, + ) + + // 重放:只用行内快照,绝不重读当前价格 + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-frozen-price") == null && + System.nanoTime() < deadline + ) { + delay(50) + } + val event = database.tokenStatsDao().getEvent("evt-frozen-price")!! + // 历史仍 A(发生时快照) + assertEquals(2.0, event.inputPricePerMillion!!, 1e-12) + assertEquals(0.5, event.cachedInputPricePerMillion!!, 1e-12) + assertEquals(5.0, event.outputPricePerMillion!!, 1e-12) + assertEquals(PricingSource.CONFIG_OVERRIDE.name, event.pricingSource) + // 800*2 + 200*0.5 + 500*5 = 4200(每百万) + assertEquals(4200.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) + + // 新请求(当前重估路径)用改价后的 B + val current = + TokenStatRequestContext( + eventId = "evt-current-price", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + current.onUsage(usage().copy(cacheWriteTokens = 0L), 1) + current.finish(TokenStatStatus.COMPLETED) + TokenStatsLedger.record(context, current) + val currentEvent = database.tokenStatsDao().getEvent("evt-current-price")!! + assertEquals(9.0, currentEvent.inputPricePerMillion!!, 1e-12) + } + } + + // ==== P1-2:失败段退避重试(不依赖新请求) ==== + + @Test + fun `failed drain retries with backoff without any new request`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousInsertTimeout = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 200 + try { + // 预置一个事件到 spool(模拟冷启动遗留的失败段) + val request = + TokenStatRequestContext( + eventId = "evt-retry-1", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.onUsage(usage(), 1) + request.finish(TokenStatStatus.COMPLETED) + val line = + TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") + + // 数据库故障:replay 后落账失败 + TokenStatsLedger.databaseProvider = { throw RuntimeException("db down") } + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + delay(300) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复数据库:**不产生任何新请求**,退避定时重试必须自行恢复 + TokenStatsLedger.databaseProvider = { database } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) + while (database.tokenStatsDao().getEvent("evt-retry-1") == null && + System.nanoTime() < deadline + ) { + delay(100) + } + assertNotNull("backoff retry must recover without new requests", database.tokenStatsDao().getEvent("evt-retry-1")) + assertEquals(1, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.insertTimeoutMs = previousInsertTimeout + TokenStatsLedger.databaseProvider = { database } + } + } + } + + // ==== P1-3:reset 与 spool 一致性(durable tombstone) ==== + + @Test + fun `full reset tombstone prevents spool resurrection`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // 预置“已接受但未入 Room”的事件:直接写 sealed 段(startedAtMs 在 reset 前) + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + val pre = + TokenStatRequestContext( + eventId = "evt-pre-reset", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis() - 60_000, + ) + pre.onUsage(usage(), 1) + pre.finish(TokenStatStatus.COMPLETED) + val line = TokenStatsLedger.prepareEventLine(context, pre, pre.toSpoolBaseJson()) + File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") + + TokenStatsResetCoordinator.daoProvider = { database.tokenStatsDao() } + try { + // 全量重置:tombstone 与删除同事务;随后排空丢弃被覆盖的行 + TokenStatsResetCoordinator.resetAllStatistics(context) + delay(500) + assertEquals( + "pre-reset spool event must never resurrect", + 0, + database.tokenStatsDao().countEvents(), + ) + // reset 后的新请求正常记录(tombstone 只覆盖 reset 前开始的事件) + val post = + TokenStatRequestContext( + eventId = "evt-post-reset", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), + ) + post.onUsage(usage(), 1) + post.finish(TokenStatStatus.COMPLETED) + TokenTrackingAIService.recordSafely(context, post) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-post-reset") == null && + System.nanoTime() < deadline + ) { + delay(50) + } + assertEquals(1, database.tokenStatsDao().countEvents()) + assertNotNull(database.tokenStatsDao().getEvent("evt-post-reset")) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + + @Test + fun `model reset only clears matching identity and keeps other model spool events`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + val deep = + TokenStatRequestContext( + eventId = "evt-pre-deep", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis() - 60_000, + ) + deep.onUsage(usage(), 1) + deep.finish(TokenStatStatus.COMPLETED) + val open = + TokenStatRequestContext( + eventId = "evt-pre-open", + category = TokenStatCategory.CHAT, + configId = "cfg-2", + provider = "OPENAI", + model = "gpt-4o", + startedAtMs = System.currentTimeMillis() - 60_000, + ) + open.onUsage(usage(), 1) + open.finish(TokenStatStatus.COMPLETED) + File(spoolDir, "sealed_1.jsonl").writeText( + TokenStatsLedger.prepareEventLine(context, deep, deep.toSpoolBaseJson()) + "\n" + + TokenStatsLedger.prepareEventLine(context, open, open.toSpoolBaseJson()) + "\n" + ) + + TokenStatsResetCoordinator.daoProvider = { database.tokenStatsDao() } + try { + TokenStatsResetCoordinator.resetStatisticsForProviderModel( + context, + "DEEPSEEK:deepseek-chat", + ) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-pre-open") == null && + System.nanoTime() < deadline + ) { + delay(50) + } + // 被模型 tombstone 覆盖:不复活;其他模型保留 + assertNull(database.tokenStatsDao().getEvent("evt-pre-deep")) + assertNotNull(database.tokenStatsDao().getEvent("evt-pre-open")) + assertEquals(1, database.tokenStatsDao().countEvents()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + } + } + } + + // ==== P1-4:append 故障不丢事件(有界紧急队列 + 恢复) ==== + + @Test + fun `append failure defers to emergency queue and recovers with exactly one event each`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs + TokenTrackingAIService.recordTimeoutMs = 1_000 + try { + // 让 spool 目录不可创建:filesDir 下同名文件占位 + val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolPath.writeText("i am a file, not a directory") + var failures = 0 + (0 until 5).forEach { index -> + val request = + TokenStatRequestContext( + eventId = "evt-emergency-$index", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.onUsage(usage(), 1) + request.finish(TokenStatStatus.COMPLETED) + try { + TokenTrackingAIService.recordSafely(context, request) + fail("non-durable append must not return normally") + } catch (_: TokenStatsPersistenceException) { + failures++ + } + } + // 全部明确失败;无内存队列冒充 durable 副本 + assertEquals(5, failures) + assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // P2-4:deferred 事件不登记 waiter(latch 已直接完成) + assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) + + // 修复磁盘后,失败调用不会凭空出现未确认事件 + spoolPath.delete() + TokenStatSpool.replay(context) + delay(100) + assertEquals(0, database.tokenStatsDao().countEvents()) + assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) + } finally { + TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout + } + } + } + + @Test + fun `append failure fails success and is suppressed on model failure`() = runBlocking { + val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolPath.writeText("not a directory") + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + try { + tracked(FakeAiService()).sendMessage(context = context).collect { } + fail("successful model result must not hide statistics persistence failure") + } catch (_: TokenStatsPersistenceException) { + } + + val modelFailure = IOException("model failed") + val failing = FakeAiService { _ -> stream { throw modelFailure } } + try { + tracked(failing).sendMessage(context = context).collect { } + fail("model failure must propagate") + } catch (e: IOException) { + assertTrue("original model exception stays primary", e === modelFailure) + assertEquals(1, e.suppressed.size) + assertTrue(e.suppressed[0] is TokenStatsPersistenceException) + } + } + } + + // ==== P2-2:损坏行整段隔离(保留证据) ==== + + @Test + fun `corrupt line quarantines the whole segment with evidence and does not re-block`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + val valid1 = + TokenStatRequestContext( + eventId = "evt-q-1", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + valid1.onUsage(usage(), 1) + valid1.finish(TokenStatStatus.COMPLETED) + val valid2 = + TokenStatRequestContext( + eventId = "evt-q-2", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + valid2.onUsage(usage(), 1) + valid2.finish(TokenStatStatus.COMPLETED) + val segment = File(spoolDir, "sealed_1.jsonl") + segment.writeText( + TokenStatsLedger.prepareEventLine(context, valid1, valid1.toSpoolBaseJson()) + + "\n{corrupt raw evidence line\n" + + TokenStatsLedger.prepareEventLine(context, valid2, valid2.toSpoolBaseJson()) + "\n" + ) + + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-q-2") == null && + System.nanoTime() < deadline + ) { + delay(50) + } + // 有效行全部落账 + assertEquals(2, database.tokenStatsDao().countEvents()) + // 证据保留:quarantine 文件存在且含损坏原文;原段已移除 + val quarantined = + spoolDir.listFiles().orEmpty().single { it.name.startsWith("quarantine_") } + assertTrue("quarantine evidence must exist", quarantined.isFile) + assertTrue(quarantined.readText().contains("corrupt raw evidence line")) + assertFalse("original segment must be gone", segment.exists()) + + // 再次 replay:quarantine 被忽略,不重复插入、不重新阻塞 + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + delay(300) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertTrue(quarantined.isFile) + } + } + + // ==== P2-3:段删除失败 → 视为未完成,退避重试 ==== + + @Test + fun `segment delete failure keeps segment and backs off until recovery`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + val request = + TokenStatRequestContext( + eventId = "evt-del-1", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.onUsage(usage(), 1) + request.finish(TokenStatStatus.COMPLETED) + val segment = File(spoolDir, "sealed_1.jsonl") + segment.writeText( + TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + "\n" + ) + // 强制段删除失败(确定性):事件已插入(IGNORE 幂等),但段保留 + val deleteAllowed = java.util.concurrent.atomic.AtomicBoolean(false) + TokenStatSpool.segmentDeleteForTest = { seg -> + if (deleteAllowed.get()) seg.delete() else false + } + try { + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.replay(context) + val insertedDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent("evt-del-1") == null && + System.nanoTime() < insertedDeadline + ) { + delay(50) + } + // 事件已插入,但段删除失败 → 段保留,进入退避(不忙循环) + assertTrue("segment must survive delete failure", segment.exists()) + + // 恢复删除能力:退避重试最终删除段,不重复插入 + deleteAllowed.set(true) + val deleteDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) + while (segment.exists() && System.nanoTime() < deleteDeadline) { + delay(100) + } + assertFalse("segment must be removed after delete recovery", segment.exists()) + assertEquals(1, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.segmentDeleteForTest = null + } + } + } + + // ==== P1-5:显式全零 usage 也是已观察到的 usage ==== + + @Test + fun `explicit zero usage payload records zero fields with usageObserved true`() = runBlocking { + val fake = + FakeAiService { onUsage -> + stream { + emit("answer") + onUsage?.invoke( + com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( + uncachedInputTokens = 0L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = 0L, + outputTokens = 0L, + reasoningTokens = 0L, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + source = "test", + ), + 1, + ) + } + } + tracked(fake).sendMessage(context = context).collect { } + val event = database.tokenStatsDao().getAllEvents()[0] + // 字段存在且为 0L:真实 0,不是未知 + assertEquals(0L, event.uncachedInputTokens) + assertEquals(0L, event.cachedInputTokens) + assertEquals(0L, event.outputTokens) + assertEquals(0L, event.reasoningTokens) + assertTrue( + "zero payload is still observed usage", + event.diagnosticsJson!!.contains("\"usageObserved\":true"), + ) + } + + // ==== P1 终审:恢复屏障对 in-flight provider/stream 请求的 request/session fencing ==== + + @Test + fun `restore barrier rejects in-flight and same-process requests until simulated restart`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // 真实 TokenTracking + fake provider:请求停在 provider 流阶段(未收尾)时 + // 执行完整 restore(block 模拟恢复替换数据库 + clearAfter 删除旧 spool)→ + // 旧请求释放后收尾 append 被请求 fence 明确拒绝(模型成功不伪装);同进程 + // 新请求被拒绝开始;模拟进程重启(reset 状态)后新请求可正常写入。 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val fake = + FakeAiService { _ -> + stream { + emit("partial answer") + entered.complete(Unit) + release.await() + emit("tail") + } + } + var primary: Throwable? = null + val requestJob = + launch { + try { + tracked(fake) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + fail("old in-flight request must fail after a completed restore") + } catch (e: Throwable) { + primary = e + if (e is CancellationException) throw e + } + } + assertTrue( + "request must be paused in the provider stage", + withTimeoutOrNull(10.seconds) { entered.await() } != null, + ) + // 请求进行中执行恢复:epoch 在屏障开始原子递增(旧请求失效),替换开始后 + // 本进程不再接受任何事件(accepting=false,UI“稍后重启”窗口语义) + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + database.tokenStatsDao().deleteAllEvents() + } + release.complete(Unit) + requestJob.join() + assertTrue( + "old successful request must receive an explicit persistence exception, was: $primary", + primary is TokenStatsPersistenceException, + ) + // 恢复后的 spool/Room 无旧事件:旧请求从未写入(fence 在写 spool 前拒绝) + assertEquals(0, database.tokenStatsDao().countEvents()) + val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + assertFalse( + "restored spool must contain no old events", + spoolDir.exists() && + spoolDir.listFiles().orEmpty().any { it.isFile && it.length() > 0L }, + ) + // 同进程新请求:newRequest 明确拒绝开始(不污染新 DB) + try { + tracked(FakeAiService()) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + fail("new tracking requests must be rejected until process restart") + } catch (e: TokenStatsPersistenceException) { + // expected + } + assertEquals(0, database.tokenStatsDao().countEvents()) + // 模拟进程重启:reset 状态后新请求可写 + TokenStatSpool.clearPendingStateForTest() + tracked(FakeAiService()) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { + delay(50) + } + assertEquals(1, database.tokenStatsDao().countEvents()) + } + } + + @Test + fun `restore failure before replacement keeps accepting new requests while old in-flight is rejected`() = + runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // 旧请求停在 provider 阶段;restore 在替换前失败(drain 阶段失败——epoch 已 + // 递增但 accepting 保持 true)→ 旧请求释放后被 fence 拒绝;同进程新请求 + // (新 epoch)照常落账——替换前失败可继续。 + val spool = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val pre = + TokenStatRequestContext( + eventId = "evt-pre-restore-old", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + pre.onUsage(usage(), 1) + pre.finish(TokenStatStatus.COMPLETED) + File(spool, "sealed_1.jsonl").writeText( + TokenStatsLedger.prepareEventLine(context, pre, pre.toSpoolBaseJson()) + "\n", + ) + val entered = CompletableDeferred() + val release = CompletableDeferred() + val fake = + FakeAiService { _ -> + stream { + emit("partial") + entered.complete(Unit) + release.await() + } + } + var primary: Throwable? = null + val requestJob = + launch { + try { + tracked(fake) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + fail("old in-flight request must be rejected after a restore attempt") + } catch (e: Throwable) { + primary = e + if (e is CancellationException) throw e + } + } + assertTrue( + "request must be paused in the provider stage", + withTimeoutOrNull(10.seconds) { entered.await() } != null, + ) + // 替换前失败:restore barrier 的 drain 阶段失败(段读取故障),block 绝不执行 + TokenStatSpool.segmentReadErrorForTest = { true } + try { + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = true, + clearAfter = true, + ) { + fail("replacement must never run") + } + fail("restore must fail in the drain phase") + } catch (e: IOException) { + assertTrue("restore must report the drain failure", e.message!!.contains("drained")) + } + } finally { + TokenStatSpool.segmentReadErrorForTest = null + } + // 旧请求释放:epoch 不匹配 → 明确拒绝(不写 spool/DB) + release.complete(Unit) + requestJob.join() + assertTrue( + "old in-flight request must be rejected, was: $primary", + primary is TokenStatsPersistenceException, + ) + // 同进程新请求(新 epoch):替换前失败可继续,正常落账;旧 spool 段(restore + // 失败未替换/未清理)一并排空到未被替换的旧 DB + tracked(FakeAiService()) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().countEvents() < 2 && System.nanoTime() < deadline) { + delay(50) + } + // 只有旧 spool 段 + 新请求两个事件:in-flight 旧请求被 fence 拒绝, + // 其事件(第 3 个)绝不出现 + val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() + assertEquals(2, ids.size) + assertTrue(ids.contains("evt-pre-restore-old")) + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt new file mode 100644 index 000000000..9ee8486f8 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt @@ -0,0 +1,444 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import com.ai.assistance.operit.data.model.ModelConfigData +import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistration +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +/** + * ToolPkg JS provider usage 协议测试(P1-5 + 评审 P2-1): + * - usage 对象携带 attempt 序号(新协议):同 attempt 流式更新部分合并, + * 不同 attempt 分别入账; + * - 不携带 attempt(旧协议):整个逻辑请求的累计完整快照,后报覆盖先报, + * 绝不猜测 attempt; + * - 真实 hook 层测试:通过 [ToolPkgMainHookRunner] 注入假 runner,驱动 + * sendMessage 的真实编排(intermediate channel、解码、usage 提取、chunk + * 发射、attempt 语义),不只是 JSON parser。 + */ +class ToolPkgJsAiProviderServiceTest { + + private fun service(): ToolPkgJsAiProviderService { + val config = ModelConfigData(id = "cfg-1", name = "cfg-1") + val registration = + ToolPkgAiProviderRegistration( + containerPackageName = "com.example.testpkg", + providerId = "test-provider", + displayName = "Test Provider", + description = "", + listModelsFunctionName = "", + sendMessageFunctionName = "", + testConnectionFunctionName = "", + calculateInputTokensFunctionName = "", + ) + return ToolPkgJsAiProviderService(config, registration) + } + + /** 假 runner:按给定 intermediate/final JSON 驱动真实 hook 编排层。 */ + private fun runnerWith( + intermediates: List, + final: String, + ): ToolPkgMainHookRunner = + ToolPkgMainHookRunner { _, _, _, _, _, _, _, _, onIntermediateResult -> + intermediates.forEach { raw -> + onIntermediateResult?.invoke(raw) + } + Result.success(final) + } + + private class ReportedUsage( + val input: Long, + val output: Long, + val attempt: Int, + val completeSnapshot: Boolean, + ) + + private fun report(usage: com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) = + ReportedUsage( + input = usage.totalInputTokens ?: -1L, + output = usage.outputTokens ?: -1L, + attempt = attempt, + completeSnapshot = usage.completeSnapshot, + ) + + private fun runSendMessage( + svc: ToolPkgJsAiProviderService, + runner: ToolPkgMainHookRunner, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, + ): String = runBlocking { + svc.mainHookRunnerOverride = runner + val collected = StringBuilder() + svc.sendMessage( + context = mock(Context::class.java), + onUsageReported = onUsageReported, + ).collect { collected.append(it) } + collected.toString() + } + + @Test + fun `usage protocol carries attempt number from usage object`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject( + """ + {"usage": {"input": 100, "cachedInput": 20, "output": 50, "attempt": 2}} + """.trimIndent() + ) + ) + val usage = svc.extractUsage(decoded)!! + assertEquals(2, usage.attempt) + assertTrue("attempt present must be tracked", usage.attemptPresent) + assertEquals(100L, usage.input) + assertEquals(20L, usage.cachedInput) + assertEquals(50L, usage.output) + } + + @Test + fun `attemptNumber alias is supported`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject( + """ + {"usage": {"input": 10, "output": 5, "attemptNumber": 5}} + """.trimIndent() + ) + ) + val usage = svc.extractUsage(decoded)!! + assertEquals(5, usage.attempt) + assertTrue(usage.attemptPresent) + } + + @Test + fun `attempt defaults to 1 when provider omits the field and marks old protocol`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject("""{"usage": {"input": 10, "output": 5}}""") + ) + val usage = svc.extractUsage(decoded)!! + assertEquals(1, usage.attempt) + // 评审 P2-1:缺 attempt 的旧协议上报必须被显式标记,绝不猜测 attempt + assertFalse("attempt absent must not be guessed", usage.attemptPresent) + } + + @Test + fun `attempt is coerced to at least 1`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject("""{"usage": {"input": 10, "output": 5, "attempt": 0}}""") + ) + assertEquals(1, svc.extractUsage(decoded)!!.attempt) + } + + @Test + fun `top-level usage without usage object is accepted`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject("""{"input": 30, "output": 9, "attempt": 3}""") + ) + val usage = svc.extractUsage(decoded)!! + assertEquals(3, usage.attempt) + assertEquals(30L, usage.input) + assertEquals(9L, usage.output) + } + + @Test + fun `no usage fields returns null`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue(JSONObject("""{"chunk": "text"}""")) + assertNull(svc.extractUsage(decoded)) + assertNull(svc.extractUsage(ToolPkgJsAiProviderService.ProviderHookValue.NullValue)) + } + + // ==== 评审 P2-1:账本路径全程 Long,负值拒绝为未知 ==== + + @Test + fun `usage values beyond int range are preserved as longs`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject( + """{"usage": {"input": 5000000000, "cachedInput": 2000000000, "output": 3000000000, "attempt": 1}}""" + ) + ) + val usage = svc.extractUsage(decoded)!! + assertEquals(5_000_000_000L, usage.input) + assertEquals(2_000_000_000L, usage.cachedInput) + assertEquals(3_000_000_000L, usage.output) + } + + @Test + fun `negative usage values are rejected as unknown`() { + val svc = service() + val decoded = + ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( + JSONObject("""{"usage": {"input": -5, "output": 10}}""") + ) + val usage = svc.extractUsage(decoded)!! + assertNull("negative input must be unknown", usage.input) + assertEquals(10L, usage.output) + } + + // ==== 真实 hook 层(评审 P2-1)==== + + @Test + fun `old protocol without attempt is one cumulative complete snapshot per report`() { + val svc = service() + val reports = mutableListOf() + val text = + runSendMessage( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 300, "output": 100}}""", + ), + final = """{"usage": {"input": 500, "output": 400}, "chunk": "done"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertEquals("done", text) + // 旧协议:两次上报都是请求级累计完整快照,attempt 固定 1,绝不猜测递增 + assertEquals(2, reports.size) + reports.forEach { r -> + assertEquals(1, r.attempt) + assertTrue( + "old protocol report must be complete snapshot, was $r", + r.completeSnapshot, + ) + } + assertEquals(300L, reports[0].input) + assertEquals(500L, reports[1].input) + } + + @Test + fun `new protocol attempt numbers are forwarded and merged per attempt`() { + val svc = service() + val reports = mutableListOf() + val text = + runSendMessage( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 300, "output": 100, "attempt": 1}}""", + """{"usage": {"input": 500, "output": 400, "attempt": 2}}""", + ), + final = """{"chunk": "final"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertEquals("final", text) + // 新协议:attempt 序号原样转发,不猜测、不覆盖 + assertEquals(2, reports.size) + assertEquals(1, reports[0].attempt) + assertEquals(2, reports[1].attempt) + assertFalse("new protocol report is a partial update", reports[0].completeSnapshot) + assertFalse(reports[1].completeSnapshot) + assertEquals(300L, reports[0].input) + assertEquals(500L, reports[1].input) + } + + @Test + fun `new protocol same attempt streaming updates stay on the same attempt`() { + val svc = service() + val reports = mutableListOf() + runSendMessage( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 300, "output": 100, "attempt": 1}}""", + // 同 attempt 流式更新(只带 output):不分配新 attempt + """{"usage": {"output": 150, "attempt": 1}}""", + ), + final = """{"chunk": "ok"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertEquals(2, reports.size) + assertEquals(1, reports[0].attempt) + assertEquals(1, reports[1].attempt) + // 同 attempt 流式更新为部分快照:省略的 input 保留旧值(由上下文合并) + assertFalse(reports[1].completeSnapshot) + } + + @Test + fun `final result usage is reported after intermediate usage`() { + val svc = service() + val reports = mutableListOf() + runSendMessage( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", + ), + final = """{"usage": {"input": 120, "output": 25, "attempt": 1}}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertEquals(2, reports.size) + // 最终结果 payload 的 usage 也必须上报(intermediate + final 都走同一通道) + assertEquals(120L, reports[1].input) + assertEquals(25L, reports[1].output) + assertEquals(1, reports[1].attempt) + } + + // ==== 评审 P1-6:新协议跨 attempt 不得继承全局 current 计数 ==== + + @Test + fun `new protocol attempt missing input does not inherit global counters`() { + val svc = service() + val reports = mutableListOf() + val text = + runSendMessage( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 100, "output": 50, "attempt": 1}}""", + // attempt 2 首次只上报 output:input 必须保持未知, + // 绝不能填入全局 currentInput(100)造成虚假累计 + """{"usage": {"output": 10, "attempt": 2}}""", + ), + final = """{"chunk": "done"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertEquals("done", text) + assertEquals(2, reports.size) + assertEquals(1, reports[0].attempt) + assertEquals(100L, reports[0].input) + assertEquals(2, reports[1].attempt) + assertEquals("input must be unknown for attempt 2", -1L, reports[1].input) + + // 账本聚合:attempt1 input 已知 + attempt2 input 未知 → 该分量保持未知, + // 绝不把 100 继承为 200 + val ctx = + com.ai.assistance.operit.data.stats.TokenStatRequestContext( + eventId = "evt-toolpkg-attempt-gap", + category = com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "TEST", + model = "toolpkg-model", + startedAtMs = 1000L, + ) + reports.forEach { r -> + ctx.onUsage( + com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( + uncachedInputTokens = if (r.input >= 0) r.input else null, + outputTokens = r.output, + cacheWriteSeparateBilling = false, + completeSnapshot = r.completeSnapshot, + source = "toolpkg_js", + ), + r.attempt, + ) + } + val aggregated = ctx.aggregatedUsage()!! + assertNull("aggregated input must stay unknown, not fabricated", aggregated.uncachedInputTokens) + assertEquals(60L, aggregated.outputTokens) + } + + // ==== 评审 P1-7:testConnection 的 usage 提取与 attempt 转发 ==== + + private fun runTestConnection( + svc: ToolPkgJsAiProviderService, + runner: ToolPkgMainHookRunner, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, + ): Result = runBlocking { + svc.mainHookRunnerOverride = runner + svc.testConnection(context = mock(Context::class.java), onUsageReported = onUsageReported) + } + + @Test + fun `test connection forwards intermediate and final usage like a normal request`() { + val svc = service() + val reports = mutableListOf() + val result = + runTestConnection( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", + """{"usage": {"output": 25, "attempt": 1}}""", + ), + final = """{"usage": {"input": 120, "output": 25, "attempt": 1}, "success": true, "message": "ok"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertTrue(result.isSuccess) + // 中间 + 最终结果都走同一 usage 提取/attempt 转发(P1-7) + assertEquals(3, reports.size) + assertEquals(1, reports[0].attempt) + assertEquals(100L, reports[0].input) + assertEquals(25L, reports[1].output) + assertEquals(120L, reports[2].input) + assertEquals(1, reports[2].attempt) + assertFalse("new protocol report is a partial update", reports[2].completeSnapshot) + } + + @Test + fun `test connection failure still forwards usage before failing`() { + val svc = service() + val reports = mutableListOf() + val result = + runTestConnection( + svc, + runnerWith( + intermediates = listOf("""{"usage": {"input": 50, "output": 5, "attempt": 1}}"""), + final = """{"usage": {"input": 80, "output": 9, "attempt": 1}, "success": false, "error": "denied"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertTrue("connection must fail", result.isFailure) + // 失败结果里的 usage 同样被转发(不丢) + assertEquals(2, reports.size) + assertEquals(80L, reports[1].input) + } + + @Test + fun `test connection forwards multiple attempts`() { + val svc = service() + val reports = mutableListOf() + val result = + runTestConnection( + svc, + runnerWith( + intermediates = + listOf( + """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", + """{"usage": {"input": 200, "output": 20, "attempt": 2}}""", + ), + final = """{"success": true, "message": "ok"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + assertTrue(result.isSuccess) + assertEquals(2, reports.size) + assertEquals(1, reports[0].attempt) + assertEquals(2, reports[1].attempt) + assertEquals(200L, reports[1].input) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt index 98a66ecce..3de2c7398 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt @@ -109,8 +109,8 @@ class ApiPreferencesResetFailureTest { TokenStatsResetCoordinator.daoProvider = { dao } try { assertTrue(prefs.resetAllProviderModelTokenCounts()) - verify(dao).deleteAllEvents() - verify(dao).deleteAllBaselines() + // 全量重置:tombstone 与删除在同一事务(resetAllStatisticsTx) + verify(dao).resetAllStatisticsTx() Unit } finally { TokenStatsResetCoordinator.daoProvider = null @@ -126,8 +126,10 @@ class ApiPreferencesResetFailureTest { TokenStatsResetCoordinator.daoProvider = { dao } try { assertTrue(prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat")) - verify(dao).deleteEventsByProviderModel("DEEPSEEK", "deepseek-chat") - verify(dao).deleteBaselinesByProviderModel("DEEPSEEK", "deepseek-chat") + verify(dao).resetModelTx( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + ) Unit } finally { TokenStatsResetCoordinator.daoProvider = null diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt index 702153825..692d83d83 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt @@ -14,7 +14,6 @@ import java.sql.PreparedStatement import java.sql.ResultSet import java.sql.ResultSetMetaData import java.sql.SQLException -import java.sql.Savepoint import java.sql.Types /** @@ -46,8 +45,6 @@ class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { createStatement().use { it.execute("PRAGMA journal_mode = MEMORY") } } - private val savepoints = HashMap() - override fun prepare(sql: String): SQLiteStatement { val trimmed = sql.trim() return when { @@ -55,14 +52,15 @@ class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { TransactionStatement { beginJdbcTransaction() } trimmed == "END TRANSACTION" -> TransactionStatement { endJdbcTransaction(commit = true) } - trimmed.startsWith("ROLLBACK TRANSACTION TO SAVEPOINT") -> - TransactionStatement { rollbackToSavepoint(extractName(trimmed)) } + trimmed.startsWith("ROLLBACK TRANSACTION TO SAVEPOINT") || + trimmed.startsWith("SAVEPOINT ") || + trimmed.startsWith("RELEASE SAVEPOINT ") -> + // Room 连接池的嵌套事务通过 SAVEPOINT 实现(语句形如 + // `SAVEPOINT '1'`):原样执行即可,不要拆名字(sqlite-jdbc + // 的 JDBC Savepoint API 转义有问题) + TransactionStatement { executeRawSql(trimmed) } trimmed == "ROLLBACK TRANSACTION" -> TransactionStatement { endJdbcTransaction(commit = false) } - trimmed.startsWith("SAVEPOINT ") -> - TransactionStatement { createSavepoint(extractName(trimmed)) } - trimmed.startsWith("RELEASE SAVEPOINT ") -> - TransactionStatement { releaseSavepoint(extractName(trimmed)) } else -> JdbcSQLiteStatement(connection.prepareStatement(sql)) } } @@ -73,6 +71,14 @@ class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { connection.close() } + private fun executeRawSql(sql: String) { + try { + connection.createStatement().use { it.execute(sql) } + } catch (e: Exception) { + throw IllegalStateException("raw sql failed: [$sql]", e) + } + } + private fun beginJdbcTransaction() { if (connection.autoCommit) { connection.autoCommit = false @@ -85,28 +91,6 @@ class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { connection.autoCommit = true } - private fun createSavepoint(name: String) { - savepoints[name] = connection.setSavepoint(name) - } - - private fun releaseSavepoint(name: String) { - val savepoint = savepoints.remove(name) ?: return - connection.releaseSavepoint(savepoint) - } - - private fun rollbackToSavepoint(name: String) { - // SQL 语义:回滚到保存点不会释放保存点。 - val savepoint = savepoints[name] ?: return - connection.rollback(savepoint) - } - - private fun extractName(sql: String): String { - val start = sql.indexOf('\'') - val end = sql.lastIndexOf('\'') - if (start < 0 || end <= start) return sql.substringAfterLast(' ').trim() - return sql.substring(start + 1, end) - } - private class TransactionStatement(private val action: () -> Unit) : SQLiteStatement { override fun bindBlob(index: Int, value: ByteArray) = Unit override fun bindDouble(index: Int, value: Double) = Unit diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt new file mode 100644 index 000000000..8547ca59c --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt @@ -0,0 +1,435 @@ +package com.ai.assistance.operit.data.stats + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * provider 原始 usage → 阶段 1 契约归一化测试。 + * + * 语义要点: + * - 未知(缺失字段)→ null;确认 0 → 0(例如无缓存读取、无缓存写入); + * - OpenAI 系 completion_tokens 包含推理 → reasoningIncludedInOutput = true; + * - Anthropic input_tokens 不含缓存分量(文档:总量 = input + cache_read + cache_creation), + * 缓存写入独立保留、独立计费; + * - Gemini candidatesTokenCount 包含 thought → 推理已包含在输出。 + */ +class ProviderUsageNormalizerTest { + + // ==== OpenAI chat/completions ==== + + @Test + fun `openai chat completions splits cached input and keeps cache write and reasoning`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 1000, + "completion_tokens": 500, + "prompt_tokens_details": {"cached_tokens": 200}, + "output_tokens_details": {"reasoning_tokens": 50} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(800L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull("cache write not provided -> unknown", snapshot.cacheWriteTokens) + assertFalse("OpenAI 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + assertEquals(500L, snapshot.outputTokens) + assertEquals(50L, snapshot.reasoningTokens) + assertEquals(true, snapshot.reasoningIncludedInOutput) + assertEquals(ProviderUsageNormalizer.SOURCE_OPENAI_CHAT_COMPLETIONS, snapshot.source) + } + + @Test + fun `openai chat completions supports cache creation and zero-cached semantics`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 900, + "completion_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 0, "cache_creation_input_tokens": 300} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(900L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(300L, snapshot.cacheWriteTokens) + assertEquals(100L, snapshot.outputTokens) + } + + @Test + fun `openai chat completions returns null when no usage present`() { + assertNull(ProviderUsageNormalizer.openAiChatCompletions(null)) + assertNull(ProviderUsageNormalizer.openAiChatCompletions(JSONObject("{}"))) + } + + @Test + fun `openai without cached details keeps input split unknown not claiming uncached total`() { + // 常规 OpenAI 响应常缺 prompt_tokens_details:cached 拆分未知时, + // 不得把总输入确定为 uncached(分类确定性) + val usage = + JSONObject( + """ + { + "prompt_tokens": 1000, + "completion_tokens": 500 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.cachedInputTokens) + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + assertEquals(1000L, snapshot.totalInputTokens) + assertEquals(500L, snapshot.outputTokens) + assertNull(snapshot.cacheWriteTokens) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + @Test + fun `openai explicit zero cached split keeps uncached equal to total`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 100, + "completion_tokens": 40, + "prompt_tokens_details": {"cached_tokens": 0} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.uncachedInputTokens) + } + + @Test + fun `openai chat completions handles input_tokens aliases`() { + val usage = + JSONObject( + """ + { + "input_tokens": 100, + "output_tokens": 40, + "input_tokens_details": {"cached_tokens": 30} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(70L, snapshot.uncachedInputTokens) + assertEquals(30L, snapshot.cachedInputTokens) + assertEquals(40L, snapshot.outputTokens) + } + + // ==== OpenAI Responses API ==== + + @Test + fun `openai responses keeps reasoning tokens separately and marks included`() { + val usage = + JSONObject( + """ + { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": {"cached_tokens": 200}, + "output_tokens_details": {"reasoning_tokens": 120} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiResponses(usage)!! + assertEquals(800L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertEquals(500L, snapshot.outputTokens) + assertEquals(120L, snapshot.reasoningTokens) + assertEquals(true, snapshot.reasoningIncludedInOutput) + assertFalse("OpenAI Responses 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + assertEquals(ProviderUsageNormalizer.SOURCE_OPENAI_RESPONSES, snapshot.source) + } + + // ==== Anthropic ==== + + @Test + fun `anthropic keeps cache read and cache write as independent components`() { + val usage = + JSONObject( + """ + { + "input_tokens": 500, + "cache_read_input_tokens": 200, + "cache_creation_input_tokens": 100, + "output_tokens": 300 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + // 文档语义:input_tokens 不含缓存分量,三个分量各自独立 + assertEquals(500L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.cacheWriteTokens) + assertEquals(800L, snapshot.totalInputTokens) + assertEquals(300L, snapshot.outputTokens) + assertNull("Anthropic 不提供独立推理 token", snapshot.reasoningTokens) + assertEquals("Anthropic output_tokens 包含 thinking", true, snapshot.reasoningIncludedInOutput) + assertTrue("Anthropic 缓存创建独立计费", snapshot.cacheWriteSeparateBilling) + } + + @Test + fun `anthropic zero cache components are explicit zeros not unknown`() { + val usage = + JSONObject( + """ + { + "input_tokens": 50, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "output_tokens": 10 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.cacheWriteTokens) + } + + @Test + fun `anthropic absent cache fields stay unknown`() { + val usage = JSONObject("""{"input_tokens": 50, "output_tokens": 10}""") + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + assertNull(snapshot.cachedInputTokens) + assertNull(snapshot.cacheWriteTokens) + // 无任何缓存分量:总输入即 input_tokens + assertEquals(50L, snapshot.totalInputTokens) + } + + // ==== Gemini ==== + + @Test + fun `gemini normalizes usage metadata with cached content and thoughts`() { + val metadata = + JSONObject( + """ + { + "promptTokenCount": 1000, + "cachedContentTokenCount": 300, + "candidatesTokenCount": 400, + "thoughtsTokenCount": 90 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(700L, snapshot.uncachedInputTokens) + assertEquals(300L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull("Gemini 无缓存写入概念", snapshot.cacheWriteTokens) + assertEquals(400L, snapshot.outputTokens) + assertEquals(90L, snapshot.reasoningTokens) + assertEquals("candidatesTokenCount 包含 thought", true, snapshot.reasoningIncludedInOutput) + } + + @Test + fun `gemini without thoughts field keeps reasoning unknown and cached split unknown`() { + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "candidatesTokenCount": 20 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertNull(snapshot.reasoningTokens) + // cachedContentTokenCount 缺失:cached 拆分未知,不得把总输入确定为 uncached + assertNull(snapshot.cachedInputTokens) + assertNull(snapshot.uncachedInputTokens) + // 拆分未知时仍保留 provider 明确上报的总输入 + assertEquals(100L, snapshot.totalInputTokens) + assertEquals(20L, snapshot.outputTokens) + assertFalse("Gemini 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + } + + // ==== 本地模型 ==== + + @Test + fun `local providers report measured counts with explicit zero cache`() { + val snapshot = ProviderUsageNormalizer.local(1200, 340, ProviderUsageNormalizer.SOURCE_LLAMA) + assertEquals(1200L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.cacheWriteTokens) + assertEquals(1200L, snapshot.totalInputTokens) + assertEquals(340L, snapshot.outputTokens) + assertNull(snapshot.reasoningTokens) + assertNull(snapshot.reasoningIncludedInOutput) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + // ==== ToolPkg ==== + + @Test + fun `toolpkg derives uncached from total minus cached`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = 1000, + cachedInput = 250, + output = 300, + completeSnapshot = true, + ) + assertEquals(750L, snapshot.uncachedInputTokens) + assertEquals(250L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull(snapshot.cacheWriteTokens) + assertEquals(300L, snapshot.outputTokens) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + // ==== 快照语义 ==== + + @Test + fun `negative provider values are rejected as unknown not recorded`() { + // 评审 P2-5:负值/异常数据必须拒绝为未知,绝不静默落负数 + val negative = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject( + """{"prompt_tokens": -100, "completion_tokens": 500}""" + ) + ) + // 负输入被拒 → uncached/total 未知;output 仍有效 + assertNull(negative!!.uncachedInputTokens) + assertNull(negative.totalInputTokens) + assertEquals(500L, negative.outputTokens) + + val negativeOutput = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 100, "completion_tokens": -50}""") + ) + assertEquals(100L, negativeOutput!!.totalInputTokens) + assertNull("negative output must be unknown", negativeOutput.outputTokens) + } + + @Test + fun `values beyond int range are carried as long without overflow`() { + // 评审 P2-5:JSON 值超过 Int 范围时必须原样以 Long 承载 + val huge = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject( + """{"prompt_tokens": 3000000000, "completion_tokens": 2500000000}""" + ) + ) + // 拆分未知(无 prompt_tokens_details)时 uncached 必须保持未知(设计语义); + // 总量与输出仍以 Long 原样承载,绝不 Int 溢出 + assertNull(huge!!.uncachedInputTokens) + assertEquals(3000000000L, huge.totalInputTokens) + assertEquals(2500000000L, huge.outputTokens) + } + + @Test + fun `snapshot hasKnownFields keeps explicit zero components and drops fully unknown`() { + // 完全无已知字段 → 无有效快照(normalizer 返回 null) + assertFalse(ProviderUsageSnapshot(source = "t").hasKnownFields()) + + // provider 明确全零也是有效快照:0 不得变成未知 + val zeroOnly = + ProviderUsageSnapshot( +uncachedInputTokens = 0L, +cachedInputTokens = 0L, +outputTokens = 0L, + source = "t", + ) + assertTrue(zeroOnly.hasKnownFields()) + + val withValue = + ProviderUsageSnapshot( +uncachedInputTokens = 0L, +cachedInputTokens = 0L, +cacheWriteTokens = 5L, +outputTokens = 0L, + source = "t", + ) + assertTrue(withValue.hasKnownFields()) + } + + // ==== 评审 P1-5:显式全零 payload 按字段存在判断,0L 是真实 0 而非未知 ==== + + @Test + fun `openai chat completions explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 0, "completion_tokens": 0}""") + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertNull("cached split absent stays unknown", snapshot.cachedInputTokens) + } + + @Test + fun `openai responses explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.openAiResponses( + JSONObject("""{"input_tokens": 0, "output_tokens": 0}""") + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `anthropic explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.anthropic( + JSONObject("""{"input_tokens": 0, "output_tokens": 0}"""), + completeSnapshot = true, + )!! + assertEquals(0L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `gemini explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.gemini( + JSONObject( + """{"promptTokenCount": 0, "cachedContentTokenCount": 0, "candidatesTokenCount": 0}""" + ) + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `toolpkg explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = 0L, + cachedInput = 0L, + output = 0L, + completeSnapshot = true, + ) + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertEquals(0L, snapshot.uncachedInputTokens) + } + + @Test + fun `toolpkg missing fields stay unknown and never inherit counters`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = null, + cachedInput = null, + output = 10L, + completeSnapshot = false, + ) + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.totalInputTokens) + assertEquals(10L, snapshot.outputTokens) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt index 57f040d08..7dbe57682 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -102,7 +102,7 @@ class TokenBaselineImportRunnerTest { private fun openDatabase(filesDir: File): AppDatabase = Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_28_29) + .addMigrations(AppDatabase.MIGRATION_20_21) .allowMainThreadQueries() .build() diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt index b14ae812f..a17f73565 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -43,10 +43,10 @@ class TokenCostCalculatorTest { TokenCostCalculator.computeCost( usage = TokenUsageInput( - uncachedInputTokens = 800, - cachedInputTokens = 200, - cacheWriteTokens = 0, - outputTokens = 500, +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +cacheWriteTokens = 0L, +outputTokens = 500L, ), pricing = tokenPricing, ) @@ -54,25 +54,25 @@ class TokenCostCalculatorTest { assertEquals(0.0019, cost.amount!!, 1e-12) // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 = 0.0008 + 0.0001 + 0.001 assertEquals(PricingCurrency.USD, cost.currency) - assertEquals(1000, cost.billedInputTokens) - assertEquals(500, cost.billedOutputTokens) + assertEquals(1000L, cost.billedInputTokens) + assertEquals(500L, cost.billedOutputTokens) } @Test fun `reasoning included in output is not billed twice`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, - reasoningTokens = 300, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, +reasoningTokens = 300L, reasoningIncludedInOutput = true, ) val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - assertEquals(500, cost.billedOutputTokens) + assertEquals(500L, cost.billedOutputTokens) // 1000/1e6*1 + 0 + 500/1e6*2 = 0.001 + 0.001 assertEquals(0.002, cost.amount!!, 1e-12) } @@ -81,17 +81,17 @@ class TokenCostCalculatorTest { fun `reasoning declared separate is added to billed output`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, - reasoningTokens = 300, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, +reasoningTokens = 300L, reasoningIncludedInOutput = false, ) val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - assertEquals(800, cost.billedOutputTokens) + assertEquals(800L, cost.billedOutputTokens) // 1000/1e6*1 + 800/1e6*2 = 0.001 + 0.0016 assertEquals(0.0026, cost.amount!!, 1e-12) } @@ -100,24 +100,24 @@ class TokenCostCalculatorTest { fun `reasoning without inclusion declaration defaults to included`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - outputTokens = 500, - reasoningTokens = 300, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +outputTokens = 500L, +reasoningTokens = 300L, reasoningIncludedInOutput = null, ) val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - assertEquals(500, cost.billedOutputTokens) + assertEquals(500L, cost.billedOutputTokens) } @Test fun `unknown output tokens produce unknown cost not zero`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, outputTokens = null, ) @@ -131,8 +131,8 @@ class TokenCostCalculatorTest { val usage = TokenUsageInput( uncachedInputTokens = null, - cachedInputTokens = 0, - outputTokens = 500, +cachedInputTokens = 0L, +outputTokens = 500L, ) val cost = TokenCostCalculator.computeCost(usage, tokenPricing) @@ -144,16 +144,16 @@ class TokenCostCalculatorTest { fun `null cached input keeps cost unknown while zero cached input is a real zero`() { val unknownCache = TokenUsageInput( - uncachedInputTokens = 1000, +uncachedInputTokens = 1000L, cachedInputTokens = null, - outputTokens = 500, +outputTokens = 500L, ) val noCacheRead = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, ) assertNull(TokenCostCalculator.computeCost(unknownCache, tokenPricing).amount) @@ -165,17 +165,17 @@ class TokenCostCalculatorTest { fun `null cache write keeps cost unknown while zero cache write is a real zero`() { val unknownWrite = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, cacheWriteTokens = null, - outputTokens = 500, +outputTokens = 500L, ) val noCacheWrite = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, ) assertNull(TokenCostCalculator.computeCost(unknownWrite, tokenPricing).amount) @@ -190,17 +190,17 @@ class TokenCostCalculatorTest { fun `cache write tokens are billed at cache write price when known`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 400, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 400L, +outputTokens = 500L, ) val cost = TokenCostCalculator.computeCost(usage, tokenPricing) // 1000/1e6*1 + 0 + 400/1e6*0.75 + 500/1e6*2 = 0.001 + 0.0003 + 0.001 assertEquals(0.0023, cost.amount!!, 1e-12) - assertEquals(400, cost.billedCacheWriteTokens) + assertEquals(400L, cost.billedCacheWriteTokens) } @Test @@ -209,10 +209,10 @@ class TokenCostCalculatorTest { tokenPricing.copy(cacheWritePricePerMillion = null) val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 400, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 400L, +outputTokens = 500L, ) val cost = TokenCostCalculator.computeCost(usage, pricingWithoutWritePrice) @@ -224,9 +224,9 @@ class TokenCostCalculatorTest { fun `unknown pricing produces unknown cost not zero`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +outputTokens = 500L, ) val cost = TokenCostCalculator.computeCost(usage, unknownPricing) @@ -238,7 +238,7 @@ class TokenCostCalculatorTest { fun `count mode cost equals per request price`() { val cost = TokenCostCalculator.computeCost( - usage = TokenUsageInput(outputTokens = 10), +usage = TokenUsageInput(outputTokens = 500L), pricing = countPricing, ) @@ -263,16 +263,68 @@ class TokenCostCalculatorTest { TokenCostCalculator.computeCost( usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, ), pricing = zeroPricing, ) assertEquals(0.0, cost.amount!!, 1e-12) } + + @Test + fun `total input bills at unified price when split unknown and prices equal`() { + val equalPricing = tokenPricing.copy(cachedInputPricePerMillion = 1.0) + val usage = + TokenUsageInput( + uncachedInputTokens = null, + cachedInputTokens = null, +totalInputTokens = 1000L, +outputTokens = 500L, + // OpenAI 兼容系/Gemini:无独立缓存写入计费概念 + cacheWriteSeparateBilling = false, + ) + + val cost = TokenCostCalculator.computeCost(usage, equalPricing) + + // 1000/1e6*1 + 500/1e6*2 = 0.001 + 0.001 + assertEquals(0.002, cost.amount!!, 1e-12) + assertEquals(1000L, cost.billedInputTokens) + } + + @Test + fun `total input keeps cost unknown when split unknown and prices differ`() { + // tokenPricing:input 1.0 vs cached 0.5,单价不同且拆分未知 → 不得伪造 uncached + val usage = + TokenUsageInput( + uncachedInputTokens = null, + cachedInputTokens = null, +totalInputTokens = 1000L, +outputTokens = 500L, + cacheWriteSeparateBilling = false, + ) + + assertNull(TokenCostCalculator.computeCost(usage, tokenPricing).amount) + } + + @Test + fun `split known ignores total input fallback`() { + val usage = + TokenUsageInput( +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +totalInputTokens = 1000L, +outputTokens = 500L, + cacheWriteSeparateBilling = false, + ) + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + assertEquals(1000L, cost.billedInputTokens) + // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 + assertEquals(0.0019, cost.amount!!, 1e-12) + } } class TokenCostCurrencyTest { @@ -346,10 +398,10 @@ class TokenCostCurrencyTest { fun `revaluation uses current pricing instead of historical snapshot`() { val usage = TokenUsageInput( - uncachedInputTokens = 1000, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 500, +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, ) val historicalSnapshot = ResolvedPricing( diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt new file mode 100644 index 000000000..85b1923b7 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt @@ -0,0 +1,5609 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class TokenStatReliabilityTest { + private lateinit var root: File + private lateinit var context: Context + private lateinit var database: AppDatabase + + @Before + fun setUp() { + root = kotlin.io.path.createTempDirectory("token-stat-reliability").toFile() + context = mock().also { ctx -> + whenever(ctx.applicationContext).thenReturn(ctx) + whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") + whenever(ctx.filesDir).thenReturn(root) + whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } + } + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + TokenStatsLedger.databaseProvider = { database } + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + TokenStatSpool.clearPendingStateForTest() + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.afterSegmentReadForTest = null + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.quarantineAtomicMoveForTest = null + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.directoryListingForTest = null + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.fileSyncForTest = null + // P1 终审:Windows JVM 测试统一注入“目录 fsync 支持且成功”(平台无关)——生产 + // Android/Linux 支持目录 fd fsync;UNSUPPORTED/FAILED 只由显式 fail-closed 测试在 + // 测试体内注入并在 finally 还原,不存在“原地排空”平台模式。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealCopyForTest = null + } + + @After + fun tearDown() { + // P1-1 终审修复:测试可能以“目录项未确认持久”状态结束(gate=false),tearDown 的快照 + // barrier 会重新 bootstrap——必须先恢复“目录 fsync 支持且成功”的平台常态(Windows JVM + // 真实探测恒为 UNSUPPORTED),否则 gate 在 tearDown 中失败并掩盖测试结果。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + runBlocking { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } + } + TokenStatsLedger.databaseProvider = null + TokenStatsLedger.legacyPriceProvider = null + TokenStatSpool.afterSegmentReadForTest = null + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.quarantineAtomicMoveForTest = null + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.directoryListingForTest = null + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.prepareTimeoutMs = 5_000L + TokenStatSpool.insertTimeoutMs = 5_000L + TokenStatSpool.exclusiveQuiesceTimeoutMs = 5_000L + // 每个测试结束必须无遗留 spool worker 线程:shutdown 已释放的 worker 后确认终止 + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + database.close() + } + + private fun request( + id: String, + generation: Long = 0L, + startedAt: Long = 1_000L, + ) = TokenStatRequestContext( + eventId = id, + category = TokenStatCategory.CHAT, + configId = "cfg", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = startedAt, + acceptedGeneration = generation, + // P1 终审:请求“开始”时同步捕获 restore epoch(与生产 newRequest 一致);恢复屏障 + // 递增 epoch 后,捕获于屏障前的旧请求在 append 时被明确拒绝。 + sessionEpoch = TokenStatSpool.captureRestoreEpoch(), + ).apply { + onUsage( + ProviderUsageSnapshot( + uncachedInputTokens = 10L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + outputTokens = 5L, + source = "test", + ), + ) + finish(TokenStatStatus.COMPLETED, startedAt) + } + + private suspend fun line(request: TokenStatRequestContext): String = + TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + + private suspend fun awaitEvent(id: String) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent(id) == null && System.nanoTime() < deadline) delay(20) + } + + /** + * 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 + * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 + */ + private fun gateIgnoringInterrupts(gate: CountDownLatch) { + while (true) { + try { + if (gate.await(1, TimeUnit.SECONDS)) return + } catch (_: InterruptedException) { + } + } + } + + /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ + private fun awaitNoSpoolWorkerThreads() { + fun live(): List = + Thread.getAllStackTraces().entries + .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } + .map { it.key.name } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + if (live().isEmpty()) return + Thread.sleep(20) + } + fail("spool worker threads leaked: ${live()}") + } + + @Test + fun `price override read timeout is durable unknown not default`() = runBlocking { + database.tokenStatsDao().upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 99.0, + cachedInputPricePerMillion = 99.0, + outputPricePerMillion = 99.0, + ) + TokenStatsLedger.legacyPriceProvider = { _, _ -> delay(Long.MAX_VALUE); null } + TokenStatSpool.prepareTimeoutMs = 50L + + TokenTrackingAIService.recordSafely(context, request("price-timeout")) + awaitEvent("price-timeout") + val event = database.tokenStatsDao().getEvent("price-timeout")!! + assertEquals(PricingSource.UNKNOWN.name, event.pricingSource) + assertNull(event.inputPricePerMillion) + assertNull(event.costInPricingCurrency) + assertTrue(event.diagnosticsJson!!.contains("pricing_read_timeout")) + } + + @Test + fun `more than two thousand append failures never return durable`() = runBlocking { + File(root, TokenStatSpool.SPOOL_DIR_NAME).writeText("not a directory") + Mockito.mockStatic(AppLogger::class.java).use { + repeat(2_001) { index -> + try { + TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")) + fail("append failure must throw") + } catch (_: TokenStatsPersistenceException) { + } + } + } + assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + + @Test + fun `generation handles same millisecond clock rollback and request spanning reset`() = runBlocking { + val dao = database.tokenStatsDao() + val oldSameMillisecond = request("old-same-ms", generation = 0L, startedAt = 5_000L) + val oldFinishesAfterReset = request("old-spanning", generation = 0L, startedAt = 9_000L) + + dao.resetAllStatisticsTx() + assertEquals(1L, dao.currentResetGeneration()) + TokenStatsLedger.recordWith(context, dao, oldSameMillisecond) + TokenStatsLedger.recordWith(context, dao, oldFinishesAfterReset) + assertEquals(0, dao.countEvents()) + + // New request after reset is accepted even if its wall clock moved backwards. + TokenStatsLedger.recordWith( + context, + dao, + request("new-clock-rollback", generation = 1L, startedAt = 1L), + ) + assertEquals(1, dao.countEvents()) + assertEquals(1L, dao.getEvent("new-clock-rollback")!!.acceptedGeneration) + } + + @Test + fun `restore barrier waits for segment read and old task cannot insert afterward`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("old-before-restore")) + "\n") + val read = CountDownLatch(1) + val release = CountDownLatch(1) + TokenStatSpool.afterSegmentReadForTest = { + read.countDown() + release.await(10, TimeUnit.SECONDS) + } + TokenStatSpool.replay(context) + assertTrue(read.await(10, TimeUnit.SECONDS)) + + val restore = async { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + // Simulates the restored database contents replacing everything inserted before + // this exclusive section. No old worker may insert after this point. + database.tokenStatsDao().deleteAllEvents() + } + } + delay(100) + assertFalse("restore must wait for the in-flight old drain", restore.isCompleted) + release.countDown() + restore.await() + delay(100) + assertNull(database.tokenStatsDao().getEvent("old-before-restore")) + } + + @Test + fun `interrupt ignoring insert never locks spool and restore barrier stays clean`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousInsert = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 100 + try { + val spoolDir = File(root, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + // 先构建行(价格解析走真实 DAO),再安装忽略中断但可释放的 insert 挂起 + val lineA = line(request("evt-hung-a")) + val lineB = line(request("evt-hung-b")) + val realDao = database.tokenStatsDao() + val release = CountDownLatch(1) + val blockingDao = mock() + whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> + // SQLite 忽略中断:cancel(true) 无法终止;释放后委托真实 DAO 完成 + gateIgnoringInterrupts(release) + runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } + } + whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> + runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } + } + whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> + runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } + } + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) + TokenStatsLedger.databaseProvider = { proxy } + + // append A durable;drain 启动后 insert 挂起(忽略中断) + assertTrue(TokenStatSpool.append(context, lineA, "evt-hung-a")) + val latchDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < latchDeadline) { + delay(10) + } + assertEquals(1, TokenStatSpool.pendingLatchCountForTest()) + + // 硬上限(insertTimeoutMs)之后:锁必须已释放,append 不再被阻塞 + val startedSecond = System.nanoTime() + assertTrue(TokenStatSpool.append(context, lineB, "evt-hung-b")) + val secondElapsedMs = (System.nanoTime() - startedSecond) / 1_000_000 + assertTrue("append must never block on the hung insert: ${secondElapsedMs}ms", secondElapsedMs < 10_000) + + // restore barrier:wedged insert 仍存活(已通过 fence、正在 Room 内)时, + // 必须有界失败且绝不替换文件;等待结束后旧 insert 仍登记在 registry + val startedRestore = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } + fail("restore must fail bounded while an old insert is still live") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 + assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) + assertEquals(1, TokenStatSpool.activeInsertCountForTest()) + + // 模拟重启前必须释放并确认旧 insert 线程终止:释放门闩 → registry 真正清空 + release.countDown() + val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { + delay(10) + } + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + + // 丢弃已完成的旧 worker(shutdown 后线程真实终止),再模拟进程重启; + // 被卡任务由新排空重放幂等完成(失败的 restore 从未替换数据库) + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) + + TokenStatsLedger.databaseProvider = { database } + TokenTrackingAIService.recordSafely(context, request("evt-after-restore")) + awaitEvent("evt-after-restore") + awaitEvent("evt-hung-a") + awaitEvent("evt-hung-b") + assertEquals(3, database.tokenStatsDao().countEvents()) + + // 重试 restore:registry 已空,替换模拟可执行,恢复后的 DB 无旧事件 + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + database.tokenStatsDao().deleteAllEvents() + } + assertEquals(0, database.tokenStatsDao().countEvents()) + assertNull(database.tokenStatsDao().getEvent("evt-hung-a")) + assertNull(database.tokenStatsDao().getEvent("evt-hung-b")) + assertNull(database.tokenStatsDao().getEvent("evt-after-restore")) + + // 恢复后的新事件正常落账且只出现一次。P1 终审:恢复替换已开始(accepting= + // false),同进程后续事件被明确拒绝——必须先模拟进程重启(reset 状态)才 + // 允许写入;这正是 UI“稍后重启”窗口的语义。 + TokenStatSpool.resetExecutorsForTest() + TokenTrackingAIService.recordSafely(context, request("evt-post-restore")) + awaitEvent("evt-post-restore") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("evt-post-restore", database.tokenStatsDao().getAllEvents().single().eventId) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.insertTimeoutMs = previousInsert + } + } + } + + @Test + fun `crash half line never splices the next healthy event`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃残留:active 尾部半行 JSON,无换行 + File(spool, "active.jsonl").writeText("{\"v\":2,\"eventId\":\"evt-crash-half\"") + TokenTrackingAIService.recordSafely(context, request("evt-healthy-after-crash")) + TokenStatSpool.replay(context) + awaitEvent("evt-healthy-after-crash") + // 健康事件恰好一次进入 Room,残缺证据完整保留在 quarantine + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("evt-healthy-after-crash", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue("partial evidence must be preserved", evidence.single().readText().contains("evt-crash-half")) + } + } + + @Test + fun `restore with a live Room insert fails bounded before replacement and later restore is clean`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousInsert = TokenStatSpool.insertTimeoutMs + val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs + TokenStatSpool.insertTimeoutMs = 100 + TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("evt-live-a")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n") + + // 真实 Room + 真实 spool 文件;DAO 层在 fence 之后、事务写入前挂起 + // (模拟 SQLite 已持有连接、忽略中断的旧 insert),释放后委托真实 DAO + val realDao = database.tokenStatsDao() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val blockingDao = mock() + whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> + entered.countDown() + // SQLite 忽略中断:阻塞中的 insert 必须继续等待,不能被 task.cancel 打断 + while (true) { + try { + if (release.await(1, TimeUnit.SECONDS)) break + } catch (_: InterruptedException) { + } + } + runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } + } + whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> + runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } + } + whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> + runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } + } + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) + TokenStatsLedger.databaseProvider = { proxy } + + TokenStatSpool.replay(context) + assertTrue( + "insert must have passed the fence and be inside Room", + entered.await(10, TimeUnit.SECONDS) + ) + assertEquals(1, TokenStatSpool.activeInsertCountForTest()) + + // insert timeout 已释放 lifecycleMutex;restore 门闩必须有界失败, + // 替换块绝不执行(数据库不被覆盖/污染),durable 段保留 + val startedRestore = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + fail("replacement must never run while an old insert is live") + } + fail("restore must fail bounded") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 + assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) + assertEquals(0, database.tokenStatsDao().countEvents()) + assertTrue( + "durable segment must survive a failed restore", + File(spool, "sealed_1.jsonl").exists() + ) + + // 释放旧 insert:它只能写入未被替换的旧库;registry 清空后重试 restore 干净通过 + release.countDown() + awaitEvent("evt-live-a") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + // 模拟恢复数据库替换:旧事件必须已从排空路径彻底消失 + database.tokenStatsDao().deleteAllEvents() + } + assertEquals(0, database.tokenStatsDao().countEvents()) + // 自定义 SQLiteDriver 的 Room 没有 SupportSQLiteOpenHelper:直接复用 JVM 驱动 + // 打开同一数据库文件校验完整性 + val integrity = + JdbcSQLiteDriver().open(File(root, "app_database").absolutePath).use { connection -> + connection.prepare("PRAGMA integrity_check").use { statement -> + statement.step() + statement.getText(0) + } + } + assertEquals("restored database must pass integrity check", "ok", integrity) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.insertTimeoutMs = previousInsert + TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce + } + } + } + + @Test + fun `quarantine export and delete file work never runs on the caller main thread`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 超过 16MiB 的证据:满上限 + 额外段(复制/fsync 足够大,能卡住 Main) + RandomAccessFile(File(spool, "quarantine_existing_sealed_1.jsonl"), "rw").use { + it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) + } + File(spool, "quarantine_existing_sealed_2.jsonl").writeText("legacy-over-cap\n") + + val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } + Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) + val ioThreads = ConcurrentHashMap.newKeySet() + val previousIo = TokenStatSpool.ioDispatcher + TokenStatSpool.ioDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + Dispatchers.IO.dispatch(context) { + ioThreads += Thread.currentThread().name + block.run() + } + } + } + try { + withContext(Dispatchers.Main) { + val exported = + TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertTrue(exported.size >= 2) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exported.map { it.name }.toSet()) + } + assertTrue("file I/O must actually dispatch", ioThreads.isNotEmpty()) + assertFalse( + "evidence file I/O must never run on the main thread: $ioThreads", + ioThreads.any { it == "test-main-thread" } + ) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + } finally { + TokenStatSpool.ioDispatcher = previousIo + Dispatchers.resetMain() + mainExecutor.shutdown() + } + } + + @Test + fun `database preparation timeouts stay single flight with bounded threads`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousPrepare = TokenStatSpool.prepareTimeoutMs + TokenStatSpool.prepareTimeoutMs = 50 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("evt-db-prep-hang")) + "\n") + // 数据库准备挂起且忽略中断(可释放):每次 drain 循环都必须单飞复用同一任务 + val release = CountDownLatch(1) + TokenStatsLedger.databaseProvider = { + gateIgnoringInterrupts(release) + database + } + TokenStatSpool.replay(context) + // 第 1 个退避周期 + delay(1_200) + TokenStatSpool.replay(context) + // 第 2 个退避周期 + delay(2_200) + val dbThreads = Thread.getAllStackTraces().keys.count { + it.isAlive && it.name.startsWith("operit-token-stats-database") + } + assertTrue("database preparation must stay single-flight: $dbThreads", dbThreads <= 1) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 释放被卡住的准备任务并确认旧 worker 真实终止后再模拟重启 + release.countDown() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + + // 恢复后(重置 worker 模拟重启)事件仍能落账 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitEvent("evt-db-prep-hang") + assertEquals(1, database.tokenStatsDao().countEvents()) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.prepareTimeoutMs = previousPrepare + } + } + } + + @Test + fun `restore cleanup deletion failure is explicit`() = runBlocking { + File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs(); resolve("active.jsonl").writeText("x") } + TokenStatSpool.spoolDeleteForTest = { false } + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore cleanup failure must propagate") + } catch (e: IOException) { + assertTrue(e.message!!.contains("cleanup failed")) + } + } + + @Test + fun `snapshot barrier moves spool only event into Room exactly once`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-backup")) + "\n") + + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }) + } + // A replay after the snapshot/restore boundary is idempotent and cannot duplicate it. + TokenStatSpool.replay(context) + delay(100) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `two corrupt segments quarantine uniquely and healthy segment drains`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{bad-one\n") + File(spool, "sealed_2.jsonl").writeText("{bad-two\n") + File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-corrupt")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-corrupt") + } + assertEquals(1, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertEquals(2, evidence.map { it.name }.toSet().size) + } + + @Test + fun `quarantine at cap summarizes over-cap segment and keeps within-cap full evidence`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{new-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + // 硬边界:容量内完整证据保留;超限损坏段替换为固定大小摘要并移除原段 + assertTrue(existing.exists()) + assertFalse("over-cap corrupt segment must be replaced by its summary", File(spool, "sealed_2.jsonl").exists()) + assertEquals(1, TokenStatSpool.quarantineEvidence(context).size) + assertTrue( + "evidence disk usage must stay within the hard cap", + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("over-cap evidence must be reported as a bounded summary", summary) + assertEquals(1, summary!!.recordCount) + + // 导出包含摘要文件;确认删除只作用于完整证据(摘要保留为滚动记录) + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals(2, exported.size) + assertTrue(exported.any { it.name == "quarantine_summary.jsonl" }) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) + assertFalse(existing.exists()) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } + + @Test + fun `quarantine hard cap keeps disk bounded far beyond cap and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + repeat(8) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{bad-$index\n") } + File(spool, "sealed_10.jsonl").writeText(line(request("healthy-beyond-cap")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitEvent("healthy-beyond-cap") + } + // 远超上限时:磁盘占用有界(完整证据不超上限)、摘要累计、健康段照常排空 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-beyond-cap", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence disk usage must stay within the hard cap", + evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(summary) + assertEquals(8, summary!!.recordCount) + assertTrue( + "summary must have a fixed upper bound", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + ) + + // 导出/删除入口在满容量时可调用,摘要随导出提供 + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals(2, exported.size) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + } + + @Test + fun `quarantine summary is rolling and never contains corrupt content`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val total = TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + 50 + repeat(total) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{corrupt-body-$index\n") } + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(summary) + assertTrue( + "summary must roll at a fixed line cap: ${summary!!.recordCount}", + summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + ) + assertTrue( + "summary must have a fixed byte cap", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + ) + val summaryText = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest records must survive the roll", summaryText.contains("sealed_${total + 1}.jsonl")) + assertTrue("summary must carry hash, bytes and line counts", summaryText.contains("sha256")) + assertFalse("summary must never embed corrupt content", summaryText.contains("corrupt-body")) + assertTrue(existing.exists()) + assertTrue( + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + } + + @Test + fun `quarantine summary publishes atomically via fallback when atomic move unsupported`() = + runBlocking { + val previous = TokenStatSpool.quarantineAtomicMoveForTest + // 强制 ATOMIC_MOVE 不支持(P1-1):必须走 old/new/backup 回退且结果完整 + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + // 目标已存在(旧摘要)时回退协议必须保留旧值直到新值就绪 + File(spool, "quarantine_summary.jsonl").writeText("{\"old\":\"value\"}\n") + File(spool, "sealed_2.jsonl").writeText("{forced-fallback-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertEquals(2, summary.recordCount) + val text = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest record must survive the fallback publish", text.contains("sealed_2.jsonl")) + assertTrue("old record must be preserved in the rebuilt summary", text.contains("\"old\":\"value\"")) + assertTrue(text.contains("sha256")) + assertFalse("fallback must not leave staged sidecars", File(spool, "quarantine_summary.jsonl.new").exists()) + assertFalse("fallback must not leave backup sidecars", File(spool, "quarantine_summary.jsonl.bak").exists()) + } finally { + TokenStatSpool.quarantineAtomicMoveForTest = previous + } + } + + @Test + fun `quarantine summary publish failure keeps old summary and pending segment`() = runBlocking { + val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summaryFile = File(spool, "quarantine_summary.jsonl") + summaryFile.writeText("{\"old\":\"preserved\"}\n") + // 让回退提交失败:.bak 位置放一个非空目录,renameTo 无法覆盖(发布失败路径) + val bakDir = File(spool, "quarantine_summary.jsonl.bak") + bakDir.mkdirs() + File(bakDir, "lock").writeText("x") + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val segment = File(spool, "sealed_2.jsonl") + segment.writeText("{fail-publish-bad\n") + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + delay(800) + } + // 发布失败:旧摘要保持完整、待处理段保留、错误可见(不声称成功) + assertEquals("{\"old\":\"preserved\"}\n", summaryFile.readText()) + assertTrue("pending segment must be retained on publish failure", segment.exists()) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } finally { + TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic + File(bakDir, "lock").delete() + bakDir.delete() + File(spool, "quarantine_summary.jsonl.new").delete() + } + } + + @Test + fun `quarantine summary survives interruption at each replacement step`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summaryFile = File(spool, "quarantine_summary.jsonl") + val oldContent = "{\"k\":\"old\"}\n" + val newContent = "{\"k\":\"new\"}\n" + + // 窗口 A:target 缺失、.new 完整就绪(target→bak 之后、.new→target 之前崩溃) + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.new").writeText(newContent) + assertTrue(summaryFile.delete()) + val infoA = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(infoA) + val recoveredA = summaryFile.readText().trim() + assertTrue( + "interruption must recover complete old or new: $recoveredA", + recoveredA == oldContent.trim() || recoveredA == newContent.trim(), + ) + + // 窗口 B:target 缺失、.bak=完整旧(bak 已就绪但恢复前崩溃) + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) + assertTrue(summaryFile.delete()) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(oldContent.trim(), summaryFile.readText().trim()) + + // 窗口 C:target=完整新、.bak=残留旧(提交后、清理前崩溃) + summaryFile.writeText(newContent) + File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(newContent.trim(), summaryFile.readText().trim()) + assertFalse("stale backup must be cleaned after a successful read", File(spool, "quarantine_summary.jsonl.bak").exists()) + + // 窗口 D:仅 .tmp 残留(tmp 写入后崩溃)→ target 完整旧 + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.tmpstale").writeText(newContent) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(oldContent.trim(), summaryFile.readText().trim()) + } + + @Test + fun `quarantine summary byte cap enforced with oversized pre-existing summary`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 预置超字节上限但行数很少的旧摘要(旧版本残留/手工膨胀),裁剪必须自愈 + val bigLine = "{\"padding\":\"${"x".repeat(30 * 1024)}\"}\n" + File(spool, "quarantine_summary.jsonl").writeText(bigLine.repeat(3)) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{byte-cap-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertTrue( + "summary must shrink below the byte cap: ${summary.summaryBytes}", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, + ) + assertTrue(summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES) + assertTrue("newest record must survive the byte roll", File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) + } + + @Test + fun `quarantine summary retry after crash does not duplicate record`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val body = "{retry-bad\n" + File(spool, "sealed_2.jsonl").writeText(body) + // 模拟“上次摘要已发布、段删除前崩溃”:摘要已有同一段(file+sha256)的完整记录 + val sha = + java.security.MessageDigest.getInstance("SHA-256") + .digest(body.toByteArray(Charsets.UTF_8)) + .joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + File(spool, "quarantine_summary.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${body.length}," + + "\"sha256\":\"$sha\",\"lineCount\":1,\"corruptLines\":1}\n", + ) + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + // 崩溃重试幂等:不重复追加记录,段正常处置 + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertEquals("crash retry must not duplicate the record", 1, summary.recordCount) + assertTrue(File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) + } + + @Test + fun `within-cap corrupt rename failure is kept as bounded pending-delete evidence and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rename-fail-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-pending")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + // 只让“移入证据区”的重命名失败,pending-delete 重命名放行(容量内预算允许) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-pending") + awaitNoSealedSegments(spool) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + // 健康事件恰一次入库;删除/重命名失败的段已移出 sealed 扫描队列为有界证据 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-after-pending", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + val pending = evidence.filter { it.name.startsWith("quarantine_pending_delete_") } + assertEquals("failed rename must be retained as pending-delete evidence", 1, pending.size) + assertTrue("full evidence must be preserved", pending.single().readText().contains("rename-fail-bad")) + assertTrue( + "error evidence must stay within the hard cap", + evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, + ) + assertFalse("no tombstone needed while the pending budget fits", File(spool, "quarantine_skip_manifest.jsonl").exists()) + + // 维护/后台重试:恢复重命名能力后,下一次 drain 把 pending 证据移回证据区 + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "pending-delete evidence must be restored to the evidence area", + restored.any { it.name.startsWith("quarantine_") && !it.name.startsWith("quarantine_pending_delete_") }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `over-cap delete failure with full evidence area tombstone the segment and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{tombstone-bad\n") + File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-tombstone")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + // 注入删除失败:只对 over-cap 损坏段生效(P1-2),健康段删除不受影响 + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name == "sealed_2.jsonl") false else null + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-tombstone") + } finally { + TokenStatSpool.segmentDeleteForTest = null + } + } + // 后续健康事件恰一次入库;删除失败的 over-cap 段被 tombstone 跳过(摘要已有 hash/bytes) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-after-tombstone", database.tokenStatsDao().getAllEvents().single().eventId) + assertTrue( + "tombstoned segment must be recorded in the bounded manifest", + File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_2.jsonl"), + ) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "quarantine evidence area must stay within the hard cap (managed set separately bounded)", + evidence.filter { it.name.startsWith("quarantine_") }.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, + ) + // P1-3:tombstoned 原 sealed 作为 managed evidence 可见(参与 UI 计数/导出/删除) + assertTrue( + "tombstoned original sealed must appear as managed evidence", + evidence.any { it.name == "sealed_2.jsonl" }, + ) + assertTrue( + TokenStatSpool.quarantineEvidence(context).none { it.name.startsWith("quarantine_pending_delete_") }, + ) + + // 维护/后台重试:恢复删除能力后,下一次 drain 删除 tombstoned 段并移除记录 + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_2.jsonl") + awaitManifestWithout(spool, "sealed_2.jsonl") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } + + @Test + fun `permanent dispose failures fill the managed set bounded then refuse appends and recover`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P1-1:删除与重命名永久失败(只针对 sealed 段:损坏处置、维护移回全部失败) + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name.startsWith("sealed_")) false else null + } + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + try { + // 超过受管集合上限的损坏段:受管集合封顶,剩余段有界跳过 + repeat(TokenStatSpool.MAX_TOMBSTONE_ENTRIES + 5) { index -> + File(spool, "sealed_${index + 1}.jsonl").writeText("{permanent-fail-$index\n") + } + val drainStart = System.nanoTime() + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val manifestCount: () -> Int = { + safeManifestText(manifest) + ?.lineSequence()?.filter { it.isNotBlank() }?.count() ?: 0 + } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + manifestCount() != TokenStatSpool.MAX_TOMBSTONE_ENTRIES + ) { + delay(20) + } + val drainMs = (System.nanoTime() - drainStart) / 1_000_000 + assertTrue("drain must return bounded: ${drainMs}ms", drainMs < 10_000) + delay(500) + val entryCount = manifestCount() + assertEquals( + "managed set must cap at the hard limit, never roll identities away", + TokenStatSpool.MAX_TOMBSTONE_ENTRIES, + entryCount, + ) + // 总占用有界:受管段(≤4MiB/段 × 上限)+ manifest(≤64KiB)+ 证据区 + val totalBytes = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "total spool usage must be bounded: $totalBytes", + totalBytes <= TokenStatSpool.MAX_TOMBSTONE_ENTRIES * (TokenStatSpool.MAX_SEGMENT_BYTES + 4096) + + TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + 1_048_576, + ) + + // 超限新业务明确失败且无伪 durable + try { + TokenStatSpool.append(context, line(request("refused-after-cap")), "refused-after-cap") + fail("append beyond managed capacity must throw TokenStatsPersistenceException") + } catch (e: TokenStatsPersistenceException) { + } + assertEquals(0, database.tokenStatsDao().countEvents()) + + // snapshot barrier 有界失败(未受管段仍在队列),绝不死锁 + val snapStart = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { } + fail("snapshot must not claim drained while unmanageable segments remain") + } catch (e: IOException) { + assertTrue(e.message!!.contains("pending events")) + } + val snapMs = (System.nanoTime() - snapStart) / 1_000_000 + assertTrue("snapshot must be bounded: ${snapMs}ms", snapMs < 10_000) + + // 恢复文件系统:maintenance 清理受管段与陈旧条目 → 容量释放 → 新业务可继续 + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + awaitManifestWithout(spool, "sealed_") + TokenTrackingAIService.recordSafely(context, request("after-managed-recovery")) + awaitEvent("after-managed-recovery") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "after-managed-recovery", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + } finally { + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `stale tombstone identity never deletes or skips a reused-name healthy segment`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 旧损坏段 sealed_1 曾因处置失败被 tombstone(身份 = 旧内容 hash) + val oldBody = "{old-corrupt\n" + File(spool, "sealed_1.jsonl").writeText(oldBody) + // 崩溃窗口:旧文件被外部删除,manifest 尚未更新 + assertTrue(File(spool, "sealed_1.jsonl").delete()) + File(spool, "quarantine_skip_manifest.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"${sha256Hex(oldBody.toByteArray(Charsets.UTF_8))}\",\"overCap\":false}\n", + ) + // 新健康段复用同名(不同 hash) + File(spool, "sealed_1.jsonl").writeText(line(request("reused-name-healthy")) + "\n") + TokenStatSpool.replay(context) + awaitEvent("reused-name-healthy") + awaitSegmentGone(spool, "sealed_1.jsonl") + // P1-2:健康段恰一次真实入库,绝不被 tombstone 跳过或删除 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("reused-name-healthy", database.tokenStatsDao().getAllEvents().single().eventId) + // 陈旧记录被移除 + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertFalse( + "stale tombstone must be removed", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + } + } + + @Test + fun `twice-rename-failure original sealed is managed evidence exportable and ack-deleted`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{twice-rename-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-evidence")) + "\n") + // P1-3:两次重命名都失败(进证据区 + pending-delete 都失败)→ tombstone 原段 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-evidence") + // 等 drain 完成损坏段处置:tombstone 记录落盘(原段保留在磁盘上, + // 不能等它消失——受管失败段本就不消失) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "tombstone must be recorded for the twice-rename-failed segment", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + assertEquals(1, database.tokenStatsDao().countEvents()) + // tombstoned 原 sealed 必须作为 managed evidence 参与计数/字节 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "original sealed must appear as managed evidence", + evidence.any { it.name == "sealed_1.jsonl" }, + ) + // 导出包含原文件(原文件名,身份可追溯)并附 manifest + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertTrue(exported.any { it.name == "sealed_1.jsonl" }) + assertTrue(exported.any { it.name == "quarantine_skip_manifest.jsonl" }) + assertTrue( + "exported managed evidence must retain the corrupt content", + exported.single { it.name == "sealed_1.jsonl" }.readText().contains("twice-rename-bad"), + ) + // ack 确认删除:按 identity 删除原文件并移除对应 manifest 记录 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) + assertFalse("acked managed evidence must be deleted", File(spool, "sealed_1.jsonl").exists()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertFalse( + "manifest entry must be removed after ack", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + assertTrue(TokenStatSpool.quarantineEvidence(context).isEmpty()) + // 健康继续 + TokenTrackingAIService.recordSafely(context, request("after-evidence-ack")) + awaitEvent("after-evidence-ack") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + } + + @Test + fun `quarantine summary byte cap counts UTF-8 bytes for non-ASCII lines`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P2-1:预置摘要的 UTF-16 长度低于 64KiB,但 UTF-8 字节超上限(每字符 3 字节) + val chineseLine = "{\"padding\":\"${"统".repeat(22 * 1024)}\"}\n" + File(spool, "quarantine_summary.jsonl").writeText(chineseLine) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{utf8-cap-bad\n") + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertTrue( + "summary UTF-8 bytes must respect the cap: ${summary.summaryBytes}", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, + ) + val text = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest record must survive the roll", text.contains("sealed_2.jsonl")) + assertTrue( + "oversized non-ASCII line must be replaced by a fixed ASCII truncated record", + text.contains("\"truncated\":true"), + ) + assertFalse("truncated record must never embed content", text.contains("统")) + } + } + + @Test + fun `export recovers canonical summary and manifest when only sidecars remain`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P2-2:崩溃窗口——canonical 缺失,内容只在 .new sidecar(完整、已 fsync) + val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" + File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) + val manifestContent = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":2,\"sha256\":\"def\",\"overCap\":true}\n" + File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals( + summaryContent, + exported.single { it.name == "quarantine_summary.jsonl" }.readText(), + ) + assertEquals( + manifestContent, + exported.single { it.name == "quarantine_skip_manifest.jsonl" }.readText(), + ) + // canonical 也已被恢复,后续信息/ack 不再依赖 sidecar + assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) + assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) + } + } + + @Test + fun `same-name same-size same-mtime replacement is never skipped isolated or acked away`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val healthyLine = line(request("replacement-healthy")) + val healthyBytes = (healthyLine + "\n").toByteArray(Charsets.UTF_8).size + // 旧损坏段与健康新段字节数完全一致(P1-1:仅凭 length+mtime 的缓存才会被骗) + val oldBody = "{old-corrupt-" + "x".repeat(healthyBytes - "{old-corrupt-".length - 1) + "\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val fixedMtime = 1_700_000_000_000L + + val file = File(spool, "sealed_1.jsonl") + file.writeText(oldBody) + file.setLastModified(fixedMtime) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + // 先建立旧身份(旧实现中身份哈希缓存在此记住 length+mtime+sha) + TokenStatSpool.quarantineEvidence(context) + + // 同名同长同 mtime 替换为不同内容(健康行) + assertTrue(file.delete()) + file.writeText(healthyLine + "\n") + file.setLastModified(fixedMtime) + + // ack:陈旧记录被移除,健康文件绝不删除 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertTrue("ack must never delete a replaced same-name healthy segment", file.exists()) + assertFalse( + "stale tombstone must be removed by ack", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + + // 重建陈旧记录,让维护入口与扫描器都看到它 + manifest.writeText( + "{\"ts\":2,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + // replay:维护清理不删不隔离、扫描器不跳过,健康事件恰一次 + TokenStatSpool.replay(context) + awaitEvent("replacement-healthy") + awaitSegmentGone(spool, "sealed_1.jsonl") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "replacement-healthy", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + assertFalse( + "stale tombstone must be removed after replay", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + assertTrue( + "healthy segment must never be isolated as evidence", + TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, + ) + } + } + + @Test + fun `total spool cap stops appends while dao permanently fails and recovers after drain`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + // 行先于失败 DAO 生成(行生成需要真实价格读取),DAO 只负责排空失败 + val lines = (0 until 400).map { index -> + line(request("cap-$index")) to "cap-$index" + } + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap:约 3 个小段 + 行余量;DAO 永久失败 → sealed 段只增不减 + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + try { + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertTrue("append must be refused once the total cap is reached: $rejected", rejected > 0) + val totalAtRejection = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "total spool bytes must never exceed the cap: $totalAtRejection", + totalAtRejection <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0), + ) + // 固定 cap 前停止:拒绝后不再发布任何新字节(seal 只是改名不增字节) + val frozen = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + repeat(20) { + try { + TokenStatSpool.append(context, lines[0].first, "refused-$it") + fail("append after cap must keep failing") + } catch (e: TokenStatsPersistenceException) { + } + } + assertEquals( + "no new spool bytes may be published after the cap", + frozen, + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // drain 成功(DAO 恢复)后空间释放,append 继续 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + TokenTrackingAIService.recordSafely(context, request("after-total-cap-recovery")) + awaitEvent("after-total-cap-recovery") + assertEquals( + "after-total-cap-recovery", + database.tokenStatsDao().getEvent("after-total-cap-recovery")!!.eventId, + ) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `single legal line exactly at the total cap is accepted and the next is refused`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + // cap 与单行合法上限一致:恰好一行可写入,第二行必须明确拒绝 + TokenStatSpool.totalSpoolMaxBytesForTest = TokenStatSpool.MAX_LINE_BYTES.toLong() + // 行必须先于失败 DAO 生成(行生成需要真实价格读取);排空失败段才留在 + // spool,第二次 append 才会在总容量检查处触顶 + val padded = + padLineTo( + line(request("single-line-at-cap")), + TokenStatSpool.MAX_LINE_BYTES, + ) + val secondLine = line(request("refused-after-single")) + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + try { + assertEquals( + TokenStatSpool.MAX_LINE_BYTES, + (padded + "\n").toByteArray(Charsets.UTF_8).size, + ) + assertTrue(TokenStatSpool.append(context, padded, "single-line-at-cap")) + try { + TokenStatSpool.append(context, secondLine, "refused-after-single") + fail("append beyond the total cap must throw TokenStatsPersistenceException") + } catch (e: TokenStatsPersistenceException) { + } + // DAO 恢复后排空成功:单行事件恰一次,被拒绝的行从未发布 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitEvent("single-line-at-cap") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "single-line-at-cap", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `evidence info and ack recover manifest and summary from new sidecar without export`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val oldBody = "{sidecar-new-only\n" + File(spool, "sealed_1.jsonl").writeText(oldBody) + val sha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestContent = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha\",\"overCap\":false}\n" + // 崩溃窗口:canonical 缺失,内容只在 .new(完整、已 fsync) + File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) + val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" + File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) + + // 不先 export:直接调用 evidence/info/ack + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "managed evidence must be visible after sidecar recovery", + evidence.any { it.name == "sealed_1.jsonl" }, + ) + val info = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("summary info must recover from sidecar", info) + assertEquals(1, info!!.recordCount) + // canonical 已恢复且 sidecar 身份被清理 + assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) + assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) + assertFalse(File(spool, "quarantine_skip_manifest.jsonl.new").exists()) + assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) + // ack 按身份删除 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "manifest entry must be removed after ack", + File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_1.jsonl"), + ) + } + } + + @Test + fun `append capacity check recovers a full managed set from backup sidecar`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃窗口:canonical 缺失,只有 .bak(完整旧值)——受管集合已满 + val content = (1..TokenStatSpool.MAX_TOMBSTONE_ENTRIES).joinToString("\n") { index -> + "{\"ts\":1,\"file\":\"sealed_$index.jsonl\",\"bytes\":3," + + "\"sha256\":\"${sha256Hex("x$index".toByteArray(Charsets.UTF_8))}\",\"overCap\":true}" + } + "\n" + File(spool, "quarantine_skip_manifest.jsonl.bak").writeText(content) + try { + // append 容量检查必须看到恢复后的满受管集合:明确拒绝且不发布新文件 + TokenStatSpool.append(context, line(request("refused-bak-recovery")), "refused-bak-recovery") + fail("append must fail when the recovered managed set is full") + } catch (e: TokenStatsPersistenceException) { + } + assertEquals(0, database.tokenStatsDao().countEvents()) + // canonical 已恢复且 .bak 身份被清理 + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertEquals( + TokenStatSpool.MAX_TOMBSTONE_ENTRIES, + manifest.readText().lineSequence().filter { it.isNotBlank() }.count(), + ) + assertFalse(File(spool, "quarantine_skip_manifest.jsonl.bak").exists()) + } + } + + @Test + fun `each export uses its own empty directory and stale exports never leak`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_first_sealed_1.jsonl").writeText("first-bad\n") + val exportA = File(root, "token_stats_quarantine_A") + val exportedA = TokenStatSpool.exportQuarantineEvidence(context, exportA) + assertTrue(exportedA.any { it.name.startsWith("quarantine_first_") }) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exportedA.map { it.name }.toSet()) + // 第二次导出到新目录:只含本次证据,上一次的残留绝不混入/冒充 + File(spool, "quarantine_second_sealed_2.jsonl").writeText("second-bad\n") + val exportB = File(root, "token_stats_quarantine_B") + val exportedB = TokenStatSpool.exportQuarantineEvidence(context, exportB) + assertTrue(exportedB.any { it.name.startsWith("quarantine_second_") }) + assertFalse( + "a previous export must never leak into the new export directory", + exportB.listFiles().orEmpty().any { it.name.startsWith("quarantine_first_") }, + ) + assertFalse( + "previous export must never be reported as this run's result", + exportedB.any { it.name.startsWith("quarantine_first_") }, + ) + } + + @Test + fun `export into a non-empty destination is refused without touching its content`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_x_sealed_1.jsonl").writeText("bad\n") + val dest = File(root, "token_stats_quarantine_existing").apply { mkdirs() } + val userFile = File(dest, "user-notes.txt").apply { writeText("do not touch") } + try { + TokenStatSpool.exportQuarantineEvidence(context, dest) + fail("export into a non-empty destination must be refused") + } catch (e: IOException) { + assertTrue("refusal must name the reason", e.message!!.contains("not empty")) + } + assertEquals("do not touch", userFile.readText()) + assertFalse( + "no evidence may be written into a refused destination", + dest.listFiles().orEmpty().any { it.name.startsWith("quarantine_") }, + ) + } + + @Test + fun `manifest read failure fails closed scanner ack and append and recovers after restore`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{managed-bad\n") + // 先正常建立受管失败段(重命名失败 → tombstone 记录落盘) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + + // 注入 manifest 读取失败:scanner/容量/维护全部中止退避,受管段不处理 + TokenStatSpool.metadataReadErrorForTest = { file -> + file.name == "quarantine_skip_manifest.jsonl" + } + TokenStatSpool.replay(context) + delay(700) + assertTrue( + "managed segment must not be processed while the manifest is unreadable", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue( + "manifest entry must be retained", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // ack 报错:manifest 不可读时不能确认删除 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail while the manifest is unreadable") + } catch (e: IOException) { + } + assertTrue( + "entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // 容量检查 fail-closed:append 不发布、不声称 durable + assertFalse( + "append must fail closed while the manifest is unreadable", + TokenStatSpool.append( + context, + line(request("fail-closed-append")), + "fail-closed-append", + ), + ) + assertFalse( + File(spool, "active.jsonl").isFile && File(spool, "active.jsonl").length() > 0L, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复后正常:append 重新 durable,ack 按身份删除并清理记录 + TokenStatSpool.metadataReadErrorForTest = null + assertTrue( + TokenStatSpool.append( + context, + line(request("after-manifest-recovery")), + "after-manifest-recovery", + ), + ) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "entry must be removed after a successful ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + TokenStatSpool.replay(context) + awaitEvent("after-manifest-recovery") + } finally { + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `segment read failure keeps managed entries and ack refuses until identity is readable`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{unreadable-bad\n") + // 正常建立受管失败段(重命名失败 → tombstone) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + + // 段原始字节读取失败(身份校验 UNREADABLE):受管段不处理、entry 保留 + TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_1.jsonl" } + TokenStatSpool.replay(context) + delay(700) + assertTrue( + "unreadable managed segment must be skipped, never processed", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue( + "manifest entry must be retained for the unreadable segment", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // evidence 列表不暴露身份不可校验的受管段(不可安全导出/ack) + assertTrue( + TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, + ) + // ack 不能成功 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail while the segment identity is unreadable") + } catch (e: IOException) { + assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) + } + assertTrue(File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // 恢复后正常:ack 按身份删除并移除记录 + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "entry must be removed after a successful ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } finally { + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack manifest read failure preserves quarantine evidence managed evidence and manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // quarantine area 完整证据(无 manifest 记录)+ 受管失败段(重命名失败 → tombstone) + File(spool, "quarantine_area_sealed_1.jsonl").writeText("area-bad\n") + File(spool, "sealed_2.jsonl").writeText("{managed-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // manifest 不可读 → 整个 ack 失败:quarantine + managed + manifest 全部保留 + TokenStatSpool.metadataReadErrorForTest = { file -> + file.name == "quarantine_skip_manifest.jsonl" + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("quarantine_area_sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail while the manifest is unreadable") + } catch (e: IOException) { + } + assertTrue( + "quarantine evidence must survive a failed ack", + File(spool, "quarantine_area_sealed_1.jsonl").exists(), + ) + assertTrue( + "managed evidence must survive a failed ack", + File(spool, "sealed_2.jsonl").exists(), + ) + assertTrue( + "manifest entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + assertTrue( + "no ack trash directory may be left behind", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } finally { + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack with later unreadable managed identity keeps earlier match and all entries`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{first-bad\n") + File(spool, "sealed_2.jsonl").writeText("{second-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 后一个段身份不可校验(UNREADABLE)→ 整个 ack 失败:前一个 MATCH 也不删除 + TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_2.jsonl" } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail when any managed identity is unreadable") + } catch (e: IOException) { + assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) + } + assertTrue( + "earlier matched segment must not be deleted on a partial failure", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue(File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "both entries must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // 恢复后一次 ack 按身份删除两个段并移除两条记录 + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_2.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + } finally { + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack staging rename failure rolls back staged renames and keeps manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rollback-a\n") + File(spool, "sealed_2.jsonl").writeText("{rollback-b\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 第 2 个文件的 stage rename 失败 → 第 1 个已 stage 的文件必须回滚, + // manifest 不改;回滚 rename 的目标是 spool 根目录,不受注入影响 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == "sealed_2.jsonl" -> false + else -> null + } + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail when staging a rename fails") + } catch (e: IOException) { + assertTrue("ack must report the staging failure", e.message!!.contains("stage")) + } + assertTrue( + "staged file must be rolled back after a failed rename", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue(File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "no trash directory may remain after rollback", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + assertTrue( + "both entries must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // 恢复真实 rename 后 ack 成功:按身份删除两个段并移除两条记录 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_2.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack manifest write failure rolls back all staged files and keeps old manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val managed = File(spool, "sealed_1.jsonl").apply { writeText("{managed-bad\n") } + val quarantine = + File(spool, "quarantine_area_sealed_2.jsonl").apply { writeText("area-bad\n") } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val oldManifest = + "{\"file\":\"${managed.name}\",\"bytes\":${managed.length()}," + + "\"sha256\":\"${sha256Hex(managed.readBytes())}\",\"overCap\":false}\n" + manifest.writeText(oldManifest) + TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(managed.name, quarantine.name), + ) + fail("ack must fail when the manifest cannot be published") + } catch (e: IOException) { + assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) + } finally { + TokenStatSpool.metadataWriteErrorForTest = null + } + assertTrue("managed evidence must be restored", managed.isFile) + assertTrue("quarantine evidence must be restored", quarantine.isFile) + assertEquals("old manifest must remain byte-for-byte intact", oldManifest, manifest.readText()) + assertTrue( + "no trash directory may remain after a successful rollback", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } + } + + @Test + fun `ack rollback move with not durable dir sync keeps uncommitted trash and maintenance recovers it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText("{rollback-sync-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + var calls = 0 + try { + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + TokenStatSpool.segmentRenameForTest = null + // 阶段 2:manifest 重写失败触发回滚;回滚 move 的目录项 sync(第 7 次: + // 1 次 manifest 严格读取 + 1 次 trash 创建 + 2 次暂存 + 2 次状态写入) + // 失败 → trash 保留 UNCOMMITTED 状态、上层失败,绝不静默(P2) + TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 7) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail when the rollback dir sync is not OK") + } catch (e: IOException) { + assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained after a not-durable rollback", 1, trashDirs.size) + val state = File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue( + "state must remain UNCOMMITTED for maintenance rollback", + state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + // 回滚 move 已可见(证据回到原路径)但目录项未确认:mapping 仍持有身份, + // 维护按状态机幂等完成 + assertTrue("evidence is back at its original path", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 3:恢复后维护按 UNCOMMITTED + mapping 完成回滚并删除 trash;损坏 + // sealed 随后被扫描器重新隔离为完整证据(与 ack 崩溃窗口协议一致) + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be resolved by maintenance after recovery", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val body = "{rollback-sync-bad\n" + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < evidenceDeadline && + TokenStatSpool.quarantineEvidence(context).none { it.readText() == body } + ) { + delay(20) + } + assertEquals( + "evidence must be re-quarantined exactly once after the rollback", + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `committed ack trash residue counts into the total cap and maintenance cleans it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousDelete = TokenStatSpool.spoolDeleteForTest + try { + // 大证据文件:ack 后留在 trash(删除被强制失败),必须计入总容量 + val evidence = File(spool, "quarantine_trash_cap_sealed_1.jsonl") + RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB,28KiB 残留 + // 证据已让每次 append 的递归投影超限——旧实现只数顶层会放行到实际 36KiB + TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 + TokenStatSpool.spoolDeleteForTest = { false } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("committed trash must remain when deletion is forced to fail", 1, trashDirs.size) + assertEquals( + "commit flip must be persisted in the trash state file", + TokenStatSpool.ACK_STATE_COMMITTED + "\n", + File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).readText(), + ) + + // 行先于失败 DAO 生成(行生成需要真实价格读取);DAO 只负责排空失败 + val lines = (0 until 200).map { index -> + line(request("trash-cap-$index")) to "trash-cap-$index" + } + // DAO 永久失败 → sealed 段只增不减;递归总容量必须计入 trash 残留 + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertEquals( + "every append must be refused while the trash residue holds the admission budget: $rejected", + lines.size, + rejected, + ) + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + fun recursiveTotal(): Long = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "recursive total including trash must never exceed the cap: ${recursiveTotal()}", + recursiveTotal() <= cap, + ) + // 冻结断言:拒绝后不再发布任何字节 + val frozen = recursiveTotal() + repeat(10) { + try { + TokenStatSpool.append(context, lines[0].first, "refused-trash-$it") + fail("append after trash-inclusive cap must keep failing") + } catch (e: TokenStatsPersistenceException) { + } + } + assertEquals(frozen, recursiveTotal()) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 维护补删恢复:删除恢复后 replay 清掉 committed trash + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.replay(context) + val cleanupDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < cleanupDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "committed trash must be removed by maintenance once deletion works", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + // DAO 恢复后排空与 append 都恢复正常 + TokenStatsLedger.databaseProvider = { database } + assertTrue( + TokenStatSpool.append( + context, + line(request("after-trash-recovery")), + "after-trash-recovery", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-trash-recovery") + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.spoolDeleteForTest = previousDelete + } + } + } + + @Test + fun `ack refuses when trash state metadata would push the total over the cap`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + try { + // 大量小证据文件 → mapping 状态文件较大;cap 只留 4KiB 头部空间, + // 4 槽位最坏投影(mapping ~10KiB × 4)必然超限 + val files = (0 until 60).map { index -> + File(spool, "quarantine_many_$index.jsonl").apply { writeText("bad-$index\n") } + } + val totalNow = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + TokenStatSpool.totalSpoolMaxBytesForTest = totalNow + 4 * 1024 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, files.map { it.name }.toSet()) + fail("ack must fail when the trash state metadata does not fit the total cap") + } catch (e: IOException) { + } + // 全部证据仍在原位、没有 trash 目录残留、总量不超限(stage 已回滚) + files.forEach { assertTrue("evidence must stay in place: ${it.name}", it.exists()) } + assertTrue( + "no trash directory may remain after the refused ack", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + assertTrue( + "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L), + ) + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `ack staging failure with rollback failure keeps uncommitted trash and maintenance recovers it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rb-fail-a\n") + File(spool, "sealed_2.jsonl").writeText("{rb-fail-b\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 第 2 个文件 stage 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == "sealed_2.jsonl" -> false + to.parentFile?.name != null && + !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && + to.name == "sealed_1.jsonl" -> false + else -> null + } + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained after rollback failure", 1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("staged evidence must stay in trash", File(trash, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertTrue("sealed_2 must stay in place (stage never happened)", File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "trash state must be UNCOMMITTED with a mapping", + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + .readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + assertTrue( + "manifest entry must remain", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertTrue( + "manifest entry must remain", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // replay 维护(rename 仍被注入失败):不删 trash、不删证据、manifest 条目保留 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must never delete uncommitted trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive maintenance", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertFalse( + "sealed_2 was restored to the evidence area by maintenance", + File(spool, "sealed_2.jsonl").exists(), + ) + + // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚并自愈 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be gone after a successful maintenance rollback", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + awaitManifestWithout(spool, "sealed_1.jsonl") + awaitManifestWithout(spool, "sealed_2.jsonl") + // 两份证据都回到完整证据区(可导出/可 ack) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.name.contains("sealed_1.jsonl") }) + assertTrue(evidence.any { it.name.contains("sealed_2.jsonl") }) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `crash window with published manifest rolls back uncommitted trash and scanner re-quarantines`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 手工构造崩溃窗口:主 manifest 已发布(不含该身份),但 commit 标记未写。 + // P1-1:UNCOMMITTED 绝不根据 manifest 缺失推断已提交——必须回滚证据, + // 回滚后的损坏 sealed 会被扫描器重新隔离(ack 视失败但不丢证据)。 + val body = "{crash-window-bad\n" + val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", + ) + // manifest 不存在 = 条目已全部移除(旧实现会据此误判 committed 并删除证据) + TokenStatSpool.replay(context) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && trash.exists()) delay(20) + assertFalse( + "maintenance must roll back uncommitted crash-window trash", + trash.exists(), + ) + // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据,绝不丢失 + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var reQuarantined = false + while (System.nanoTime() < evidenceDeadline && !reQuarantined) { + reQuarantined = TokenStatSpool.quarantineEvidence(context).any { + it.name.contains("sealed_1.jsonl") && it.readText() == body + } + if (!reQuarantined) delay(20) + } + assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) + } + } + + @Test + fun `ordinary evidence stage failure with rollback failure keeps uncommitted trash and maintenance restores it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 普通(非受管)quarantine 证据:从不在 tombstone manifest 中。P1-1 修复前, + // 维护会因 manifest 缺失推断“已提交”而删除 trash(丢失未确认的证据)。 + val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText("{ord-a\n") } + val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText("{ord-b\n") } + // 第 2 个文件 stage rename 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == ev2.name -> false + to.parentFile?.name != null && + !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && + to.name == ev1.name -> false + else -> null + } + } + try { + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(ev1.name, ev2.name), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals(1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("staged evidence must stay in trash", File(trash, ev1.name).exists()) + assertFalse(ev1.exists()) + assertTrue("ev2 stage never happened", ev2.exists()) + + // replay 维护(回滚 rename 仍被注入失败):绝不删除 trash 与证据 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must never delete uncommitted ordinary evidence", trash.exists()) + assertTrue(File(trash, ev1.name).exists()) + assertFalse("no partial rollback may occur", ev1.exists()) + + // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚,证据不删最终恢复 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be gone after a successful maintenance rollback", trash.exists()) + assertTrue("ev1 must be restored to the evidence area", ev1.exists()) + assertTrue("ev2 must stay in the evidence area", ev2.exists()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(setOf(ev1.name, ev2.name), evidence.map { it.name }.toSet()) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `partially corrupt ack trash mapping is fail-closed and maintenance retains everything`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{p12-a\n" + val body2 = "{p12-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + // 首行有效 mapping + 一行损坏 mapping:mapNotNull 会静默丢弃损坏行, + // 只回滚 1 个文件并删除 trash——旧实现会丢失第 2 份证据(P1-2) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + TokenStatSpool.replay(context) + delay(700) + assertTrue("partially corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse("no rollback may happen from a partial mapping", File(spool, "sealed_1.jsonl").exists()) + // UI 可见:作为 stuck 受管证据列出 + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + + // 修复为重复 mapping(同一原名两条)→ 仍然 fail-closed 保留 + val lineA = + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + lineA, + ) + TokenStatSpool.replay(context) + delay(700) + assertTrue("duplicate mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + + // 完整修复 mapping(两份证据都被覆盖)→ 维护回滚并自愈 + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) + // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + } + } + + @Test + fun `partial mapping with unreadable trash enumeration is fail-closed and manifest stays verbatim`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{enum-null-a\n" + val body2 = "{enum-null-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha1\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + // 首行有效 mapping + 一行损坏:全有或全无解析必然失败;trash 枚举再失败时, + // 即使 mapping 已覆盖可见证据,未枚举的证据也无法排除 → 仍必须 fail-closed + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) + File(spool, "sealed_9.jsonl").writeText(line(request("enum-null-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + awaitEvent("enum-null-healthy") + delay(700) + assertTrue("trash must be retained while its enumeration fails", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse( + "no rollback may happen from a partial mapping with failed enumeration", + File(spool, "sealed_1.jsonl").exists(), + ) + assertFalse("no un-enumerated evidence may be deleted", File(spool, "sealed_2.jsonl").exists()) + assertEquals( + "manifest must be preserved verbatim", + manifestLine, + safeManifestText(manifest), + ) + // P1-6 fail-closed:stuck 证据枚举走同一 seam——枚举失败时 UI 查询必须 + // 明确抛错,绝不能返回部分/空列表误导用户删除 + try { + TokenStatSpool.stuckAckTrashEvidence(context) + fail("stuck ack trash evidence must fail while trash enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + try { + TokenStatSpool.stuckAckTrashBytes(context) + fail("stuck ack trash bytes must fail while trash enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 有界:重复维护轮不改写 manifest、不处置 trash + TokenStatSpool.replay(context) + delay(700) + assertTrue(trash.exists()) + assertEquals( + "repeated maintenance rounds must not rewrite the manifest", + manifestLine, + safeManifestText(manifest), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复枚举 + 完整 mapping → 维护回滚并自愈(cleanup 成功) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once enumeration and mapping recover", trash.exists()) + // P1-6:枚举成功且无 trash 时才是真正的空列表 + assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var evidence: List = emptyList() + while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { + evidence = TokenStatSpool.quarantineEvidence(context) + if (evidence.size != 2) delay(20) + } + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + } + } + + @Test + fun `spool root enumeration failure makes trash state unknown and blocks stale removal`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 消失原件的 manifest 条目:根枚举失败时无法证明旧身份不被未枚举的 trash 持有 + val oldBody = "{root-enum-stale\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + // UNCOMMITTED trash 真实持有该身份(根枚举失败时完全不可见) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + // 健康段:根枚举失败期间 drain 必须 fail-closed 退避——段保留、绝不入 Room + File(spool, "sealed_9.jsonl").writeText(line(request("root-enum-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + delay(700) + assertNull( + "no segment may drain while the root enumeration fails", + database.tokenStatsDao().getEvent("root-enum-healthy"), + ) + assertTrue("healthy segment must be preserved", File(spool, "sealed_9.jsonl").exists()) + assertEquals( + "stale removal must be blocked while the root enumeration fails", + manifestLine, + safeManifestText(manifest), + ) + assertTrue( + "trash must be retained while the root enumeration fails", + trash.exists(), + ) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + // 有界:重复维护轮保持原样 + TokenStatSpool.replay(context) + delay(700) + assertEquals(manifestLine, safeManifestText(manifest)) + assertTrue(trash.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 枚举恢复:健康段排空;身份确实被 trash 持有 → 回滚后按 MATCH 处置,条目最终移除 + TokenStatSpool.replay(context) + awaitEvent("root-enum-healthy") + awaitManifestWithout(spool, "sealed_1.jsonl") + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once enumeration recovers", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var restored = false + while (System.nanoTime() < evidenceDeadline && !restored) { + restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } + if (!restored) delay(20) + } + assertTrue("held evidence must be restored after recovery", restored) + } + } + + @Test + fun `drain stays bounded and healthy appends stay durable while enumeration fails then recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 健康段:根枚举失败期间 drain fail-closed 退避——段保留、不入 Room + File(spool, "sealed_1.jsonl").writeText(line(request("enum-drain-1")) + "\n") + // 陈旧候选:消失原件身份仍在 manifest + val oldBody = "{enum-drain-stale\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + // UNCOMMITTED trash 真实持有 sealed_2(valid mapping) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_2.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + delay(700) + assertNull( + "no segment may drain while the root enumeration fails", + database.tokenStatsDao().getEvent("enum-drain-1"), + ) + assertTrue("healthy segment must be preserved", File(spool, "sealed_1.jsonl").exists()) + // 有界:trash 不处置、manifest 不重写 + assertTrue(trash.exists()) + assertEquals(manifestLine, safeManifestText(manifest)) + // 健康 append 在枚举失败期间仍然 durable(事件留在 active,不排空) + assertTrue( + TokenStatSpool.append( + context, + line(request("enum-append-2")), + "enum-append-2", + ), + ) + delay(700) + assertNull( + "appended event must stay durable but not drain while the root enumeration fails", + database.tokenStatsDao().getEvent("enum-append-2"), + ) + assertTrue("appended event must stay in active.jsonl", File(spool, "active.jsonl").exists()) + assertTrue(trash.exists()) + assertEquals(manifestLine, safeManifestText(manifest)) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 枚举恢复后处理:健康段与 active 排空;stale 清理与回滚完成 + TokenStatSpool.replay(context) + awaitEvent("enum-drain-1") + awaitEvent("enum-append-2") + awaitManifestWithout(spool, "sealed_2.jsonl") + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back after enumeration recovers", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var restored = false + while (System.nanoTime() < evidenceDeadline && !restored) { + restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } + if (!restored) delay(20) + } + assertTrue("held identity evidence must be restored after recovery", restored) + } + } + + @Test + fun `maintenance defers rollback while trash enumeration fails and cleanup succeeds after the seam recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{enum-recovery-bad\n" + val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", + ) + // mapping 完全有效也必须在枚举失败时 fail-closed:无法证明没有未枚举的证据 + // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) + File(spool, "sealed_9.jsonl").writeText(line(request("enum-recovery-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + awaitEvent("enum-recovery-healthy") + delay(700) + assertTrue( + "valid mapping must still be fail-closed while enumeration fails", + trash.exists(), + ) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertFalse( + "no rollback may happen while enumeration fails", + File(spool, "sealed_1.jsonl").exists(), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam → rollback cleanup 成功:trash 删除、证据回到原槽位、被扫描器隔离 + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be deleted after the successful rollback", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var reQuarantined = false + while (System.nanoTime() < evidenceDeadline && !reQuarantined) { + reQuarantined = TokenStatSpool.quarantineEvidence(context).any { + it.isFile && it.readText() == body + } + if (!reQuarantined) delay(20) + } + assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) + } + } + + @Test + fun `spool root enumeration failure aborts snapshot and seal without touching segments then recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = line(request("enum-null-sealed")) + "\n" + val body2 = line(request("enum-null-active")) + "\n" + val sealed1 = File(spool, "sealed_1.jsonl") + sealed1.writeText(body1) + val active = File(spool, "active.jsonl") + active.writeText(body2) + val activeText = active.readText() + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + // active 已有内容:下一次 append 必走 seal 路径(P1-7 场景) + TokenStatSpool.MAX_SEGMENT_BYTES = active.length() + 1 + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + // 1) 快照的 drain 阶段 fail-closed → block 绝不执行、文件原字节不变 + var blockRan = false + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must fail while the spool root enumeration fails") + } catch (e: IOException) { + assertTrue( + "failure must come from the snapshot drain barrier", + e.message!!.contains("drain"), + ) + } + assertFalse("snapshot block must not run", blockRan) + assertEquals("sealed_1 must stay byte-identical", body1, sealed1.readText()) + assertEquals("active must stay byte-identical", activeText, active.readText()) + + // 2) seal 绝不覆盖:需要 seal 的 append 明确失败,sealed_1/active 原样 + assertFalse( + "append requiring a seal must fail while enumeration fails", + TokenStatSpool.append( + context, + line(request("enum-null-extra")), + "enum-null-extra", + ), + ) + assertEquals("sealed_1 must never be overwritten", body1, sealed1.readText()) + assertEquals("active must not be sealed or truncated", activeText, active.readText()) + + // 3) drain 保留:后台 drain 轮退避,文件与事件原样 + TokenStatSpool.replay(context) + delay(700) + assertEquals("sealed_1 must be preserved by the failing drain", body1, sealed1.readText()) + assertEquals("active must be preserved by the failing drain", activeText, active.readText()) + assertNull( + "sealed event must not reach Room while enumeration fails", + database.tokenStatsDao().getEvent("enum-null-sealed"), + ) + assertNull( + "active event must not reach Room while enumeration fails", + database.tokenStatsDao().getEvent("enum-null-active"), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 4) 恢复 seam → 两事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("enum-null-sealed") + awaitEvent("enum-null-active") + assertEquals( + "each preserved event must be recorded exactly once", + 2, + database.tokenStatsDao().countEvents(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal never overwrites an occupied target and picks the next safe index`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val bodyA = line(request("seal-collide-a")) + "\n" + val bodyB = line(request("seal-collide-b")) + "\n" + File(spool, "sealed_1.jsonl").writeText(bodyA) + val sealed2 = File(spool, "sealed_2.jsonl") + sealed2.writeText(bodyB) + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + // active 已含一条完整行;枚举缝隐藏 sealed_2 → 计算出的 next=2 已被 + // 占用,seal 必须跳到 3,绝不覆盖 sealed_2 + val bodyC = line(request("seal-collide-c")) + "\n" + File(spool, "active.jsonl").writeText(bodyC) + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) { + dir.listFiles()?.filter { it.name != "sealed_2.jsonl" }?.toTypedArray() + } else { + dir.listFiles() + } + } + try { + assertTrue( + "append must seal active to a free index and succeed", + TokenStatSpool.append( + context, + line(request("seal-collide-d")), + "seal-collide-d", + ), + ) + // seam 仍生效:并发 drain 与 seal 都看不见 sealed_2 → 占用目标不可能被覆盖 + assertEquals( + "occupied sealed target must never be overwritten", + bodyB, + sealed2.readText(), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复枚举后全部事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("seal-collide-a") + awaitEvent("seal-collide-b") + awaitEvent("seal-collide-c") + awaitEvent("seal-collide-d") + assertEquals( + "each event must be recorded exactly once", + 4, + database.tokenStatsDao().countEvents(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal publish conflict keeps target bytes and seals active at a higher index`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val seamBody = "{pre-existing-conflict\n" + try { + // active 已含两条完整行;下一次 append 必触发 seal(候选编号 1) + val lineA = line(request("seal-seam-a")) + val lineB = line(request("seal-seam-b")) + val lineC = line(request("seal-seam-c")) + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var hooks = 0 + TokenStatSpool.beforeSealPublishForTest = { target -> + hooks += 1 + if (hooks == 1) { + // 候选选定后、实际 publish 前出现同名不同内容的目标(模拟异常残留) + target.writeText(seamBody) + true + } else { + null + } + } + // 停掉后台 writer:seal 仍在 append 内同步完成,但断言阶段不会被并发 drain + // 改写/隔离文件(确定性) + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal active to a free index and succeed", + TokenStatSpool.append(context, lineC, "seal-seam-c"), + ) + } finally { + TokenStatSpool.beforeSealPublishForTest = null + } + // 冲突目标原字节不变;active 数据安全落到更高编号 sealed_2;新事件在 active + assertEquals( + "conflict target must keep its original bytes", + seamBody, + File(spool, "sealed_1.jsonl").readText(), + ) + assertEquals( + "active data must be sealed to a higher index", + lineA + "\n" + lineB + "\n", + File(spool, "sealed_2.jsonl").readText(), + ) + assertEquals( + "new event must be durable in active", + lineC + "\n", + File(spool, "active.jsonl").readText(), + ) + // 全部事件各恰一次入 Room;冲突残留被隔离为完整证据、字节不变 + TokenStatSpool.replay(context) + awaitEvent("seal-seam-a") + awaitEvent("seal-seam-b") + awaitEvent("seal-seam-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "conflict residue must be preserved byte-identical as evidence", + evidence.any { it.isFile && it.readText() == seamBody }, + ) + } finally { + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal crash window recovers before append and each event drains exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("hardlink-window-a")) + val lineB = line(request("hardlink-window-b")) + val active = File(spool, "active.jsonl") + active.writeText(lineA + "\n") + // 模拟崩溃窗口:createLink(sealed_1, active) 成功但 active 删除前崩溃 → 同 inode + Files.createLink(File(spool, "sealed_1.jsonl").toPath(), active.toPath()) + // 停掉后台 writer:恢复发生在 append 内同步完成,断言不被并发 drain 干扰 + TokenStatSpool.shutdownWriterForTest() + // append 必须先恢复重复(删除 sealed 副本)再写入,绝不能把新内容写进已 seal 段 + assertTrue(TokenStatSpool.append(context, lineB, "hardlink-window-b")) + assertFalse( + "sealed duplicate must be removed before append, never polluted", + File(spool, "sealed_1.jsonl").exists(), + ) + TokenStatSpool.replay(context) + awaitEvent("hardlink-window-a") + awaitEvent("hardlink-window-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("hardlink-window-a", "hardlink-window-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `seal publish with active delete failure is rolled back and later recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("delete-fail-a")) + val lineB = line(request("delete-fail-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var failures = 0 + TokenStatSpool.sealActiveDeleteForTest = { + failures += 1 + false + } + try { + // seal:createLink 成功 → 删除 active 失败 → 回滚链接 → seal 失败 + // → append 明确失败(B 未发布、无伪 durable) + assertFalse( + "append must fail when the post-publish active delete fails", + TokenStatSpool.append(context, lineB, "delete-fail-b"), + ) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + } + assertEquals(1, failures) + // 回滚成功:无 sealed 残留;active 保持原内容 + assertTrue( + "rolled-back seal must leave no sealed residue", + spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }, + ) + assertEquals(lineA + "\n", File(spool, "active.jsonl").readText()) + // 恢复后:既有事件恰一次入 Room,被拒绝的 B 从未发布 + TokenStatSpool.replay(context) + awaitEvent("delete-fail-a") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue( + "append must succeed after the delete failure recovers", + TokenStatSpool.append(context, lineB, "delete-fail-b"), + ) + awaitEvent("delete-fail-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink unsupported falls back to atomic no-replace copy publish`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("copy-fallback-a")) + val lineB = line(request("copy-fallback-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + // 停掉后台 writer:seal 在 append 内同步完成(copy 回退),断言不被并发 drain 干扰 + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal via the copy fallback and succeed", + TokenStatSpool.append(context, lineB, "copy-fallback-b"), + ) + } finally { + TokenStatSpool.sealHardLinkForTest = null + } + // copy 发布成功:sealed_1 = active 原内容,active = 新事件 + assertEquals(lineA + "\n", File(spool, "sealed_1.jsonl").readText()) + assertEquals(lineB + "\n", File(spool, "active.jsonl").readText()) + // 恢复 writer 后全部事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("copy-fallback-a") + awaitEvent("copy-fallback-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("copy-fallback-a", "copy-fallback-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback crash window content duplicate is recovered and drains once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("copy-window-a")) + val lineB = line(request("copy-window-b")) + val lineC = line(request("copy-window-c")) + try { + val content = lineA + "\n" + lineB + "\n" + // 模拟 copy 回退崩溃窗口:sealed_1 复制完成、active 删除未发生 + // (两个独立 inode 同内容) + File(spool, "active.jsonl").writeText(content) + File(spool, "sealed_1.jsonl").writeText(content) + TokenStatSpool.MAX_SEGMENT_BYTES = content.length.toLong() + 1 + TokenStatSpool.shutdownWriterForTest() + // append 必须先按内容识别并删除 sealed 副本;随后的 seal 把内容重新封为 + // 唯一的 sealed_1(单份,绝不重复拼接、绝不污染旧副本) + assertTrue(TokenStatSpool.append(context, lineC, "copy-window-c")) + assertEquals( + "sealed segment must hold the single copy of the old active content", + content, + File(spool, "sealed_1.jsonl").readText(), + ) + assertEquals( + "new event must be durable in active", + lineC + "\n", + File(spool, "active.jsonl").readText(), + ) + TokenStatSpool.replay(context) + awaitEvent("copy-window-a") + awaitEvent("copy-window-b") + awaitEvent("copy-window-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("copy-window-a", "copy-window-b", "copy-window-c"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback target fsync failure retains active and recovers exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("target-fsync-a")) + val lineB = line(request("target-fsync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.fileSyncForTest = { false } + TokenStatSpool.shutdownWriterForTest() + try { + // copy 完成后目标 fsync 失败:必须保留 active、处置目标、明确失败 + assertFalse( + "append must fail when the sealed target fsync fails", + TokenStatSpool.append(context, lineB, "target-fsync-b"), + ) + } finally { + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "no normal sealed segment may be left from the failed publish", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + // 目标被隔离为 seal_failed_*(identity 确认通过,内容 = active 前缀/相等) + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) + // 恢复:维护清理隔离副本,既有事件恰一次入 Room;被拒事件随后发布成功 + TokenStatSpool.replay(context) + awaitEvent("target-fsync-a") + awaitSegmentGone(spool, isolated.name) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "target-fsync-b")) + awaitEvent("target-fsync-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback first dir sync failure retains active and recovers exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dirsync-fail-a")) + val lineB = line(request("dirsync-fail-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + try { + // 目标创建未确认持久(目录 sync 失败):必须保留 active、处置目标、明确失败 + assertFalse( + "append must fail when the target-creating dir sync fails", + TokenStatSpool.append(context, lineB, "dirsync-fail-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED,恢复 + // 路径必须回到注入的 OK 才能运行正常 seal 发布/排空协议) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "no normal sealed segment may be left from the failed publish", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + TokenStatSpool.replay(context) + awaitEvent("dirsync-fail-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "dirsync-fail-b")) + awaitEvent("dirsync-fail-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback post-active-delete dir sync failure keeps durable target and drains once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("post-sync-a")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(目标 + // 创建)成功,第四次(active 删除)失败 + if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + // 目标已 data+creation durable 后删除 active 的目录同步失败:返回 FAILED + // 阻止本轮后续 append 污染,但绝不回滚已 durable 的 target + assertFalse( + "append must fail when the post-delete dir sync fails", + TokenStatSpool.append(context, line(request("post-sync-b")), "post-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals(4, dirSyncCalls) + assertEquals( + "durable target must be kept with the full content", + lineA + "\n", + File(spool, "sealed_1.jsonl").readText(), + ) + assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) + // 恢复:target 是唯一内容持有者,正常排空,事件恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("post-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("post-sync-c")), "post-sync-c")) + awaitEvent("post-sync-c") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal first dir sync failure rolls back link retains active and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("link-sync-a")) + val lineB = line(request("link-sync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + try { + // 链接目录项未确认持久:必须回滚链接、保留 active、明确失败 + assertFalse( + "append must fail when the link-creating dir sync fails", + TokenStatSpool.append(context, lineB, "link-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "rolled-back seal must leave no hardlink residue", + spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, + ) + TokenStatSpool.replay(context) + awaitEvent("link-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "link-sync-b")) + awaitEvent("link-sync-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal post-active-delete dir sync failure keeps durable link and drains once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("link-post-sync-a")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(链接 + // 创建)成功,第四次(active 删除)失败 + if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the post-delete dir sync fails", + TokenStatSpool.append(context, line(request("link-post-sync-b")), "link-post-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals(4, dirSyncCalls) + assertTrue( + "durable link must be kept", + File(spool, "sealed_1.jsonl").exists(), + ) + assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) + // 恢复:link 是唯一内容持有者(同 inode),正常排空,事件恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("link-post-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("link-post-sync-c")), "link-post-sync-c")) + awaitEvent("link-post-sync-c") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `mid-copy partial target is isolated never drained or overwritten and active is retained`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("partial-a")) + val lineB = line(request("partial-b")) + val partial = lineA + "\n" // copy 中途只写入了完整行的前缀内容 + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + TokenStatSpool.shutdownWriterForTest() + try { + // copy 中途失败留下部分目标:append 必须明确失败且 active 保留 + assertFalse( + "append must fail when the seal copy fails mid-way", + TokenStatSpool.append(context, line(request("partial-c")), "partial-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals( + "active must be retained with the full content", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "partial target must not remain as a normal sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) + // 恢复:隔离副本由维护清理;部分内容绝不入 Room(完整内容只排空一次) + TokenStatSpool.replay(context) + awaitEvent("partial-a") + awaitEvent("partial-b") + awaitSegmentGone(spool, isolated.name) + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal copy partial cleanup failures tombstone the target never drain it and recover as bounded evidence`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("tombstone-partial-a")) + val lineB = line(request("tombstone-partial-b")) + val partial = lineA + "\n" // 严格部分:只是 active 第一行的前缀内容,身份与完整内容不同 + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + TokenStatSpool.segmentDeleteForTest = { f -> + if (f.name.startsWith("sealed_")) false else null + } + try { + assertFalse( + "append must fail when the seal copy fails mid-way", + TokenStatSpool.append(context, line(request("tombstone-partial-c")), "tombstone-partial-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + } + // rename/delete 都失败 → tombstone skip:manifest 记录身份,scanner 跳过 + val partialFile = File(spool, "sealed_1.jsonl") + assertTrue("partial target must stay at the candidate name", partialFile.exists()) + assertEquals("partial bytes must be preserved", partial, partialFile.readText()) + assertTrue( + "partial target must be recorded in the tombstone manifest", + safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, + ) + // 带 seams 恢复:tombstoned 部分目标被跳过(文件保留、绝不普通排空),健康 + // 内容封到下一编号并恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("tombstone-partial-a") + awaitEvent("tombstone-partial-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + assertTrue("tombstoned partial must still exist", partialFile.exists()) + // 移除失败 seam 后维护把部分目标移入完整证据区(有界证据)并移除 manifest 条目 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_1.jsonl") + awaitManifestWithout(spool, "sealed_1.jsonl") + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "isolated partial must become bounded quarantine evidence", + evidence.any { it.isFile && it.readText() == partial }, + ) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal copy partial target isolation with not durable dir sync writes tombstone evidence and fails closed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dispose-sync-a")) + val lineB = line(request("dispose-sync-b")) + val partial = lineA + "\n" + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + // gate(2) OK;隔离 rename 后的目录项 sync(第 3 次)失败 → dispose 返回 + // 失败并写 tombstone(候选 sealed 身份受管证据),绝不只留日志(P2) + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the partial target disposal is not durable", + TokenStatSpool.append(context, line(request("dispose-sync-c")), "dispose-sync-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + // 隔离文件本身是受管证据(seal_failed_*,可见/导出/ack/维护),tombstone + // 按候选 sealed 身份记录:崩溃后 sealed_1 以同内容重现时 scanner 跳过 + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertTrue( + "candidate identity must be tombstoned so a reappeared sealed_1 is skipped", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertEquals( + "active must be retained", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:维护清理隔离副本,健康内容各恰一次入 Room(tombstone 条目随文件 + // 消失确认后移除) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitEvent("dispose-sync-a") + awaitEvent("dispose-sync-b") + awaitSegmentGone(spool, isolated.name) + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal copy partial target deletion with not durable dir sync writes tombstone evidence and fails closed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dispose-del-a")) + val lineB = line(request("dispose-del-b")) + val partial = lineA + "\n" + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + // 隔离 rename 失败 → 走安全删除;删除后的目录项 sync(第 3 次)失败 → + // 按候选 sealed 身份写 tombstone 并返回失败(P2:绝不只留日志) + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the partial target deletion is not durable", + TokenStatSpool.append(context, line(request("dispose-del-c")), "dispose-del-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + } + // 删除可见但未确认:候选名字不再存在,tombstone 记录其稳定身份(崩溃后 + // 以同内容重现时 scanner 跳过,绝不普通排空) + assertFalse("partial target deletion is visible", File(spool, "sealed_1.jsonl").exists()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertTrue( + "candidate identity must be tombstoned for the crash-reappearance window", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertEquals( + "active must be retained", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:tombstone 条目随文件消失确认后移除,健康内容各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitEvent("dispose-del-a") + awaitEvent("dispose-del-b") + awaitManifestWithout(spool, "sealed_1.jsonl") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback with durable dir syncs publishes and drains each event exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dir-durable-a")) + val lineB = line(request("dir-durable-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + TokenStatSpool.DirSyncResult.OK // 模拟 Android/Linux 目录 fsync 成功 + } + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal via copy with durable dir syncs", + TokenStatSpool.append(context, lineB, "dir-durable-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + // P1 终审:封段发布 2 次目录同步(目标创建、active 删除)+ append 侧 1 次 + // (seal 删除 active 后新 active 属首次创建,目录项必须确认持久)+ P1-1 + // bootstrap gate 2 次(filesDir + spool 目录,本测试进程首次使用) + assertEquals(5, dirSyncCalls) + assertEquals("sealed_1 must hold the old content", lineA + "\n", File(spool, "sealed_1.jsonl").readText()) + assertEquals("active must hold the new event", lineB + "\n", File(spool, "active.jsonl").readText()) + TokenStatSpool.replay(context) + awaitEvent("dir-durable-a") + awaitEvent("dir-durable-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("dir-durable-a", "dir-durable-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal dir sync UNSUPPORTED never deletes active and never publishes`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("unsupported-link-a")) + val lineB = line(request("unsupported-link-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 平台明确不支持目录 fsync:发布路径必须 fail-closed(UNSUPPORTED ≠ 成功), + // 硬链接已建立但目录项未持久 → 回滚链接、保留唯一 fsynced active、绝不 PUBLISHED。 + // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), + // 第三次是链接创建的目录项,第四次是回滚删除链接的目录项(P2 终审: + // 回滚删除同样是目录项变更,必须确认持久,非 OK 同时失效 gate)。 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the platform does not support dir fsync", + TokenStatSpool.append(context, lineB, "unsupported-link-b"), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + assertEquals(4, dirSyncCalls) + assertEquals( + "active must be retained byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "UNSUPPORTED must never publish a sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy seal dir sync UNSUPPORTED never deletes active and never publishes`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("unsupported-copy-a")) + val lineB = line(request("unsupported-copy-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + // copy 回退的目录同步同样 fail-closed:目标已处置、active 保留、绝不 PUBLISHED。 + // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), + // 第三次是 copy 目标创建的目录项。 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the platform does not support dir fsync", + TokenStatSpool.append(context, lineB, "unsupported-copy-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED)—— + // 后续 quarantineEvidence 读取需要恢复 tombstone 写入残留的 `.new` + // sidecar(P2 受管证据),strict 读取要求目录 sync OK 才能返回。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + // 5 次目录 sync = bootstrap gate(2) + copy 目标创建(1) + 失败目标隔离 + // rename(1) + tombstone 暂存(1,P2:隔离后目录项未确认 → 按候选 sealed + // 身份写 tombstone 受管证据,绝不只留日志) + assertEquals(5, dirSyncCalls) + assertEquals( + "active must be retained byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "UNSUPPORTED must never publish a sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + // 部分目标被隔离为 seal_failed_*(受管失败发布证据,立即可见) + val isolated = + spool.listFiles().orEmpty().single { it.isFile && it.name.startsWith("seal_failed_") } + assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) + assertTrue( + "isolated target must be visible as quarantine evidence", + TokenStatSpool.quarantineEvidence(context).any { it.name == isolated.name }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `unsupported dir sync fails closed never clears active and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("failclosed-a")) + val lineB = line(request("failclosed-b")) + val lineC = line(request("failclosed-c")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 平台明确不支持目录 fsync:封段发布必须 fail-closed——绝不原地清空/删除 + // 唯一 fsynced active,也绝不返回 durable + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail closed when the platform does not support dir fsync", + TokenStatSpool.append(context, lineC, "failclosed-c"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // active 原字节保留;无 sealed/seal_failed 发布残留;无事件入 Room + assertEquals( + "active must be retained byte-identical", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertTrue( + "fail-closed mode must never publish sealed segments", + spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("sealed_") }, + ) + assertTrue( + "fail-closed mode must never create seal_failed targets", + spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("seal_failed_") }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录 fsync 恢复 OK 后 append 成功,全部事件各恰一次入 Room + assertTrue(TokenStatSpool.append(context, lineC, "failclosed-c")) + TokenStatSpool.replay(context) + awaitEvent("failclosed-a") + awaitEvent("failclosed-b") + awaitEvent("failclosed-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("failclosed-a", "failclosed-b", "failclosed-c"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `first spool directory creation with unsupported dir sync returns false and retries after recovery`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + assertFalse("spool must not pre-exist", spool.exists()) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must not return durable when the first spool dir creation cannot be confirmed", + TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // 已创建的目录可保留供重试,但从未声明 durable;active 尚未写入 + assertTrue("created spool dir may be retained for retry", spool.isDirectory) + assertFalse("active must not be written before the dir entry is durable", File(spool, "active.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复能力 OK:重试成功,事件恰一次入 Room + assertTrue( + TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), + ) + TokenStatSpool.replay(context) + awaitEvent("first-dir-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("first-dir-a"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `first active file creation with unsupported dir sync returns false retains line and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录,已确认);第三次 + // (首建 active 的目录项)平台不支持——内容已写+fsync 但目录项未确认 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + val lineA = line(request("first-active-a")) + try { + assertFalse( + "append must not return durable when the first active creation dir sync is unsupported", + TokenStatSpool.append(context, lineA, "first-active-a"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // 写+fd.sync 已发生但目录项未确认:本次不 durable,源 line 内容保留在 active + assertEquals(3, dirSyncCalls) + assertEquals( + "source line must be retained on disk", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复能力 OK:下一次 append 先经 bootstrap gate 重新确认目录项再写新事件, + // 两者各恰一次 + assertTrue( + TokenStatSpool.append(context, line(request("first-active-b")), "first-active-b"), + ) + TokenStatSpool.replay(context) + awaitEvent("first-active-a") + awaitEvent("first-active-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("first-active-a", "first-active-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `unsupported dir sync fails closed for every append until recovery then drains exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val count = 24 + try { + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.MAX_SEGMENT_BYTES = 700 + TokenStatSpool.shutdownWriterForTest() + // 目录 fsync 不支持:首次 spool/active 创建无法确认目录项——每次 append + // 都 fail-closed(绝不返回 durable、绝不永久挂起、绝不清空已写入内容) + repeat(count) { index -> + assertFalse( + "append must fail closed under unsupported dir sync without stalling: $index", + TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), + ) + } + assertTrue("created spool dir may be retained for retry", spool.isDirectory) + assertFalse( + "active must not be written before any directory entry is durable", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:能力恢复 OK 后重试/replay,全部事件各恰一次入 Room + // (P1 终审:Windows JVM 真实探测恒为 UNSUPPORTED,必须回到注入的 OK) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + repeat(count) { index -> + assertTrue( + TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), + ) + } + TokenStatSpool.replay(context) + repeat(count) { index -> awaitEvent("win-failclosed-$index") } + awaitNoSealedSegments(spool) + assertEquals(count, database.tokenStatsDao().countEvents()) + assertEquals( + count, + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet().size, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `corrupt active tail with unsupported dir sync fails closed retaining original bytes then recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("corrupt-failclosed-a")) + val lineB = line(request("corrupt-failclosed-b")) + // 崩溃残留:active 尾部半行 JSON,无换行 + val original = lineA + "\n" + "{\"v\":2,\"eventId\":\"corrupt-failclosed-tail\"" + try { + File(spool, "active.jsonl").writeText(original) + TokenStatSpool.MAX_SEGMENT_BYTES = original.length.toLong() + 1 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + // 损坏尾行需要封段处置:目录项未确认持久前绝不隔离证据、绝不插入 + // 健康行、绝不截断/清空 active(copy+file sync 之后必须 dir sync OK + // 才允许继续) + assertFalse( + "append must fail closed when sealing a corrupt tail needs dir fsync", + TokenStatSpool.append(context, lineB, "corrupt-failclosed-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // active 原字节不动(含损坏尾行);尚无证据被切走;无事件入 Room + assertEquals( + "active must retain the original bytes including the corrupt tail", + original, + File(spool, "active.jsonl").readText(), + ) + assertTrue( + "no evidence may be cut before its directory entry is durable", + TokenStatSpool.quarantineEvidence(context).isEmpty(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录 fsync OK 后损坏尾行作为完整证据隔离(至少一个 durable 位置), + // 健康事件各恰一次入 Room + assertTrue(TokenStatSpool.append(context, lineB, "corrupt-failclosed-b")) + TokenStatSpool.replay(context) + awaitEvent("corrupt-failclosed-a") + awaitEvent("corrupt-failclosed-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "corrupt tail evidence must be preserved with the original bytes", + evidence.any { it.readText() == original }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal failed target deletion failure stays visible exportable ackable and ack frees the cap`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // seal 发布失败隔离的部分目标(受管失败发布证据)与普通证据并存 + val failed = File(spool, "seal_failed_${UUID.randomUUID().toString().replace("-", "")}") + failed.writeText("{partial-copy-evidence\n") + val regular = File(spool, "quarantine_existing_sealed_1.jsonl") + regular.writeText("{regular-evidence\n") + // 维护删除失败(seam):隔离副本保留、下一轮重试,绝不自动消失 + TokenStatSpool.segmentDeleteForTest = { f -> + if (f.name.startsWith("seal_failed_")) false else null + } + try { + TokenStatSpool.replay(context) + delay(700) + assertTrue("deletion failure must keep the failed target", failed.exists()) + // 可见:quarantineEvidence 含 seal_failed_*,字节计入证据总量 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue("seal_failed target must be visible as evidence", evidence.any { it.name == failed.name }) + assertTrue("regular evidence must stay visible", evidence.any { it.name == regular.name }) + assertTrue( + "seal_failed bytes must count toward the evidence total", + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } >= failed.length(), + ) + // 导出包含隔离目标 + val exported = + TokenStatSpool.exportQuarantineEvidence(context, File(root, "p2-evidence-export")) + assertTrue("seal_failed target must be exportable", exported.any { it.name == failed.name }) + // 用户确认删除(NOFOLLOW/path 根校验在 ack 内部)→ 证据消失、容量释放 + val bytesBefore = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(failed.name)) + assertFalse("ack must delete the seal_failed target", failed.exists()) + val bytesAfter = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } + assertTrue("ack must release the held evidence bytes", bytesAfter < bytesBefore) + assertTrue( + "remaining evidence must still be intact", + TokenStatSpool.quarantineEvidence(context).any { it.name == regular.name }, + ) + } finally { + TokenStatSpool.segmentDeleteForTest = null + } + } + } + + @Test + fun `corrupt uncommitted trash mapping never drops held manifest identity for vanished original`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P1 场景:sealed_1 是受管失败段(tombstone 条目),文件已被 ack stage 进 + // UNCOMMITTED trash 后崩溃(主 manifest 已发布、commit 翻转未写),随后状态 + // mapping 损坏(一条有效 + 一条损坏)。根文件缺失时,旧实现因 held 集合为空 + // 会把 sealed_1 条目按 stale 移除——fail-closed 被违背。 + val body1 = "{corrupt-held-a\n" + val body2 = "{corrupt-held-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha1\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + + TokenStatSpool.replay(context) + delay(700) + assertTrue("corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse( + "no rollback may happen from a partial mapping", + File(spool, "sealed_1.jsonl").exists(), + ) + assertEquals( + "manifest sealed_1 entry must be preserved verbatim", + manifestLine, + safeManifestText(manifest), + ) + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + + // 后续维护轮保持有界:hasUnknown 时整轮跳过 manifest 重写,条目逐字不变 + TokenStatSpool.replay(context) + delay(700) + assertEquals( + "repeated maintenance rounds must not rewrite the manifest", + manifestLine, + safeManifestText(manifest), + ) + + // 修复 mapping(两份证据都被覆盖)→ 维护回滚到根并安全重新隔离/处置: + // sealed_1 与 manifest 身份 MATCH → 移入完整证据区并移除条目;sealed_2 被 + // 扫描器重新隔离。manifest 与证据状态最终一致。 + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) + awaitManifestWithout(spool, "sealed_1.jsonl") + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var evidence: List = emptyList() + while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { + evidence = TokenStatSpool.quarantineEvidence(context) + if (evidence.size != 2) delay(20) + } + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.isFile && it.readText() == body1 }) + assertTrue(evidence.any { it.isFile && it.readText() == body2 }) + } + } + + @Test + fun `scanner keeps manifest identity when corrupt uncommitted trash may hold the reused-name original`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 旧身份仍可能被损坏 mapping 的 UNCOMMITTED trash 持有:根目录同名新文件与 + // manifest 条目 MISMATCH 时,scanner 绝不能按 stale 移除条目(否则旧身份 + // 失去保护,回滚后重新隔离也无法与受管集合对应)。 + val oldBody = "{scanner-held-old\n" + val newBody = "{scanner-held-new\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n" + + "{corrupt-json\n", + ) + File(spool, "sealed_1.jsonl").writeText(newBody) + + TokenStatSpool.replay(context) + // 同名新文件照常被处理进完整证据区(内容不变),但 manifest 条目必须保留 + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var quarantined = false + while (System.nanoTime() < evidenceDeadline && !quarantined) { + quarantined = TokenStatSpool.quarantineEvidence(context).any { + it.isFile && it.readText() == newBody + } + if (!quarantined) delay(20) + } + assertTrue("the reused-name new file must be processed into the evidence area", quarantined) + assertTrue("corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertEquals( + "manifest entry must be retained while the old identity may be held in trash", + manifestLine, + safeManifestText(manifest), + ) + } + } + + @Test + fun `stateless non-empty ack trash is visible exportable ackable and append recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB;28KiB 无状态 + // trash 残留必须让每次 append 的递归投影超限(占用绝不隐藏,P1-3) + TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + val evidence = File(trash, "sealed_stuck_1.jsonl") + RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } + // 无状态非空 trash:maintenance fail-closed 保留(绝不删除),UI 可见 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must retain a stateless non-empty trash", trash.exists()) + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + assertEquals(listOf(trash), TokenStatSpool.quarantineEvidence(context)) + assertEquals(28L * 1024, TokenStatSpool.stuckAckTrashBytes(context)) + + // 释放前:cap 被 trash 占用 → 新统计 append 明确拒绝 + val lines = (0 until 200).map { index -> + line(request("stuck-cap-$index")) to "stuck-cap-$index" + } + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertEquals(lines.size, rejected) + + // export 将 trash 目录内容复制到唯一子目录(含状态/sidecar) + val base = File(root, "export-stuck").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination) + assertTrue(exported.any { it.name == trash.name }) + val exportedTrash = File(destination, trash.name) + assertTrue(exportedTrash.isDirectory) + assertEquals(28L * 1024, File(exportedTrash, evidence.name).length()) + + // 确认删除 stuck trash(显式授权,无需 mapping)→ 容量释放 → append 恢复 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(trash.name)) + assertFalse("ack must delete the acknowledged stuck trash", trash.exists()) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-stuck-ack")), + "after-stuck-ack", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-stuck-ack") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `export fails closed when the spool root enumeration fails and recovers after the seam is restored`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 两个非空 stuck trash + 一个普通隔离文件:根枚举失败时 export 绝不能 + // 成功遗漏任何证据(P1-6) + val trashA = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trashA.mkdirs() + val evidenceA = File(trashA, "sealed_1.jsonl") + evidenceA.writeText("{root-null-a\n") + val trashB = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trashB.mkdirs() + val evidenceB = File(trashB, "sealed_2.jsonl") + evidenceB.writeText("{root-null-b\n") + val quarantineFile = File(spool, "quarantine_sealed_3.jsonl") + quarantineFile.writeText("{root-null-ev\n") + val base = File(root, "export-root-null").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + try { + TokenStatSpool.exportQuarantineEvidence(context, destination) + fail("export must fail when the spool root enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 源证据全部保留 + assertTrue(trashA.exists()) + assertTrue(evidenceA.exists()) + assertTrue(trashB.exists()) + assertTrue(evidenceB.exists()) + assertTrue(quarantineFile.exists()) + // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 + assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) + assertFalse(destination.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam 后完整 export 含全部证据(stuck trash 子目录 + 隔离文件) + val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) + assertTrue(exported.any { it.name == trashA.name }) + assertTrue(exported.any { it.name == trashB.name }) + assertTrue(exported.any { it.name == quarantineFile.name }) + val exportedTrash = File(destination2, trashA.name) + assertTrue(exportedTrash.isDirectory) + assertTrue(File(exportedTrash, evidenceA.name).readText() == "{root-null-a\n") + } + } + + @Test + fun `export fails closed when an ack trash directory enumeration fails and recovers after the seam is restored`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + val evidence = File(trash, "sealed_1.jsonl") + evidence.writeText("{child-null-evidence\n") + // 普通隔离文件先于 trash 被复制:child 枚举失败时目标目录已含部分内容 + val quarantineFile = File(spool, "quarantine_sealed_2.jsonl") + quarantineFile.writeText("{child-null-quarantine\n") + val base = File(root, "export-child-null").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + try { + TokenStatSpool.exportQuarantineEvidence(context, destination) + fail("export must fail when an ack trash directory enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 源证据全部保留 + assertTrue(trash.exists()) + assertTrue(evidence.exists()) + assertTrue(quarantineFile.exists()) + // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 + assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) + assertFalse(destination.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam 后完整 export 含全部证据 + val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) + assertTrue(exported.any { it.name == trash.name }) + assertTrue(exported.any { it.name == quarantineFile.name }) + val exportedTrash = File(destination2, trash.name) + assertTrue(exportedTrash.isDirectory) + assertTrue(File(exportedTrash, evidence.name).readText() == "{child-null-evidence\n") + } + } + + @Test + fun `rollback never overwrites an occupied slot and recovers after the slot frees`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃窗口 + 回滚目标被同名不同内容的新文件占用 + val oldBody = "{old-occupied-bad\n" + val newBody = "{new-occupant-bad\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + // 主 manifest 仍含旧身份(ack 未提交)→ 必须回滚而非删除 + File(spool, "quarantine_skip_manifest.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + File(spool, "sealed_1.jsonl").writeText(newBody) + + TokenStatSpool.replay(context) + // 回滚目标被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + File(spool, "sealed_1.jsonl").exists() + ) { + delay(20) + } + assertTrue( + "occupied-slot rollback must retain the trash evidence", + trash.exists() && File(trash, "sealed_1.jsonl").exists(), + ) + // 新内容未被覆盖:作为健康处理进入完整证据区(身份仍是新内容) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "the new occupant must be processed into the evidence area untouched", + evidence.any { it.name.contains("sealed_1.jsonl") }, + ) + assertTrue( + "the new occupant content must be intact", + evidence.first { it.name.contains("sealed_1.jsonl") }.readText() == newBody, + ) + assertTrue( + "manifest entry must be retained while the old identity is held in trash", + safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, + ) + + // 槽位释放后(新文件已移入证据区)→ replay:回滚成功并自愈 + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the slot frees", trash.exists()) + awaitManifestWithout(spool, "sealed_1.jsonl") + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals("both the old and the new evidence must be present", 2, restored.size) + assertTrue(restored.any { it.readText() == oldBody }) + assertTrue(restored.any { it.readText() == newBody }) + } + } + + @Test + fun `ack refuses path traversal names without touching spool files`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_safe_sealed_1.jsonl").writeText("safe-bad\n") + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("../outside.jsonl")) + fail("ack must refuse traversal names") + } catch (e: IOException) { + assertTrue("refusal must name the unsafe target", e.message!!.contains("unsafe")) + } + assertTrue(File(spool, "quarantine_safe_sealed_1.jsonl").exists()) + } + + @Test + fun `first summary publish at the total cap edge with fallback sidecars keeps total bounded`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 + // 强制回退协议(P1-1:canonical/.new/.bak/tmp sidecar 瞬态同时存在) + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 证据区打满(16MiB 硬 cap)→ 新损坏段必须走 summarize 路径(首次 summary 写) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + // 数据总量:16MiB 证据 + 7.5MiB 损坏段 ≈ 23.5MiB,接近 24MiB 总上限边缘 + val segment = File(spool, "sealed_2.jsonl") + RandomAccessFile(segment, "rw").use { + it.setLength(7L * 1024 * 1024 + 512L * 1024) + } + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + val totalBytes: () -> Long = { + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + TokenStatSpool.replay(context) + // 轮询:整个处置过程实际 top-level 总字节始终 ≤ 总上限 + val pollDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (segment.exists() && System.nanoTime() < pollDeadline) { + assertTrue( + "total must stay within the cap while draining: ${totalBytes()}", + totalBytes() <= cap, + ) + delay(20) + } + assertTrue("total must stay within the cap at rest: ${totalBytes()}", totalBytes() <= cap) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("first summary must be published at the cap edge", summary) + assertTrue( + "summary must carry the over-cap segment record", + File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl"), + ) + // sidecar 已清理(回退发布完成) + assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) + assertFalse(File(spool, "quarantine_summary.jsonl.bak").exists()) + // 维护(ack 证据区)后 append 恢复 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-cap-edge-summary")), + "after-cap-edge-summary", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-cap-edge-summary") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic + } + } + } + + @Test + fun `metadata publish refused at the hard cap edge stays bounded and maintenance restores appends`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 证据区打满 16MiB:12MiB + 4MiB 两个文件(ack 其中一个后仍满 → 继续 summarize) + val existingBig = File(spool, "quarantine_a_sealed_1.jsonl") + RandomAccessFile(existingBig, "rw").use { it.setLength(12L * 1024 * 1024) } + val existingSmall = File(spool, "quarantine_b_sealed_2.jsonl") + RandomAccessFile(existingSmall, "rw").use { it.setLength(4L * 1024 * 1024) } + // 数据总量恰好等于总上限:首次 summary 发布的投影(+4×content)必超限 + val segment = File(spool, "sealed_3.jsonl") + RandomAccessFile(segment, "rw").use { it.setLength(8L * 1024 * 1024) } + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + TokenStatSpool.replay(context) + delay(900) + // 有界失败:不写正式 summary、段保留、总量不超过上限 + assertFalse( + "summary must not be published when the metadata budget is exhausted", + File(spool, "quarantine_summary.jsonl").exists(), + ) + assertTrue("pending segment must be retained", segment.exists()) + assertTrue( + "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= cap, + ) + // 维护释放空间(此时总量恰好 = cap,ack 的 trash 状态元数据投影按 P1-1 + // 必被拒——见 ack 状态预算测试;这里模拟外部/维护释放:移除一个证据文件) + // → 重试成功发布摘要 → append 恢复 + assertTrue(existingSmall.delete()) + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_3.jsonl") + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("summary must be published after maintenance frees the budget", summary) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-budget-recovery")), + "after-budget-recovery", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-budget-recovery") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `concurrent exports keep unique directories and one failing export never deletes the other success`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_aa_sealed_1.jsonl").writeText("aa-bad\n") + File(spool, "quarantine_bb_sealed_2.jsonl").writeText("bb-bad\n") + val base = File(root, "export-runs").apply { mkdirs() } + // P2:UI 命名 = 时间戳前缀 + UUID;同一毫秒前缀下 UUID 保证目录唯一, + // 目录用 Files.createDirectory 独占创建(已存在即失败) + fun uniqueExportDir(ts: String): File = + File(base, "token_stats_quarantine_${ts}_${UUID.randomUUID().toString().replace("-", "")}") + .also { Files.createDirectory(it.toPath()) } + val destA = uniqueExportDir("20260806_120000") + val destB = uniqueExportDir("20260806_120000") + // 两个导出经 lifecycleMutex 串行:A 的 manifest 两次读取(manifestContent + + // evidence 列表)放行,B 的 manifestContent 读取(第 3 次)注入失败 + var manifestReads = 0 + TokenStatSpool.metadataReadErrorForTest = { file -> + if (file.name == "quarantine_skip_manifest.jsonl") { + manifestReads += 1 + manifestReads == 3 + } else { + false + } + } + try { + val exportA = async { TokenStatSpool.exportQuarantineEvidence(context, destA) } + val exportB = async { + try { + TokenStatSpool.exportQuarantineEvidence(context, destB) + fail("export B must fail with the injected manifest read failure") + } catch (e: IOException) { + } + } + val exportedA = exportA.await() + exportB.await() + // 各自目录独立且完整:A 成功导出两份证据 + assertEquals( + setOf("quarantine_aa_sealed_1.jsonl", "quarantine_bb_sealed_2.jsonl"), + exportedA.map { it.name }.toSet(), + ) + assertEquals(exportedA.size, destA.listFiles().orEmpty().size) + // 失败的导出绝不删除另一成功导出的目录/内容;自身目录也未被删除 + assertTrue("successful export directory must stay intact", destA.isDirectory) + assertTrue(destA.listFiles().orEmpty().all { it.isFile }) + assertTrue("failed export directory must not be deleted by the spool", destB.isDirectory) + + // 恢复后正常:新的导出成功且只含本次证据 + TokenStatSpool.metadataReadErrorForTest = null + val retried = TokenStatSpool.exportQuarantineEvidence( + context, + uniqueExportDir("20260806_120001"), + ) + assertTrue(retried.any { it.name.startsWith("quarantine_aa_") }) + assertTrue(retried.any { it.name.startsWith("quarantine_bb_") }) + } finally { + TokenStatSpool.metadataReadErrorForTest = null + } + } + } + + // ── P1 终审:durable bootstrap gate / 维护目录项严格同步 ────────────────────── + + @Test + fun `bootstrap gate re-confirms unconfirmed spool dir entry after simulated restart and never merges events`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + val lineA = line(request("bootstrap-dir-a")) + val lineB = line(request("bootstrap-dir-b")) + try { + // 上一进程:首次创建 spool 目录,父目录/新目录的目录项 sync 失败(磁盘 + // 可见但未确认持久)——append 明确失败,active 未写入 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) + assertTrue("created spool dir is visible on disk", spool.isDirectory) + assertFalse("no line may be written before dir entries are durable", File(spool, "active.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),磁盘状态保留 + TokenStatSpool.clearPendingStateForTest() + // 目录项仍无法确认:本次 append 必须失败,绝不写新行(第二事件此前从未写入) + assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) + assertFalse( + "no line may be written while the spool dir entry is unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复:bootstrap gate 重新确认目录项后,两个事件各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) + assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) + TokenStatSpool.replay(context) + awaitEvent("bootstrap-dir-a") + awaitEvent("bootstrap-dir-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("bootstrap-dir-a", "bootstrap-dir-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `bootstrap gate re-confirms unconfirmed active entry after simulated restart and keeps bytes until confirmed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("bootstrap-active-a")) + val lineB = line(request("bootstrap-active-b")) + var dirSyncCalls = 0 + try { + // 上一进程:bootstrap gate 两次确认通过,但首建 active 的目录项 sync 失败 + // (内容已写+fsync、磁盘可见、未确认) + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-active-a")) + assertEquals(3, dirSyncCalls) + assertEquals( + "unconfirmed line must stay visible on disk", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),active 字节保留 + TokenStatSpool.clearPendingStateForTest() + // 目录项仍无法确认:本次 append 失败且 active 字节不变(绝不再追加新行) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) + assertEquals( + "active bytes must be unchanged while the dir entry is unconfirmed", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复:gate 重新确认后追加第二事件(此前从未写入),两事件各恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) + TokenStatSpool.replay(context) + awaitEvent("bootstrap-active-a") + awaitEvent("bootstrap-active-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("bootstrap-active-a", "bootstrap-active-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `restore cleanup dir sync failure invalidates the gate so consecutive appends fail without writing and recover exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineB = line(request("gate-restore-b")) + val lineC = line(request("gate-restore-c")) + var calls = 0 + try { + // 阶段 0:gate=true——经快照 barrier 完成 bootstrap 两次确认(filesDir + + // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态) + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { + File(spool, "active.jsonl").writeText(line(request("gate-restore-a")) + "\n") + } + assertEquals(2, calls) + // 阶段 1:restore 清理删除 spool 目录,删除后 filesDir 目录项 sync(第 3 次) + // 失败 → restore 明确失败;删除开始前 gate 必须已失效(P1-1 修复) + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore must fail when the cleanup dir sync fails") + } catch (e: IOException) { + assertTrue(e.message!!.contains("durable")) + } + assertEquals(3, calls) + assertFalse("spool deletion is visible", spool.exists()) + // 阶段 2:restore 替换已开始(清理失败属替换后失败)——P1 终审 fence + // 拒绝本进程一切后续 append(accepting=false,直到重启),任何事件绝不 + // 写入;若 fence 失效,bootstrap gate 也已失效,同样全部失败 + assertFalse(TokenStatSpool.append(context, lineB, "gate-restore-b")) + assertFalse(TokenStatSpool.append(context, lineC, "gate-restore-c")) + assertFalse( + "no event may be written while dir entries are unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复 OK。P1 终审:恢复替换已开始(清理失败属于替换后失败), + // 同进程事件被明确拒绝——先模拟进程重启(reset 状态)才允许写入; + // 重启后目录项重新确认(bootstrap),两事件各恰一次入 Room。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.clearPendingStateForTest() + assertTrue(TokenStatSpool.append(context, lineB, "gate-restore-b")) + assertTrue(TokenStatSpool.append(context, lineC, "gate-restore-c")) + TokenStatSpool.replay(context) + awaitEvent("gate-restore-b") + awaitEvent("gate-restore-c") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `maintenance seal dir sync failure after gate true forces the next append to re-bootstrap before writing`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("gate-maint-a")) + val lineB = line(request("gate-maint-b")) + var calls = 0 + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + // 阶段 1:bootstrap gate 两次确认 OK(gate=true),随后维护 drain 的封段 + // 发布目录项 sync(第 3 次)失败 → 维护失败;gate 必须同步失效(P1-1) + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + val sealDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (calls < 3 && System.nanoTime() < sealDeadline) delay(10) + TokenStatSpool.shutdownWriterForTest() + assertTrue("seal must have been attempted", calls >= 3) + // 阶段 2:gate 已失效且目录 sync 持续失败——下一次 append 必须重新 + // bootstrap;bootstrap 失败 → append 明确失败且 active 字节不变 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + assertFalse(TokenStatSpool.append(context, lineB, "gate-maint-b")) + assertEquals( + "active must stay byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复——bootstrap 重新确认后 append 成功,事件各恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "gate-maint-b")) + TokenStatSpool.replay(context) + awaitEvent("gate-maint-a") + awaitEvent("gate-maint-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + /** + * P1-2/P1-3 终审通用 runner:以 [result](FAILED/UNSUPPORTED)运行一次完整场景,保证 + * 前后内存/磁盘/数据库状态隔离(spool 目录重建 + 内存标记复位 + 事件表清空),并在 + * finally 还原全部注入缝。场景开始前恢复“目录 fsync 支持且成功”的平台常态 + * (Windows JVM 真实探测恒为 UNSUPPORTED),使场景内部的 phase-1 正常协议可用。 + */ + private suspend fun runDirSyncFailClosedScenario( + result: TokenStatSpool.DirSyncResult, + tag: String, + scenario: suspend (TokenStatSpool.DirSyncResult, String) -> Unit, + ) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + Mockito.mockStatic(AppLogger::class.java).use { + try { + spool.deleteRecursively() + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + database.tokenStatsDao().deleteAllEvents() + scenario(result, tag) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.spoolDeleteForTest = null + spool.deleteRecursively() + } + } + } + + @Test + fun `over-cap dispose delete with FAILED and UNSUPPORTED dir sync keeps summary retryable and recovers without duplicate`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_$tag.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{$tag-overcap-bad\n") + // bootstrap gate(2) + 摘要严格发布(2) 成功,删除后目录项 sync(第 5 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 4) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + delay(900) + // 摘要已发布(可见)、段已删除(可见)但目录项未确认:本轮不得声称完成—— + // 无事件入 Room;恢复后摘要不重复。目录 sync 未恢复前严格读取不信任 canonical + // (P1-2),此处直接断言摘要文件可见。 + assertTrue( + "summary canonical must be published and visible", + File(spool, "quarantine_summary.jsonl").isFile, + ) + assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项 sync OK 后幂等完成(摘要记录不重复、无遗留队列) + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `pending-delete evidence restore with FAILED and UNSUPPORTED dir sync rebuilds retryable record and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{$tag-pending-bad\n" + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-pending-healthy-$tag")) + "\n") + // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + TokenStatSpool.replay(context) + awaitEvent("syncfail-pending-healthy-$tag") + assertEquals(1, database.tokenStatsDao().countEvents()) + val pending = spool.listFiles().orEmpty().single { + it.isFile && it.name.startsWith("quarantine_pending_delete_") + } + // 阶段 2:维护恢复 rename 后目录项 sync 失败(bootstrap gate 已在阶段 1 确认, + // 本阶段第一次 sync 就是恢复 rename 的目录项)→ 尽力移回 pending-delete 名 + // (重建可重试记录),本轮不推进 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = { result } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "pending-delete record must be rebuilt when the restore rename is not durable", + pending.exists(), + ) + // pending-delete 文件本身是受管证据(计入 quarantineEvidence),但必须仍是 + // pending-delete 名(未被推进到完整证据区) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence must still be the pending-delete record", + evidence.single().name.startsWith("quarantine_pending_delete_"), + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 恢复:rename 目录项确认持久后证据恰一次回到完整证据区 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, restored.size) + assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `seal-failed target cleanup with FAILED and UNSUPPORTED dir sync does not advance and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val isolated = File(spool, "seal_failed_$tag-partial") + isolated.writeText("{partial-$tag\n") + File(spool, "sealed_9.jsonl").writeText(line(request("syncfail-sealfailed-healthy-$tag")) + "\n") + // bootstrap gate(2) OK,seal_failed 删除后的目录项 sync(第 3 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + delay(900) + // 删除可见但未确认:本轮不推进(健康段也不排空);隔离副本不丢证据 + assertFalse("seal-failed target deletion is visible", isolated.exists()) + assertTrue("healthy segment must stay pending while the round is not durable", File(spool, "sealed_9.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项确认持久后健康段恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitEvent("syncfail-sealfailed-healthy-$tag") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone over-cap delete with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_$tag.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_2.jsonl").writeText("{$tag-managed-bad\n") + // 阶段 1:删除失败 + 证据区已满 → over-cap tombstone 条目(正常协议) + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name == "sealed_2.jsonl") false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + // 阶段 1 的 drain 可能仍在收尾(摘要/条目发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + // 阶段 2:维护删除成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; + // 本阶段第 1 次 sync 是 manifest 严格读取,第 2 次才是删除的目录项)→ manifest + // 条目保留(可重试记录)、本轮不推进 + TokenStatSpool.segmentDeleteForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 2) result else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "manifest entry must be retained while the deletion is unconfirmed", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:确认“消失”持久后条目幂等移除,摘要记录不重复 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitManifestWithout(spool, "sealed_2.jsonl") + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone evidence restore rename with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{$tag-evidence-bad\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-evidence-healthy-$tag")) + "\n") + // 阶段 1:两次 rename 都失败 → tombstone(容量内,overCap=false);健康段排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + awaitEvent("syncfail-evidence-healthy-$tag") + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + // 阶段 2:恢复 rename 成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; + // 本阶段第 1 次 sync 是 manifest 严格读取、第 2 次是容量判定读取、第 3 次才是 + // restore rename 的目录项)→ 条目保留、本轮不推进 + TokenStatSpool.segmentRenameForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 3) result else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "manifest entry must be retained while the restore rename is unconfirmed", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertFalse("sealed original is gone (rename visible)", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "evidence must already be at the quarantine name", + TokenStatSpool.quarantineEvidence(context).any { it.readText() == body }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 恢复:确认 rename 持久后条目幂等移除,证据恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitManifestWithout(spool, "sealed_1.jsonl") + assertEquals( + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack staging with FAILED and UNSUPPORTED dir sync fails closed keeps evidence and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + // 两个失败点:trash 目录创建后的目录项 sync(failCall=1)、首个证据移动后的 + // 目录项 sync(failCall=2,跨 spool 根与 trash 两个目录) + for (failCall in 1..2) { + spool.deleteRecursively() + spool.mkdirs() + TokenStatSpool.clearPendingStateForTest() + File(spool, "sealed_1.jsonl").writeText("{$tag-ackstage-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "phase-1 tombstone entry must exist before ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先 + // 静默至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + TokenStatSpool.segmentRenameForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == failCall) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail when a staging boundary dir sync is not OK; calls=$calls failCall=$failCall result=$result") + } catch (e: IOException) { + } + // 操作失败、状态保留:证据未丢、manifest 未改 + assertTrue("managed evidence must stay in place", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } + // 清理失败迭代留下的空 trash(mkdir 已可见但目录项 sync 未确认;维护入口对 + // 空 trash 同样安全删除,此处等价地清理后重试) + spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + .forEach { it.deleteRecursively() } + // 恢复:目录项 sync OK 后 ack 恰一次完成(证据删除、条目移除) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue( + "no trash residue after a successful ack", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack commit flip with FAILED and UNSUPPORTED dir sync fails closed retains uncommitted trash and maintenance recovers it`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val sealedBody = "{$tag-flip-bad\n" + val areaBody = "area-body-$tag\n" + File(spool, "sealed_1.jsonl").writeText(sealedBody) + File(spool, "quarantine_area_$tag.jsonl").writeText(areaBody) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "phase-1 tombstone entry must exist before ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + TokenStatSpool.segmentRenameForTest = null + // 第 11 次 sync = COMMITTED 翻转的暂存目录项(manifest 严格读取 1 + mkdir 1 + // + staging 4 + 状态文件 2 + manifest 重写 2 + 翻转 staging 1)——翻转未确认 + // 持久 → ack 失败、状态保留 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 11) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "quarantine_area_$tag.jsonl"), + ) + fail("ack must fail when the commit flip is not durable; calls=$calls result=$result") + } catch (e: IOException) { + assertTrue(e.message!!.contains("commit")) + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained", 1, trashDirs.size) + val trash = trashDirs.single() + val state = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue( + "state must remain UNCOMMITTED for maintenance rollback", + state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + assertTrue("staged managed evidence stays in trash", File(trash, "sealed_1.jsonl").exists()) + assertTrue("staged area evidence stays in trash", File(trash, "quarantine_area_$tag.jsonl").exists()) + assertTrue( + "manifest entries were already published", + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true, + ) + // 维护恢复:UNCOMMITTED 按 mapping+identity 回滚 → 证据各恰一次回到原路径 + // (损坏 sealed 随后被扫描器重新隔离为证据) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be rolled back by maintenance", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.count { it.readText() == sealedBody }) + assertEquals(1, evidence.count { it.readText() == areaBody }) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack trash delete with FAILED and UNSUPPORTED dir sync fails closed and retry is idempotent`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val evidence = File(spool, "quarantine_ackdelete_$tag.jsonl") + evidence.writeText("{$tag-ackdelete\n") + // 第 8 次 sync = COMMITTED 翻转后 trash 删除的目录项(mkdir 1 + staging 2 + + // 状态文件 2 + 翻转 2 + 删除 sync 1)——删除可见但未确认 → ack 失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 8) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + fail("ack must fail when the trash deletion is not durable") + } catch (e: IOException) { + assertTrue(e.message!!.contains("deletion not durable")) + } + assertFalse("trash deletion is visible", evidence.exists()) + assertTrue( + "no trash residue", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + // 重试幂等:证据已可见删除,再次 ack 无操作成功(崩溃后 COMMITTED trash 重现 + // 由维护有界补删) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + assertFalse(evidence.exists()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone manifest publish with FAILED and UNSUPPORTED dir sync fails closed keeps old manifest and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText("{$tag-publish-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-publish-healthy-$tag")) + "\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + // bootstrap gate(2) OK,tombstone manifest 严格发布的暂存目录项 sync(第 3 次) + // 失败 → 发布 FAILED(不是 RECORDED):manifest 未发布、段保留、健康段不排空 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + delay(900) + assertFalse("manifest must not be published", manifest.exists()) + assertTrue("original segment must be retained", File(spool, "sealed_1.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项 sync OK 后按正常协议完成——损坏段作为证据恰一次隔离、健康段 + // 恰一次入 Room(manifest 从未发布,无重复条目) + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitEvent("syncfail-publish-healthy-$tag") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == "{$tag-publish-bad\n" }, + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `restore cleanup with FAILED and UNSUPPORTED dir sync fails closed and retry after recovery is idempotent`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "active.jsonl").writeText("{$tag-restore\n") + // bootstrap gate(2) OK,spool 目录删除后的 filesDir 目录项 sync(第 3 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore must fail when the spool cleanup is not durable") + } catch (e: IOException) { + assertTrue("restore state must be retained", e.message!!.contains("durable")) + } + assertFalse("spool deletion is visible", spool.exists()) + // 重试幂等:目录已不存在时跳过删除,确认持久后 restore 成功 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + assertFalse(spool.exists()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `seal duplicate cleanup with FAILED and UNSUPPORTED dir sync fails closed until confirmed and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("syncfail-dup-a-$tag")) + val lineB = line(request("syncfail-dup-b-$tag")) + File(spool, "active.jsonl").writeText(lineA + "\n") + File(spool, "sealed_1.jsonl").writeText(lineA + "\n") // copy 回退崩溃窗口副本 + // bootstrap gate(2) OK,重复副本删除后的目录项 sync(第 3 次)失败 → 恢复 + // 未确认:append fail-closed,绝不带着“可能还有重复”的状态写入 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + assertFalse(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) + assertTrue("active is intact", File(spool, "active.jsonl").readText() == lineA + "\n") + assertFalse("duplicate removal is visible", File(spool, "sealed_1.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:无重复 → 正常追加,两事件各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) + TokenStatSpool.replay(context) + awaitEvent("syncfail-dup-a-$tag") + awaitEvent("syncfail-dup-b-$tag") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("syncfail-dup-a-$tag", "syncfail-dup-b-$tag"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + // ==== P2 终审:目录遗漏修复(回滚删除/反向 rename 的严格目录同步、mapping 身份捕获) ==== + + @Test + fun `seal rollback deletion sync failure fails closed and next append re-bootstraps before writing`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("rollback-sync-a")) + val lineB = line(request("rollback-sync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 硬链接创建目录项 OK → active 删除失败 → 回滚删除链接:删除后的目录项 + // sync(第 4 次)失败(P2 终审)→ 回滚未确认持久、gate 同步失效、seal 明确失败 + var dirSyncCalls = 0 + TokenStatSpool.sealActiveDeleteForTest = { false } + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 1-2 bootstrap gate;3 链接创建目录项 OK;4 回滚删除的目录项 FAILED + if (dirSyncCalls == 4) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the seal rollback deletion is not durable", + TokenStatSpool.append(context, lineB, "rollback-sync-b"), + ) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals("rollback deletion must be sync-confirmed (P2)", 4, dirSyncCalls) + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "rolled-back seal must leave no sealed residue", + spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, + ) + // 恢复:gate 已失效——下一次 append 先 bootstrap 重新确认目录项再正常写入 + assertTrue(TokenStatSpool.append(context, lineB, "rollback-sync-b")) + TokenStatSpool.replay(context) + awaitEvent("rollback-sync-a") + awaitEvent("rollback-sync-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `pending-delete reverse rename not durable keeps retryable record and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{reverse-rename-bad\n" + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("reverse-rename-healthy")) + "\n") + // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + TokenStatSpool.replay(context) + awaitEvent("reverse-rename-healthy") + val pending = spool.listFiles().orEmpty().single { + it.isFile && it.name.startsWith("quarantine_pending_delete_") + } + TokenStatSpool.segmentRenameForTest = null + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 2:维护恢复 rename 可见但目录项 sync 失败 → 尽力反向 rename 回 + // pending-delete 名(重建明确可重试记录);反向 rename 的目录项同样必须严格 + // sync(P2 终审),未确认持久绝不视为已重建 → 本轮退避,记录保留 + var failSyncs = true + TokenStatSpool.dirSyncForTest = { + if (failSyncs) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "pending-delete record must be rebuilt when the restore rename is not durable", + pending.exists(), + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence must still be the pending-delete record", + evidence.single().name.startsWith("quarantine_pending_delete_"), + ) + // 退避期间任何 append 都不发布新字节(gate 已失效,bootstrap 重新确认前拒绝) + assertFalse( + TokenStatSpool.append(context, line(request("reverse-rename-blocked")), "reverse-rename-blocked"), + ) + assertFalse( + "no event may be written while dir entries are unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复——记录移回完整证据区,事件仍恰一次,后续 append 正常 + failSyncs = false + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, restored.size) + assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("reverse-rename-post")), "reverse-rename-post")) + TokenStatSpool.replay(context) + awaitEvent("reverse-rename-post") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + } + + @Test + fun `ack rollback mixed move success with sync failure writes complete mapping from actual locations and maintenance recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{p23-a\n" + val body2 = "{p23-b\n" + val body3 = "{p23-c\n" + val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText(body1) } + val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText(body2) } + val ev3 = File(spool, "quarantine_ord_c_sealed_3.jsonl").apply { writeText(body3) } + // ev1/ev2 成功 stage;ev3 stage 失败触发回滚。回滚时 ev2 移回失败(留在 trash), + // ev1 移回成功但目录项 sync 失败(第 7 次)——此时再写 UNCOMMITTED 状态时 + // ev1 已不在 trash,mapping 身份必须从实际所在位置(original)捕获(P2 终审), + // 绝不能从已移走的 target 盲读(会得到 0 字节/空哈希甚至写失败) + var calls = 0 + TokenStatSpool.ackAtomicMoveForTest = { from, to -> + when { + to.name == ev3.name -> false + to.name == ev2.name && from.parentFile?.name?.startsWith("quarantine_ack_trash_") == true -> + false + else -> null + } + } + TokenStatSpool.dirSyncForTest = { + calls += 1 + // 1 trash 创建;2-5 stage;6-7 回滚 ev1 的双目录 sync(第 7 次失败) + if (calls == 7) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + try { + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(ev1.name, ev2.name, ev3.name), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained", 1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("ev2 rollback failed so it stays in trash", File(trash, ev2.name).exists()) + assertTrue("ev1 rollback move is visible at the original path", ev1.exists()) + assertTrue("ev3 was never staged", ev3.exists()) + // 状态 mapping 必须完整且身份正确(P2 终审:从实际所在位置捕获) + val stateFile = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue("state must be written after the not-durable rollback", stateFile.isFile) + val lines = stateFile.readText().lineSequence().filter { it.isNotBlank() }.toList() + assertEquals(TokenStatSpool.ACK_STATE_UNCOMMITTED, lines.first()) + assertEquals("mapping must cover both staged files", 2, lines.size - 1) + val entryA = JSONObject(lines[1]) + assertEquals(ev1.name, entryA.getString("o")) + assertEquals(body1.toByteArray(Charsets.UTF_8).size.toLong(), entryA.getLong("b")) + assertEquals(sha256Hex(body1.toByteArray(Charsets.UTF_8)), entryA.getString("s")) + val entryB = JSONObject(lines[2]) + assertEquals(ev2.name, entryB.getString("o")) + assertEquals(body2.toByteArray(Charsets.UTF_8).size.toLong(), entryB.getLong("b")) + assertEquals(sha256Hex(body2.toByteArray(Charsets.UTF_8)), entryB.getString("s")) + // UI 可管理:stuck trash 作为受管证据可见 + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + // 维护按 mapping+identity 完整回滚:trash 删除、全部证据回到证据区 + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be resolved by maintenance once moves recover", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(3, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + assertTrue(evidence.any { it.readText() == body3 }) + assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) + assertEquals(0, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.dirSyncForTest = null + } + } + } + + private fun padLineTo(line: String, targetBytes: Int): String { + val overhead = ",\"pad\":\"\"".toByteArray(Charsets.UTF_8).size + val current = (line + "\n").toByteArray(Charsets.UTF_8).size + val padding = targetBytes - current - overhead + check(padding >= 0) { "line too large to pad: $current + $overhead > $targetBytes" } + return line + ",\"pad\":\"" + "x".repeat(padding) + "\"" + } + + /** + * manifest 轮询安全读:drain 线程可能正在原子替换该文件,Windows 下同一瞬间的读取 + * 会以共享冲突失败;失败时短暂重试,持续失败返回 null 由调用方重试/断言兜底。 + */ + private fun safeManifestText(manifest: File): String? { + repeat(3) { + try { + return if (manifest.isFile) manifest.readText() else null + } catch (e: Exception) { + Thread.sleep(10) + } + } + return null + } + + private fun sha256Hex(bytes: ByteArray): String { + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + } + + private suspend fun awaitNoPendingEvidence(spool: File) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("quarantine_pending_delete_") } + ) { + delay(20) + } + } + + private suspend fun awaitSegmentGone(spool: File, name: String) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && File(spool, name).exists()) delay(20) + } + + private suspend fun awaitManifestWithout(spool: File, name: String) { + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains(name) == true + ) { + delay(20) + } + assertFalse( + "tombstone entry must be removed after cleanup", + safeManifestText(manifest)?.contains(name) == true, + ) + } + + private suspend fun awaitNoSealedSegments(spool: File) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") } + ) { + delay(20) + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt new file mode 100644 index 000000000..41622f3c1 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt @@ -0,0 +1,826 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * 统一 usage 记录器测试(真实 Room 数据库,JVM 驱动): + * 身份/展示分组自动创建、价格层级与成本、幂等防重、未知 vs 0、 + * 数据库写入失败不影响业务、取消传播、诊断字段脱敏语义。 + */ +class TokenStatsLedgerTest { + + private lateinit var tempDir: File + private lateinit var database: AppDatabase + private lateinit var context: Context + + @Before + fun setUp() { + tempDir = kotlin.io.path.createTempDirectory("ledger-test").toFile() + context = mockContext(tempDir) + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + TokenStatsLedger.databaseProvider = { database } + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + } + + @After + fun tearDown() { + TokenStatsLedger.databaseProvider = null + TokenStatsLedger.legacyPriceProvider = null + database.close() + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + private fun request( + eventId: String = "evt-1", + category: TokenStatCategory = TokenStatCategory.CHAT, + status: TokenStatStatus? = TokenStatStatus.COMPLETED, + usage: ProviderUsageSnapshot? = + ProviderUsageSnapshot( +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +cacheWriteTokens = 0L, +outputTokens = 500L, +reasoningTokens = 50L, + reasoningIncludedInOutput = true, + source = "test", + ), + firstTokenAtMs: Long? = 1200, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o-2024-11-20", + ): TokenStatRequestContext { + val ctx = + TokenStatRequestContext( + eventId = eventId, + category = category, + configId = configId, + provider = provider, + model = model, + startedAtMs = 1000, + ) + usage?.let { ctx.onUsage(it) } + firstTokenAtMs?.let { ctx.onFirstToken(it) } + status?.let { ctx.finish(it, 2000) } + return ctx + } + + @Test + fun `records event with identity display group pricing snapshot and cost`() = runBlocking { + TokenStatsLedger.record(context, request()) + + val event = database.tokenStatsDao().getEvent("evt-1")!! + assertEquals(TokenStatCategory.CHAT.name, event.category) + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(1000L, event.startedAtMs) + assertEquals(2000L, event.endedAtMs) + assertEquals(1200L, event.firstTokenAtMs) + assertEquals(800L, event.uncachedInputTokens) + assertEquals(200L, event.cachedInputTokens) + assertEquals(0L, event.cacheWriteTokens) + assertEquals(500L, event.outputTokens) + assertEquals(50L, event.reasoningTokens) + assertEquals(true, event.reasoningIncludedInOutput) + // gpt-4o-2024-11-20 内置默认价(USD 计价):1.5/1.5/6 每百万 + assertEquals("USD", event.pricingCurrency) + assertEquals(PricingSource.DEFAULT.name, event.pricingSource) + assertEquals("TOKEN", event.billingMode) + assertEquals( + 4500.0 / 1_000_000.0, + event.costInPricingCurrency!!, + 1e-12, + ) + + // 身份与展示分组自动创建 + val identity = + database.tokenStatsDao().getIdentityByTriple("cfg-1", "OPENAI", "gpt-4o-2024-11-20")!! + assertEquals(event.statIdentityId, identity.identityId) + val display = database.tokenStatsDao().getDisplayModel(identity.displayModelId) + assertNotNull(display) + + // 诊断字段只含脱敏元数据 + val diagnostics = event.diagnosticsJson!! + assertTrue(diagnostics.contains("\"source\":\"test\"")) + assertTrue(diagnostics.contains("\"usageObserved\":true")) + assertTrue(diagnostics.contains("\"usageReportCount\":1")) + assertTrue("diagnostics must not contain content or credentials", !diagnostics.contains("apiKey")) + } + + @Test + fun `same eventId is idempotent and usage report count is deduplicated`() = runBlocking { + TokenStatsLedger.record(context, request(eventId = "evt-dup")) + TokenStatsLedger.record(context, request(eventId = "evt-dup")) + + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `duplicate usage callbacks keep last snapshot only`() = runBlocking { + val ctx = request(eventId = "evt-last") + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 1L, +outputTokens = 2L, + source = "first", + ) + ) + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 10L, +outputTokens = 20L, + source = "last", + ) + ) + // request() 默认已上报一次 + 本次两次 + assertEquals(3, ctx.usageReportCount) + assertEquals("last", ctx.lastUsage!!.source) + } + + @Test + fun `no usage keeps event with null fields and null cost`() = runBlocking { + TokenStatsLedger.record(context, request(usage = null, firstTokenAtMs = null)) + + val event = database.tokenStatsDao().getEvent("evt-1")!! + assertNull(event.uncachedInputTokens) + assertNull(event.cachedInputTokens) + assertNull(event.cacheWriteTokens) + assertNull(event.outputTokens) + assertNull(event.reasoningTokens) + assertNull(event.costInPricingCurrency) + assertNull("no first token -> null", event.firstTokenAtMs) + assertNotNull("event still recorded without usage", event.eventId) + assertTrue(event.diagnosticsJson!!.contains("\"usageObserved\":false")) + } + + @Test + fun `failed and cancelled statuses are recorded with their usage`() = runBlocking { + val failed = + request(eventId = "evt-fail", status = TokenStatStatus.FAILED, firstTokenAtMs = null) + TokenStatsLedger.record(context, failed) + val failedEvent = database.tokenStatsDao().getEvent("evt-fail")!! + assertEquals(TokenStatStatus.FAILED.name, failedEvent.status) + assertEquals(800L, failedEvent.uncachedInputTokens) + assertNull(failedEvent.firstTokenAtMs) + + val cancelled = request(eventId = "evt-cancel", status = TokenStatStatus.CANCELLED) + TokenStatsLedger.record(context, cancelled) + val cancelledEvent = database.tokenStatsDao().getEvent("evt-cancel")!! + assertEquals(TokenStatStatus.CANCELLED.name, cancelledEvent.status) + assertEquals(500L, cancelledEvent.outputTokens) + } + + @Test + fun `category is fixed business classification`() = runBlocking { + val cases = + listOf( + TokenStatCategory.SUBAGENT, + TokenStatCategory.SUMMARY, + TokenStatCategory.TITLE, + TokenStatCategory.MEMORY, + TokenStatCategory.CHARACTER_GENERATION, + TokenStatCategory.CONNECTION_TEST, + TokenStatCategory.OTHER, + ) + cases.forEachIndexed { index, category -> + TokenStatsLedger.record( + context, + request(eventId = "evt-cat-$index", category = category), + ) + assertEquals( + category.name, + database.tokenStatsDao().getEvent("evt-cat-$index")!!.category, + ) + } + } + + @Test + fun `config override and legacy override drive pricing source and cost`() = runBlocking { + val dao = database.tokenStatsDao() + + // CONFIG 覆盖 + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 3.0, + outputPricePerMillion = 5.0, + ) + TokenStatsLedger.record( + context, + request( + eventId = "evt-cfg", + provider = "DEEPSEEK", + model = "deepseek-chat", + ), + ) + val cfgEvent = database.tokenStatsDao().getEvent("evt-cfg")!! + assertEquals(PricingSource.CONFIG_OVERRIDE.name, cfgEvent.pricingSource) + assertEquals("USD", cfgEvent.pricingCurrency) + assertEquals(2.0, cfgEvent.inputPricePerMillion!!, 1e-12) + // 默认 usage:cacheWrite=0(确认无缓存写入),800*2 + 200*0.5 + 500*5 = 4200(每百万) + assertEquals(4200.0 / 1_000_000.0, cfgEvent.costInPricingCurrency!!, 1e-12) + + // LEGACY 覆盖(旧 DataStore 价格) + TokenStatsLedger.legacyPriceProvider = { _, _ -> + LegacyPriceSettings( + billingMode = BillingMode.TOKEN, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 2.0, + ) + } + TokenStatsLedger.record( + context, + request( + eventId = "evt-legacy", + configId = "cfg-2", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = + ProviderUsageSnapshot( +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +cacheWriteTokens = 100L, +outputTokens = 500L, + source = "t", + ), + ), + ) + val legacyEvent = database.tokenStatsDao().getEvent("evt-legacy")!! + assertEquals(PricingSource.LEGACY_OVERRIDE.name, legacyEvent.pricingSource) + // cacheWriteTokens=100 且 cacheWritePricePerMillion=null(旧系统无缓存写入价)→ + // 成本必须为 null(未知),不得静默按 0 计费 + assertNull("cache write without price keeps cost unknown", legacyEvent.costInPricingCurrency) + } + + @Test + fun `separate reasoning billed additionally only when not included in output`() = runBlocking { + val dao = database.tokenStatsDao() + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ) + + // 推理已包含在 output:只按 output 计费 + val included = + request( + eventId = "evt-included", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = + ProviderUsageSnapshot( +uncachedInputTokens = 100L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 200L, +reasoningTokens = 50L, + reasoningIncludedInOutput = true, + source = "t", + ), + ) + TokenStatsLedger.record(context, included) + val includedCost = database.tokenStatsDao().getEvent("evt-included")!!.costInPricingCurrency!! + + // 推理独立计数:output + reasoning 一起按输出单价 + val separate = + request( + eventId = "evt-separate", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = + ProviderUsageSnapshot( +uncachedInputTokens = 100L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 200L, +reasoningTokens = 50L, + reasoningIncludedInOutput = false, + source = "t", + ), + ) + TokenStatsLedger.record(context, separate) + val separateCost = database.tokenStatsDao().getEvent("evt-separate")!!.costInPricingCurrency!! + + assertEquals((100.0 / 1_000_000.0) + (200.0 / 1_000_000.0 * 2.0), includedCost, 1e-12) + assertEquals( + (100.0 / 1_000_000.0) + ((200.0 + 50.0) / 1_000_000.0 * 2.0), + separateCost, + 1e-12, + ) + assertTrue("separate reasoning must cost more", separateCost > includedCost) + } + + @Test + fun `database write failure is logged and never breaks the caller`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + TokenStatsLedger.databaseProvider = { + throw RuntimeException("db unavailable") + } + try { + TokenStatsLedger.record(context, request()) + // 失败被吞掉(已记录日志),不向上抛出 + } catch (e: Exception) { + fail("DB write failure must not propagate: ${e.message}") + } + } + } + + @Test + fun `cancellation propagates and is not swallowed as write failure`() = runBlocking { + TokenStatsLedger.databaseProvider = { + throw CancellationException("test cancellation") + } + + try { + TokenStatsLedger.record(context, request()) + fail("CancellationException must propagate") + } catch (e: CancellationException) { + assertEquals("test cancellation", e.message) + } + } + + @Test + fun `first token is only set once`() { + val ctx = request(firstTokenAtMs = null) + ctx.onFirstToken(1100) + ctx.onFirstToken(9999) + assertEquals(1100L, ctx.firstTokenAtMs) + } + + @Test + fun `finish is only applied once`() { + val ctx = request() + ctx.finish(TokenStatStatus.COMPLETED, 2000) + ctx.finish(TokenStatStatus.FAILED, 9999) + assertEquals(TokenStatStatus.COMPLETED, ctx.status) + assertEquals(2000, ctx.endedAtMs) + } + + // ==== P1-3:费用 fixture(典型 provider 快照) ==== + private suspend fun configPricing(dao: com.ai.assistance.operit.data.dao.TokenStatsDao) { + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 3.0, + outputPricePerMillion = 5.0, + ) + } + + @Test + fun `typical openai chat completion without cache write fields still computes cost`() = + runBlocking { + configPricing(database.tokenStatsDao()) + // OpenAI 常规响应没有 cache_creation:cacheWrite=null 且无独立计费概念, + // 不得因缺该字段令全成本未知 + val snapshot = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject( + """ + { + "prompt_tokens": 1000, + "completion_tokens": 500, + "prompt_tokens_details": {"cached_tokens": 200} + } + """.trimIndent() + ) + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-openai-typical", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = snapshot, + ), + ) + val event = database.tokenStatsDao().getEvent("evt-openai-typical")!! + assertEquals(800L, event.uncachedInputTokens) + assertEquals(200L, event.cachedInputTokens) + assertNull(event.cacheWriteTokens) + // 800*2 + 200*0.5 + 500*5 = 4200(每百万) + assertEquals(4200.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) + } + + @Test + fun `typical openai responses and gemini fixtures compute cost without cache write`() = + runBlocking { + configPricing(database.tokenStatsDao()) + val openai = + ProviderUsageNormalizer.openAiResponses( + JSONObject( + """ + { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": {"cached_tokens": 200} + } + """.trimIndent() + ) + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-openai-responses", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = openai, + ), + ) + val responsesEvent = database.tokenStatsDao().getEvent("evt-openai-responses")!! + assertEquals(4200.0 / 1_000_000.0, responsesEvent.costInPricingCurrency!!, 1e-12) + + val gemini = + ProviderUsageNormalizer.gemini( + JSONObject( + """ + { + "promptTokenCount": 1000, + "cachedContentTokenCount": 300, + "candidatesTokenCount": 400 + } + """.trimIndent() + ) + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-gemini", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = gemini, + ), + ) + val geminiEvent = database.tokenStatsDao().getEvent("evt-gemini")!! + // 700*2 + 300*0.5 + 400*5 = 3550(每百万) + assertEquals(3550.0 / 1_000_000.0, geminiEvent.costInPricingCurrency!!, 1e-12) + } + + @Test + fun `anthropic typical fixture bills cache write separately`() = runBlocking { + configPricing(database.tokenStatsDao()) + val anthropic = + ProviderUsageNormalizer.anthropic( + JSONObject( + """ + { + "input_tokens": 500, + "cache_read_input_tokens": 200, + "cache_creation_input_tokens": 100, + "output_tokens": 300 + } + """.trimIndent() + ) + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-anthropic", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = anthropic, + ), + ) + val event = database.tokenStatsDao().getEvent("evt-anthropic")!! + assertEquals(100L, event.cacheWriteTokens) + // 500*2 + 200*0.5 + 100*3 + 300*5 = 2900(每百万) + assertEquals(2900.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) + } + + @Test + fun `anthropic absent cache write keeps cost unknown while openai absent cached split stays null`() = + runBlocking { + configPricing(database.tokenStatsDao()) + // Anthropic 缓存创建独立计费:字段缺失即分量未知 → 成本未知 + val anthropic = + ProviderUsageNormalizer.anthropic( + JSONObject("""{"input_tokens": 500, "output_tokens": 300}""") + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-anthropic-unknown", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = anthropic, + ), + ) + val anthropicEvent = database.tokenStatsDao().getEvent("evt-anthropic-unknown")!! + assertNull("独立计费分量未知 → 成本必须未知", anthropicEvent.costInPricingCurrency) + + // OpenAI 缺 cached details:输入拆分未知 → 不把总输入确定为 uncached,成本未知 + val openai = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-openai-unknown-split", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = openai, + ), + ) + val openaiEvent = database.tokenStatsDao().getEvent("evt-openai-unknown-split")!! + assertNull("cached 拆分未知 → uncached 必须未知", openaiEvent.uncachedInputTokens) + assertNull(openaiEvent.cachedInputTokens) + assertNull("输入拆分未知 → 成本未知", openaiEvent.costInPricingCurrency) + } + + // ==== P1-4:attempt 聚合 ==== + + @Test + fun `usage across attempts aggregates without double counting same attempt`() = runBlocking { + configPricing(database.tokenStatsDao()) + val ctx = + TokenStatRequestContext( + eventId = "evt-attempts", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = 1000, + ) + // attempt 1 上报(流式多 chunk 重复上报) + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 310L, +cachedInputTokens = 100L, +outputTokens = 120L, + cacheWriteSeparateBilling = false, + source = "test", + ), + attempt = 1, + ) + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 310L, +cachedInputTokens = 100L, +outputTokens = 120L, + cacheWriteSeparateBilling = false, + source = "test", + ), + attempt = 1, + ) + // attempt 2 成功上报 + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 500L, +cachedInputTokens = 200L, +outputTokens = 400L, + cacheWriteSeparateBilling = false, + source = "test", + ), + attempt = 2, + ) + ctx.finish(TokenStatStatus.COMPLETED, 2000) + TokenStatsLedger.record(context, ctx) + + val event = database.tokenStatsDao().getEvent("evt-attempts")!! + // 同 attempt 取最后一次(310)+ attempt2(500)= 810;输出 120 + 400 = 520 + assertEquals(810L, event.uncachedInputTokens) + assertEquals(300L, event.cachedInputTokens) + assertEquals(520L, event.outputTokens) + // 费用按聚合用量计算:810*2 + 300*0.5 + 520*5 = 4370(每百万) + assertEquals(4370.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) + assertTrue(event.diagnosticsJson!!.contains("\"usageReportCount\":3")) + assertTrue(event.diagnosticsJson!!.contains("\"attemptCount\":2")) + } + + @Test + fun `aggregated usage keeps component unknown when any attempt leaves it unknown`() { + val ctx = + TokenStatRequestContext( + eventId = "evt-partial", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = 1000, + ) + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 100L, +outputTokens = 10L, + source = "test", + ), + attempt = 1, + ) + ctx.onUsage( + ProviderUsageSnapshot( +uncachedInputTokens = 200L, + outputTokens = null, + source = "test", + ), + attempt = 2, + ) + val aggregated = ctx.aggregatedUsage()!! + assertEquals(300L, aggregated.uncachedInputTokens) + assertNull("任一 attempt 未知则分量保持未知", aggregated.outputTokens) + } + + // ==== P2-1:总输入(totalInputTokens)与单价相同时的费用覆盖 ==== + + private suspend fun equalPricePricing(dao: com.ai.assistance.operit.data.dao.TokenStatsDao) { + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg-1", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 1.0, + cacheWritePricePerMillion = 3.0, + outputPricePerMillion = 5.0, + ) + } + + @Test + fun `typical compat endpoint without cached details computes cost when prices equal`() = + runBlocking { + equalPricePricing(database.tokenStatsDao()) + // OpenAI 兼容端点常规响应缺 prompt_tokens_details:拆分未知, + // 但 provider 明确上报总输入;输入与缓存输入单价相同 → 可按总输入计费 + val openai = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") + )!! + assertEquals(1000L, openai.totalInputTokens) + assertNull(openai.uncachedInputTokens) + assertNull(openai.cachedInputTokens) + TokenStatsLedger.record( + context, + request( + eventId = "evt-openai-total", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = openai, + ), + ) + val openaiEvent = database.tokenStatsDao().getEvent("evt-openai-total")!! + // 1000*1 + 500*5 = 3500(每百万) + assertEquals(3500.0 / 1_000_000.0, openaiEvent.costInPricingCurrency!!, 1e-12) + + // Gemini 缺 cachedContentTokenCount:同样按总输入计费 + val gemini = + ProviderUsageNormalizer.gemini( + JSONObject("""{"promptTokenCount": 600, "candidatesTokenCount": 200}""") + )!! + assertEquals(600L, gemini.totalInputTokens) + assertNull(gemini.uncachedInputTokens) + TokenStatsLedger.record( + context, + request( + eventId = "evt-gemini-total", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = gemini, + ), + ) + val geminiEvent = database.tokenStatsDao().getEvent("evt-gemini-total")!! + // 600*1 + 200*5 = 1600(每百万) + assertEquals(1600.0 / 1_000_000.0, geminiEvent.costInPricingCurrency!!, 1e-12) + } + + @Test + fun `total input keeps cost unknown when input and cached prices differ`() = runBlocking { + configPricing(database.tokenStatsDao()) // input 2.0 vs cached 0.5:单价不同 + val openai = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") + )!! + assertEquals(1000L, openai.totalInputTokens) + TokenStatsLedger.record( + context, + request( + eventId = "evt-openai-diff-price", + provider = "DEEPSEEK", + model = "deepseek-chat", + usage = openai, + ), + ) + val event = database.tokenStatsDao().getEvent("evt-openai-diff-price")!! + // 单价不同且拆分未知:不得把总输入伪装成 uncached,成本保持未知 + assertNull(event.uncachedInputTokens) + assertNull(event.costInPricingCurrency) + } + + // ==== P2-2:结构化列(v30)持久化 ==== + + @Test + fun `structured billing columns are persisted for direct revaluation`() = runBlocking { + val openai = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") + )!! + TokenStatsLedger.record( + context, + request( + eventId = "evt-structured", + usage = openai, + ), + ) + val event = database.tokenStatsDao().getEvent("evt-structured")!! + assertEquals(1000L, event.totalInputTokens) + assertEquals(false, event.cacheWriteSeparateBilling) + // 无 usage 的事件保持 null(未知),与 0 可区分 + TokenStatsLedger.record( + context, + request(eventId = "evt-structured-null", usage = null), + ) + val nullEvent = database.tokenStatsDao().getEvent("evt-structured-null")!! + assertNull(nullEvent.totalInputTokens) + assertNull(nullEvent.cacheWriteSeparateBilling) + } + + // ==== P1-1:同 attempt 增量快照按最新非空字段合并 ==== + + @Test + fun `same attempt incremental snapshots merge latest non-null fields without summing`() { + val ctx = + TokenStatRequestContext( + eventId = "evt-claude-stream", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "ANTHROPIC", + model = "claude-sonnet", + startedAtMs = 1000, + ) + // 真实 message_start 形态:完整 input/cache/cacheWrite,output 为占位 0 + val messageStart = + ProviderUsageNormalizer.anthropic( + JSONObject( + """ + {"input_tokens": 100, "cache_read_input_tokens": 50, + "cache_creation_input_tokens": 10, "output_tokens": 0} + """.trimIndent() + ) + )!! + // 真实 message_delta 形态:只有累计 output + val messageDelta = + ProviderUsageNormalizer.anthropic(JSONObject("""{"output_tokens": 300}"""))!! + ctx.onUsage(messageStart, attempt = 1) + ctx.onUsage(messageDelta, attempt = 1) + + val aggregated = ctx.aggregatedUsage()!! + assertEquals(100L, aggregated.uncachedInputTokens) + assertEquals(50L, aggregated.cachedInputTokens) + assertEquals(10L, aggregated.cacheWriteTokens) + assertEquals(300L, aggregated.outputTokens) + // 输出是累计值:只取最新,不能 start(0) + delta(300) 相加 + assertEquals(300L, aggregated.outputTokens) + assertEquals(160L, aggregated.totalInputTokens) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt index 8544b7c55..c9a938c8a 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt @@ -187,7 +187,8 @@ class TokenStatsRoomMigrationTest { assertTrue("token_stat_price_overrides", tables.contains("token_stat_price_overrides")) assertTrue("token_stat_baselines", tables.contains("token_stat_baselines")) - // 迁移可重入(CREATE IF NOT EXISTS):以驱动变体再跑一次 + // 迁移可重入(CREATE IF NOT EXISTS):以驱动变体再跑一次; + // Room 打开时已应用 28→29→30,重放 28→29 不改变版本号 JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> AppDatabase.MIGRATION_20_21.migrate(connection) assertEquals(21, userVersion(connection)) @@ -243,11 +244,11 @@ class TokenStatsRoomMigrationTest { startedAtMs = 1000L, endedAtMs = 2000L, firstTokenAtMs = 1200L, - uncachedInputTokens = 800, - cachedInputTokens = 200, - cacheWriteTokens = 100, - outputTokens = 500, - reasoningTokens = 100, +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +cacheWriteTokens = 100L, +outputTokens = 500L, +reasoningTokens = 50L, reasoningIncludedInOutput = true, billingMode = BillingMode.TOKEN.name, pricingCurrency = "USD", @@ -263,7 +264,7 @@ class TokenStatsRoomMigrationTest { assertEquals(1, dao.countEvents()) val readBack = dao.getEvent("req-1")!! assertEquals(0.001975, readBack.costInPricingCurrency!!, 1e-12) - assertEquals(100, readBack.cacheWriteTokens) + assertEquals(100L, readBack.cacheWriteTokens) assertEquals(0.75, readBack.cacheWritePricePerMillion!!, 1e-12) // 未知分量以 null 落库,0 是确认值:null vs 0 必须可区分 @@ -455,10 +456,10 @@ class TokenStatsRoomMigrationTest { status = TokenStatStatus.COMPLETED.name, startedAtMs = 1000L, endedAtMs = 2000L, - uncachedInputTokens = 100, - cachedInputTokens = 0, - cacheWriteTokens = 0, - outputTokens = 50, +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +cacheWriteTokens = 100L, +outputTokens = 500L, billingMode = BillingMode.TOKEN.name, pricingCurrency = "USD", inputPricePerMillion = 1.0, @@ -547,9 +548,9 @@ class TokenStatsRoomMigrationTest { status = TokenStatStatus.COMPLETED.name, startedAtMs = 1000L, endedAtMs = 2000L, - uncachedInputTokens = 800, - cachedInputTokens = 200, - outputTokens = 500, +uncachedInputTokens = 800L, +cachedInputTokens = 200L, +outputTokens = 500L, billingMode = BillingMode.TOKEN.name, pricingCurrency = "USD", inputPricePerMillion = 1.0, @@ -573,13 +574,109 @@ class TokenStatsRoomMigrationTest { assertEquals(1, dao.countEvents()) assertEquals("merged-group", dao.getIdentity(identityId)!!.displayModelId) val readBack = dao.getEvent("req-1")!! - assertEquals(800, readBack.uncachedInputTokens) + assertEquals(800L, readBack.uncachedInputTokens) assertEquals(0.0019, readBack.costInPricingCurrency!!, 1e-12) } finally { database.close() } } + /** 用导出的 v29 schema JSON 构造一个真实的 v29 数据库文件(含一条事件行)。 */ + private fun buildV29Database(dbPath: String) { + val schemaFile = File(schemaDir, "29.json") + assertTrue("schema export missing: ${schemaFile.absolutePath}", schemaFile.isFile) + val schema = json.decodeFromString(schemaFile.readText()) + assertEquals(29, schema.database.version) + + DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> + connection.createStatement().use { statement -> + schema.database.entities.forEach { entity -> + statement.execute(entity.createSql.replace("\${TABLE_NAME}", entity.tableName)) + entity.indices.forEach { index -> + statement.execute(index.createSql.replace("\${TABLE_NAME}", entity.tableName)) + } + } + statement.execute( + "CREATE TABLE IF NOT EXISTS room_master_table " + + "(id INTEGER PRIMARY KEY, identity_hash TEXT NOT NULL)" + ) + statement.execute( + "INSERT OR REPLACE INTO room_master_table (id, identity_hash) " + + "VALUES(42, '${schema.database.identityHash}')" + ) + statement.execute("PRAGMA user_version = 29") + // 旧数据:迁移前插入一条事件,验证迁移后数据保留(含价格快照) + statement.execute( + "INSERT INTO token_stat_identities " + + "(identityId, configId, provider, model, displayModelId) " + + "VALUES ('identity-1', '', 'DEEPSEEK', 'deepseek-chat', 'deepseek-chat')" + ) + statement.execute( + "INSERT INTO token_stat_events " + + "(eventId, statIdentityId, category, status, startedAtMs, endedAtMs, " + + "firstTokenAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + + "outputTokens, reasoningTokens, reasoningIncludedInOutput, billingMode, " + + "pricingCurrency, inputPricePerMillion, cachedInputPricePerMillion, " + + "cacheWritePricePerMillion, outputPricePerMillion, pricePerRequest, " + + "pricingSource, costInPricingCurrency) " + + "VALUES ('evt-v29', 'identity-1', 'CHAT', 'COMPLETED', 1000, 2000, 1200, " + + "800, 200, 100, 500, 50, 1, 'TOKEN', 'USD', 1.0, 0.5, 2.0, 3.0, NULL, " + + "'DEFAULT', 0.0019)" + ) + } + } + } + + @Test + fun `v29 database migrates to v30 keeping events and adding diagnostics column`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV29Database(dbFile.absolutePath) + + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo,包括新列) + val dao = database.tokenStatsDao() + val readBack = dao.getEvent("evt-v29") + assertNotNull("migration must preserve legacy event rows", readBack) + assertEquals(800L, readBack!!.uncachedInputTokens) + assertEquals("DEFAULT", readBack.pricingSource) + assertNull("v29 rows have no diagnostics", readBack.diagnosticsJson) + // v30 新增的结构化列对旧行保持 null(未知),与新写入可区分 + assertNull(readBack.totalInputTokens) + assertNull(readBack.cacheWriteSeparateBilling) + + // 新列可写 + dao.insertEvent( + readBack.copy( + eventId = "evt-v30", +totalInputTokens = 1000L, + cacheWriteSeparateBilling = false, + diagnosticsJson = "{\"source\":\"openai_chat_completions\",\"usageObserved\":true}", + ) + ) + val v30Event = dao.getEvent("evt-v30")!! + assertEquals(1000L, v30Event.totalInputTokens) + assertEquals(false, v30Event.cacheWriteSeparateBilling) + assertTrue(v30Event.diagnosticsJson!!.contains("\"source\":\"openai_chat_completions\"")) + + // 迁移可重入(ALTER 幂等):以驱动变体再跑一次 + JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> + AppDatabase.MIGRATION_20_21.migrate(connection) + assertEquals(30, userVersion(connection)) + } + } finally { + database.close() + } + } + @Test fun `production support sqlite migration variant runs the shared sql on a real v20 database`() { val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt new file mode 100644 index 000000000..a28efb956 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt @@ -0,0 +1,134 @@ +package com.ai.assistance.operit.ui.features.settings.screens + +import java.io.File +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * P2:导出失败/取消清理的调度缝测试——删除绝不在 Main;失败分支如实上报,取消分支在 + * NonCancellable 下完成有界清理后继续传播取消。 + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class QuarantineExportCleanupTest { + private lateinit var mainExecutor: ExecutorService + private val ioThreadNames = ConcurrentHashMap.newKeySet() + private lateinit var previousDispatcher: CoroutineDispatcher + + @Before + fun setUp() { + mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } + Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) + previousDispatcher = QuarantineExportCleanup.ioDispatcher + QuarantineExportCleanup.ioDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + Dispatchers.IO.dispatch(context) { + ioThreadNames += Thread.currentThread().name + block.run() + } + } + } + } + + @After + fun tearDown() { + QuarantineExportCleanup.ioDispatcher = previousDispatcher + QuarantineExportCleanup.deleteRecursivelyForTest = null + ioThreadNames.clear() + Dispatchers.resetMain() + mainExecutor.shutdown() + } + + private fun exportDir(): File = + File.createTempFile("quarantine-export-cleanup", "").apply { delete(); mkdirs() } + + @Test + fun `cleanup failure is reported and deletion never runs on the main thread`() = runBlocking { + val destination = exportDir() + var deleteCalls = 0 + var deleteThread = "" + QuarantineExportCleanup.deleteRecursivelyForTest = { + deleteCalls += 1 + deleteThread = Thread.currentThread().name + false // 模拟删除失败 + } + try { + val cleaned = withContext(Dispatchers.Main) { + QuarantineExportCleanup.deleteRecursively(destination) + } + assertFalse("cleanup failure must be reported to the caller", cleaned) + assertEquals(1, deleteCalls) + assertTrue("deletion must run on the injected IO dispatcher", ioThreadNames.isNotEmpty()) + assertFalse( + "cleanup must never run on the main thread: $ioThreadNames", + ioThreadNames.any { it == "test-main-thread" }, + ) + assertTrue("deletion thread must not be main", deleteThread != "test-main-thread") + } finally { + QuarantineExportCleanup.deleteRecursivelyForTest = null + } + } + + @Test + fun `cleanup still runs when the caller coroutine is cancelled and cancellation propagates`() = + runBlocking { + val destination = exportDir() + var cleanupCount = 0 + var cleanupResult: Boolean? = null + var cleanupThread = "" + QuarantineExportCleanup.deleteRecursivelyForTest = { + cleanupCount += 1 + cleanupThread = Thread.currentThread().name + true + } + try { + // 门闩确保协程已进入 delay(否则 cancelAndJoin 会在块体启动前取消,catch 不执行) + val enteredDelay = CompletableDeferred() + val job = launch(Dispatchers.Main) { + try { + enteredDelay.complete(Unit) + delay(Long.MAX_VALUE) + } catch (e: CancellationException) { + // 模拟 UI 取消分支:NonCancellable+IO 完成有界清理后再重抛取消 + cleanupResult = + QuarantineExportCleanup.deleteRecursively(destination, nonCancellable = true) + throw e + } + } + enteredDelay.await() + job.cancelAndJoin() + assertEquals("cleanup must run exactly once despite cancellation", 1, cleanupCount) + assertEquals("cleanup must report success under cancellation", true, cleanupResult) + assertTrue( + "cleanup must run on IO even when the caller is cancelled", + cleanupThread != "test-main-thread", + ) + assertFalse( + "cleanup must never run on the main thread: $ioThreadNames", + ioThreadNames.any { it == "test-main-thread" }, + ) + assertTrue("cancellation must keep propagating", job.isCancelled) + } finally { + QuarantineExportCleanup.deleteRecursivelyForTest = null + } + } +} From b181ee96bfe47f0ae1103bbb58e76afbc46e3df4 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 06:58:45 +0800 Subject: [PATCH 03/28] feat(stats): add token usage aggregation queries (cherry picked from commit da679adb7707b38bfd11a252fd781f480956fe9b) --- .../operit/data/dao/TokenStatsDao.kt | 158 +++ .../operit/data/preferences/ApiPreferences.kt | 35 + .../operit/data/stats/TokenCostCalculator.kt | 6 + .../operit/data/stats/TokenStatsAggregator.kt | 669 +++++++++++++ .../data/stats/TokenStatsQueryModels.kt | 270 +++++ .../data/stats/TokenStatsQueryService.kt | 215 ++++ .../operit/data/stats/TokenStatsTimeRange.kt | 219 +++++ .../data/stats/RecordingSQLiteDriver.kt | 127 +++ .../data/stats/TokenCostCalculatorTest.kt | 41 + .../data/stats/TokenStatsAggregatorTest.kt | 922 ++++++++++++++++++ .../stats/TokenStatsQueryServiceRoomTest.kt | 866 ++++++++++++++++ .../data/stats/TokenStatsTimeRangeTest.kt | 287 ++++++ 12 files changed, 3815 insertions(+) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index 78e704a5d..ade8c3c6d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -12,6 +12,8 @@ import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeRead +import com.ai.assistance.operit.data.stats.TokenStatsQuerySnapshot import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @@ -47,6 +49,162 @@ abstract class TokenStatsDao { @Query("SELECT COUNT(*) FROM token_stat_events") abstract suspend fun countEvents(): Int + /** + * 阶段 3 统计查询:单次读取指定时间范围(半开区间 [startMs, endMs), + * 按 [TokenStatEventEntity.startedAtMs] 归属)内的全部事件,在内存中单遍聚合。 + * 走 `index_token_stat_events_startedAtMs` 索引;禁止逐桶/逐模型拆分查询。 + */ + @Query( + "SELECT * FROM token_stat_events " + + "WHERE startedAtMs >= :startMs AND startedAtMs < :endMs" + ) + abstract suspend fun getEventsInRange(startMs: Long, endMs: Long): List + + /** + * 阶段 3 统计查询:单次读取时间范围内属于给定展示模型分组(identity 的 + * displayModelId,单一事实来源)的事件。IN 列表由调用方提供,模型数再多也 + * 只有一条查询,不产生按模型 N+1。 + */ + @Query( + "SELECT e.* FROM token_stat_events e " + + "INNER JOIN token_stat_identities i ON e.statIdentityId = i.identityId " + + "WHERE e.startedAtMs >= :startMs AND e.startedAtMs < :endMs " + + "AND i.displayModelId IN (:displayModelIds)" + ) + abstract suspend fun getEventsInRangeForDisplayModels( + startMs: Long, + endMs: Long, + displayModelIds: List, + ): List + + /** 时间范围内是否存在事件(初始回退选择用,EXISTS 短路,走 startedAtMs 索引)。 */ + @Query( + "SELECT EXISTS(" + + "SELECT 1 FROM token_stat_events " + + "WHERE startedAtMs >= :startMs AND startedAtMs < :endMs" + + ")" + ) + abstract suspend fun rangeHasEvents(startMs: Long, endMs: Long): Boolean + + /** + * 生命周期分页读取(P2-1):`(startedAtMs, eventId)` 键集分页,升序、无重复、 + * 无遗漏;配合 [loadLifetimeSnapshot] 在**同一事务**内逐页读取,避免整表 + * 实体化的内存峰值。调用方只在 `page.size == limit` 时推进游标继续取下一页。 + */ + @Query( + "SELECT * FROM token_stat_events " + + "WHERE (startedAtMs > :afterStartMs OR " + + "(startedAtMs = :afterStartMs AND eventId > :afterEventId)) " + + "ORDER BY startedAtMs ASC, eventId ASC LIMIT :limit" + ) + abstract suspend fun getEventsPage( + afterStartMs: Long, + afterEventId: String, + limit: Int, + ): List + + /** + * 单条 IN 查询允许的最大参数个数(P2-2):SQLite 变量上限默认 999, + * 留 99 余量取 900;超过时在同一事务内分块查询再合并。 + */ + companion object { + const val MAX_IN_VALUES = 900 + } + + /** + * 阶段 3 范围查询的**同事务只读快照**(P1-2):identity/display model/价格覆盖 + * (重估口径才读)/事件在同一个 Room 事务内固定读取,事务外纯聚合;并发写入 + * 要么整体可见要么整体不可见,杜绝“summary 有事件但模型桶缺失”的拆分状态。 + * + * [displayModelIds] 语义(P2-2):null = 全部模型;空列表 = **无事件**(不是 + * 全部);非空 = 走 JOIN 单条 IN 查询;超过 [MAX_IN_VALUES] 时在**同一事务** + * 内按 ≤900 分块查询(去重后),合并结果按 (startedAtMs, eventId) 稳定排序。 + */ + @Transaction + open suspend fun loadRangeSnapshot( + startMs: Long, + endMs: Long, + displayModelIds: List?, + includeOverrides: Boolean, + ): TokenStatsQuerySnapshot { + val identitiesById = getAllIdentities().associateBy { it.identityId } + val displayModelsById = getAllDisplayModels().associateBy { it.displayModelId } + val overrides = if (includeOverrides) getAllPriceOverrides() else emptyList() + val events = + when { + displayModelIds == null -> getEventsInRange(startMs, endMs) + displayModelIds.isEmpty() -> emptyList() + else -> getEventsInRangeForDisplayModelsChunked(startMs, endMs, displayModelIds) + } + return TokenStatsQuerySnapshot( + events = events, + identitiesById = identitiesById, + displayModelsById = displayModelsById, + overrides = overrides, + baselines = emptyList(), + ) + } + + /** + * 生命周期快照(P1-2/P2-1):identity/display model/价格覆盖/baseline 在 + * **同一事务**内一次读取;事件按 `(startedAtMs, eventId)` 键集分页(每页至多 + * [pageSize] 条)逐页回调 [onEventsPage],由聚合器增量累加——避免整表实体化 + * 峰值,且分页与事务同界(页面间快照一致)。 + */ + @Transaction + open suspend fun loadLifetimeSnapshot( + includeOverrides: Boolean, + pageSize: Int, + onEventsPage: ( + List, + Map, + List, + ) -> Unit, + ): TokenStatsLifetimeRead { + val identitiesById = getAllIdentities().associateBy { it.identityId } + val displayModelsById = getAllDisplayModels().associateBy { it.displayModelId } + val overrides = if (includeOverrides) getAllPriceOverrides() else emptyList() + val baselines = getAllBaselines() + var afterStartMs = Long.MIN_VALUE + var afterEventId = "" + var totalEvents = 0L + while (true) { + val page = getEventsPage(afterStartMs, afterEventId, pageSize) + if (page.isEmpty()) break + totalEvents += page.size + onEventsPage(page, identitiesById, overrides) + if (page.size < pageSize) break + val last = page.last() + afterStartMs = last.startedAtMs + afterEventId = last.eventId + } + return TokenStatsLifetimeRead( + identitiesById = identitiesById, + displayModelsById = displayModelsById, + overrides = overrides, + baselines = baselines, + totalEvents = totalEvents, + ) + } + + private suspend fun getEventsInRangeForDisplayModelsChunked( + startMs: Long, + endMs: Long, + displayModelIds: List, + ): List { + val distinct = displayModelIds.distinct() + if (distinct.size <= MAX_IN_VALUES) { + return getEventsInRangeForDisplayModels(startMs, endMs, distinct) + } + val merged = ArrayList() + for (chunk in distinct.chunked(MAX_IN_VALUES)) { + merged += getEventsInRangeForDisplayModels(startMs, endMs, chunk) + } + // 分块结果合并后按 (startedAtMs, eventId) 稳定排序(聚合对顺序不敏感, + // 这里只是为了契约明确;分块都在同一事务快照内,不产生拆分状态)。 + return merged.sortedWith(compareBy({ it.startedAtMs }, { it.eventId })) + } + @Query("SELECT * FROM token_stat_events WHERE statIdentityId = :identityId") abstract fun observeEventsByIdentity(identityId: String): Flow> diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 12ed94572..b5e924e83 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -107,6 +107,11 @@ class ApiPreferences private constructor(private val context: Context) { fun getPricePerRequestKey(providerModel: String) = floatPreferencesKey("price_per_request_${providerModel.replace(":", "_")}") + /** 旧系统价格/计费方式键前缀(与 [legacyPriceSettingsFrom] 的键构造对应)。 */ + val LEGACY_PRICE_KEY_PREFIXES = + listOf("model_input_price_", "model_cached_input_price_", "model_output_price_", + "billing_mode_", "price_per_request_") + private val providerNameCandidates = ApiProviderType.values().map { it.name }.sortedByDescending { it.length } @@ -732,6 +737,36 @@ class ApiPreferences private constructor(private val context: Context) { providerModel: String ): com.ai.assistance.operit.data.stats.LegacyPriceSettings? { val preferences = context.apiDataStore.data.first() + return legacyPriceSettingsFrom(preferences, providerModel) + } + + /** + * 旧系统**全部** provider:model 用户价格设置的一次快照读取(阶段 3 统计查询 + * 重估口径用):整个偏好文件只读一次(P1-2,杜绝按 identity 逐条读取 DataStore + * 的多次挂起)。键约定与 [legacyPriceSettingsFor] 完全一致:价格键缺失或为 0 + * 视为未设置,只有 > 0 的值才算用户设置;无任何设置的模型不出现。 + */ + suspend fun allLegacyPriceSettings(): Map { + val preferences = context.apiDataStore.data.first() + val candidates = linkedSetOf() + preferences.asMap().keys.forEach { key -> + val name = key.name + for (prefix in LEGACY_PRICE_KEY_PREFIXES) { + if (name.startsWith(prefix) && name.length > prefix.length) { + candidates += decodeProviderModelFromKeySuffix(name.substring(prefix.length)) + break + } + } + } + return candidates.associateWith { providerModel -> + legacyPriceSettingsFrom(preferences, providerModel) + } + } + + private fun legacyPriceSettingsFrom( + preferences: Preferences, + providerModel: String + ): com.ai.assistance.operit.data.stats.LegacyPriceSettings? { val billingRaw = preferences[getBillingModeKey(providerModel)] val settings = com.ai.assistance.operit.data.stats.LegacyPriceSettings( diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt index 5bcd15d35..a02a995ee 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -73,6 +73,12 @@ object TokenCostCalculator { } fun computeCost(usage: TokenUsageInput, pricing: ResolvedPricing): TokenCostResult { + // 统一边界(P1-1):未知定价(known = false,如未知模型的内置全 0 缺省或 + // 空价格覆盖行)无论用量是否完整,成本一律为 null(未知),绝不算出伪 0。 + // 阶段 2 落账与阶段 3 重估都走本入口,因此该防线同时保护两条路径。 + if (!pricing.known) { + return TokenCostResult(amount = null, currency = pricing.currency) + } if (pricing.billingMode == BillingMode.COUNT) { val price = pricing.pricePerRequest return TokenCostResult( diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt new file mode 100644 index 000000000..55605cc00 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt @@ -0,0 +1,669 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import java.math.BigDecimal +import java.math.MathContext +import java.time.ZoneId +import java.util.EnumMap + +/** + * 统计聚合器(阶段 3 核心):纯函数、对事件列表单遍聚合。 + * + * - **unknown 与 0 严格区分**:token/成本字段为 null = 未知(provider 未上报), + * 0 = provider 确认该分量为 0。聚合结果显式携带 known/unknown 计数, + * 部分未知可表达([TokenStatsCostSummary.isFullyKnown]),绝不静默当作 0。 + * - **费用**:HISTORICAL 用事件价格快照原币成本([TokenStatEventEntity.costInPricingCurrency]), + * REVALUED 用当前分层价格 × 事件用量重算([TokenCostCalculator] 同一计费语义, + * 缓存写入/推理包含边界一致,不重复收费);原币按币种分别累计,再按当前手动 + * 汇率换算到目标币种(BigDecimal 累加防漂移,Double 只出现在边界)。 + * - **展示模型**:identity.displayModelId 是分组的单一事实来源(默认规范化同名 + + * 用户手动别名都落在这里);合并模型合计 == 各身份合计之和 == 范围总计。 + * - **性能**:时长由时间戳安全计算(非负校验 + 结束≥开始),无效样本记为 + * unknown 排除平均;token 合计用饱和加法,绝不因 Long 溢出出现负数。 + * - 传入的事件列表由调用方([TokenStatsQueryService])用同事务快照取回,本层不 + * 执行任何 IO;生命周期总览支持 [TokenStatsEventTotalsAccumulator] 分页增量累加 + * (P2-1),与单列表路径共用同一数学。 + */ +object TokenStatsAggregator { + + private val MC = MathContext.DECIMAL64 + + // ==== 生命周期累计总览(事件 + baseline,独立于时间/筛选) ==== + + fun lifetime( + events: List, + baselines: List, + identitiesById: Map, + overrides: List, + legacyPrices: Map, + params: TokenStatsQueryParams, + ): TokenStatsLifetimeOverview { + val accumulator = TokenStatsEventTotalsAccumulator(legacyPrices, params) + accumulator.addPage(events, identitiesById, overrides) + return lifetimeFrom(accumulator.totals(), baselines, params) + } + + /** + * 生命周期总览的增量路径(P2-1):事件合计由 [TokenStatsEventTotalsAccumulator] + * 分页累加(DAO 同事务分页喂入,不整表实体化),baseline 在事务外纯聚合。 + * 与单列表路径 [lifetime] 共用同一数学,结果必须逐字段一致。 + */ + fun lifetimeFrom( + eventsTotals: TokenStatsTotals, + baselines: List, + params: TokenStatsQueryParams, + ): TokenStatsLifetimeOverview { + val baselineTotals = baselineTotalsOf(baselines, params) + return TokenStatsLifetimeOverview( + eventTotals = eventsTotals, + baselineTotals = baselineTotals, + combinedRequests = + TokenCostCalculator.saturatedAdd(eventsTotals.requests, baselineTotals.requests), + ) + } + + /** + * 生命周期事件合计的增量累加器(P2-1):DAO 分页回调逐页喂入 [addPage], + * 事务结束后 [totals] 产出与单列表聚合完全一致的 [TokenStatsTotals]。 + * 内部沿用 [eventCost]/[buildCostSummary] 的同一计费与换算语义。 + */ + internal class TokenStatsEventTotalsAccumulator( + private val legacyPrices: Map, + private val params: TokenStatsQueryParams, + ) { + private var pricing: PricingContext? = null + private var identitiesById: Map = emptyMap() + private var requests = 0L + private val uncached = TokenComponentAccumulator() + private val cached = TokenComponentAccumulator() + private val cacheWrite = TokenComponentAccumulator() + private val totalInput = TokenComponentAccumulator() + private val output = TokenComponentAccumulator() + private val reasoning = TokenComponentAccumulator() + private val originalCosts = EnumMap(PricingCurrency::class.java) + private var costUnknownCount = 0L + + fun addPage( + events: List, + identities: Map, + overrides: List, + ) { + if (pricing == null) pricing = PricingContext(overrides, legacyPrices, params) + identitiesById = identities + for (event in events) { + requests = TokenCostCalculator.saturatedAdd(requests, 1L) + uncached.accept(event.uncachedInputTokens) + cached.accept(event.cachedInputTokens) + cacheWrite.accept(event.cacheWriteTokens) + totalInput.accept(event.totalInputTokens) + output.accept(event.outputTokens) + reasoning.accept(event.reasoningTokens) + val (amount, currency) = + eventCost(event, identities[event.statIdentityId], pricing!!, params) + if (amount == null) { + costUnknownCount += 1 + } else { + originalCosts.merge(currency, BigDecimal(amount)) { left, right -> left.add(right) } + } + } + } + + fun totals(): TokenStatsTotals = + TokenStatsTotals( + requests = requests, + uncachedInput = uncached.aggregate(requests), + cachedInput = cached.aggregate(requests), + cacheWrite = cacheWrite.aggregate(requests), + totalInput = totalInput.aggregate(requests), + output = output.aggregate(requests), + reasoning = reasoning.aggregate(requests), + cost = + buildCostSummary( + original = originalCosts, + unknownCount = costUnknownCount, + totalCount = requests, + params = params, + ), + ) + + private class TokenComponentAccumulator { + private var knownSum = 0L + private var knownCount = 0L + private var unknownCount = 0L + + fun accept(value: Long?) { + if (value == null) { + unknownCount += 1 + } else { + knownCount += 1 + knownSum = TokenCostCalculator.saturatedAdd(knownSum, value) + } + } + + fun aggregate(totalEventCount: Long): TokenStatsTokenAggregate = + TokenStatsTokenAggregate( + knownSum = knownSum, + knownEventCount = knownCount, + unknownEventCount = unknownCount, + totalEventCount = totalEventCount, + ) + } + } + + // ==== 时间范围数据(汇总 + 趋势桶 + 明细) ==== + + fun rangeData( + events: List, + identitiesById: Map, + displayModelsById: Map, + overrides: List, + legacyPrices: Map, + range: TokenStatsTimeRange, + granularity: TokenStatsGranularity, + zone: ZoneId, + params: TokenStatsQueryParams, + ): TokenStatsRangeData { + // 分类筛选在聚合入口统一应用:汇总、桶、明细反映同一筛选结果 + val filtered = filterByCategory(events, params) + val pricing = pricingContext(overrides, legacyPrices, params) + + val summary = totalsOf(filtered, identitiesById, pricing, params) + val performance = performanceOf(filtered) + + val buckets = buildBuckets( + filtered, identitiesById, pricing, params, range, granularity, zone, + ) + + val displayModels = displayModelBreakdowns(filtered, identitiesById, displayModelsById, pricing, params) + val categories = categoryBreakdowns(filtered, identitiesById, pricing, params) + val statuses = statusBreakdowns(filtered, identitiesById, pricing, params) + + return TokenStatsRangeData( + range = range, + granularity = granularity, + eventCount = filtered.size.toLong(), + summary = summary, + performance = performance, + buckets = buckets, + displayModels = displayModels, + categories = categories, + statuses = statuses, + ) + } + + // ==== 桶构建 ==== + + private fun buildBuckets( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + range: TokenStatsTimeRange, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): List { + val bucketStarts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) + val bucketEnds = + bucketStarts.indices.map { index -> + TokenStatsTimeRanges.bucketEndMs(bucketStarts, index, granularity, zone) + } + // 单遍分摊:每个事件恰好落入一个桶(补齐空桶由固定桶骨架保证) + val bucketEvents = Array(bucketStarts.size) { mutableListOf() } + for (event in events) { + val index = + TokenStatsTimeRanges.bucketIndexOf(event.startedAtMs, bucketStarts, granularity, zone) + ?: continue + bucketEvents[index].add(event) + } + return bucketStarts.indices.map { index -> + val bucketEventsForIndex = bucketEvents[index] + TokenStatsTrendBucket( + bucketStartMs = bucketStarts[index], + bucketEndMs = bucketEnds[index], + totals = totalsOf(bucketEventsForIndex, identitiesById, pricing, params), + byModel = modelBucketsOf(bucketEventsForIndex, identitiesById, pricing, params), + performance = performanceOf(bucketEventsForIndex), + ) + } + } + + private fun modelBucketsOf( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): Map { + val byDisplay = LinkedHashMap>() + for (event in events) { + val identity = identitiesById[event.statIdentityId] ?: continue + byDisplay.getOrPut(identity.displayModelId) { mutableListOf() }.add(event) + } + return byDisplay.mapValues { (_, modelEvents) -> + TokenStatsModelBucket( + requests = modelEvents.size.toLong(), + uncachedInput = sumKnownTokens(modelEvents) { it.uncachedInputTokens }, + cachedInput = sumKnownTokens(modelEvents) { it.cachedInputTokens }, + cacheWrite = sumKnownTokens(modelEvents) { it.cacheWriteTokens }, + output = sumKnownTokens(modelEvents) { it.outputTokens }, + reasoning = sumKnownTokens(modelEvents) { it.reasoningTokens }, + unknownTokenEventCount = + modelEvents.count { + it.uncachedInputTokens == null || it.cachedInputTokens == null || it.outputTokens == null + }.toLong(), + cost = costSummaryOf(modelEvents, identitiesById, pricing, params), + ) + } + } + + // ==== 明细 ==== + + private fun displayModelBreakdowns( + events: List, + identitiesById: Map, + displayModelsById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): List { + val byDisplay = LinkedHashMap>() + for (event in events) { + val identity = identitiesById[event.statIdentityId] ?: continue + byDisplay.getOrPut(identity.displayModelId) { mutableListOf() }.add(event) + } + return byDisplay.map { (displayModelId, modelEvents) -> + val display = displayModelsById[displayModelId] + val byIdentity = LinkedHashMap>() + for (event in modelEvents) { + byIdentity.getOrPut(event.statIdentityId) { mutableListOf() }.add(event) + } + val identities = + byIdentity.map { (identityId, identityEvents) -> + val identity = identitiesById.getValue(identityId) + TokenStatsIdentityBreakdown( + identityId = identityId, + configId = identity.configId, + provider = identity.provider, + model = identity.model, + totals = totalsOf(identityEvents, identitiesById, pricing, params), + pricing = pricingInfoFor(identity, identityEvents, pricing, params), + ) + }.sortedWith(compareByDescending { it.totals.requests }) + TokenStatsDisplayModelBreakdown( + displayModelId = displayModelId, + displayName = display?.displayName ?: displayModelId, + normalizedModel = display?.normalizedModel ?: displayModelId, + totals = totalsOf(modelEvents, identitiesById, pricing, params), + identities = identities, + ) + }.sortedWith( + compareByDescending { it.totals.requests } + .thenBy { it.displayName.lowercase() } + ) + } + + private fun categoryBreakdowns( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): List = + events.groupBy { TokenStatCategory.fromName(it.category) } + .map { (category, categoryEvents) -> + TokenStatsCategoryBreakdown( + category = category, + totals = totalsOf(categoryEvents, identitiesById, pricing, params), + ) + } + .sortedWith( + compareByDescending { it.totals.requests } + .thenBy { it.category.name } + ) + + private fun statusBreakdowns( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): List = + TokenStatStatus.entries.mapNotNull { status -> + val statusEvents = events.filter { TokenStatStatus.fromName(it.status) == status } + if (statusEvents.isEmpty()) { + null + } else { + TokenStatsStatusBreakdown( + status = status, + totals = totalsOf(statusEvents, identitiesById, pricing, params), + ) + } + } + + // ==== 合计 ==== + + private fun totalsOf( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): TokenStatsTotals = + TokenStatsTotals( + requests = events.size.toLong(), + uncachedInput = tokenAggregateOf(events) { it.uncachedInputTokens }, + cachedInput = tokenAggregateOf(events) { it.cachedInputTokens }, + cacheWrite = tokenAggregateOf(events) { it.cacheWriteTokens }, + totalInput = tokenAggregateOf(events) { it.totalInputTokens }, + output = tokenAggregateOf(events) { it.outputTokens }, + reasoning = tokenAggregateOf(events) { it.reasoningTokens }, + cost = costSummaryOf(events, identitiesById, pricing, params), + ) + + private fun tokenAggregateOf( + events: List, + pick: (TokenStatEventEntity) -> Long?, + ): TokenStatsTokenAggregate { + var sum = 0L + var known = 0L + for (event in events) { + val value = pick(event) ?: continue + known += 1 + sum = TokenCostCalculator.saturatedAdd(sum, value) + } + return TokenStatsTokenAggregate( + knownSum = sum, + knownEventCount = known, + unknownEventCount = events.size.toLong() - known, + totalEventCount = events.size.toLong(), + ) + } + + private fun sumKnownTokens( + events: List, + pick: (TokenStatEventEntity) -> Long?, + ): Long = + events.fold(0L) { acc, event -> + val value = pick(event) ?: return@fold acc + TokenCostCalculator.saturatedAdd(acc, value) + } + + // ==== 费用 ==== + + private fun costSummaryOf( + events: List, + identitiesById: Map, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): TokenStatsCostSummary { + val original = EnumMap(PricingCurrency::class.java) + var unknownCount = 0L + for (event in events) { + val (amount, currency) = eventCost(event, identitiesById[event.statIdentityId], pricing, params) + if (amount == null) { + unknownCount += 1 + continue + } + original.merge(currency, BigDecimal(amount)) { left, right -> left.add(right) } + } + return buildCostSummary(original, unknownCount, events.size.toLong(), params) + } + + /** + * 单事件原币费用贡献: + * - HISTORICAL:事件保存的“发生时”快照(null = 未知,不猜测、不当作 0); + * - REVALUED:当前分层价格 × 事件用量重算(同一 [TokenCostCalculator] 语义, + * 缓存写入独立计费与推理包含边界与落账时一致,不重复收费)。 + * **未知定价(identity 缺失或 [ResolvedPricing.known] = false)一律贡献 null + * (P1-1)**:即使默认价表给出全 0 价格,也不能把“无法定价”伪装成 0 元。 + */ + private fun eventCost( + event: TokenStatEventEntity, + identity: TokenStatIdentityEntity?, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): Pair = + if (params.mode == TokenStatsCostMode.REVALUED) { + val resolved = identity?.let { pricing.pricingFor(it) } + if (resolved == null || !resolved.known) { + null to parseCurrency(event.pricingCurrency) + } else { + val result = TokenCostCalculator.computeCost(event.toUsageInput(), resolved) + result.amount to result.currency + } + } else { + event.costInPricingCurrency to parseCurrency(event.pricingCurrency) + } + + private fun baselineTotalsOf( + baselines: List, + params: TokenStatsQueryParams, + ): TokenStatsBaselineTotals { + var requests = 0L + var inputTokens = 0L + var cachedInputTokens = 0L + var outputTokens = 0L + var anyEstimated = false + val original = EnumMap(PricingCurrency::class.java) + var unknownCount = 0L + for (baseline in baselines) { + requests = TokenCostCalculator.saturatedAdd(requests, baseline.requestCount) + inputTokens = TokenCostCalculator.saturatedAdd(inputTokens, baseline.inputTokens) + cachedInputTokens = + TokenCostCalculator.saturatedAdd(cachedInputTokens, baseline.cachedInputTokens) + outputTokens = TokenCostCalculator.saturatedAdd(outputTokens, baseline.outputTokens) + anyEstimated = anyEstimated || baseline.isEstimated + val amount = baseline.costInPricingCurrency + if (amount == null) { + unknownCount += 1 + } else { + original.merge( + parseCurrency(baseline.pricingCurrency), + BigDecimal(amount), + ) { left, right -> left.add(right) } + } + } + return TokenStatsBaselineTotals( + identityCount = baselines.size.toLong(), + requests = requests, + inputTokens = inputTokens, + cachedInputTokens = cachedInputTokens, + outputTokens = outputTokens, + cost = + buildCostSummary( + original = original, + unknownCount = unknownCount, + totalCount = baselines.size.toLong(), + params = params, + modeOverride = TokenStatsCostMode.HISTORICAL, + ), + anyEstimated = anyEstimated, + ) + } + + /** 原币合计 → 目标币种(BigDecimal 换算,边界才转 Double)。 */ + private fun buildCostSummary( + original: EnumMap, + unknownCount: Long, + totalCount: Long, + params: TokenStatsQueryParams, + modeOverride: TokenStatsCostMode? = null, + ): TokenStatsCostSummary { + val usd = original[PricingCurrency.USD] ?: BigDecimal.ZERO + val cny = original[PricingCurrency.CNY] ?: BigDecimal.ZERO + val convertedUsd = convertTo(usd, PricingCurrency.USD, params.targetCurrency, params.manualRate) + val convertedCny = convertTo(cny, PricingCurrency.CNY, params.targetCurrency, params.manualRate) + val amounts = + mapOf( + PricingCurrency.USD to usd, + PricingCurrency.CNY to cny, + ).filterValues { it.signum() != 0 } + .mapValues { (_, value) -> value.toDouble() } + return TokenStatsCostSummary( + currency = params.targetCurrency, + knownAmount = convertedUsd.add(convertedCny, MC).toDouble(), + unknownContributionCount = unknownCount, + totalContributionCount = totalCount, + rateUsed = params.manualRate, + rateIsEstimated = params.rateIsEstimated, + mode = modeOverride ?: params.mode, + originalCurrencyAmounts = amounts, + ) + } + + private fun convertTo( + amount: BigDecimal, + from: PricingCurrency, + to: PricingCurrency, + rate: Double, + ): BigDecimal { + if (from == to) return amount + return if (from == PricingCurrency.USD) { + amount.multiply(BigDecimal(rate), MC) + } else { + amount.divide(BigDecimal(rate), MC) + } + } + + // ==== 性能 ==== + + /** 性能聚合(internal 供测试直接调用)。 */ + internal fun performanceOf(events: List): TokenStatsPerformance { + var ttftKnown = 0L + var ttftTotal = 0L + var ttftUnknown = 0L + var generationKnown = 0L + var generationTotal = 0L + var generationUnknown = 0L + for (event in events) { + val started = event.startedAtMs + val first = event.firstTokenAtMs + val ended = event.endedAtMs + // 时长安全计算:非负时间戳 + 结束≥开始;无效样本记为 unknown 排除平均 + if (first != null && started >= 0 && first >= started) { + ttftKnown += 1 + ttftTotal = TokenCostCalculator.saturatedAdd(ttftTotal, first - started) + } else { + ttftUnknown += 1 + } + if (first != null && ended >= 0 && first >= 0 && ended >= first) { + generationKnown += 1 + generationTotal = TokenCostCalculator.saturatedAdd(generationTotal, ended - first) + } else { + generationUnknown += 1 + } + } + return TokenStatsPerformance( + ttft = durationAggregate(ttftKnown, ttftUnknown, ttftTotal), + generationDuration = durationAggregate(generationKnown, generationUnknown, generationTotal), + ) + } + + private fun durationAggregate(known: Long, unknown: Long, totalMs: Long): TokenStatsDurationAggregate = + TokenStatsDurationAggregate( + knownCount = known, + unknownCount = unknown, + totalMs = totalMs, + averageMs = if (known > 0L) totalMs / known.toDouble() else 0.0, + ) + + // ==== 价格上下文(重估用) ==== + + private fun pricingContext( + overrides: List, + legacyPrices: Map, + params: TokenStatsQueryParams, + ): PricingContext = PricingContext(overrides, legacyPrices, params) + + private class PricingContext( + private val overrides: List, + private val legacyPrices: Map, + private val params: TokenStatsQueryParams, + ) { + private val cache = HashMap() + + fun pricingFor(identity: TokenStatIdentityEntity): ResolvedPricing? { + if (params.mode != TokenStatsCostMode.REVALUED) return null + return cache.getOrPut(identity.identityId) { resolveCurrent(identity) } + } + + private fun resolveCurrent(identity: TokenStatIdentityEntity): ResolvedPricing { + val providerModel = identity.providerModel + return TokenPriceResolver.resolve( + provider = identity.provider, + model = identity.model, + configId = identity.configId, + overrides = overrides, + legacyOverride = legacyPrices[providerModel], + defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), + ) + } + } + + private fun pricingInfoFor( + identity: TokenStatIdentityEntity, + events: List, + pricing: PricingContext, + params: TokenStatsQueryParams, + ): TokenStatsPricingInfo? { + if (events.isEmpty()) return null + return if (params.mode == TokenStatsCostMode.REVALUED) { + pricing.pricingFor(identity)?.toPricingInfo() + } else { + val latest = events.maxByOrNull { it.startedAtMs } ?: return null + TokenStatsPricingInfo( + billingMode = BillingMode.fromString(latest.billingMode), + currency = parseCurrency(latest.pricingCurrency), + inputPricePerMillion = latest.inputPricePerMillion, + cachedInputPricePerMillion = latest.cachedInputPricePerMillion, + cacheWritePricePerMillion = latest.cacheWritePricePerMillion, + outputPricePerMillion = latest.outputPricePerMillion, + pricePerRequest = latest.pricePerRequest, + source = PricingSource.fromName(latest.pricingSource), + known = latest.pricingSource != PricingSource.UNKNOWN.name, + ) + } + } + + // ==== 工具 ==== + + private fun filterByCategory( + events: List, + params: TokenStatsQueryParams, + ): List { + val categories = params.categories ?: return events + return events.filter { TokenStatCategory.fromName(it.category) in categories } + } + + private fun parseCurrency(raw: String): PricingCurrency = + if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD + + private fun TokenStatEventEntity.toUsageInput(): TokenUsageInput = + TokenUsageInput( + uncachedInputTokens = uncachedInputTokens, + cachedInputTokens = cachedInputTokens, + cacheWriteTokens = cacheWriteTokens, + totalInputTokens = totalInputTokens, + outputTokens = outputTokens, + reasoningTokens = reasoningTokens, + reasoningIncludedInOutput = reasoningIncludedInOutput, + // null = 旧行未声明独立计费概念:按保守默认 true(该字段未知时 + // 缓存写入未知会阻塞费用,与“未知不当作 0”一致) + cacheWriteSeparateBilling = cacheWriteSeparateBilling ?: true, + ) + + private fun ResolvedPricing.toPricingInfo(): TokenStatsPricingInfo = + TokenStatsPricingInfo( + billingMode = billingMode, + currency = currency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + source = source, + known = known, + ) +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt new file mode 100644 index 000000000..22910a663 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -0,0 +1,270 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity + +/** + * 统计查询领域模型(阶段 3,供阶段 4 UI/ViewModel 直接复用)。 + * + * unknown 与 0 的约定贯穿全部聚合结果: + * - token/价格/成本为 null 表示“未知”,**不得静默当作 0**; + * - 0 表示 provider 确认该分量为 0(如确认无缓存读取)。 + * 聚合层用 [TokenStatsTokenAggregate]/[TokenStatsCostSummary] 显式携带 + * known/unknown 计数,部分未知可表达,绝不伪装成精确 0。 + */ + +/** 费用口径:事件历史快照(账单价)或当前分层价格重估。 */ +enum class TokenStatsCostMode { + /** 使用事件保存的“发生时”价格快照与原币成本(默认口径)。 */ + HISTORICAL, + + /** 使用当前分层价格(覆盖 > 旧系统价格 > 内置默认价)× 事件用量重算。 */ + REVALUED, +} + +/** + * 聚合参数。汇率只由用户手动设置;未设置时调用方传入默认估算值 + * [TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE] 并标记 [rateIsEstimated] = true + * (界面必须显示估算提示)。修改汇率只改变统一币种换算,不改变历史原币成本。 + */ +data class TokenStatsQueryParams( + /** 总计展示目标币种。 */ + val targetCurrency: PricingCurrency = PricingCurrency.CNY, + /** 当前手动 USD→CNY 汇率;必须为正。 */ + val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, + /** true = 汇率是默认估算值(非用户手动设置),界面必须标记估算。 */ + val rateIsEstimated: Boolean = true, + /** 费用口径:历史快照(默认)或当前价格重估。 */ + val mode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL, + /** 展示模型筛选(identity.displayModelId);null = 全部。 */ + val displayModelIds: Set? = null, + /** 业务分类筛选;null = 全部分类。 */ + val categories: Set? = null, +) { + init { + require(manualRate > 0.0) { "manual rate must be positive" } + } +} + +/** + * 费用合计。已知部分按目标币种汇总(BigDecimal 累加,无 Double 漂移), + * 原币金额按币种分别保存(供 UI 按原币/模型堆叠);无法定价的贡献 + * (unknown)计数保留,[isFullyKnown] = false 表示结果为部分未知。 + */ +data class TokenStatsCostSummary( + /** 目标展示币种。 */ + val currency: PricingCurrency, + /** 已知贡献合计(目标币种,BigDecimal 累加结果)。 */ + val knownAmount: Double, + /** 无法定价(unknown)的贡献条数;不为 0 时结果是 partial,不能当精确账单。 */ + val unknownContributionCount: Long, + /** 参与合计的贡献条数(事件数或 baseline 行数)。 */ + val totalContributionCount: Long, + /** 本次换算使用的 USD→CNY 汇率。 */ + val rateUsed: Double, + /** true = 汇率是默认估算值。 */ + val rateIsEstimated: Boolean, + /** 费用口径。baseline 无论参数如何都按冻结快照(HISTORICAL)展示。 */ + val mode: TokenStatsCostMode, + /** 原币已知合计(仅含 > 0 币种,供堆叠/分币种展示)。 */ + val originalCurrencyAmounts: Map, +) { + val isFullyKnown: Boolean + get() = unknownContributionCount == 0L + + val hasAnyContribution: Boolean + get() = totalContributionCount > 0L +} + +/** + * 单分量 token 合计:已知事件求和(Long 饱和加法,绝不回绕为负), + * 未知事件单独计数,区分“全部已知”与“部分未知”。 + */ +data class TokenStatsTokenAggregate( + /** 已知分量的和(饱和加法,上限 Long.MAX_VALUE)。 */ + val knownSum: Long, + /** 该分量已知的事件数。 */ + val knownEventCount: Long, + /** 该分量未知(provider 未上报)的事件数;0 与未知严格区分。 */ + val unknownEventCount: Long, + /** 参与合计的事件总数。 */ + val totalEventCount: Long, +) { + val isFullyKnown: Boolean + get() = unknownEventCount == 0L +} + +/** + * 时长聚合(首 Token 延迟 / 生成时长)。 + * 无效时长(时间戳缺失、结束早于开始、负数时间戳)一律记为 unknown, + * 不进平均;knownCount == 0 时 [averageMs] 为 0(调用方应展示“无数据”而非均值)。 + */ +data class TokenStatsDurationAggregate( + val knownCount: Long, + val unknownCount: Long, + /** 已知时长总和(毫秒,饱和加法)。 */ + val totalMs: Long, + /** 平均时长(毫秒);knownCount == 0 时为 0.0。 */ + val averageMs: Double, +) { + val hasData: Boolean + get() = knownCount > 0L +} + +/** 性能指标:首 Token 延迟(TTFT)与生成时长(首个 token 到结束)。 */ +data class TokenStatsPerformance( + val ttft: TokenStatsDurationAggregate, + val generationDuration: TokenStatsDurationAggregate, +) + +/** 一组事件(或一个分组)的完整合计。 */ +data class TokenStatsTotals( + val requests: Long, + val uncachedInput: TokenStatsTokenAggregate, + val cachedInput: TokenStatsTokenAggregate, + val cacheWrite: TokenStatsTokenAggregate, + val totalInput: TokenStatsTokenAggregate, + val output: TokenStatsTokenAggregate, + val reasoning: TokenStatsTokenAggregate, + val cost: TokenStatsCostSummary, +) + +/** 旧数据迁移 baseline 的生命周期合计(费用按迁移时冻结快照,恒为估算口径)。 */ +data class TokenStatsBaselineTotals( + /** baseline 行数(身份数)。 */ + val identityCount: Long, + val requests: Long, + val inputTokens: Long, + val cachedInputTokens: Long, + val outputTokens: Long, + val cost: TokenStatsCostSummary, + /** 任一行估算标记为 true 即 true(baseline 本身就是估算,正常恒为 true)。 */ + val anyEstimated: Boolean, +) + +/** 生命周期累计总览(独立于时间/模型/分类筛选;事件 + baseline)。 */ +data class TokenStatsLifetimeOverview( + val eventTotals: TokenStatsTotals, + val baselineTotals: TokenStatsBaselineTotals, + /** 事件 + baseline 请求数合计(饱和加法)。 */ + val combinedRequests: Long, +) + +/** 单个图表桶:半开区间 [bucketStartMs, bucketEndMs),含按展示模型拆分。 */ +data class TokenStatsTrendBucket( + val bucketStartMs: Long, + val bucketEndMs: Long, + val totals: TokenStatsTotals, + /** displayModelId -> 桶内该展示模型的合计(费用堆叠按模型/原币)。 */ + val byModel: Map, + val performance: TokenStatsPerformance, +) + +/** 桶内单个展示模型的合计(token 为已知分量和,unknown 事件单独计数)。 */ +data class TokenStatsModelBucket( + val requests: Long, + val uncachedInput: Long, + val cachedInput: Long, + val cacheWrite: Long, + val output: Long, + val reasoning: Long, + /** 任一核心 token 分量(uncached/cached/output)未知的事件数。 */ + val unknownTokenEventCount: Long, + val cost: TokenStatsCostSummary, +) + +/** 单价的展示信息:历史口径取该身份最近事件的价格快照;重估口径取当前解析。 */ +data class TokenStatsPricingInfo( + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double?, + val cachedInputPricePerMillion: Double?, + val cacheWritePricePerMillion: Double?, + val outputPricePerMillion: Double?, + val pricePerRequest: Double?, + val source: PricingSource, + val known: Boolean, +) + +/** 单个统计身份(configId+provider+model)的展开明细。 */ +data class TokenStatsIdentityBreakdown( + val identityId: String, + val configId: String, + val provider: String, + val model: String, + val totals: TokenStatsTotals, + val pricing: TokenStatsPricingInfo?, +) + +/** 展示模型分组明细:默认规范化同名归组 + 用户手动别名(identity.displayModelId)。 */ +data class TokenStatsDisplayModelBreakdown( + val displayModelId: String, + val displayName: String, + val normalizedModel: String, + val totals: TokenStatsTotals, + val identities: List, +) + +/** 业务分类合计。 */ +data class TokenStatsCategoryBreakdown( + val category: TokenStatCategory, + val totals: TokenStatsTotals, +) + +/** 请求状态合计。 */ +data class TokenStatsStatusBreakdown( + val status: TokenStatStatus, + val totals: TokenStatsTotals, +) + +/** + * 指定时间范围的完整查询结果:范围总计、性能、趋势桶(补齐空桶、 + * 桶合计 == 范围总计)、展示模型/身份、分类、状态明细。 + * baseline 无时间分布,永不进入范围数据。 + */ +data class TokenStatsRangeData( + val range: TokenStatsTimeRange, + val granularity: TokenStatsGranularity, + val eventCount: Long, + val summary: TokenStatsTotals, + val performance: TokenStatsPerformance, + val buckets: List, + val displayModels: List, + val categories: List, + val statuses: List, +) + +/** + * 范围查询的同事务只读快照(P1-2):identity/display model/价格覆盖/事件由 + * [com.ai.assistance.operit.data.dao.TokenStatsDao.loadRangeSnapshot] 在**同一个 + * Room 事务**内固定读取(SQLite 事务内快照一致),事务外由聚合器纯函数消费。 + * 并发写入要么整体可见要么整体不可见,杜绝“summary 有事件但模型桶缺失”的 + * 拆分状态;查询期间不重复取 DAO。 + */ +data class TokenStatsQuerySnapshot( + val events: List, + val identitiesById: Map, + val displayModelsById: Map, + val overrides: List, + val baselines: List, +) + +/** + * 生命周期快照的固定小表部分(P1-2/P2-1):identity/display model/价格覆盖/ + * baseline 在同一事务内一次读取;事件不实体化——由 + * [com.ai.assistance.operit.data.dao.TokenStatsDao.loadLifetimeSnapshot] 按 + * `(startedAtMs, eventId)` 键集分页逐页回调增量累加器(每页至多 [pageSize]), + * 避免整表实体化的内存峰值,且分页与事务同界(页面间快照一致)。 + */ +data class TokenStatsLifetimeRead( + val identitiesById: Map, + val displayModelsById: Map, + val overrides: List, + val baselines: List, + val totalEvents: Long, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt new file mode 100644 index 000000000..0a8e22c4a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -0,0 +1,215 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.preferences.ApiPreferences +import java.time.ZoneId +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 统计查询服务(阶段 3):同事务只读快照 + [TokenStatsAggregator] 单遍聚合。 + * + * 查询策略(防 N+1 / 一致快照): + * - 组成响应的**全部 Room 读取**(identity/display model/价格覆盖/事件/baseline) + * 在**同一个 Room 事务**内固定读取([TokenStatsDao.loadRangeSnapshot] / + * [TokenStatsDao.loadLifetimeSnapshot]),事务外纯聚合:并发写入要么整体可见 + * 要么整体不可见,杜绝“summary 有事件但模型桶缺失”的拆分状态(P1-2); + * - 展示模型筛选在快照事务内走 JOIN 单条 IN 查询,模型数超过 900(SQLite 变量 + * 上限留余量)时在**同一事务**内分块合并(P2-2);null = 全部、空 = 无事件; + * - 生命周期总览不整表实体化:事件按 `(startedAtMs, eventId)` 键集分页 + * (每页 [lifetimeEventPageSize] 条)在同事务内喂给增量累加器(P2-1); + * - 重估口径的旧系统价格(DataStore)**先读一次快照**( + * [ApiPreferences.allLegacyPriceSettings]),再进入 Room 快照事务;Room 事务 + * 不能挂起 DataStore,先后顺序明确——价格只影响重估,不影响事件结构(P1-2)。 + * + * 线程(P2-3):所有公共入口显式 [withContext] 到 [queryDispatcher](默认 + * [Dispatchers.IO],测试可注入),阶段 4 Main 调用不阻塞。 + * + * 汇率与币种:聚合接收当前手动 USD/CNY 汇率(默认 + * [TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE] = 7.0 并标记 estimated), + * 按目标币种换算;事件原币成本永远不变。 + */ +object TokenStatsQueryService { + + internal var databaseProvider: ((Context) -> AppDatabase)? = null + + /** + * 旧系统价格注入缝(一次快照读取整表):生产走 + * [ApiPreferences.allLegacyPriceSettings](单次 DataStore 读取);测试注入桩, + * 避免触碰真实 DataStore。 + */ + internal var legacyPricesProvider: (suspend (Context) -> Map)? = null + + /** Room 查询 + 聚合的执行线程(P2-3):生产默认 IO,测试可注入记录线程的调度器。 */ + internal var queryDispatcher: CoroutineDispatcher = Dispatchers.IO + + /** 生命周期事件分页大小(P2-1):固定批次读取 + 增量聚合,避免整表实体化峰值。 */ + internal var lifetimeEventPageSize: Int = 1_000 + + // ==== 核心查询(DAO 直连,生产与测试共用) ==== + + /** 生命周期累计总览(事件 + baseline,独立于筛选;事件分页增量聚合,不整表实体化)。 */ + suspend fun lifetimeOverview( + dao: TokenStatsDao, + params: TokenStatsQueryParams, + legacyPrices: Map = emptyMap(), + ): TokenStatsLifetimeOverview { + val accumulator = TokenStatsAggregator.TokenStatsEventTotalsAccumulator(legacyPrices, params) + val read = + dao.loadLifetimeSnapshot( + includeOverrides = params.mode == TokenStatsCostMode.REVALUED, + pageSize = lifetimeEventPageSize, + onEventsPage = { page, identities, overrides -> + accumulator.addPage(page, identities, overrides) + }, + ) + return TokenStatsAggregator.lifetimeFrom( + eventsTotals = accumulator.totals(), + baselines = read.baselines, + params = params, + ) + } + + /** + * 指定时间范围的完整查询(汇总 + 趋势桶 + 模型/分类/状态明细)。 + * 全部 Room 读取在 [TokenStatsDao.loadRangeSnapshot] 同一事务快照内; + * 粒度按范围时长由 [TokenStatsTimeRanges.granularityFor] 选择。 + */ + suspend fun rangeData( + dao: TokenStatsDao, + range: TokenStatsTimeRange, + params: TokenStatsQueryParams, + zone: ZoneId, + legacyPrices: Map = emptyMap(), + ): TokenStatsRangeData { + val snapshot = dao.loadRangeSnapshot( + startMs = range.startMs, + endMs = range.endMs, + displayModelIds = params.displayModelIds?.toList(), + includeOverrides = params.mode == TokenStatsCostMode.REVALUED, + ) + return TokenStatsAggregator.rangeData( + events = snapshot.events, + identitiesById = snapshot.identitiesById, + displayModelsById = snapshot.displayModelsById, + overrides = snapshot.overrides, + legacyPrices = legacyPrices, + range = range, + granularity = TokenStatsTimeRanges.granularityFor(range), + zone = zone, + params = params, + ) + } + + /** 时间范围内是否存在事件(初始回退探测,每条都是索引 EXISTS 短路查询)。 */ + suspend fun rangeHasEvents(dao: TokenStatsDao, range: TokenStatsTimeRange): Boolean = + dao.rangeHasEvents(range.startMs, range.endMs) + + /** + * 首次进入的初始回退建议:按 `5h -> 12h -> 24h -> 7d -> 30d` 顺序返回 + * 最近有实际事件的范围;全部为空时返回 5h。 + * “用户手选后不再自动跳转”由调用方(阶段 4 UI/ViewModel)持久化, + * 本函数只计算首次建议,不改变任何状态。 + */ + suspend fun initialPresetWithData( + dao: TokenStatsDao, + zone: ZoneId, + nowMs: Long, + ): TokenStatsPreset { + for (preset in TokenStatsPreset.INITIAL_FALLBACK_ORDER) { + val range = TokenStatsTimeRanges.rangeFor(preset, nowMs, zone) + if (dao.rangeHasEvents(range.startMs, range.endMs)) return preset + } + return TokenStatsPreset.LAST_5H + } + + // ==== 生产入口(Context 解析数据库/旧价格;汇率由调用方在 params 中提供) ==== + + suspend fun lifetimeOverview(context: Context, params: TokenStatsQueryParams): TokenStatsLifetimeOverview = + withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> + lifetimeOverview(dao, params, legacyPrices) + } + + suspend fun rangeData( + context: Context, + range: TokenStatsTimeRange, + params: TokenStatsQueryParams, + zone: ZoneId = ZoneId.systemDefault(), + ): TokenStatsRangeData = + withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> + rangeData(dao, range, params, zone, legacyPrices) + } + + suspend fun presetRangeData( + context: Context, + preset: TokenStatsPreset, + params: TokenStatsQueryParams, + zone: ZoneId = ZoneId.systemDefault(), + nowMs: Long = System.currentTimeMillis(), + ): TokenStatsRangeData = + withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> + rangeData( + dao, + TokenStatsTimeRanges.rangeFor(preset, nowMs, zone), + params, + zone, + legacyPrices, + ) + } + + suspend fun initialPresetWithData( + context: Context, + zone: ZoneId = ZoneId.systemDefault(), + nowMs: Long = System.currentTimeMillis(), + ): TokenStatsPreset = + withContext(queryDispatcher) { + initialPresetWithData(daoOf(context), zone, nowMs) + } + + /** + * 生产入口统一骨架(P1-2/P2-3): + * 1. 显式切到 [queryDispatcher] 执行 Room + 聚合(Main 不阻塞); + * 2. **先**读旧价格一次快照(DataStore,Room 事务内不能挂起 DataStore); + * 3. **再**解析数据库一次并进入 Room 快照事务; + * 4. 事务外纯聚合。 + */ + private suspend fun withDatabaseAndLegacyPrices( + context: Context, + params: TokenStatsQueryParams, + block: suspend (TokenStatsDao, Map) -> T, + ): T { + val appContext = context.applicationContext + return withContext(queryDispatcher) { + val legacyPrices = readLegacyPrices(appContext, params) + val database = + databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + block(database.tokenStatsDao(), legacyPrices) + } + } + + private suspend fun daoOf(context: Context): TokenStatsDao { + val appContext = context.applicationContext + val injected = databaseProvider + return (injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext)).tokenStatsDao() + } + + /** + * 重估口径的旧系统价格:**单次快照读取**整表(不逐 identity 反复读 DataStore)。 + * 顺序契约:必须在 Room 快照事务之前读取(见 [withDatabaseAndLegacyPrices])。 + */ + private suspend fun readLegacyPrices( + context: Context, + params: TokenStatsQueryParams, + ): Map { + if (params.mode != TokenStatsCostMode.REVALUED) return emptyMap() + val injected = legacyPricesProvider + return if (injected != null) { + injected(context) + } else { + ApiPreferences.getInstance(context).allLegacyPriceSettings() + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt new file mode 100644 index 000000000..fad2a45c8 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt @@ -0,0 +1,219 @@ +package com.ai.assistance.operit.data.stats + +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * 统计时间预设(阶段 3 契约,见计划 §2.5): + * + * - 5h/12h/24h 为**滚动窗口**:`[now - 时长, now)`。 + * - 今天/昨天/7d/30d/本月/上月为**自然日/自然月**:按设备当前时区([ZoneId]) + * 的日历边界计算,禁止用固定毫秒加减替代日历运算(DST 与跨月自动正确)。 + * - 7d = 今天往前 7 个自然日(含今天);30d = 今天往前 30 个自然日(含今天), + * 与 Rainytoken 参考实现一致。 + * - 自定义范围显式传 [TokenStatsTimeRanges.customRange]。 + * + * “首次进入自动回退”只选择最近有实际数据的前缀(5h→12h→24h→7d→30d,见 + * [TokenStatsPreset.INITIAL_FALLBACK_ORDER]);用户手动选择后不再自动跳转是 + * 调用方(阶段 4 UI/ViewModel)的持久化职责,本层只提供纯函数建议。 + */ +enum class TokenStatsPreset { + LAST_5H, + LAST_12H, + LAST_24H, + TODAY, + YESTERDAY, + LAST_7D, + LAST_30D, + THIS_MONTH, + LAST_MONTH, + CUSTOM; + + companion object { + /** 首次进入的自动回退顺序:依次选择最近有实际事件的范围。 */ + val INITIAL_FALLBACK_ORDER: List = + listOf(LAST_5H, LAST_12H, LAST_24H, LAST_7D, LAST_30D) + } +} + +/** + * 时间范围,**半开区间** `[startMs, endMs)`:`startedAtMs == endMs` 的事件 + * 不属于该范围;endMs 是下一边界(如次日 0 点),不是包含式终点。 + */ +data class TokenStatsTimeRange(val startMs: Long, val endMs: Long) { + init { + require(endMs > startMs) { "endMs must be after startMs" } + } + + val durationMs: Long + get() = endMs - startMs +} + +/** 图表桶粒度:10 分钟 / 1 小时 / 1 自然日(本地时区对齐)。 */ +enum class TokenStatsGranularity { + TEN_MINUTES, + HOURLY, + DAILY, +} + +/** + * 时间预设边界与图表桶对齐计算。 + * + * 桶边界在**本地时间**上对齐(10 分钟整点、整点小时、自然日 0 点),并用 + * java.time 的 plusMinutes/plusHours/plusDays 在本地时区上推进:跨 DST 的 + * 小时/日桶自动得到 23/25 小时的正确 epoch 跨度,且相邻桶起点单调递增、 + * 覆盖无空洞(回退重复的小时也会出现两个不同 epoch 的桶)。 + */ +object TokenStatsTimeRanges { + + const val TEN_MINUTES_MS: Long = 10 * 60 * 1000L + const val HOUR_MS: Long = 60 * 60 * 1000L + const val DAY_MS: Long = 24 * HOUR_MS + + /** 防御:自定义范围过大时限制桶数量,避免病态输入拖垮内存/UI。 */ + private const val MAX_BUCKETS = 10_000 + + fun rangeFor(preset: TokenStatsPreset, nowMs: Long, zone: ZoneId): TokenStatsTimeRange { + require(preset != TokenStatsPreset.CUSTOM) { "CUSTOM preset requires customRange(startMs, endMs)" } + val now = Instant.ofEpochMilli(nowMs).atZone(zone) + return when (preset) { + TokenStatsPreset.LAST_5H -> rollingRange(now, 5L) + TokenStatsPreset.LAST_12H -> rollingRange(now, 12L) + TokenStatsPreset.LAST_24H -> rollingRange(now, 24L) + TokenStatsPreset.TODAY -> naturalDayRange(now, 0L) + TokenStatsPreset.YESTERDAY -> naturalDayRange(now, -1L) + TokenStatsPreset.LAST_7D -> naturalDaysRange(now, 7L) + TokenStatsPreset.LAST_30D -> naturalDaysRange(now, 30L) + TokenStatsPreset.THIS_MONTH -> naturalMonthRange(now, 0L) + TokenStatsPreset.LAST_MONTH -> naturalMonthRange(now, -1L) + TokenStatsPreset.CUSTOM -> error("unreachable") + } + } + + /** 自定义范围:调用方提供显式边界,仍强制半开区间 [startMs, endMs)。 */ + fun customRange(startMs: Long, endMs: Long): TokenStatsTimeRange = + TokenStatsTimeRange(startMs, endMs) + + /** + * 按范围时长选择合理桶粒度:≤12h → 10 分钟;≤48h → 1 小时; + * 更长时间(7d/30d/月)→ 1 自然日。自定义范围同样适用。 + */ + fun granularityFor(range: TokenStatsTimeRange): TokenStatsGranularity = + when { + range.durationMs <= 12L * HOUR_MS -> TokenStatsGranularity.TEN_MINUTES + range.durationMs <= 2L * DAY_MS -> TokenStatsGranularity.HOURLY + else -> TokenStatsGranularity.DAILY + } + + /** + * 覆盖 [range] 的桶起点列表(本地时间对齐,升序、不相交)。 + * 最后一个桶的终点是日历对齐的下一个桶起点,可能超出 range.endMs; + * 事件归属按 `[桶起点, 下个桶起点)` 判定,落在范围内的每个事件恰好属于一个桶。 + */ + fun bucketStarts( + range: TokenStatsTimeRange, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): List { + val first = truncateToBucket(Instant.ofEpochMilli(range.startMs).atZone(zone), granularity) + val starts = ArrayList() + var current = first + while (current.toInstant().toEpochMilli() < range.endMs) { + starts += current.toInstant().toEpochMilli() + current = advanceBucket(current, granularity) + if (starts.size > MAX_BUCKETS) { + error( + "range too large for $granularity granularity " + + "(bucket count would exceed $MAX_BUCKETS)" + ) + } + } + return starts + } + + /** 桶 [index] 的结束时间:本地对齐的下一个桶起点(日历推进,非固定毫秒)。 */ + fun bucketEndMs( + bucketStarts: List, + index: Int, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): Long { + if (index + 1 < bucketStarts.size) return bucketStarts[index + 1] + val last = Instant.ofEpochMilli(bucketStarts[index]).atZone(zone) + return advanceBucket(last, granularity).toInstant().toEpochMilli() + } + + /** + * 事件时间戳所属的桶下标(桶起点列表升序)。ts 落在 + * `[第一个桶起点, 最后一个桶终点)` 之外返回 null(防御,正常输入不触发)。 + */ + fun bucketIndexOf( + ts: Long, + bucketStarts: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): Int? { + var floor = bucketStarts.binarySearch(ts) + if (floor < 0) floor = -floor - 2 + if (floor < 0) return null + if (bucketEndMs(bucketStarts, floor, granularity, zone) <= ts) return null + return floor + } + + private fun rollingRange(now: ZonedDateTime, hours: Long): TokenStatsTimeRange { + val endMs = now.toInstant().toEpochMilli() + return TokenStatsTimeRange(endMs - hours * HOUR_MS, endMs) + } + + private fun naturalDayRange(now: ZonedDateTime, offsetDays: Long): TokenStatsTimeRange { + val start = now.toLocalDate().atStartOfDay(now.zone).plusDays(offsetDays) + return TokenStatsTimeRange( + start.toInstant().toEpochMilli(), + start.plusDays(1).toInstant().toEpochMilli(), + ) + } + + /** 含今天在内往前 [days] 个自然日:`[今天-(days-1)天 0 点, 明天 0 点)`。 */ + private fun naturalDaysRange(now: ZonedDateTime, days: Long): TokenStatsTimeRange { + val todayStart = now.toLocalDate().atStartOfDay(now.zone) + val start = todayStart.minusDays(days - 1) + return TokenStatsTimeRange( + start.toInstant().toEpochMilli(), + todayStart.plusDays(1).toInstant().toEpochMilli(), + ) + } + + private fun naturalMonthRange(now: ZonedDateTime, offsetMonths: Long): TokenStatsTimeRange { + val month = now.toLocalDate().withDayOfMonth(1).plusMonths(offsetMonths) + val start = month.atStartOfDay(now.zone) + val end = month.plusMonths(1).atStartOfDay(now.zone) + return TokenStatsTimeRange( + start.toInstant().toEpochMilli(), + end.toInstant().toEpochMilli(), + ) + } + + private fun truncateToBucket( + zdt: ZonedDateTime, + granularity: TokenStatsGranularity, + ): ZonedDateTime = + when (granularity) { + TokenStatsGranularity.TEN_MINUTES -> + zdt.withMinute(zdt.minute / 10 * 10).withSecond(0).withNano(0) + TokenStatsGranularity.HOURLY -> + zdt.withMinute(0).withSecond(0).withNano(0) + TokenStatsGranularity.DAILY -> + zdt.toLocalDate().atStartOfDay(zdt.zone) + } + + private fun advanceBucket( + zdt: ZonedDateTime, + granularity: TokenStatsGranularity, + ): ZonedDateTime = + when (granularity) { + TokenStatsGranularity.TEN_MINUTES -> zdt.plusMinutes(10) + TokenStatsGranularity.HOURLY -> zdt.plusHours(1) + TokenStatsGranularity.DAILY -> zdt.plusDays(1) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt new file mode 100644 index 000000000..5421b0f8a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt @@ -0,0 +1,127 @@ +package com.ai.assistance.operit.data.stats + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement + +/** + * 记录型 SQLite 驱动(仅测试):包装 [JdbcSQLiteDriver],记录每条真实执行的 SQL、 + * 绑定参数与返回行数([RecordedSql]),用于断言: + * - 固定查询次数(防 N+1)、绝不调用全表读取(getAllEvents); + * - IN 分块大小(`?` 占位符个数与绑定值); + * - 生命周期分页的 LIMIT 绑定与每页行数(P2-1 页界)。 + * + * 时机与复用说明:Room 2.8 连接池会**缓存已 prepare 的语句**(同一事务内相同 + * SQL 复用同一 statement,重新绑定并重新 step),且 close 语句发生在 DAO 调用 + * 返回之后。因此本记录器按**执行周期**记录:绑定开始新周期,终止 step(返回 + * false,同步于查询执行)完成当前周期并落记录——行数同时确定,测试在 DAO 调用 + * 返回后读取 [executed] 即完整、确定,不依赖异步 close。 + */ +class RecordingSQLiteDriver : SQLiteDriver { + + private val delegate = JdbcSQLiteDriver() + + /** 已执行完成的语句周期记录(按执行顺序)。 */ + val executed = mutableListOf() + + fun clear() = executed.clear() + + override fun open(fileName: String): SQLiteConnection = + RecordingConnection(delegate.open(fileName), executed) +} + +/** 单次语句执行周期:SQL 文本、绑定参数(index -> 值)、返回行数。 */ +class RecordedSql( + val sql: String, + val binds: Map, + val rows: Int, +) { + /** SQL 中 `?` 占位符个数(Room 动态生成的 IN 列表直接反映参数个数)。 */ + val questionMarkCount: Int + get() = sql.count { it == '?' } + + /** 绑定值(按 index 升序)的文本表示,如 `1=1000;2=...`。 */ + fun bindText(): String = binds.toSortedMap().entries.joinToString(";") { (index, value) -> "$index=$value" } + + override fun toString(): String = "$sql | ${bindText()} | rows=$rows" +} + +private class RecordingConnection( + private val delegate: SQLiteConnection, + private val sink: MutableList, +) : SQLiteConnection by delegate { + override fun prepare(sql: String): SQLiteStatement = + RecordingStatement(delegate.prepare(sql), sql, sink) +} + +private class RecordingStatement( + private val delegate: SQLiteStatement, + private val sql: String, + private val sink: MutableList, +) : SQLiteStatement by delegate { + + private val binds = ArrayList>() + private var rows = 0 + private var cycleComplete = true + + private fun startCycleIfNeeded() { + if (cycleComplete) { + cycleComplete = false + binds.clear() + rows = 0 + } + } + + override fun bindBlob(index: Int, value: ByteArray) { + startCycleIfNeeded() + binds += index to "" + delegate.bindBlob(index, value) + } + + override fun bindDouble(index: Int, value: Double) { + startCycleIfNeeded() + binds += index to value.toString() + delegate.bindDouble(index, value) + } + + override fun bindLong(index: Int, value: Long) { + startCycleIfNeeded() + binds += index to value.toString() + delegate.bindLong(index, value) + } + + override fun bindText(index: Int, value: String) { + startCycleIfNeeded() + binds += index to value + delegate.bindText(index, value) + } + + override fun bindNull(index: Int) { + startCycleIfNeeded() + binds += index to "NULL" + delegate.bindNull(index) + } + + override fun step(): Boolean { + // 无绑定参数的语句(如 SELECT * FROM token_stat_identities)也要开始周期 + startCycleIfNeeded() + val advanced = delegate.step() + if (advanced) { + rows += 1 + } else if (!cycleComplete) { + // 终止 step(同步于查询执行):行数已确定,完成当前执行周期 + cycleComplete = true + sink += RecordedSql(sql, binds.toMap(), rows) + } + return advanced + } + + override fun close() { + // 连接池异步 close:若周期未完成(异常路径),补一条占位记录 + if (!cycleComplete) { + cycleComplete = true + sink += RecordedSql(sql, binds.toMap(), rows) + } + delegate.close() + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt index a17f73565..e289aa267 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -234,6 +234,47 @@ outputTokens = 500L, assertNull(cost.amount) } + @Test + fun `unknown pricing with zero prices is still unknown not zero`() { + // P1-1:默认价表对未知模型给出全 0 价格但 known = false(zeroPricing), + // 统一边界必须返回 null——即使用量完整也不能把“无法定价”算成 0 元。 + val zeroValuedUnknown = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + cacheWritePricePerMillion = 0.0, + outputPricePerMillion = 0.0, + source = PricingSource.UNKNOWN, + known = false, + ) + val usage = + TokenUsageInput( +uncachedInputTokens = 1000L, +cachedInputTokens = 0L, +cacheWriteTokens = 0L, +outputTokens = 500L, + ) + + assertNull(TokenCostCalculator.computeCost(usage, zeroValuedUnknown).amount) + } + + @Test + fun `unknown count pricing with zero per request price is still unknown`() { + // P1-1:COUNT 模式同样受 known 边界约束,不能按 0 元单次价算出伪 0。 + val unknownCount = + ResolvedPricing( + billingMode = BillingMode.COUNT, + currency = PricingCurrency.USD, + pricePerRequest = 0.0, + source = PricingSource.UNKNOWN, + known = false, + ) + + assertNull(TokenCostCalculator.computeCost(TokenUsageInput(outputTokens = 500L), unknownCount).amount) + } + @Test fun `count mode cost equals per request price`() { val cost = diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt new file mode 100644 index 000000000..46a98ca8e --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt @@ -0,0 +1,922 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 聚合器纯函数测试(阶段 3): + * unknown 与 0 严格区分、部分未知费用、历史快照/当前重估双口径、汇率变化 + * 只影响换算不影响原币、模型分组/别名/多 config 一致性、桶合计 == 范围总计、 + * 性能 unknown 排除平均、Long 饱和、BigDecimal 精度。 + */ +class TokenStatsAggregatorTest { + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val params = TokenStatsQueryParams() + + // ==== 构建辅助 ==== + + private fun identity( + id: String, + configId: String = "cfg-$id", + provider: String = "PROVIDER", + model: String = "model-$id", + displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), + ): TokenStatIdentityEntity = + TokenStatIdentityEntity( + identityId = id, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + + private fun displayModel(id: String, name: String): TokenStatDisplayModelEntity = + TokenStatDisplayModelEntity( + displayModelId = id, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(id), + displayName = name, + ) + + private fun event( + id: String, + identityId: String, + startedAtMs: Long, + endedAtMs: Long = startedAtMs + 1_000L, + firstTokenAtMs: Long? = startedAtMs + 200L, + uncached: Long? = 100L, + cached: Long? = 0L, + cacheWrite: Long? = 0L, + totalInput: Long? = null, + output: Long? = 50L, + reasoning: Long? = null, + reasoningIncluded: Boolean? = true, + cacheWriteSeparateBilling: Boolean? = null, + category: String = TokenStatCategory.CHAT.name, + status: String = TokenStatStatus.COMPLETED.name, + billingMode: String = BillingMode.TOKEN.name, + pricingCurrency: String = PricingCurrency.USD.name, + inputPrice: Double? = 1.0, + cachedPrice: Double? = 1.0, + cacheWritePrice: Double? = null, + outputPrice: Double? = 2.0, + pricePerRequest: Double? = null, + pricingSource: String = PricingSource.DEFAULT.name, + cost: Double? = null, + ): TokenStatEventEntity = + TokenStatEventEntity( + eventId = id, + statIdentityId = identityId, + category = category, + status = status, + acceptedGeneration = 0L, + startedAtMs = startedAtMs, + endedAtMs = endedAtMs, + firstTokenAtMs = firstTokenAtMs, + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + totalInputTokens = totalInput, + outputTokens = output, + reasoningTokens = reasoning, + reasoningIncludedInOutput = reasoningIncluded, + cacheWriteSeparateBilling = cacheWriteSeparateBilling, + billingMode = billingMode, + pricingCurrency = pricingCurrency, + inputPricePerMillion = inputPrice, + cachedInputPricePerMillion = cachedPrice, + cacheWritePricePerMillion = cacheWritePrice, + outputPricePerMillion = outputPrice, + pricePerRequest = pricePerRequest, + pricingSource = pricingSource, + costInPricingCurrency = cost, + diagnosticsJson = null, + ) + + private fun baseline( + identityId: String, + inputTokens: Long = 100L, + cachedInputTokens: Long = 10L, + outputTokens: Long = 50L, + requestCount: Long = 5L, + pricingCurrency: String = PricingCurrency.USD.name, + cost: Double? = 2.0, + isEstimated: Boolean = true, + ): TokenStatBaselineEntity = + TokenStatBaselineEntity( + identityId = identityId, + inputTokens = inputTokens, + cachedInputTokens = cachedInputTokens, + outputTokens = outputTokens, + requestCount = requestCount, + pricingCurrency = pricingCurrency, + costInPricingCurrency = cost, + isEstimated = isEstimated, + fingerprint = "fp", + importedAtMs = 0L, + frozenBillingMode = BillingMode.TOKEN.name, + ) + + private fun aggregated(events: List, identities: List): TokenStatsTotals = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = identities.associateBy { it.identityId }, + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ).summary + + // ==== 生命周期 ==== + + @Test + fun `lifetime combines events and baseline with converted costs`() { + val id1 = identity("id-1") + val events = + listOf( + event("e1", "id-1", 1000L, cost = 0.5), + event("e2", "id-1", 2000L, cost = 1.5), + ) + val overview = + TokenStatsAggregator.lifetime( + events = events, + baselines = listOf(baseline("id-1", cost = 2.0)), + identitiesById = mapOf("id-1" to id1), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params, + ) + assertEquals(2L, overview.eventTotals.requests) + assertEquals(14.0, overview.eventTotals.cost.knownAmount, 1e-9) // (0.5+1.5)*7 + assertTrue(overview.eventTotals.cost.isFullyKnown) + assertEquals(1L, overview.baselineTotals.identityCount) + assertEquals(5L, overview.baselineTotals.requests) + assertEquals(14.0, overview.baselineTotals.cost.knownAmount, 1e-9) // 2.0*7 + assertEquals(7L, overview.combinedRequests) + assertTrue(overview.baselineTotals.anyEstimated) + assertEquals(TokenStatsCostMode.HISTORICAL, overview.baselineTotals.cost.mode) + } + + @Test + fun `incremental lifetime accumulator equals single list aggregation`() { + // P2-1:生命周期分页路径(DAO 逐页喂入累加器)与单列表路径必须逐字段一致。 + val id1 = identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20") + val events = + (0 until 2500).map { index -> + event( + "e$index", "id-1", index.toLong() * 1000L, + uncached = 100L, cached = 0L, output = 50L, cost = null, + ) + } + val baselines = listOf(baseline("id-1", cost = 2.0)) + + val single = + TokenStatsAggregator.lifetime( + events = events, + baselines = baselines, + identitiesById = mapOf("id-1" to id1), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ) + + val accumulator = + TokenStatsAggregator.TokenStatsEventTotalsAccumulator( + legacyPrices = emptyMap(), + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ) + events.chunked(333).forEach { chunk -> + accumulator.addPage(chunk, mapOf("id-1" to id1), emptyList()) + } + val paged = + TokenStatsAggregator.lifetimeFrom( + eventsTotals = accumulator.totals(), + baselines = baselines, + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ) + + val a = single.eventTotals + val b = paged.eventTotals + assertEquals(a.requests, b.requests) + assertEquals(a.uncachedInput, b.uncachedInput) + assertEquals(a.cachedInput, b.cachedInput) + assertEquals(a.cacheWrite, b.cacheWrite) + assertEquals(a.totalInput, b.totalInput) + assertEquals(a.output, b.output) + assertEquals(a.reasoning, b.reasoning) + assertEquals(a.cost.knownAmount, b.cost.knownAmount, 1e-9) + assertEquals(a.cost.unknownContributionCount, b.cost.unknownContributionCount) + assertEquals(a.cost.totalContributionCount, b.cost.totalContributionCount) + assertEquals(a.cost.originalCurrencyAmounts, b.cost.originalCurrencyAmounts) + assertEquals(a.cost.isFullyKnown, b.cost.isFullyKnown) + assertEquals(single.combinedRequests, paged.combinedRequests) + assertEquals(single.baselineTotals, paged.baselineTotals) + } + + @Test + fun `unknown cost contributions are partial not zero`() { + val events = + listOf( + event("e1", "id-1", 1000L, cost = 1.0), + event("e2", "id-1", 2000L, cost = null), + ) + val cost = aggregated(events, listOf(identity("id-1"))).cost + assertEquals(7.0, cost.knownAmount, 1e-9) + assertEquals(1L, cost.unknownContributionCount) + assertEquals(2L, cost.totalContributionCount) + assertFalse(cost.isFullyKnown) + } + + @Test + fun `zero cost is a known contribution`() { + val cost = aggregated(listOf(event("e1", "id-1", 1000L, cost = 0.0)), listOf(identity("id-1"))).cost + assertEquals(0.0, cost.knownAmount, 1e-9) + assertEquals(0L, cost.unknownContributionCount) + assertTrue(cost.isFullyKnown) + assertTrue(cost.originalCurrencyAmounts.isEmpty()) + } + + @Test + fun `token unknown and zero are distinguished`() { + val events = + listOf( + event("e1", "id-1", 1000L, uncached = null, output = 0L), + event("e2", "id-1", 2000L, uncached = 0L, output = 0L), + event("e3", "id-1", 3000L, uncached = 10L, output = 5L), + ) + val totals = aggregated(events, listOf(identity("id-1"))) + assertEquals(10L, totals.uncachedInput.knownSum) + assertEquals(2L, totals.uncachedInput.knownEventCount) + assertEquals(1L, totals.uncachedInput.unknownEventCount) + assertEquals(3L, totals.uncachedInput.totalEventCount) + assertFalse(totals.uncachedInput.isFullyKnown) + // output:三个事件都已知(含两个真实 0) + assertEquals(5L, totals.output.knownSum) + assertEquals(0L, totals.output.unknownEventCount) + assertTrue(totals.output.isFullyKnown) + } + + // ==== 汇率与原币 ==== + + @Test + fun `rate change affects converted total but not original currency amounts`() { + val events = + listOf( + event("e1", "id-1", 1000L, cost = 1.0), + event("e2", "id-1", 2000L, cost = 1.0), + ) + val low = aggregatedWithRate(events, 7.0).cost + val high = aggregatedWithRate(events, 7.5).cost + assertEquals(14.0, low.knownAmount, 1e-9) + assertEquals(15.0, high.knownAmount, 1e-9) + assertEquals(2.0, low.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) + assertEquals(2.0, high.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) + } + + @Test + fun `mixed currency events convert per currency`() { + val events = + listOf( + event("e1", "id-1", 1000L, pricingCurrency = PricingCurrency.USD.name, cost = 1.0), + event("e2", "id-1", 2000L, pricingCurrency = PricingCurrency.CNY.name, cost = 7.0), + ) + val cny = aggregatedWithParams(events, params.copy(targetCurrency = PricingCurrency.CNY)).cost + assertEquals(14.0, cny.knownAmount, 1e-9) // 1*7 + 7 + assertEquals(1.0, cny.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) + assertEquals(7.0, cny.originalCurrencyAmounts[PricingCurrency.CNY]!!, 1e-9) + + val usd = aggregatedWithParams(events, params.copy(targetCurrency = PricingCurrency.USD)).cost + assertEquals(2.0, usd.knownAmount, 1e-9) // 1 + 7/7 + } + + private fun aggregatedWithRate(events: List, rate: Double): TokenStatsTotals = + aggregatedWithParams(events, params.copy(manualRate = rate)) + + private fun aggregatedWithParams( + events: List, + p: TokenStatsQueryParams, + identities: List = listOf(identity("id-1")), + ): TokenStatsTotals = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = identities.associateBy { it.identityId }, + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = p, + ).summary + + // ==== 当前价格重估 ==== + + @Test + fun `revalued mode recomputes cost from current pricing and usage`() { + val events = + listOf( + // 快照成本缺失(未知),重估后按当前价格算出已知成本 + event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null), + ) + val totals = + aggregatedWithParams( + events, + params.copy( + mode = TokenStatsCostMode.REVALUED, + manualRate = 7.0, + targetCurrency = PricingCurrency.CNY, + ), + identities = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), + ) + val cost = totals.cost + assertTrue(cost.isFullyKnown) + // (1000*1.5 + 500*6)/1e6 = 0.0045 USD -> 0.0315 CNY(gpt-4o-2024-11-20 内置价) + assertEquals(0.0315, cost.knownAmount, 1e-9) + assertEquals(0.0045, cost.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) + } + + @Test + fun `revalued mode does not double bill included reasoning`() { + fun totalsWith(reasoningIncluded: Boolean?): TokenStatsCostSummary { + val events = + listOf( + event( + "e1", "id-1", 1000L, + uncached = 0L, cached = 0L, output = 500L, + reasoning = 100L, reasoningIncluded = reasoningIncluded, cost = null, + ), + ) + return aggregatedWithParams( + events, + params.copy(mode = TokenStatsCostMode.REVALUED, manualRate = 1.0, targetCurrency = PricingCurrency.USD), + identities = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), + ).cost + } + val included = totalsWith(reasoningIncluded = true) + assertEquals(0.003, included.knownAmount, 1e-9) // 500 * 6 / 1e6 + val separate = totalsWith(reasoningIncluded = false) + assertEquals(0.0036, separate.knownAmount, 1e-9) // (500+100) * 6 / 1e6 + assertTrue(separate.knownAmount > included.knownAmount) + } + + @Test + fun `revalued cache write separate billing without price stays unknown`() { + val openai = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")) + val events = + listOf( + event( + "e1", "id-1", 1000L, + uncached = 1_000L, cached = 0L, cacheWrite = 100L, output = 500L, + cacheWriteSeparateBilling = true, cacheWritePrice = null, cost = null, + ), + ) + val separateCost = aggregatedWithParams( + events, params.copy(mode = TokenStatsCostMode.REVALUED), identities = openai, + ).cost + assertFalse(separateCost.isFullyKnown) + assertEquals(1L, separateCost.unknownContributionCount) + + val mergedCost = aggregatedWithParams( + listOf( + event( + "e2", "id-1", 2000L, + uncached = 1_000L, cached = 0L, cacheWrite = 100L, output = 500L, + cacheWriteSeparateBilling = false, cacheWritePrice = null, cost = null, + ), + ), + params.copy(mode = TokenStatsCostMode.REVALUED), + identities = openai, + ).cost + assertTrue(mergedCost.isFullyKnown) + } + + @Test + fun `revalued config override wins over provider override`() { + val identityWithConfig = identity("id-1", configId = "cfg-1") + val providerOverride = + TokenStatPriceOverrideEntity.normalized( + scope = TokenPriceResolver.SCOPE_PROVIDER_MODEL, + provider = "PROVIDER", + model = "model-id-1", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 10.0, + cachedInputPricePerMillion = 10.0, + cacheWritePricePerMillion = null, + outputPricePerMillion = 20.0, + pricePerRequest = null, + ) + val configOverride = + TokenStatPriceOverrideEntity.normalized( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "PROVIDER", + model = "model-id-1", + configId = "cfg-1", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = 2.0, + cacheWritePricePerMillion = null, + outputPricePerMillion = 4.0, + pricePerRequest = null, + ) + val events = + listOf(event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null)) + val totals = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to identityWithConfig), + displayModelsById = emptyMap(), + overrides = listOf(providerOverride, configOverride), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ).summary + // (1000*2 + 500*4)/1e6 = 0.004 USD -> 0.028 CNY(用 CONFIG 价,非 provider 价的 0.14) + assertEquals(0.028, totals.cost.knownAmount, 1e-9) + } + + @Test + fun `revalued unknown identity contributes unknown cost`() { + val totals = + aggregatedWithParams( + listOf(event("e1", "missing-id", 1000L, cost = null)), + params.copy(mode = TokenStatsCostMode.REVALUED), + ) + assertFalse(totals.cost.isFullyKnown) + assertEquals(1L, totals.cost.unknownContributionCount) + } + + @Test + fun `revalued unknown model with complete usage is unknown not zero`() { + // P1-1:identity 存在但 provider/model 未知(无覆盖、无内置价 → + // zeroPricing known = false):即使用量完整,费用也必须是 unknown 贡献, + // 绝不能伪装成 known 的 0 元。 + val totals = + aggregatedWithParams( + listOf( + event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null), + ), + params.copy(mode = TokenStatsCostMode.REVALUED), + identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), + ) + assertEquals(1L, totals.cost.unknownContributionCount) + assertEquals(1L, totals.cost.totalContributionCount) + assertEquals(0.0, totals.cost.knownAmount, 1e-9) + assertFalse(totals.cost.isFullyKnown) + assertTrue(totals.cost.originalCurrencyAmounts.isEmpty()) + } + + @Test + fun `revalued count mode with unknown pricing is unknown not zero`() { + // P1-1:COUNT 模式同样受 known 边界约束(zeroPricing 的按次价 > 0 只是 + // 兜底默认,未知模型 known = false → 成本 unknown)。 + val totals = + aggregatedWithParams( + listOf( + event( + "e1", "id-1", 1000L, + uncached = 1_000L, cached = 0L, output = 500L, cost = null, + billingMode = BillingMode.COUNT.name, + ), + ), + params.copy(mode = TokenStatsCostMode.REVALUED), + identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), + ) + assertFalse(totals.cost.isFullyKnown) + assertEquals(1L, totals.cost.unknownContributionCount) + assertEquals(0.0, totals.cost.knownAmount, 1e-9) + } + + @Test + fun `revalued unknown pricing never yields fully known zero cost`() { + // P1-1 回归:旧实现把 unknown(known = false)当 0 元已知成本,isFullyKnown + // 为 true;修复后必须为 partial。 + val totals = + aggregatedWithParams( + listOf( + event("e1", "id-1", 1000L, uncached = 0L, cached = 0L, output = 0L, cost = null), + ), + params.copy(mode = TokenStatsCostMode.REVALUED), + identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), + ) + assertFalse(totals.cost.isFullyKnown) + assertEquals(1L, totals.cost.unknownContributionCount) + assertEquals(0.0, totals.cost.knownAmount, 1e-9) + } + + // ==== 桶 ==== + + /** 本地对齐的 24 小时范围(上海),保证桶 0 起点 == 范围起点。 */ + private val alignedDayStart: Long = + java.time.LocalDateTime.parse("2026-08-07T00:00:00").atZone(shanghai).toInstant().toEpochMilli() + + private fun hourlyRangeData( + events: List, + identities: List = listOf(identity("id-1")), + startHour: Long = alignedDayStart, + ): TokenStatsRangeData = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = identities.associateBy { it.identityId }, + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(startHour, startHour + 24L * TokenStatsTimeRanges.HOUR_MS), + granularity = TokenStatsGranularity.HOURLY, + zone = shanghai, + params = params, + ) + + @Test + fun `bucket sums equal range total and empty buckets are filled`() { + val start = alignedDayStart + val events = + listOf( + event("e1", "id-1", start + TokenStatsTimeRanges.HOUR_MS, cost = 1.0), + event("e2", "id-1", start + 5L * TokenStatsTimeRanges.HOUR_MS, cost = 2.0), + event("e3", "id-1", start + 10L * TokenStatsTimeRanges.HOUR_MS, cost = 0.5), + ) + val data = hourlyRangeData(events, startHour = start) + assertEquals(3L, data.summary.requests) + assertEquals(24, data.buckets.size) + assertEquals(24.5, data.summary.cost.knownAmount, 1e-9) // (1+2+0.5)*7 + + val bucketRequests = data.buckets.sumOf { it.totals.requests } + assertEquals(data.summary.requests, bucketRequests) + assertEquals( + data.summary.uncachedInput.knownSum, + data.buckets.sumOf { it.totals.uncachedInput.knownSum }, + ) + assertEquals( + data.summary.output.knownSum, + data.buckets.sumOf { it.totals.output.knownSum }, + ) + assertEquals( + data.summary.cost.knownAmount, + data.buckets.sumOf { it.totals.cost.knownAmount }, + 1e-9, + ) + assertEquals( + data.summary.cost.unknownContributionCount, + data.buckets.sumOf { it.totals.cost.unknownContributionCount }, + ) + // 空桶补齐:请求为 0、费用已知且为 0 + val empty = data.buckets[3] // 第 4 个桶(e1 在桶 1,e2 在桶 5,e3 在桶 10) + assertEquals(0L, empty.totals.requests) + assertEquals(0.0, empty.totals.cost.knownAmount, 1e-9) + assertTrue(empty.totals.cost.isFullyKnown) + } + + @Test + fun `bucket boundary events land in the correct bucket`() { + val start = alignedDayStart + val events = + listOf( + event("at-start", "id-1", start), + event("at-boundary", "id-1", start + TokenStatsTimeRanges.HOUR_MS), + ) + val data = hourlyRangeData(events, startHour = start) + assertEquals(1L, data.buckets[0].totals.requests) + assertEquals(1L, data.buckets[1].totals.requests) + } + + @Test + fun `bucket model split matches range total`() { + val id1 = identity("id-1", model = "gpt-4o") + val id2 = identity("id-2", configId = "cfg-2", model = "gpt-4o") + val events = + listOf( + event("e1", "id-1", alignedDayStart + TokenStatsTimeRanges.HOUR_MS, cost = 1.0), + event("e2", "id-2", alignedDayStart + 2L * TokenStatsTimeRanges.HOUR_MS, cost = 2.0), + ) + val data = + hourlyRangeData(events, identities = listOf(id1, id2)) + val bucket = data.buckets.first { it.totals.requests == 1L } + assertEquals(1L, bucket.byModel["gpt-4o"]!!.requests) + assertEquals(7.0, bucket.byModel["gpt-4o"]!!.cost.knownAmount, 1e-9) + assertEquals(2, data.summary.requests) + } + + // ==== 模型分组与明细 ==== + + @Test + fun `same normalized model merges into one display group across configs`() { + val id1 = identity("id-1", configId = "cfg-1", provider = "P1", model = "gpt-4o") + val id2 = identity("id-2", configId = "cfg-2", provider = "P1", model = "GPT-4o") + val events = + listOf( + event("e1", "id-1", 1000L, cost = 1.0), + event("e2", "id-2", 2000L, cost = 2.0), + ) + val data = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = listOf(id1, id2).associateBy { it.identityId }, + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + assertEquals(1, data.displayModels.size) + val model = data.displayModels.single() + assertEquals(TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), model.displayModelId) + assertEquals(2, model.identities.size) + assertEquals(2L, model.totals.requests) + assertEquals(21.0, model.totals.cost.knownAmount, 1e-9) // (1+2)*7 + // 身份分项之和 == 展示模型总计 + assertEquals( + model.totals.cost.knownAmount, + model.identities.sumOf { it.totals.cost.knownAmount }, + 1e-9, + ) + assertEquals(2L, model.identities.sumOf { it.totals.requests }) + } + + @Test + fun `manual alias groups different names via displayModelId`() { + val id1 = identity("id-1", model = "model-a", displayModelId = "aliased") + val id2 = identity("id-2", configId = "cfg-2", model = "model-b", displayModelId = "aliased") + val events = + listOf( + event("e1", "id-1", 1000L, cost = 1.0), + event("e2", "id-2", 2000L, cost = 2.0), + ) + val data = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = listOf(id1, id2).associateBy { it.identityId }, + displayModelsById = mapOf("aliased" to displayModel("aliased", "My Alias")), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + assertEquals(1, data.displayModels.size) + val model = data.displayModels.single() + assertEquals("My Alias", model.displayName) + assertEquals(2L, model.totals.requests) + // display name 缺失时回退到 displayModelId + val noDisplayRow = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = listOf(id1, id2).associateBy { it.identityId }, + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + assertEquals("aliased", noDisplayRow.displayModels.single().displayName) + } + + @Test + fun `identity pricing info reflects snapshot or current resolution`() { + val id1 = identity("id-1") + val events = listOf(event("e1", "id-1", 1000L, cost = 1.0, outputPrice = 3.0)) + val historical = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to id1), + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + val identityBreakdown = historical.displayModels.single().identities.single() + assertEquals(3.0, identityBreakdown.pricing!!.outputPricePerMillion!!, 1e-9) + assertEquals(PricingSource.DEFAULT, identityBreakdown.pricing!!.source) + + val revalued = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ) + val revaluedPricing = revalued.displayModels.single().identities.single().pricing!! + assertEquals(BillingMode.TOKEN, revaluedPricing.billingMode) + assertTrue(revaluedPricing.known) + } + + // ==== 分类与状态 ==== + + @Test + fun `category breakdown and filter`() { + val events = + listOf( + event("e1", "id-1", 1000L, category = TokenStatCategory.CHAT.name), + event("e2", "id-1", 2000L, category = TokenStatCategory.CHAT.name), + event("e3", "id-1", 3000L, category = TokenStatCategory.SUMMARY.name), + ) + val all = aggregated(events, listOf(identity("id-1"))) + assertEquals(3L, all.requests) + + val data = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to identity("id-1")), + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params.copy(categories = setOf(TokenStatCategory.CHAT)), + ) + assertEquals(2L, data.summary.requests) + assertEquals(listOf(TokenStatCategory.CHAT), data.categories.map { it.category }) + + val allData = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to identity("id-1")), + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + assertEquals(listOf(TokenStatCategory.CHAT, TokenStatCategory.SUMMARY), allData.categories.map { it.category }) + assertEquals(2L, allData.categories.first { it.category == TokenStatCategory.CHAT }.totals.requests) + } + + @Test + fun `status breakdown counts per status in enum order`() { + val events = + listOf( + event("e1", "id-1", 1000L, status = TokenStatStatus.FAILED.name), + event("e2", "id-1", 2000L, status = TokenStatStatus.COMPLETED.name), + event("e3", "id-1", 3000L, status = TokenStatStatus.COMPLETED.name), + event("e4", "id-1", 4000L, status = TokenStatStatus.TIMEOUT.name), + ) + val data = + TokenStatsAggregator.rangeData( + events = events, + identitiesById = mapOf("id-1" to identity("id-1")), + displayModelsById = emptyMap(), + overrides = emptyList(), + legacyPrices = emptyMap(), + range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), + granularity = TokenStatsGranularity.DAILY, + zone = shanghai, + params = params, + ) + assertEquals( + listOf(TokenStatStatus.COMPLETED, TokenStatStatus.TIMEOUT, TokenStatStatus.FAILED), + data.statuses.map { it.status }, + ) + assertEquals(2L, data.statuses.first { it.status == TokenStatStatus.COMPLETED }.totals.requests) + } + + // ==== 性能 ==== + + @Test + fun `performance excludes unknown samples from averages`() { + val events = + listOf( + // e1: 正常(TTFT 100ms,生成 400ms) + event("e1", "id-1", 0L, endedAtMs = 500L, firstTokenAtMs = 100L), + // e2: 无首 token -> TTFT/生成都 unknown + event("e2", "id-1", 0L, endedAtMs = 100L, firstTokenAtMs = null), + // e3: 结束早于首 token -> TTFT 有效 50ms,生成 unknown + event("e3", "id-1", 0L, endedAtMs = 40L, firstTokenAtMs = 50L), + // e4: 负时间戳 -> 全部 unknown + event("e4", "id-1", -5L, endedAtMs = 10L, firstTokenAtMs = -5L), + ) + val performance = TokenStatsAggregator.performanceOf(events) + assertEquals(2L, performance.ttft.knownCount) + assertEquals(2L, performance.ttft.unknownCount) + assertEquals(150L, performance.ttft.totalMs) + assertEquals(75.0, performance.ttft.averageMs, 1e-9) + assertEquals(1L, performance.generationDuration.knownCount) + assertEquals(3L, performance.generationDuration.unknownCount) + assertEquals(400L, performance.generationDuration.totalMs) + assertEquals(400.0, performance.generationDuration.averageMs, 1e-9) + } + + @Test + fun `performance with no data has zero averages`() { + val performance = TokenStatsAggregator.performanceOf(emptyList()) + assertEquals(0L, performance.ttft.knownCount) + assertEquals(0.0, performance.ttft.averageMs, 1e-9) + assertFalse(performance.ttft.hasData) + } + + // ==== 数值边界 ==== + + @Test + fun `long sums saturate instead of overflowing negative`() { + val events = + listOf( + event("e1", "id-1", 1000L, output = Long.MAX_VALUE), + event("e2", "id-1", 2000L, output = 10L), + ) + val totals = aggregated(events, listOf(identity("id-1"))) + assertEquals(Long.MAX_VALUE, totals.output.knownSum) + assertTrue(totals.output.knownSum > 0) + assertEquals(2L, totals.output.knownEventCount) + } + + @Test + fun `cost accumulation is precise across many events`() { + val events = + (0 until 1000).map { index -> + event("e$index", "id-1", index.toLong() * 1000L, cost = 0.1) + } + val cost = aggregated(events, listOf(identity("id-1"))).cost + assertEquals(100.0 * 7.0, cost.knownAmount, 1e-6) + assertTrue(cost.isFullyKnown) + } + + // ==== baseline ==== + + @Test + fun `baseline unknown cost counted as unknown`() { + val overview = + TokenStatsAggregator.lifetime( + events = emptyList(), + baselines = listOf(baseline("id-1", cost = null)), + identitiesById = mapOf("id-1" to identity("id-1")), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params, + ) + assertEquals(0.0, overview.baselineTotals.cost.knownAmount, 1e-9) + assertEquals(1L, overview.baselineTotals.cost.unknownContributionCount) + assertFalse(overview.baselineTotals.cost.isFullyKnown) + // baseline 恒为估算/冻结快照口径,不受重估参数影响 + val revalued = + TokenStatsAggregator.lifetime( + events = emptyList(), + baselines = listOf(baseline("id-1", cost = 1.0)), + identitiesById = mapOf("id-1" to identity("id-1")), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params.copy(mode = TokenStatsCostMode.REVALUED), + ) + assertEquals(TokenStatsCostMode.HISTORICAL, revalued.baselineTotals.cost.mode) + } + + @Test + fun `default params mark rate as estimated 7 dot 0`() { + val cost = aggregated(listOf(event("e1", "id-1", 1000L, cost = 1.0)), listOf(identity("id-1"))).cost + assertEquals(7.0, cost.rateUsed, 1e-9) + assertTrue(cost.rateIsEstimated) + assertEquals(PricingCurrency.CNY, cost.currency) + } + + @Test + fun `baseline tokens aggregate with saturation`() { + val overview = + TokenStatsAggregator.lifetime( + events = emptyList(), + baselines = + listOf( + baseline("id-1", inputTokens = Long.MAX_VALUE, requestCount = 5L), + baseline("id-2", inputTokens = 10L, requestCount = 5L), + ), + identitiesById = + mapOf("id-1" to identity("id-1"), "id-2" to identity("id-2", configId = "cfg-2")), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params, + ) + assertEquals(Long.MAX_VALUE, overview.baselineTotals.inputTokens) + assertEquals(10L, overview.baselineTotals.requests) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt new file mode 100644 index 000000000..708674816 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt @@ -0,0 +1,866 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import java.io.File +import java.time.ZoneId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +/** + * 统计查询服务集成测试(真实 Room + JVM SQLite 驱动,阶段 3): + * 同事务只读快照(P1-2,含并发提交一致性)、固定查询次数(防 N+1,SQL 记录驱动)、 + * 生命周期分页增量聚合(P2-1,>10k 不整表实体化)、展示模型筛选语义与 IN 分块 + * (P2-2,null=全部/空=无事件/1000+ 模型分块)、IO 线程与 startedAtMs 索引 + * (P2-3)、半开边界、初始回退、重估端到端、baseline 不进范围、Context 生产入口。 + */ +class TokenStatsQueryServiceRoomTest { + + private lateinit var tempDir: File + private lateinit var recordingDriver: RecordingSQLiteDriver + private lateinit var database: AppDatabase + private lateinit var context: Context + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val nowMs = localMs("2026-08-07T15:00:00", shanghai) + + private fun localMs(dateTime: String, zone: ZoneId): Long = + java.time.LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() + + @Before + fun setUp() { + tempDir = kotlin.io.path.createTempDirectory("query-service-test").toFile() + context = mockContext(tempDir) + recordingDriver = RecordingSQLiteDriver() + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(recordingDriver) + .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .allowMainThreadQueries() + .build() + } + + @After + fun tearDown() { + TokenStatsQueryService.databaseProvider = null + TokenStatsQueryService.legacyPricesProvider = null + TokenStatsQueryService.queryDispatcher = Dispatchers.IO + TokenStatsQueryService.lifetimeEventPageSize = 1_000 + database.close() + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + org.mockito.kotlin.whenever(context.applicationContext).thenReturn(context) + org.mockito.kotlin.whenever(context.packageName).thenReturn("com.ai.assistance.operit") + org.mockito.kotlin.whenever(context.filesDir).thenReturn(filesDir) + org.mockito.kotlin.whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + // ==== 种子数据 ==== + + private suspend fun seedIdentity( + dao: TokenStatsDao, + identityId: String, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o-2024-11-20", + displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), + ) { + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), + displayName = model, + ) + ) + } + + private fun event( + id: String, + identityId: String, + startedAtMs: Long, + cost: Double? = null, + status: String = TokenStatStatus.COMPLETED.name, + category: String = TokenStatCategory.CHAT.name, + uncached: Long? = 100L, + cached: Long? = 0L, + output: Long? = 50L, + ): TokenStatEventEntity = + TokenStatEventEntity( + eventId = id, + statIdentityId = identityId, + category = category, + status = status, + acceptedGeneration = 0L, + startedAtMs = startedAtMs, + endedAtMs = startedAtMs + 1_000L, + firstTokenAtMs = startedAtMs + 200L, + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = 0L, + totalInputTokens = null, + outputTokens = output, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.5, + cachedInputPricePerMillion = 1.5, + cacheWritePricePerMillion = null, + outputPricePerMillion = 6.0, + pricePerRequest = null, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = cost, + diagnosticsJson = null, + ) + + private fun identityEntity( + identityId: String, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o-2024-11-20", + ): TokenStatIdentityEntity = + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + + // ==== 集成:范围读取 ==== + + @Test + fun `range data aggregates events from one range read with half-open boundary`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + val start = localMs("2026-08-07T10:00:00", shanghai) + val end = localMs("2026-08-07T15:00:00", shanghai) + dao.insertEvents( + listOf( + event("e1", "id-1", start, cost = 1.0), + event("e2", "id-1", start + 3_600_000L, cost = 2.0), + event("e3", "id-1", end - 1L, cost = 3.0), + // 恰好等于 endMs:半开区间 [start, end),不属于范围 + event("e4", "id-1", end, cost = 4.0), + ) + ) + val data = + TokenStatsQueryService.rangeData( + dao, TokenStatsTimeRanges.customRange(start, end), + TokenStatsQueryParams(), shanghai, + ) + assertEquals(3L, data.eventCount) + assertEquals(3L, data.summary.requests) + assertEquals(42.0, data.summary.cost.knownAmount, 1e-9) // (1+2+3)*7 + // 桶合计 == 范围总计;桶数 = 5 小时 / 10 分钟 + assertEquals(30, data.buckets.size) + assertEquals( + data.summary.requests, + data.buckets.sumOf { it.totals.requests }, + ) + assertEquals( + data.summary.cost.knownAmount, + data.buckets.sumOf { it.totals.cost.knownAmount }, + 1e-9, + ) + // 明细与总计一致 + assertEquals(3L, data.displayModels.single().totals.requests) + assertEquals(3L, data.categories.single { it.category == TokenStatCategory.CHAT }.totals.requests) + assertEquals(3L, data.statuses.single().totals.requests) + // e1 在 10:00:00 整点,属于第 0 个桶 + assertEquals(1L, data.buckets[0].totals.requests) + } + + @Test + fun `lifetime overview includes events and baseline`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) + dao.upsertBaseline( + TokenStatBaselineEntity( + identityId = "id-1", + inputTokens = 100L, + cachedInputTokens = 10L, + outputTokens = 50L, + requestCount = 5L, + pricingCurrency = PricingCurrency.USD.name, + costInPricingCurrency = 2.0, + isEstimated = true, + fingerprint = "fp", + importedAtMs = 0L, + frozenBillingMode = BillingMode.TOKEN.name, + ) + ) + val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) + assertEquals(1L, overview.eventTotals.requests) + assertEquals(7.0, overview.eventTotals.cost.knownAmount, 1e-9) + assertEquals(5L, overview.baselineTotals.requests) + assertEquals(14.0, overview.baselineTotals.cost.knownAmount, 1e-9) + assertEquals(6L, overview.combinedRequests) + } + + @Test + fun `baseline never enters range data`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.upsertBaseline( + TokenStatBaselineEntity( + identityId = "id-1", + inputTokens = 100L, + cachedInputTokens = 10L, + outputTokens = 50L, + requestCount = 5L, + pricingCurrency = PricingCurrency.USD.name, + costInPricingCurrency = 2.0, + isEstimated = true, + fingerprint = "fp", + importedAtMs = 0L, + frozenBillingMode = BillingMode.TOKEN.name, + ) + ) + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, nowMs, shanghai) + val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) + assertEquals(0L, data.eventCount) + assertEquals(0L, data.summary.requests) + assertTrue(data.buckets.all { it.totals.requests == 0L }) + } + + // ==== 初始回退 ==== + + @Test + fun `initial preset falls back 5h to 12h to 24h to 7d to 30d`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + val hourMs = TokenStatsTimeRanges.HOUR_MS + + suspend fun presetFor(eventOffsetMs: Long): TokenStatsPreset { + dao.deleteAllEvents() + dao.insertEvents(listOf(event("e", "id-1", nowMs - eventOffsetMs, cost = 0.1))) + return TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs) + } + + assertEquals(TokenStatsPreset.LAST_5H, presetFor(2L * hourMs)) + assertEquals(TokenStatsPreset.LAST_12H, presetFor(10L * hourMs)) + assertEquals(TokenStatsPreset.LAST_24H, presetFor(20L * hourMs)) + // 5 天前:5h/12h/24h 都空,7d(含今天共 7 个自然日)有数据 + assertEquals(TokenStatsPreset.LAST_7D, presetFor(5L * 24L * hourMs)) + // 25 天前:只有 30d 范围有数据 + assertEquals(TokenStatsPreset.LAST_30D, presetFor(25L * 24L * hourMs)) + // 40 天前:任何预设都空 -> 回退默认 5h + assertEquals(TokenStatsPreset.LAST_5H, presetFor(40L * 24L * hourMs)) + + dao.deleteAllEvents() + assertEquals(TokenStatsPreset.LAST_5H, TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs)) + } + + // ==== 模型筛选 ==== + + @Test + fun `display model filter returns only selected model events`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1", model = "gpt-4o-2024-11-20", displayModelId = "gpt-4o-2024-11-20") + seedIdentity(dao, "id-2", configId = "cfg-2", model = "deepseek-chat", displayModelId = "deepseek-chat") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), + event("e2", "id-2", nowMs - 2 * 3_600_000L, cost = 2.0), + ) + ) + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + val data = + TokenStatsQueryService.rangeData( + dao, range, + TokenStatsQueryParams(displayModelIds = setOf("gpt-4o-2024-11-20")), + shanghai, + ) + assertEquals(1L, data.eventCount) + assertEquals(1L, data.summary.requests) + assertEquals(7.0, data.summary.cost.knownAmount, 1e-9) + assertEquals(1, data.displayModels.size) + } + + @Test + fun `empty display model filter returns no events while null returns all`() = runBlocking { + // P2-2 语义:displayModelIds = null → 全部模型;空集合 → 无事件(不是全部!) + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + + val none = + TokenStatsQueryService.rangeData( + dao, range, TokenStatsQueryParams(displayModelIds = emptySet()), shanghai, + ) + assertEquals(0L, none.eventCount) + assertEquals(0L, none.summary.requests) + assertTrue(none.displayModels.isEmpty()) + assertTrue(none.buckets.all { it.totals.requests == 0L }) + + val all = + TokenStatsQueryService.rangeData( + dao, range, TokenStatsQueryParams(displayModelIds = null), shanghai, + ) + assertEquals(1L, all.eventCount) + assertEquals(1L, all.summary.requests) + } + + // ==== 同事务快照与查询次数(SQL 记录驱动,防 N+1) ==== + + @Test + fun `range data does fixed snapshot reads without re-fetching tables`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) + recordingDriver.clear() + + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + val data = + TokenStatsQueryService.rangeData( + dao, range, TokenStatsQueryParams(), shanghai, legacyPrices = emptyMap(), + ) + assertEquals(1L, data.summary.requests) + + val statements = recordingDriver.executed.toList() + fun countWhere(predicate: (RecordedSql) -> Boolean): Int = statements.count(predicate) + // 历史口径:事件/identity/display model 各恰好一次;价格覆盖与 baseline 不读 + assertEquals( + 1, + countWhere { it.sql.contains("FROM token_stat_events") && it.sql.contains("WHERE startedAtMs") }, + ) + assertEquals( + "recorded: ${statements.joinToString("\n") { it.toString() }}", + 1, + countWhere { it.sql.contains("FROM token_stat_identities") }, + ) + assertEquals(1, countWhere { it.sql.contains("FROM token_stat_display_models") }) + assertEquals(0, countWhere { it.sql.contains("FROM token_stat_price_overrides") }) + assertEquals(0, countWhere { it.sql.contains("FROM token_stat_baselines") }) + // 绝不整表读取事件(getAllEvents) + assertEquals(0, countWhere { it.sql.trim() == "SELECT * FROM token_stat_events" }) + + // 重估口径:额外只读一次价格覆盖,其余不变 + TokenStatsQueryService.rangeData( + dao, range, TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), shanghai, + legacyPrices = emptyMap(), + ) + assertEquals( + 1, + recordingDriver.executed.count { it.sql.contains("FROM token_stat_price_overrides") }, + ) + } + + @Test + fun `display model filter uses a single IN join query not per model`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1", model = "gpt-4o-2024-11-20", displayModelId = "gpt-4o-2024-11-20") + seedIdentity(dao, "id-2", configId = "cfg-2", model = "deepseek-chat", displayModelId = "deepseek-chat") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), + event("e2", "id-2", nowMs - 2 * 3_600_000L, cost = 2.0), + ) + ) + recordingDriver.clear() + + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + val data = + TokenStatsQueryService.rangeData( + dao, range, + TokenStatsQueryParams(displayModelIds = setOf("gpt-4o-2024-11-20", "deepseek-chat")), + shanghai, + ) + assertEquals(2L, data.summary.requests) + val inQueries = recordingDriver.executed.filter { it.sql.contains("displayModelId IN") } + assertEquals(1, inQueries.size) + // 2 个范围参数 + 2 个 IN 参数 + assertEquals(4, inQueries.single().questionMarkCount) + } + + @Test + fun `display model filter over 900 models chunks IN queries in one snapshot`() = runBlocking { + // P2-2:SQLite 变量上限(默认 999)防炸;分块 ≤900 在同事务内合并 + val dao = database.tokenStatsDao() + val modelCount = 1_001 + val identities = + (0 until modelCount).map { index -> + TokenStatIdentityEntity( + identityId = "id-$index", + configId = "cfg", + provider = "PROVIDER", + model = "m$index", + displayModelId = "m$index", + ) + } + dao.insertIdentitiesIfAbsent(identities) + dao.upsertDisplayModels( + identities.map { identity -> + TokenStatDisplayModelEntity( + displayModelId = identity.displayModelId, + normalizedModel = identity.model, + displayName = identity.model, + ) + } + ) + dao.insertEvents( + identities.map { identity -> + event("e-${identity.identityId}", identity.identityId, nowMs - 3_600_000L, cost = 0.01) + } + ) + recordingDriver.clear() + + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + val data = + TokenStatsQueryService.rangeData( + dao, range, + TokenStatsQueryParams(displayModelIds = (0 until modelCount).map { "m$it" }.toSet()), + shanghai, + ) + assertEquals(modelCount.toLong(), data.eventCount) + assertEquals(modelCount.toLong(), data.summary.requests) + assertEquals(modelCount, data.displayModels.size) + + val inQueries = recordingDriver.executed.filter { it.sql.contains("displayModelId IN") } + assertEquals("IN 查询必须分块:900 + 101", 2, inQueries.size) + // 每块占位符 = 2 个范围参数 + IN 参数数,均不超过 SQLite 999 上限 + val chunkSizes = inQueries.map { it.questionMarkCount - 2 } + assertTrue("chunk sizes $chunkSizes must not exceed 900", chunkSizes.all { it <= 900 }) + assertEquals(modelCount, chunkSizes.sum()) + } + + // ==== 生命周期分页(P2-1:不整表实体化) ==== + + @Test + fun `lifetime overview pages events within page size and never materializes all`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + val count = 10_500 + dao.insertEvents( + (0 until count).map { index -> + event("bulk-$index", "id-1", nowMs - (index % 24) * 3_600_000L - index, cost = 0.1) + } + ) + recordingDriver.clear() + + val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) + assertEquals(count.toLong(), overview.eventTotals.requests) + assertEquals(count.toLong(), overview.eventTotals.cost.totalContributionCount) + assertTrue(overview.eventTotals.cost.isFullyKnown) + + val statements = recordingDriver.executed.toList() + // 绝不调用整表读取 getAllEvents + assertEquals( + 0, + statements.count { it.sql.trim() == "SELECT * FROM token_stat_events" }, + ) + val pageQueries = statements.filter { it.sql.contains("ORDER BY startedAtMs ASC") } + // 10500 / 1000 = 10 满页 + 1 部分页 + assertEquals(11, pageQueries.size) + // 每页 LIMIT 绑定 == 页大小,最大返回行数 ≤ 页大小 + assertTrue(pageQueries.all { it.binds[4] == "1000" }) + assertTrue(pageQueries.all { it.rows <= 1_000 }) + assertEquals(1_000, pageQueries.maxOf { it.rows }) + } + + @Test + fun `lifetime paging respects injected small page size with exact page bounds`() = runBlocking { + TokenStatsQueryService.lifetimeEventPageSize = 7 + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents( + (0 until 20).map { index -> + event("p$index", "id-1", nowMs - index * 1_000L, cost = 0.1) + } + ) + recordingDriver.clear() + + val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) + assertEquals(20L, overview.eventTotals.requests) + + val pageQueries = recordingDriver.executed.filter { it.sql.contains("ORDER BY startedAtMs ASC") } + // 7 + 7 + 6 + assertEquals(3, pageQueries.size) + assertEquals(listOf(7, 7, 6), pageQueries.map { it.rows }) + assertTrue(pageQueries.all { it.binds[4] == "7" }) + } + + // ==== 快照一致性(P1-2:并发提交完全前或完全后) ==== + + private fun assertInternallyConsistent(data: TokenStatsRangeData, expectedRequests: Long) { + assertEquals(expectedRequests, data.summary.requests) + assertEquals(data.summary.requests, data.buckets.sumOf { it.totals.requests }) + assertEquals(data.summary.requests, data.displayModels.sumOf { it.totals.requests }) + assertEquals( + data.summary.requests, + data.displayModels.sumOf { model -> model.identities.sumOf { it.totals.requests } }, + ) + assertEquals( + data.summary.cost.knownAmount, + data.buckets.sumOf { it.totals.cost.knownAmount }, + 1e-9, + ) + } + + @Test + fun `concurrent identity and event commit never yields partial snapshot`() = runBlocking { + // P1-2:查询服务的所有 Room 读取在**同一事务快照**内。写入协程在读者 + // 反复查询期间提交新 identity 与事件(两次独立提交,窗口内读者可能读到 + // “identity 已提交、事件未提交”的中间态)。旧实现多次独立读取可跨越该 + // 窗口,出现 summary 有事件但模型桶缺失;新实现任何时刻都只能看到 + // 完全前(5)/完全后(10)且自洽的结果。 + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-a", model = "model-a", displayModelId = "model-a") + dao.insertEvents( + (1..5).map { index -> event("a$index", "id-a", nowMs - index * 3_600_000L, cost = 1.0) } + ) + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + assertInternallyConsistent( + TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai), + 5L, + ) + + val writer = + launch(Dispatchers.IO) { + // identity 与事件分两次提交;间隔由调度器自然产生, + // 保证读者有机会落在两个提交之间 + seedIdentity(dao, "id-b", configId = "cfg-b", model = "model-b", displayModelId = "model-b") + dao.insertEvents( + (6..10).map { index -> + event("b$index", "id-b", nowMs - (index - 5) * 3_600_000L, cost = 2.0) + } + ) + } + + var sawPost = false + repeat(60) { + val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) + val requests = data.summary.requests + assertTrue("requests must be 5 or 10, was $requests", requests == 5L || requests == 10L) + assertInternallyConsistent(data, requests) + if (requests == 10L) sawPost = true + } + writer.join() + // 提交完成后:完全后状态,两个模型明细齐全 + val after = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) + assertInternallyConsistent(after, 10L) + assertEquals(2, after.displayModels.size) + assertEquals( + setOf("id-a", "id-b"), + after.displayModels.flatMap { it.identities.map { identity -> identity.identityId } }.toSet(), + ) + assertTrue(sawPost) + } + + // ==== 查询计划与线程(P2-3) ==== + + @Test + fun `time range query plan uses the startedAtMs index`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + val start = nowMs - 30L * TokenStatsTimeRanges.DAY_MS + dao.insertEvents( + (0 until 2000).map { index -> + event("idx-$index", "id-1", start + index * TokenStatsTimeRanges.HOUR_MS, cost = 0.1) + } + ) + val dbFile = context.getDatabasePath("app_database").absolutePath + JdbcSQLiteDriver().open(dbFile).use { connection -> + connection.prepare("ANALYZE").use { it.step() } + val plan = StringBuilder() + connection.prepare( + "EXPLAIN QUERY PLAN " + + "SELECT * FROM token_stat_events WHERE startedAtMs >= ? AND startedAtMs < ?" + ).use { statement -> + statement.bindLong(1, nowMs - 5L * TokenStatsTimeRanges.HOUR_MS) + statement.bindLong(2, nowMs) + while (statement.step()) { + for (column in 0 until statement.getColumnCount()) { + if (!statement.isNull(column)) plan.append(statement.getText(column)).append(' ') + } + plan.append('\n') + } + } + assertTrue( + "查询计划必须使用 startedAtMs 索引,实际: $plan", + plan.contains("index_token_stat_events_startedAtMs"), + ) + } + } + + @Test + fun `context facade executes room and aggregation on io dispatcher not caller thread`() = runBlocking { + // P2-3:生产入口显式切到 queryDispatcher(默认 Dispatchers.IO), + // 阶段 4 从 Main 调用不阻塞;通过注入缝记录执行线程。 + TokenStatsQueryService.databaseProvider = { database } + var providerThread: String? = null + TokenStatsQueryService.legacyPricesProvider = { _ -> + providerThread = Thread.currentThread().name + emptyMap() + } + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) + try { + val data = + TokenStatsQueryService.presetRangeData( + context, TokenStatsPreset.LAST_5H, + TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), + shanghai, nowMs, + ) + assertEquals(1L, data.summary.requests) + assertTrue( + "聚合必须运行在非调用线程(IO),实际: $providerThread", + providerThread != null && providerThread != "main", + ) + } finally { + TokenStatsQueryService.legacyPricesProvider = null + } + } + + // ==== 查询次数(mock DAO 固定查询契约) ==== + + @Test + fun `range data loads one snapshot and never re-fetches dao`() = runBlocking { + val dao = mock() + val snapshot = + TokenStatsQuerySnapshot( + events = listOf(event("e1", "id-1", 0L, cost = 1.0)), + identitiesById = mapOf("id-1" to identityEntity("id-1")), + displayModelsById = emptyMap(), + overrides = emptyList(), + baselines = emptyList(), + ) + wheneverBlocking { + dao.loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) + } doReturn snapshot + + val range = TokenStatsTimeRanges.customRange(0L, 3_600_000L) + val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) + assertEquals(1L, data.summary.requests) + + verifyBlocking(dao, Mockito.times(1)) { + loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) + } + verifyBlocking(dao, never()) { getAllEvents() } + verifyBlocking(dao, never()) { getEventsInRange(anyLong(), anyLong()) } + verifyBlocking(dao, never()) { getAllIdentities() } + verifyBlocking(dao, never()) { getAllDisplayModels() } + verifyBlocking(dao, never()) { getAllPriceOverrides() } + verifyBlocking(dao, never()) { getAllBaselines() } + } + + @Test + fun `range data passes display model filter list and revalued override flag`() = runBlocking { + val dao = mock() + wheneverBlocking { + dao.loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) + } doReturn + TokenStatsQuerySnapshot( + events = emptyList(), + identitiesById = emptyMap(), + displayModelsById = emptyMap(), + overrides = emptyList(), + baselines = emptyList(), + ) + + val range = TokenStatsTimeRanges.customRange(0L, 3_600_000L) + TokenStatsQueryService.rangeData( + dao, range, + TokenStatsQueryParams(displayModelIds = setOf("m1", "m2"), mode = TokenStatsCostMode.REVALUED), + shanghai, + ) + verifyBlocking(dao, Mockito.times(1)) { + loadRangeSnapshot(anyLong(), anyLong(), eq(listOf("m1", "m2")), eq(true)) + } + } + + @Test + fun `lifetime overview loads one paged snapshot`() = runBlocking { + val dao = mock() + wheneverBlocking { dao.loadLifetimeSnapshot(anyBoolean(), anyInt(), any()) } doAnswer { invocation -> + @Suppress("UNCHECKED_CAST") + val onPage = + invocation.getArgument(2) as + (List, Map, List) -> Unit + onPage(listOf(event("e1", "id-1", 0L, cost = 1.0)), mapOf("id-1" to identityEntity("id-1")), emptyList()) + TokenStatsLifetimeRead( + identitiesById = emptyMap(), + displayModelsById = emptyMap(), + overrides = emptyList(), + baselines = emptyList(), + totalEvents = 1L, + ) + } + + val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) + assertEquals(1L, overview.eventTotals.requests) + verifyBlocking(dao, Mockito.times(1)) { loadLifetimeSnapshot(anyBoolean(), anyInt(), any()) } + verifyBlocking(dao, never()) { getAllEvents() } + verifyBlocking(dao, never()) { getAllBaselines() } + } + + @Test + fun `initial preset probes at most five exists queries`() = runBlocking { + val dao = mock() + wheneverBlocking { dao.rangeHasEvents(anyLong(), anyLong()) } doReturn false + // 只有 7d 范围有数据:5h/12h/24h 各探测一次后命中 + val last7 = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_7D, nowMs, shanghai) + wheneverBlocking { dao.rangeHasEvents(last7.startMs, last7.endMs) } doReturn true + + assertEquals(TokenStatsPreset.LAST_7D, TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs)) + verifyBlocking(dao, Mockito.times(4)) { rangeHasEvents(anyLong(), anyLong()) } + + // 全部为空:5 次探测后回退默认 + val emptyDao = mock() + wheneverBlocking { emptyDao.rangeHasEvents(anyLong(), anyLong()) } doReturn false + assertEquals( + TokenStatsPreset.LAST_5H, + TokenStatsQueryService.initialPresetWithData(emptyDao, shanghai, nowMs), + ) + verifyBlocking(emptyDao, Mockito.times(5)) { rangeHasEvents(anyLong(), anyLong()) } + } + + // ==== 大事件量 ==== + + @Test + fun `large volume range query is a single read with consistent sums`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "bulk", model = "gpt-4o-2024-11-20", displayModelId = "bulk") + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, nowMs, shanghai) + val count = 10_000 + val stepMs = range.durationMs / count + val events = + (0 until count).map { index -> + event( + id = "bulk-$index", + identityId = "bulk", + startedAtMs = range.startMs + index * stepMs, + cost = (index % 10) * 0.1, + ) + } + dao.insertEvents(events) + + val data = + TokenStatsQueryService.rangeData( + dao, range, TokenStatsQueryParams(), shanghai, legacyPrices = emptyMap(), + ) + + assertEquals(count.toLong(), data.eventCount) + assertEquals(count.toLong(), data.summary.requests) + assertEquals(count.toLong(), data.buckets.sumOf { it.totals.requests }) + assertEquals( + data.summary.cost.knownAmount, + data.buckets.sumOf { it.totals.cost.knownAmount }, + 1e-6, + ) + assertEquals(data.summary.requests, data.displayModels.single().totals.requests) + // 大数据量只验证结果与查询结构,不做脆弱时限断言 + } + + // ==== 重估端到端 ==== + + @Test + fun `revalued mode resolves current overrides end to end`() = runBlocking { + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o-2024-11-20", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 1.0, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L, cost = null, uncached = 1_000L, output = 500L), + ) + ) + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + val data = + TokenStatsQueryService.rangeData( + dao, range, + TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), + shanghai, + ) + assertTrue(data.summary.cost.isFullyKnown) + // (1000*1 + 500*2)/1e6 = 0.002 USD -> 0.014 CNY(覆盖价 1/2,非内置 1.5/6) + assertEquals(0.014, data.summary.cost.knownAmount, 1e-9) + val pricing = data.displayModels.single().identities.single().pricing!! + assertEquals(BillingMode.TOKEN, pricing.billingMode) + assertTrue(pricing.known) + } + + // ==== Context 生产入口(注入缝) ==== + + @Test + fun `context facade resolves database through seam`() = runBlocking { + TokenStatsQueryService.databaseProvider = { database } + TokenStatsQueryService.legacyPricesProvider = { emptyMap() } + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), + event("e2", "id-1", nowMs - 2 * 3_600_000L, cost = 2.0), + ) + ) + val data = + TokenStatsQueryService.presetRangeData( + context, TokenStatsPreset.LAST_5H, TokenStatsQueryParams(), shanghai, nowMs, + ) + assertEquals(2L, data.summary.requests) + assertEquals(21.0, data.summary.cost.knownAmount, 1e-9) + + val preset = TokenStatsQueryService.initialPresetWithData(context, shanghai, nowMs) + assertEquals(TokenStatsPreset.LAST_5H, preset) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt new file mode 100644 index 000000000..6576f36df --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt @@ -0,0 +1,287 @@ +package com.ai.assistance.operit.data.stats + +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 时间预设/范围/桶边界测试(阶段 3): + * 所有边界用 java.time 日历运算,覆盖滚动窗口、自然日、自然月、跨月、 + * DST 春令(23 小时日)与冬令(25 小时日/重复小时)、自定义范围校验、 + * [start, end) 半开语义、桶对齐与归属。 + */ +class TokenStatsTimeRangeTest { + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val newYork = ZoneId.of("America/New_York") + + private fun localMs(dateTime: String, zone: ZoneId): Long = + LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() + + private fun local(epochMs: Long, zone: ZoneId): LocalDateTime = + LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(epochMs), zone) + + // ==== 滚动窗口 ==== + + @Test + fun `rolling presets are now minus duration half-open`() { + val now = localMs("2026-08-07T15:00:00", shanghai) + val fiveHour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, now, shanghai) + assertEquals(now - 5L * TokenStatsTimeRanges.HOUR_MS, fiveHour.startMs) + assertEquals(now, fiveHour.endMs) + + val twelveHour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_12H, now, shanghai) + assertEquals(now - 12L * TokenStatsTimeRanges.HOUR_MS, twelveHour.startMs) + + val twentyFour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_24H, now, shanghai) + assertEquals(now - 24L * TokenStatsTimeRanges.HOUR_MS, twentyFour.startMs) + } + + // ==== 自然日 ==== + + @Test + fun `today is local midnight to next midnight`() { + val now = localMs("2026-08-07T15:00:00", shanghai) + val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, shanghai) + assertEquals(localMs("2026-08-07T00:00:00", shanghai), today.startMs) + assertEquals(localMs("2026-08-08T00:00:00", shanghai), today.endMs) + } + + @Test + fun `yesterday is previous natural day`() { + val now = localMs("2026-08-07T02:00:00", shanghai) + val yesterday = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.YESTERDAY, now, shanghai) + assertEquals(localMs("2026-08-06T00:00:00", shanghai), yesterday.startMs) + assertEquals(localMs("2026-08-07T00:00:00", shanghai), yesterday.endMs) + } + + @Test + fun `last 7 and 30 days are natural days including today`() { + val now = localMs("2026-08-07T23:59:00", shanghai) + val seven = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_7D, now, shanghai) + assertEquals(localMs("2026-08-01T00:00:00", shanghai), seven.startMs) + assertEquals(localMs("2026-08-08T00:00:00", shanghai), seven.endMs) + assertEquals(7L * TokenStatsTimeRanges.DAY_MS, seven.durationMs) + + val thirty = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, now, shanghai) + assertEquals(localMs("2026-07-09T00:00:00", shanghai), thirty.startMs) + assertEquals(localMs("2026-08-08T00:00:00", shanghai), thirty.endMs) + assertEquals(30L * TokenStatsTimeRanges.DAY_MS, thirty.durationMs) + } + + // ==== 自然月 ==== + + @Test + fun `this and last month use calendar month boundaries`() { + val now = localMs("2026-08-07T15:00:00", shanghai) + val thisMonth = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, now, shanghai) + assertEquals(localMs("2026-08-01T00:00:00", shanghai), thisMonth.startMs) + assertEquals(localMs("2026-09-01T00:00:00", shanghai), thisMonth.endMs) + + val lastMonth = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, now, shanghai) + assertEquals(localMs("2026-07-01T00:00:00", shanghai), lastMonth.startMs) + assertEquals(localMs("2026-08-01T00:00:00", shanghai), lastMonth.endMs) + } + + @Test + fun `february month boundaries handle 28 and leap 29 days`() { + // 2026-03-01 时的上月 = 2026 年 2 月(28 天) + val nowFeb = localMs("2026-03-01T01:00:00", shanghai) + val feb = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, nowFeb, shanghai) + assertEquals(localMs("2026-02-01T00:00:00", shanghai), feb.startMs) + assertEquals(localMs("2026-03-01T00:00:00", shanghai), feb.endMs) + assertEquals(28L * TokenStatsTimeRanges.DAY_MS, feb.durationMs) + + // 2028-03-01 时的上月 = 2028 年 2 月(闰年 29 天) + val leapNow = localMs("2028-03-01T01:00:00", shanghai) + val leapFeb = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, leapNow, shanghai) + assertEquals(29L * TokenStatsTimeRanges.DAY_MS, leapFeb.durationMs) + + // 本月 = 3 月(31 天) + val march = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, nowFeb, shanghai) + assertEquals(localMs("2026-03-01T00:00:00", shanghai), march.startMs) + assertEquals(localMs("2026-04-01T00:00:00", shanghai), march.endMs) + assertEquals(31L * TokenStatsTimeRanges.DAY_MS, march.durationMs) + } + + // ==== 自定义与校验 ==== + + @Test + fun `custom range requires end after start`() { + val range = TokenStatsTimeRanges.customRange(1000L, 2000L) + assertEquals(1000L, range.startMs) + assertEquals(2000L, range.endMs) + try { + TokenStatsTimeRanges.customRange(2000L, 2000L) + throw AssertionError("expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // ok + } + try { + TokenStatsTimeRanges.rangeFor(TokenStatsPreset.CUSTOM, 1000L, shanghai) + throw AssertionError("expected IllegalArgumentException for CUSTOM preset") + } catch (expected: IllegalArgumentException) { + // ok + } + } + + // ==== DST ==== + + @Test + fun `spring forward day is 23 hours`() { + // 美东 2026-03-08 02:00 -> 03:00 拨快 1 小时 + val now = localMs("2026-03-08T15:00:00", newYork) + val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, newYork) + assertEquals(localMs("2026-03-08T00:00:00", newYork), today.startMs) + assertEquals(localMs("2026-03-09T00:00:00", newYork), today.endMs) + assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, today.durationMs) + + val yesterday = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.YESTERDAY, now, newYork) + assertEquals(24L * TokenStatsTimeRanges.HOUR_MS, yesterday.durationMs) + } + + @Test + fun `fall back day is 25 hours`() { + // 美东 2026-11-01 02:00 EDT -> 01:00 EST 拨慢 1 小时 + val now = localMs("2026-11-01T15:00:00", newYork) + val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, newYork) + assertEquals(localMs("2026-11-01T00:00:00", newYork), today.startMs) + assertEquals(localMs("2026-11-02T00:00:00", newYork), today.endMs) + assertEquals(25L * TokenStatsTimeRanges.HOUR_MS, today.durationMs) + } + + @Test + fun `month range across dst transition is exact calendar span`() { + // 2026-03 月:包含 23 小时日的自然月 + val now = localMs("2026-03-15T12:00:00", newYork) + val march = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, now, newYork) + assertEquals(localMs("2026-03-01T00:00:00", newYork), march.startMs) + assertEquals(localMs("2026-04-01T00:00:00", newYork), march.endMs) + assertEquals( + 31L * TokenStatsTimeRanges.DAY_MS - TokenStatsTimeRanges.HOUR_MS, + march.durationMs, + ) + } + + // ==== 粒度选择 ==== + + @Test + fun `granularity is chosen by range duration`() { + fun granularityOf(hours: Long) = + TokenStatsTimeRanges.granularityFor(TokenStatsTimeRanges.customRange(0L, hours * TokenStatsTimeRanges.HOUR_MS)) + assertEquals(TokenStatsGranularity.TEN_MINUTES, granularityOf(5)) + assertEquals(TokenStatsGranularity.TEN_MINUTES, granularityOf(12)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(13)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(24)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(48)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(49)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(7 * 24)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(31 * 24)) + } + + // ==== 桶对齐与归属 ==== + + @Test + fun `ten minute buckets align to local clock boundaries`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-08-07T13:07:00", shanghai), + localMs("2026-08-07T18:07:00", shanghai), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.TEN_MINUTES, shanghai) + // 首个桶边界为本地 13:00(早于范围起点,属正常:桶是日历对齐的) + assertEquals(localMs("2026-08-07T13:00:00", shanghai), starts.first()) + assertEquals(31, starts.size) + assertTrue(starts.zipWithNext().all { (a, b) -> b - a == TokenStatsTimeRanges.TEN_MINUTES_MS }) + } + + @Test + fun `hourly buckets across spring forward skip the missing hour`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-03-08T00:00:00", newYork), + localMs("2026-03-09T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, newYork) + assertEquals(23, starts.size) + // 单调递增且没有 02:00 本地小时的桶 + assertTrue(starts.zipWithNext().all { (a, b) -> b > a }) + assertTrue(starts.none { local(it, newYork).hour == 2 }) + // 事件归属:01:30 EST -> 01:00 桶;03:30 EDT -> 03:00 桶 + val early = localMs("2026-03-08T01:30:00", newYork) + val late = localMs("2026-03-08T03:30:00", newYork) + val earlyIndex = TokenStatsTimeRanges.bucketIndexOf(early, starts, TokenStatsGranularity.HOURLY, newYork)!! + val lateIndex = TokenStatsTimeRanges.bucketIndexOf(late, starts, TokenStatsGranularity.HOURLY, newYork)!! + assertEquals(localMs("2026-03-08T01:00:00", newYork), starts[earlyIndex]) + assertEquals(localMs("2026-03-08T03:00:00", newYork), starts[lateIndex]) + // 02:00 不存在:03:00 桶紧跟在 01:00 桶之后(无空洞) + assertEquals(earlyIndex + 1, lateIndex) + } + + @Test + fun `hourly buckets across fall back produce both repeated hour buckets`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-11-01T00:00:00", newYork), + localMs("2026-11-02T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, newYork) + assertEquals(25, starts.size) + assertTrue(starts.zipWithNext().all { (a, b) -> b > a }) + // 重复的本地 01:00 出现两次:01:00 EDT 与 01:00 EST(不同 epoch) + val hourOneBuckets = starts.filter { local(it, newYork).hour == 1 } + assertEquals(2, hourOneBuckets.size) + val first = localMs("2026-11-01T01:30:00", newYork) // 第一次 01:30(EDT) + // 第二次 01:30 是 EST(epoch 多 1 小时) + val secondEpoch = first + TokenStatsTimeRanges.HOUR_MS + val firstIndex = TokenStatsTimeRanges.bucketIndexOf(first, starts, TokenStatsGranularity.HOURLY, newYork)!! + val secondIndex = TokenStatsTimeRanges.bucketIndexOf(secondEpoch, starts, TokenStatsGranularity.HOURLY, newYork)!! + assertEquals(hourOneBuckets[0], starts[firstIndex]) + assertEquals(hourOneBuckets[1], starts[secondIndex]) + } + + @Test + fun `daily buckets across dst have exact 23 and 24 hour spans`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-03-08T00:00:00", newYork), + localMs("2026-03-10T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.DAILY, newYork) + assertEquals(2, starts.size) + assertEquals(localMs("2026-03-08T00:00:00", newYork), starts[0]) + assertEquals(localMs("2026-03-09T00:00:00", newYork), starts[1]) + assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, + TokenStatsTimeRanges.bucketEndMs(starts, 0, TokenStatsGranularity.DAILY, newYork) - starts[0]) + assertEquals(24L * TokenStatsTimeRanges.HOUR_MS, + TokenStatsTimeRanges.bucketEndMs(starts, 1, TokenStatsGranularity.DAILY, newYork) - starts[1]) + // 23:30 EDT 属于 03-08 的桶 + val lateEvent = localMs("2026-03-08T23:30:00", newYork) + assertEquals(0, TokenStatsTimeRanges.bucketIndexOf(lateEvent, starts, TokenStatsGranularity.DAILY, newYork)) + } + + @Test + fun `bucket boundaries partition events exactly once`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-08-07T00:00:00", shanghai), + localMs("2026-08-09T00:00:00", shanghai), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, shanghai) + // 逐小时采样:范围内每个整点恰好属于一个桶,桶序号随事件时间单调递增 + var previousIndex = -1 + for (hour in 0 until 48) { + val ts = range.startMs + hour * TokenStatsTimeRanges.HOUR_MS + val index = TokenStatsTimeRanges.bucketIndexOf(ts, starts, TokenStatsGranularity.HOURLY, shanghai) + assertTrue("ts=$ts must belong to a bucket", index != null) + assertTrue("bucket index must be monotonic", index!! >= previousIndex) + previousIndex = index + } + // 范围终点本身不属于任何桶(半开语义) + assertNull( + TokenStatsTimeRanges.bucketIndexOf(range.endMs, starts, TokenStatsGranularity.HOURLY, shanghai) + ) + // 范围起点之前的事件不属于任何桶 + assertNull( + TokenStatsTimeRanges.bucketIndexOf(range.startMs - 1, starts, TokenStatsGranularity.HOURLY, shanghai) + ) + } +} From a5e297942d2415d285891692c28ba9f3acdf6b1c Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:31:39 +0800 Subject: [PATCH 04/28] feat(stats): add token usage dashboard (cherry picked from commit 1b6cc10b2a0c010137cc920e0133ffda28d257d7) --- .../operit/data/dao/TokenStatsDao.kt | 115 ++ .../model/TokenStatPriceOverrideEntity.kt | 18 +- .../operit/data/preferences/ApiPreferences.kt | 114 ++ .../operit/data/stats/TokenStatsAggregator.kt | 11 +- .../data/stats/TokenStatsQueryModels.kt | 29 + .../data/stats/TokenStatsSettingsManager.kt | 215 ++++ .../data/stats/TokenStatsSettingsStore.kt | 92 ++ .../screens/TokenUsageStatisticsComponents.kt | 294 ----- .../screens/TokenUsageStatisticsScreen.kt | 919 -------------- .../features/tokenstats/TokenStatsCharts.kt | 581 +++++++++ .../features/tokenstats/TokenStatsColors.kt | 131 ++ .../tokenstats/TokenStatsComponents.kt | 1074 +++++++++++++++++ .../features/tokenstats/TokenStatsDialogs.kt | 603 +++++++++ .../tokenstats/TokenUsageStatisticsScreen.kt | 955 +++++++++++++++ .../TokenUsageStatisticsViewModel.kt | 624 ++++++++++ .../ai/assistance/operit/ui/main/OperitApp.kt | 13 +- .../operit/ui/main/components/AppContent.kt | 78 +- .../operit/ui/main/layout/PhoneLayout.kt | 7 +- .../operit/ui/main/layout/TabletLayout.kt | 7 +- .../ScreenRouteViewModelStoreOwner.kt | 92 ++ .../operit/ui/main/screens/OperitScreens.kt | 25 +- app/src/main/res/values-en/strings.xml | 121 ++ app/src/main/res/values/strings.xml | 123 ++ .../stats/TokenStatsSettingsManagerTest.kt | 492 ++++++++ .../tokenstats/TokenStatsChartsTest.kt | 70 ++ .../tokenstats/TokenStatsDatePickerTest.kt | 75 ++ .../TokenUsageStatisticsViewModelTest.kt | 909 ++++++++++++++ ...reenRouteViewModelStoreOwnerManagerTest.kt | 417 +++++++ 28 files changed, 6958 insertions(+), 1246 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index ade8c3c6d..9df6d9e98 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -12,6 +12,7 @@ import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import com.ai.assistance.operit.data.stats.TokenStatsGroupMetadataSnapshot import com.ai.assistance.operit.data.stats.TokenStatsLifetimeRead import com.ai.assistance.operit.data.stats.TokenStatsQuerySnapshot import androidx.room.Transaction @@ -187,6 +188,22 @@ abstract class TokenStatsDao { ) } + /** + * 分组元数据快照(阶段 4 P1 修复):全量身份 + 展示模型行在**同一个事务**内 + * 固定读取,与统计筛选(时间/模型/分类/状态)无关——分组管理与合并的 + * 成员/目标必须来自完整归属,而筛选范围明细只包含有事件的身份/分组 + * (事件存在与否不影响身份的分组成员身份)。 + */ + @Transaction + open suspend fun loadGroupMetadataSnapshot(): TokenStatsGroupMetadataSnapshot { + val identities = getAllIdentities() + val displayModels = getAllDisplayModels() + return TokenStatsGroupMetadataSnapshot( + identities = identities, + displayModels = displayModels, + ) + } + private suspend fun getEventsInRangeForDisplayModelsChunked( startMs: Long, endMs: Long, @@ -269,6 +286,91 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_display_models") abstract suspend fun getAllDisplayModels(): List + // ==== 展示分组受控写入(阶段 4 别名/合并) ==== + // 身份绝不可走删除式 REPLACE(级联删事件),只走安全 UPDATE; + // 展示模型行是纯展示元数据,创建用 INSERT IGNORE,改名用 UPDATE。 + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract suspend fun insertDisplayModelIfAbsent(model: TokenStatDisplayModelEntity): Long + + @Query( + "UPDATE token_stat_display_models SET displayName = :displayName " + + "WHERE displayModelId = :displayModelId" + ) + abstract suspend fun updateDisplayModelName(displayModelId: String, displayName: String): Int + + /** 展示分组行不存在时创建(displayModelId 同时作为规范化模型名),已存在则忽略。 */ + private suspend fun ensureDisplayModelRow(displayModelId: String, displayName: String? = null) { + if (getDisplayModel(displayModelId) == null) { + insertDisplayModelIfAbsent( + TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = displayModelId, + displayName = displayName ?: displayModelId, + ) + ) + } + } + + /** + * 把一组身份安全移动到目标展示分组(P4 别名/合并): + * 目标分组行不存在时先创建;身份只走 [updateIdentityDisplayModel] 的 + * 安全 UPDATE,绝不 REPLACE(REPLACE = DELETE + INSERT,会经外键级联 + * 删除该身份下的全部事件)。同一事务内完成,避免半移状态。 + */ + @Transaction + open suspend fun moveIdentitiesToDisplayModelTx( + identityIds: List, + displayModelId: String, + ) { + require(displayModelId.isNotBlank()) { "displayModelId must not be blank" } + ensureDisplayModelRow(displayModelId) + for (identityId in identityIds.distinct()) { + updateIdentityDisplayModel(identityId, displayModelId) + } + } + + /** + * 创建自定义展示分组(新 displayModelId + 展示名)并把指定身份移入, + * 同一事务内完成;[groupId] 必须不与既有分组冲突。 + */ + @Transaction + open suspend fun createDisplayGroupTx( + groupId: String, + groupName: String, + identityIds: List, + ) { + require(groupId.isNotBlank()) { "groupId must not be blank" } + require(groupName.isNotBlank()) { "groupName must not be blank" } + require(getDisplayModel(groupId) == null) { "display model already exists: $groupId" } + insertDisplayModelIfAbsent( + TokenStatDisplayModelEntity( + displayModelId = groupId, + normalizedModel = groupId, + displayName = groupName.trim(), + ) + ) + for (identityId in identityIds.distinct()) { + updateIdentityDisplayModel(identityId, groupId) + } + } + + /** + * 恢复默认规范分组:把指定展示组下每个身份按其自身模型名归回默认组 + * (displayModelId = 规范化模型名,[TokenStatIdentityResolver.displayModelIdFor]), + * 默认组行不存在时自动创建。同一事务内完成;事件/baseline 随身份跟随, + * 无任何删除或 REPLACE。 + */ + @Transaction + open suspend fun restoreDefaultGroupsTx(displayModelId: String) { + val identities = getAllIdentities().filter { it.displayModelId == displayModelId } + for (identity in identities) { + val defaultId = TokenStatIdentityResolver.displayModelIdFor(identity.model) + ensureDisplayModelRow(defaultId, displayName = identity.model) + updateIdentityDisplayModel(identity.identityId, defaultId) + } + } + // ==== 价格覆盖 ==== // 唯一性由 (scope, provider, model, configId) 规范化业务字段的唯一索引强制; // rowId 是内部自增主键,不承载业务语义。公开写入唯一入口会校验 scope 枚举 @@ -329,6 +431,19 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_price_overrides") abstract suspend fun getAllPriceOverrides(): List + /** 按规范化业务组合删除价格覆盖(阶段 4 管理入口;参数须为规范化后的值)。 */ + @Query( + "DELETE FROM token_stat_price_overrides " + + "WHERE scope = :scope AND provider = :provider " + + "AND model = :model AND configId = :configId" + ) + abstract suspend fun deletePriceOverride( + scope: String, + provider: String, + model: String, + configId: String, + ): Int + // ==== baseline ==== @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt index 994ecb91b..a0f7a20e0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt @@ -90,16 +90,22 @@ data class TokenStatPriceOverrideEntity( val canonicalModel = normalizeModel(model) require(canonicalProvider.isNotEmpty()) { "provider must not be blank" } require(canonicalModel.isNotEmpty()) { "model must not be blank" } + val canonicalConfigId = + if (validScope == PriceOverrideScope.PROVIDER_MODEL) { + "" + } else { + configId?.trim().orEmpty() + } + // CONFIG 作用域必须携带具体配置 ID:空 configId 的覆盖不会匹配任何 + // 配置实例,属于输入错误,直接拒绝落库(P1-7)。 + if (validScope == PriceOverrideScope.CONFIG) { + require(canonicalConfigId.isNotEmpty()) { "configId must not be blank for CONFIG scope" } + } return TokenStatPriceOverrideEntity( scope = validScope.name, provider = canonicalProvider, model = canonicalModel, - configId = - if (validScope == PriceOverrideScope.PROVIDER_MODEL) { - "" - } else { - configId?.trim().orEmpty() - }, + configId = canonicalConfigId, billingMode = billingMode, pricingCurrency = pricingCurrency, inputPricePerMillion = inputPricePerMillion, diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index b5e924e83..ae58d3ce7 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -133,6 +133,13 @@ class ApiPreferences private constructor(private val context: Context) { val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") + private val STATS_TARGET_CURRENCY = stringPreferencesKey("stats_target_currency") + private val STATS_COST_MODE = stringPreferencesKey("stats_cost_mode") + private val STATS_TIME_PRESET = stringPreferencesKey("stats_time_preset") + private val STATS_TIME_CUSTOM_START = longPreferencesKey("stats_time_custom_start") + private val STATS_TIME_CUSTOM_END = longPreferencesKey("stats_time_custom_end") + private val STATS_TIME_MANUAL = booleanPreferencesKey("stats_time_manual") + val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on") val FEATURE_TOGGLES_JSON = stringPreferencesKey("feature_toggles_json") // Default values @@ -955,12 +962,119 @@ class ApiPreferences private constructor(private val context: Context) { return preferences[USD_TO_CNY_EXCHANGE_RATE]?.toDouble() ?: 7.2 } + /** + * 统计页汇率读取(阶段 4):区分“用户手动设置”与“未设置”。 + * 未设置时返回默认估算 7.0([com.ai.assistance.operit.data.stats.TokenCostCurrency] + * 契约)并标记 estimated = true,界面必须显示估算提示;不联网获取汇率。 + */ + suspend fun usdToCnyRateWithEstimate(): Pair { + val preferences = context.apiDataStore.data.first() + val stored = preferences[USD_TO_CNY_EXCHANGE_RATE] + return if (stored != null) { + stored.toDouble() to false + } else { + 7.0 to true + } + } + suspend fun setUsdToCnyExchangeRate(rate: Double) { context.apiDataStore.edit { preferences -> preferences[USD_TO_CNY_EXCHANGE_RATE] = rate.toFloat() } } + // ===== 统计页偏好(阶段 4;与汇率共用 api_settings 文件,备份自动覆盖) ===== + + suspend fun getStatsTargetCurrency(): com.ai.assistance.operit.data.collects.PricingCurrency { + val preferences = context.apiDataStore.data.first() + val raw = preferences[STATS_TARGET_CURRENCY] + return if (raw.equals(com.ai.assistance.operit.data.collects.PricingCurrency.USD.name, ignoreCase = true)) { + com.ai.assistance.operit.data.collects.PricingCurrency.USD + } else { + com.ai.assistance.operit.data.collects.PricingCurrency.CNY + } + } + + suspend fun setStatsTargetCurrency( + currency: com.ai.assistance.operit.data.collects.PricingCurrency + ) { + context.apiDataStore.edit { preferences -> + preferences[STATS_TARGET_CURRENCY] = currency.name + } + } + + suspend fun getStatsCostMode(): com.ai.assistance.operit.data.stats.TokenStatsCostMode { + val preferences = context.apiDataStore.data.first() + val raw = preferences[STATS_COST_MODE] + return com.ai.assistance.operit.data.stats.TokenStatsCostMode.entries + .firstOrNull { it.name == raw } + ?: com.ai.assistance.operit.data.stats.TokenStatsCostMode.HISTORICAL + } + + suspend fun setStatsCostMode(mode: com.ai.assistance.operit.data.stats.TokenStatsCostMode) { + context.apiDataStore.edit { preferences -> + preferences[STATS_COST_MODE] = mode.name + } + } + + /** + * 统计页时间选择(阶段 4):null = 从未有任何选择(首次进入,允许自动回退)。 + * CUSTOM 预设必须同时存在合法自定义边界,否则视为未选择(防御损坏状态)。 + */ + suspend fun getStatsTimeSelection(): com.ai.assistance.operit.data.stats.TokenStatsTimeSelection? { + val preferences = context.apiDataStore.data.first() + val presetRaw = preferences[STATS_TIME_PRESET] ?: return null + val preset = com.ai.assistance.operit.data.stats.TokenStatsPreset.entries + .firstOrNull { it.name == presetRaw } + ?: return null + if (preset != com.ai.assistance.operit.data.stats.TokenStatsPreset.CUSTOM) { + return com.ai.assistance.operit.data.stats.TokenStatsTimeSelection(preset) + } + val start = preferences[STATS_TIME_CUSTOM_START] ?: return null + val end = preferences[STATS_TIME_CUSTOM_END] ?: return null + if (end <= start) return null + return com.ai.assistance.operit.data.stats.TokenStatsTimeSelection(preset, start, end) + } + + /** + * 统计页时间选择是否由用户手动做出(阶段 4)。 + * false = 首次自动回退结果;旧版本持久化的选择没有该键,按 false 处理 + * (选择本身仍被复用,只是不再区分来源,迁移合理)。 + */ + suspend fun getStatsSelectionWasManual(): Boolean { + val preferences = context.apiDataStore.data.first() + return preferences[STATS_TIME_MANUAL] ?: false + } + + /** + * 统计页时间选择保存(阶段 4):[manual] = 用户手动选择(true)或首次 + * 自动回退(false)。清除时([selection] = null)一并移除 manual 键, + * 回到“从未选择”的首次回退语义。 + */ + suspend fun setStatsTimeSelection( + selection: com.ai.assistance.operit.data.stats.TokenStatsTimeSelection?, + manual: Boolean, + ) { + context.apiDataStore.edit { preferences -> + if (selection == null) { + preferences.remove(STATS_TIME_PRESET) + preferences.remove(STATS_TIME_CUSTOM_START) + preferences.remove(STATS_TIME_CUSTOM_END) + preferences.remove(STATS_TIME_MANUAL) + return@edit + } + preferences[STATS_TIME_PRESET] = selection.preset.name + preferences[STATS_TIME_MANUAL] = manual + if (selection.preset == com.ai.assistance.operit.data.stats.TokenStatsPreset.CUSTOM) { + preferences[STATS_TIME_CUSTOM_START] = selection.customStartMs ?: 0L + preferences[STATS_TIME_CUSTOM_END] = selection.customEndMs ?: 0L + } else { + preferences.remove(STATS_TIME_CUSTOM_START) + preferences.remove(STATS_TIME_CUSTOM_END) + } + } + } + suspend fun saveMaxImageHistoryUserTurns(turns: Int) { context.apiDataStore.edit { preferences -> preferences[MAX_IMAGE_HISTORY_USER_TURNS] = turns diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt index 55605cc00..fc13ae2d8 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt @@ -170,8 +170,8 @@ object TokenStatsAggregator { zone: ZoneId, params: TokenStatsQueryParams, ): TokenStatsRangeData { - // 分类筛选在聚合入口统一应用:汇总、桶、明细反映同一筛选结果 - val filtered = filterByCategory(events, params) + // 分类/状态筛选在聚合入口统一应用:汇总、桶、明细反映同一筛选结果 + val filtered = filterByCategory(events, params).filterByStatus(params) val pricing = pricingContext(overrides, legacyPrices, params) val summary = totalsOf(filtered, identitiesById, pricing, params) @@ -637,6 +637,13 @@ object TokenStatsAggregator { return events.filter { TokenStatCategory.fromName(it.category) in categories } } + private fun List.filterByStatus( + params: TokenStatsQueryParams, + ): List { + val statuses = params.statuses ?: return this + return filter { TokenStatStatus.fromName(it.status) in statuses } + } + private fun parseCurrency(raw: String): PricingCurrency = if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt index 22910a663..af527c927 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -45,6 +45,8 @@ data class TokenStatsQueryParams( val displayModelIds: Set? = null, /** 业务分类筛选;null = 全部分类。 */ val categories: Set? = null, + /** 请求状态筛选(阶段 4);null = 全部状态。 */ + val statuses: Set? = null, ) { init { require(manualRate > 0.0) { "manual rate must be positive" } @@ -210,6 +212,21 @@ data class TokenStatsDisplayModelBreakdown( val identities: List, ) +/** + * 展示分组完整元数据(阶段 4 P1 修复):与统计筛选(时间/模型/分类/状态)完全 + * 无关的分组成员/合并目标事实来源。identity.displayModelId 是分组的单一事实 + * 来源;成员 = 全量身份按 displayModelId 分组(事件存在与否不影响成员身份), + * 组名取 display_models 行(缺失时回退 displayModelId)。筛选范围明细 + * ([TokenStatsDisplayModelBreakdown])只包含当前筛选下有事件的身份/分组, + * 不得作为分组操作的成员或目标依据。 + */ +data class TokenStatsGroupModelInfo( + val displayModelId: String, + val displayName: String, + /** 该分组下的全部身份 id(完整归属,非当前筛选范围所见)。 */ + val memberIdentityIds: List, +) + /** 业务分类合计。 */ data class TokenStatsCategoryBreakdown( val category: TokenStatCategory, @@ -268,3 +285,15 @@ data class TokenStatsLifetimeRead( val baselines: List, val totalEvents: Long, ) + +/** + * 分组元数据快照(阶段 4 P1 修复):全量身份 + 展示模型行在**同一个 Room 事务**内 + * 固定读取([com.ai.assistance.operit.data.dao.TokenStatsDao.loadGroupMetadataSnapshot]), + * 与统计筛选无关;事务外由设置管理器构建 [TokenStatsGroupModelInfo]。 + * 快照一致性原则同 [TokenStatsQuerySnapshot](并发分组变更要么整体可见要么 + * 整体不可见)。 + */ +data class TokenStatsGroupMetadataSnapshot( + val identities: List, + val displayModels: List, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt new file mode 100644 index 000000000..2e96ca780 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -0,0 +1,215 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import java.util.UUID + +/** 价格覆盖编辑草稿(界面 ↔ 管理入口的统一输入形态)。 */ +data class TokenStatsPriceOverrideDraft( + val scope: PriceOverrideScope, + val provider: String, + val model: String, + val configId: String?, + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) + +/** + * 统计页设置管理(阶段 4):价格覆盖与模型别名/分组的**受控**写入入口。 + * + * - 价格覆盖:所有价格值必须为非负有限数(NaN/Infinity/负数直接抛 + * [IllegalArgumentException],不落库);写入走 [TokenStatsDao.upsertPriceOverride] + * 的规范化唯一入口(scope 枚举 + 规范化业务字段),删除走规范化业务组合。 + * - 分组/别名:身份只通过 [TokenStatsDao] 的安全 UPDATE 移动,绝不 REPLACE + * (REPLACE 会经外键级联删除事件);展示模型行用 INSERT IGNORE / UPDATE。 + * - 本类只做校验与编排,事务边界在 DAO(@Transaction)保证。 + */ +class TokenStatsSettingsManager(private val dao: TokenStatsDao) { + + companion object { + /** 自定义分组 displayModelId 前缀(与任何规范化模型名天然不冲突)。 */ + const val CUSTOM_GROUP_ID_PREFIX = "custom-group-" + } + + // ==== 价格覆盖 ==== + + /** 校验单个价格输入:null 允许(该计费方式不使用),非空必须非负有限。 */ + fun validatePriceValue(name: String, value: Double?): Double? { + if (value == null) return null + require(value.isFinite() && value >= 0.0) { + "$name must be non-negative and finite, got $value" + } + return value + } + + /** + * 新增/编辑价格覆盖(provider/model 或 API 配置作用域)。 + * 价格值非法时抛 [IllegalArgumentException],不产生任何写入。 + */ + suspend fun upsertPriceOverride( + scope: PriceOverrideScope, + provider: String, + model: String, + configId: String?, + billingMode: BillingMode, + pricingCurrency: PricingCurrency, + inputPricePerMillion: Double?, + cachedInputPricePerMillion: Double?, + cacheWritePricePerMillion: Double?, + outputPricePerMillion: Double?, + pricePerRequest: Double?, + ) { + dao.upsertPriceOverride( + scope = scope.name, + provider = provider, + model = model, + configId = configId, + billingMode = billingMode.name, + pricingCurrency = pricingCurrency.name, + inputPricePerMillion = validatePriceValue("inputPrice", inputPricePerMillion), + cachedInputPricePerMillion = + validatePriceValue("cachedInputPrice", cachedInputPricePerMillion), + cacheWritePricePerMillion = + validatePriceValue("cacheWritePrice", cacheWritePricePerMillion), + outputPricePerMillion = validatePriceValue("outputPrice", outputPricePerMillion), + pricePerRequest = validatePriceValue("pricePerRequest", pricePerRequest), + ) + } + + /** 草稿形态的统一入口(阶段 4 UI 使用)。 */ + suspend fun upsertPriceOverride(draft: TokenStatsPriceOverrideDraft) { + upsertPriceOverride( + scope = draft.scope, + provider = draft.provider, + model = draft.model, + configId = draft.configId, + billingMode = draft.billingMode, + pricingCurrency = draft.currency, + inputPricePerMillion = draft.inputPricePerMillion, + cachedInputPricePerMillion = draft.cachedInputPricePerMillion, + cacheWritePricePerMillion = draft.cacheWritePricePerMillion, + outputPricePerMillion = draft.outputPricePerMillion, + pricePerRequest = draft.pricePerRequest, + ) + } + + /** + * 编辑已有价格覆盖(P1-7):业务键(scope/provider/model/configId)必须与 + * 现有行**规范化后一致**,只允许修改价格/币种/计费方式。防止 UI 之外 + * (或 UI 缺陷)改动业务键产生第二行或误覆盖其他覆盖。 + * 校验通过后按规范化值写回(REPLACE 覆盖同一业务组合,始终只有一行)。 + * @throws IllegalArgumentException 业务键不一致或价格非法。 + */ + suspend fun updatePriceOverride( + existing: TokenStatPriceOverrideEntity, + draft: TokenStatsPriceOverrideDraft, + ) { + val normalized = TokenStatPriceOverrideEntity.normalized( + scope = draft.scope.name, + provider = draft.provider, + model = draft.model, + configId = draft.configId, + billingMode = draft.billingMode.name, + pricingCurrency = draft.currency.name, + ) + require(normalized.scope == existing.scope) { "price override scope must not change on edit" } + require(normalized.provider == existing.provider) { "price override provider must not change on edit" } + require(normalized.model == existing.model) { "price override model must not change on edit" } + require(normalized.configId == existing.configId) { "price override configId must not change on edit" } + upsertPriceOverride(draft) + } + + /** 全部价格覆盖(管理区展示用;小表,一次读取)。 */ + suspend fun allPriceOverrides(): List = + dao.getAllPriceOverrides() + + /** 删除价格覆盖(按规范化业务组合;不存在的组合静默成功)。 */ + suspend fun deletePriceOverride( + scope: PriceOverrideScope, + provider: String, + model: String, + configId: String?, + ) { + dao.deletePriceOverride( + scope = scope.name, + // 与写入同一规范化:provider trim+小写、model trim+小写+压缩空白 + provider = provider.trim().lowercase(), + model = model.trim().lowercase().replace(Regex("\\s+"), " "), + configId = + if (scope == PriceOverrideScope.PROVIDER_MODEL) { + "" + } else { + configId?.trim().orEmpty() + }, + ) + } + + // ==== 展示分组 / 别名 ==== + + /** + * 完整展示分组元数据(阶段 4 P1 修复):与统计筛选(时间/模型/分类/状态) + * 完全无关——分组管理与合并的成员/目标必须来自全量身份/展示模型表,而不是 + * 当前筛选范围所见(范围明细只反映有事件的身份/分组,会把无事件的组成员 + * 漏掉)。返回所有分组(含无身份的空组,空组仍是合法合并目标);组名取 + * display_models 行,缺失时回退 displayModelId;输出顺序确定。 + */ + suspend fun groupModels(): List { + val snapshot = dao.loadGroupMetadataSnapshot() + val displayNames = snapshot.displayModels.associateBy { it.displayModelId } + val membersByGroup = LinkedHashMap>() + for (identity in snapshot.identities) { + membersByGroup.getOrPut(identity.displayModelId) { mutableListOf() } + .add(identity.identityId) + } + // 有展示行但无身份的空分组:仍可作为合并目标,必须保留 + for (display in snapshot.displayModels) { + membersByGroup.putIfAbsent(display.displayModelId, mutableListOf()) + } + return membersByGroup.map { (displayModelId, memberIds) -> + TokenStatsGroupModelInfo( + displayModelId = displayModelId, + displayName = displayNames[displayModelId]?.displayName ?: displayModelId, + memberIdentityIds = memberIds.sorted(), + ) + }.sortedWith(compareBy({ it.displayName.lowercase() }, { it.displayModelId })) + } + + /** 重命名展示分组(只改 displayName,不动身份归属)。 */ + suspend fun renameDisplayGroup(displayModelId: String, displayName: String) { + val trimmed = displayName.trim() + require(trimmed.isNotBlank()) { "display name must not be blank" } + dao.updateDisplayModelName(displayModelId, trimmed) + } + + /** 把指定身份移动到已有展示分组(分组行不存在时自动创建)。 */ + suspend fun moveIdentitiesToGroup(identityIds: List, displayModelId: String) { + require(identityIds.isNotEmpty()) { "identityIds must not be empty" } + dao.moveIdentitiesToDisplayModelTx(identityIds, displayModelId) + } + + /** + * 新建自定义展示分组并把指定身份移入;返回新分组 displayModelId。 + * 分组 ID 使用随机后缀,避免与规范化模型名冲突。 + */ + suspend fun createGroupAndMove(groupName: String, identityIds: List): String { + val trimmed = groupName.trim() + require(trimmed.isNotBlank()) { "group name must not be blank" } + require(identityIds.isNotEmpty()) { "identityIds must not be empty" } + val groupId = "$CUSTOM_GROUP_ID_PREFIX${UUID.randomUUID()}" + dao.createDisplayGroupTx(groupId, trimmed, identityIds) + return groupId + } + + /** 恢复默认规范分组:组内每个身份按其自身模型名归回默认组。 */ + suspend fun restoreDefaultGroups(displayModelId: String) { + dao.restoreDefaultGroupsTx(displayModelId) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt new file mode 100644 index 000000000..0fc445b2a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt @@ -0,0 +1,92 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.preferences.ApiPreferences + +/** + * 统计页持久化偏好(阶段 4):汇率、总计币种、费用口径与时间选择。 + * + * - 全部键落在 ApiPreferences 的 `api_settings` DataStore 文件内,由现有 + * 整库/ApiPreferences 备份恢复逻辑整体覆盖,不新增独立存储与凭据。 + * - 汇率**只由用户手动设置**:未设置时 [loadRateWithEstimate] 返回默认估算 + * 7.0([TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE])并标记 estimated, + * 界面必须明显标注“默认估算”;不联网获取汇率。 + * - 时间选择持久化的是“用户是否手动选过”:null = 从未选择(允许每次进入 + * 时按 5h→…→30d 自动回退);非 null = 用户选择,不再自动跳转。 + */ +interface TokenStatsSettingsStore { + + /** 当前手动汇率 + 是否默认估算(true = 未设置,界面必须标记估算)。 */ + suspend fun loadRateWithEstimate(): Pair + + /** 保存用户手动汇率;保存后 [loadRateWithEstimate] 的 estimated 为 false。 */ + suspend fun saveRate(rate: Double) + + suspend fun loadTargetCurrency(): PricingCurrency + + suspend fun saveTargetCurrency(currency: PricingCurrency) + + suspend fun loadCostMode(): TokenStatsCostMode + + suspend fun saveCostMode(mode: TokenStatsCostMode) + + /** + * 当前时间选择(首次自动回退结果或用户手选);null = 从未有任何选择, + * 进入页面时允许执行首次自动回退并持久化。 + */ + suspend fun loadTimeSelection(): TokenStatsTimeSelection? + + /** 当前时间选择是否由用户手动做出;false = 首次自动回退(或旧数据迁移)。 */ + suspend fun loadSelectionWasManual(): Boolean + + /** + * 保存/清除时间选择;[manual] = 用户手动选择(true)或首次自动回退 + * (false)。null 表示清除(回到首次自动回退语义)。 + */ + suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) +} + +/** 用户选择的时间范围(持久化形态):预设 + 可选自定义边界(毫秒,设备时区自然日)。 */ +data class TokenStatsTimeSelection( + val preset: TokenStatsPreset, + val customStartMs: Long? = null, + val customEndMs: Long? = null, +) + +/** 生产实现:直接包装 [ApiPreferences](同一 DataStore 文件,备份自动覆盖)。 */ +class ApiPreferencesTokenStatsSettingsStore(context: Context) : TokenStatsSettingsStore { + + private val api = ApiPreferences.getInstance(context) + + override suspend fun loadRateWithEstimate(): Pair = + api.usdToCnyRateWithEstimate() + + override suspend fun saveRate(rate: Double) { + api.setUsdToCnyExchangeRate(rate) + } + + override suspend fun loadTargetCurrency(): PricingCurrency = + api.getStatsTargetCurrency() + + override suspend fun saveTargetCurrency(currency: PricingCurrency) { + api.setStatsTargetCurrency(currency) + } + + override suspend fun loadCostMode(): TokenStatsCostMode = + api.getStatsCostMode() + + override suspend fun saveCostMode(mode: TokenStatsCostMode) { + api.setStatsCostMode(mode) + } + + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = + api.getStatsTimeSelection() + + override suspend fun loadSelectionWasManual(): Boolean = + api.getStatsSelectionWasManual() + + override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { + api.setStatsTimeSelection(selection, manual) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt deleted file mode 100644 index f743e2663..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ai.assistance.operit.R - -@Composable -internal fun TokenUsageSummarySection( - totalChats: Int, - totalMessages: Int, - totalTokens: Long, - totalInputTokens: Long, - totalOutputTokens: Long, - totalCachedInputTokens: Long, - totalRequests: Int, - totalCostText: String, - exchangeRateHint: String? -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.settings_usage_summary), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = stringResource(id = R.string.settings_total_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalTokens.toString(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(id = R.string.settings_total_requests), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalRequests.toString(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(id = R.string.settings_total_cost), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalCostText, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - - exchangeRateHint?.let { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_total_chats), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalChats.toString(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_total_messages), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalMessages.toString(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - SummaryLine( - label = stringResource(id = R.string.settings_input_tokens), - value = totalInputTokens.toString(), - valueColor = MaterialTheme.colorScheme.onPrimaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - SummaryLine( - label = stringResource(id = R.string.settings_output_tokens), - value = totalOutputTokens.toString(), - valueColor = MaterialTheme.colorScheme.onPrimaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - if (totalCachedInputTokens > 0L) { - SummaryLine( - label = stringResource(id = R.string.settings_cached_tokens_label), - value = totalCachedInputTokens.toString(), - valueColor = MaterialTheme.colorScheme.tertiary, - labelColor = MaterialTheme.colorScheme.tertiary - ) - } - } - } - } -} - -@Composable -private fun SummaryLine( - label: String, - value: String, - labelColor: androidx.compose.ui.graphics.Color, - valueColor: androidx.compose.ui.graphics.Color -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = labelColor - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = valueColor - ) - } -} - -@Composable -internal fun ModelUsageDistributionSection(items: List>) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(id = R.string.settings_model_usage_distribution), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - ModelUsagePieChart( - modifier = Modifier - .fillMaxWidth() - .height(220.dp), - items = items - ) - } -} - -@Composable -private fun ModelUsagePieChart( - modifier: Modifier = Modifier, - items: List> -) { - val total = remember(items) { items.sumOf { it.second }.toDouble().coerceAtLeast(1.0) } - val colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.secondary, - MaterialTheme.colorScheme.tertiary, - MaterialTheme.colorScheme.error, - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.tertiaryContainer - ) - - Card(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) { - Canvas(modifier = Modifier.fillMaxSize()) { - val diameter = size.minDimension - val topLeft = Offset((size.width - diameter) / 2f, (size.height - diameter) / 2f) - val arcSize = Size(diameter, diameter) - var startAngle = -90f - - items.forEachIndexed { index, (_, value) -> - val sweep = ((value.toDouble() / total) * 360.0).toFloat() - if (sweep > 0f) { - drawArc( - color = colors[index % colors.size], - startAngle = startAngle, - sweepAngle = sweep, - useCenter = true, - topLeft = topLeft, - size = arcSize - ) - startAngle += sweep - } - } - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items.take(8).forEachIndexed { index, (name, value) -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Canvas(modifier = Modifier.size(10.dp)) { - drawRect(color = colors[index % colors.size]) - } - Text( - text = name, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - maxLines = 1 - ) - val percent = (value.toDouble() / total) * 100.0 - Text( - text = String.format("%.1f%%", percent), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt deleted file mode 100644 index c72570cc0..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt +++ /dev/null @@ -1,919 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import android.widget.Toast -import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.repository.ChatHistoryManager -import com.ai.assistance.operit.ui.components.CustomScaffold -import java.util.Locale -import kotlinx.coroutines.launch - -private data class ModelCost( - val amount: Double, - val currency: PricingCurrency -) - -private const val DEFAULT_USD_TO_CNY_RATE = 7.2 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TokenUsageStatisticsScreen( - onBackPressed: () -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val apiPreferences = remember { ApiPreferences.getInstance(context) } - val chatHistoryManager = remember { ChatHistoryManager.getInstance(context) } - - var totalChats by remember { mutableStateOf(0) } - var totalMessages by remember { mutableStateOf(0) } - - val providerModelTokenUsage = remember { mutableStateMapOf>() } - val providerModelRequestCounts = remember { mutableStateMapOf() } - val modelPricing = remember { mutableStateMapOf>() } - val modelBillingMode = remember { mutableStateMapOf() } - val modelPricePerRequest = remember { mutableStateMapOf() } - val modelCurrencies = remember { mutableStateMapOf() } - - var showPricingDialog by remember { mutableStateOf(false) } - var selectedModel by remember { mutableStateOf("") } - var showResetDialog by remember { mutableStateOf(false) } - var showResetModelDialog by remember { mutableStateOf(false) } - var resetModel by remember { mutableStateOf("") } - - var usdToCnyRate by remember { mutableStateOf(DEFAULT_USD_TO_CNY_RATE) } - var usdToCnyRateInput by remember { mutableStateOf(DEFAULT_USD_TO_CNY_RATE.toString()) } - - LaunchedEffect(Unit) { - apiPreferences.allProviderModelTokensFlow.collect { tokensMap -> - providerModelTokenUsage.clear() - providerModelTokenUsage.putAll(tokensMap) - - tokensMap.keys.forEach { providerModel -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - modelCurrencies[providerModel] = defaults.currency - - if (!modelPricing.containsKey(providerModel)) { - modelPricing[providerModel] = Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - } - if (!modelBillingMode.containsKey(providerModel)) { - modelBillingMode[providerModel] = defaults.billingMode - } - if (!modelPricePerRequest.containsKey(providerModel)) { - modelPricePerRequest[providerModel] = defaults.pricePerRequest - } - } - } - } - - LaunchedEffect(providerModelTokenUsage.keys.toSet()) { - providerModelTokenUsage.keys.forEach { providerModel -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - - val inputPrice = apiPreferences.getModelInputPrice(providerModel) - val outputPrice = apiPreferences.getModelOutputPrice(providerModel) - val cachedInputPrice = apiPreferences.getModelCachedInputPrice(providerModel) - modelPricing[providerModel] = if ( - inputPrice > 0.0 || outputPrice > 0.0 || cachedInputPrice > 0.0 - ) { - Triple(inputPrice, outputPrice, cachedInputPrice) - } else { - Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - } - - modelBillingMode[providerModel] = apiPreferences.getBillingModeForProviderModel(providerModel) - - val savedPricePerRequest = apiPreferences.getPricePerRequestForProviderModel(providerModel) - modelPricePerRequest[providerModel] = if (savedPricePerRequest > 0.0) { - savedPricePerRequest - } else { - defaults.pricePerRequest - } - } - } - - LaunchedEffect(Unit) { - val requestCounts = apiPreferences.getAllProviderModelRequestCounts() - providerModelRequestCounts.clear() - providerModelRequestCounts.putAll(requestCounts) - } - - LaunchedEffect(Unit) { - runCatching { - totalChats = chatHistoryManager.getTotalChatCount() - totalMessages = chatHistoryManager.getTotalMessageCount() - } - } - - LaunchedEffect(Unit) { - val rate = apiPreferences.getUsdToCnyExchangeRate() - if (rate > 0.0) { - usdToCnyRate = rate - usdToCnyRateInput = rate.toString() - } - } - - val providerModelCosts by remember { - derivedStateOf { - providerModelTokenUsage.mapValues { (providerModel, tokens) -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - val currency = modelCurrencies[providerModel] ?: defaults.currency - val billingMode = modelBillingMode[providerModel] ?: defaults.billingMode - - val amount = when (billingMode) { - BillingMode.TOKEN -> { - val pricing = modelPricing[providerModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val nonCachedInput = (tokens.first - tokens.third).coerceAtLeast(0L) - (nonCachedInput / 1_000_000.0 * pricing.first) + - (tokens.second / 1_000_000.0 * pricing.second) + - (tokens.third / 1_000_000.0 * pricing.third) - } - - BillingMode.COUNT -> { - val pricePerRequest = modelPricePerRequest[providerModel] ?: defaults.pricePerRequest - val requestCount = providerModelRequestCounts[providerModel] ?: 0 - requestCount * pricePerRequest - } - } - - ModelCost(amount = amount, currency = currency) - } - } - } - - val totalInputTokens = providerModelTokenUsage.values.sumOf { it.first } - val totalOutputTokens = providerModelTokenUsage.values.sumOf { it.second } - val totalCachedInputTokens = providerModelTokenUsage.values.sumOf { it.third } - val totalTokens = totalInputTokens + totalOutputTokens - val totalRequests = providerModelRequestCounts.values.sum() - - val totalCostCny = providerModelCosts.values.sumOf { cost -> - convertToCny(cost.amount, cost.currency, usdToCnyRate) - } - - val hasUsdCost = providerModelCosts.values.any { it.currency == PricingCurrency.USD && it.amount > 0.0 } - - val modelUsageDistribution by remember { - derivedStateOf { - providerModelTokenUsage.entries - .map { it.key to (it.value.first + it.value.second) } - .filter { it.second > 0 } - .sortedByDescending { it.second } - } - } - - CustomScaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { showResetDialog = true }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(id = R.string.settings_reset_all_counts) - ) - } - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - ExchangeRateSettingsCard( - rateInput = usdToCnyRateInput, - onRateInputChange = { usdToCnyRateInput = it }, - onSave = { - val parsedRate = usdToCnyRateInput.toDoubleOrNull() - if (parsedRate != null && parsedRate > 0.0) { - usdToCnyRate = parsedRate - scope.launch { - apiPreferences.setUsdToCnyExchangeRate(parsedRate) - } - } - } - ) - } - - item { - TokenUsageSummarySection( - totalChats = totalChats, - totalMessages = totalMessages, - totalTokens = totalTokens, - totalInputTokens = totalInputTokens, - totalOutputTokens = totalOutputTokens, - totalCachedInputTokens = totalCachedInputTokens, - totalRequests = totalRequests, - totalCostText = formatCurrencyAmount(totalCostCny, PricingCurrency.CNY), - exchangeRateHint = if (hasUsdCost) { - stringResource( - id = R.string.settings_rate_applied_hint, - usdToCnyRate - ) - } else { - null - } - ) - } - - if (modelUsageDistribution.isNotEmpty()) { - item { - ModelUsageDistributionSection(items = modelUsageDistribution) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.settings_model_details), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = stringResource(id = R.string.settings_click_to_edit_pricing), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - val sortedProviderModels = providerModelTokenUsage.entries.sortedBy { it.key } - - if (sortedProviderModels.isEmpty()) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Analytics, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.settings_no_token_records), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - items(sortedProviderModels) { (providerModel, tokens) -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - val (input, output, cached) = tokens - val cost = providerModelCosts[providerModel]?.amount ?: 0.0 - val currency = modelCurrencies[providerModel] ?: defaults.currency - val pricing = modelPricing[providerModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val billingMode = modelBillingMode[providerModel] ?: defaults.billingMode - val requestCount = providerModelRequestCounts[providerModel] ?: 0 - val pricePerRequest = modelPricePerRequest[providerModel] ?: defaults.pricePerRequest - val displayCurrency = PricingCurrency.CNY - - TokenUsageModelCard( - modelName = providerModel, - inputTokens = input, - cachedInputTokens = cached, - outputTokens = output, - requestCount = requestCount, - cost = convertToCny(cost, currency, usdToCnyRate), - inputPrice = convertToCny(pricing.first, currency, usdToCnyRate), - outputPrice = convertToCny(pricing.second, currency, usdToCnyRate), - billingMode = billingMode, - pricePerRequest = convertToCny(pricePerRequest, currency, usdToCnyRate), - currency = displayCurrency, - onClick = { - selectedModel = providerModel - showPricingDialog = true - }, - onResetClick = { - resetModel = providerModel - showResetModelDialog = true - } - ) - } - } - - item { - Spacer(modifier = Modifier.height(96.dp)) - } - } - } - - if (showPricingDialog && selectedModel.isNotEmpty()) { - val defaults = DefaultModelPricingCollect.getDefaultPricing(selectedModel) - val currentPricing = modelPricing[selectedModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val currentBillingMode = modelBillingMode[selectedModel] ?: defaults.billingMode - val currentPricePerRequest = modelPricePerRequest[selectedModel] ?: defaults.pricePerRequest - val currency = modelCurrencies[selectedModel] ?: defaults.currency - val editCurrency = PricingCurrency.CNY - val currentPricingCny = Triple( - convertToCny(currentPricing.first, currency, usdToCnyRate), - convertToCny(currentPricing.second, currency, usdToCnyRate), - convertToCny(currentPricing.third, currency, usdToCnyRate) - ) - val currentPricePerRequestCny = convertToCny( - currentPricePerRequest, - currency, - usdToCnyRate - ) - - var billingMode by remember { mutableStateOf(currentBillingMode) } - var inputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.first)) } - var outputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.second)) } - var cachedInputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.third)) } - var pricePerRequest by remember { mutableStateOf(formatEditablePrice(currentPricePerRequestCny)) } - val tokenPriceInputsAreValid = - inputPrice.toDoubleOrNull() != null && - outputPrice.toDoubleOrNull() != null && - cachedInputPrice.toDoubleOrNull() != null - val countPriceInputIsValid = pricePerRequest.toDoubleOrNull() != null - val pricingInputIsValid = when (billingMode) { - BillingMode.TOKEN -> tokenPriceInputsAreValid - BillingMode.COUNT -> countPriceInputIsValid - } - - AlertDialog( - onDismissRequest = { showPricingDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_edit_model_pricing, selectedModel)) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = stringResource(id = R.string.settings_pricing_currency_hint, editCurrency.code), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = stringResource(id = R.string.settings_billing_mode), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = billingMode == BillingMode.TOKEN, - onClick = { billingMode = BillingMode.TOKEN }, - label = { Text(stringResource(id = R.string.settings_billing_mode_token)) }, - modifier = Modifier.weight(1f) - ) - FilterChip( - selected = billingMode == BillingMode.COUNT, - onClick = { billingMode = BillingMode.COUNT }, - label = { Text(stringResource(id = R.string.settings_billing_mode_count)) }, - modifier = Modifier.weight(1f) - ) - } - - HorizontalDivider() - - if (billingMode == BillingMode.TOKEN) { - Text( - text = stringResource( - id = R.string.settings_pricing_description_with_currency, - editCurrency.code - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = inputPrice, - onValueChange = { inputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_input_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = cachedInputPrice, - onValueChange = { cachedInputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_cached_input_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = outputPrice, - onValueChange = { outputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_output_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - } else { - Text( - text = stringResource( - id = R.string.settings_token_price_description_with_currency, - editCurrency.code - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = pricePerRequest, - onValueChange = { pricePerRequest = it }, - label = { - Text( - stringResource( - id = R.string.settings_price_per_request_with_currency, - editCurrency.code - ) - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - } - } - }, - confirmButton = { - TextButton( - enabled = pricingInputIsValid, - onClick = { - scope.launch { - modelBillingMode[selectedModel] = billingMode - apiPreferences.setBillingModeForProviderModel(selectedModel, billingMode) - - if (billingMode == BillingMode.TOKEN) { - val inputPriceValueCny = inputPrice.toDoubleOrNull() - val outputPriceValueCny = outputPrice.toDoubleOrNull() - val cachedInputPriceValueCny = cachedInputPrice.toDoubleOrNull() - - if ( - inputPriceValueCny != null && - outputPriceValueCny != null && - cachedInputPriceValueCny != null - ) { - val inputPriceValue = convertCnyToPricingCurrency( - inputPriceValueCny, - currency, - usdToCnyRate - ) - val outputPriceValue = convertCnyToPricingCurrency( - outputPriceValueCny, - currency, - usdToCnyRate - ) - val cachedInputPriceValue = convertCnyToPricingCurrency( - cachedInputPriceValueCny, - currency, - usdToCnyRate - ) - - modelPricing[selectedModel] = Triple( - inputPriceValue, - outputPriceValue, - cachedInputPriceValue - ) - apiPreferences.setModelInputPrice(selectedModel, inputPriceValue) - apiPreferences.setModelOutputPrice(selectedModel, outputPriceValue) - apiPreferences.setModelCachedInputPrice( - selectedModel, - cachedInputPriceValue - ) - } - } else { - val pricePerRequestValueCny = pricePerRequest.toDoubleOrNull() - - if (pricePerRequestValueCny != null) { - val pricePerRequestValue = convertCnyToPricingCurrency( - pricePerRequestValueCny, - currency, - usdToCnyRate - ) - modelPricePerRequest[selectedModel] = pricePerRequestValue - apiPreferences.setPricePerRequestForProviderModel( - selectedModel, - pricePerRequestValue - ) - } - } - } - - showPricingDialog = false - } - ) { - Text(stringResource(id = R.string.settings_save)) - } - }, - dismissButton = { - TextButton(onClick = { showPricingDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } - - if (showResetModelDialog && resetModel.isNotEmpty()) { - // 重组安全位置预取失败文案:协程/Toast 分支不得在 Composable 中 - // 调用 context.getString(触发 LocalContextGetResourceValueCall)。 - val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) - AlertDialog( - onDismissRequest = { showResetModelDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_reset_model_confirmation)) - }, - text = { - Text(text = stringResource(id = R.string.settings_reset_model_warning, resetModel)) - }, - confirmButton = { - TextButton( - onClick = { - scope.launch { - val succeeded = - apiPreferences.resetProviderModelTokenCounts(resetModel) - if (succeeded) { - providerModelRequestCounts.remove(resetModel) - } else { - // 新账本清理失败:不伪装成功、不清本地展示 - Toast.makeText( - context, - resetFailedMessage, - Toast.LENGTH_SHORT - ).show() - } - } - showResetModelDialog = false - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(id = R.string.settings_reset)) - } - }, - dismissButton = { - TextButton(onClick = { showResetModelDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } - - if (showResetDialog) { - // 重组安全位置预取失败文案(同 showResetModelDialog 分支) - val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) - AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_reset_confirmation)) - }, - text = { - Text(text = stringResource(id = R.string.settings_reset_warning)) - }, - confirmButton = { - TextButton( - onClick = { - scope.launch { - val succeeded = apiPreferences.resetAllProviderModelTokenCounts() - if (succeeded) { - providerModelRequestCounts.clear() - } else { - // 新账本清理失败:不伪装成功、不清本地展示 - Toast.makeText( - context, - resetFailedMessage, - Toast.LENGTH_SHORT - ).show() - } - } - showResetDialog = false - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(id = R.string.settings_reset)) - } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } -} - -@Composable -private fun ExchangeRateSettingsCard( - rateInput: String, - onRateInputChange: (String) -> Unit, - onSave: () -> Unit -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = stringResource(id = R.string.settings_exchange_rate_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - - Text( - text = stringResource(id = R.string.settings_exchange_rate_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = rateInput, - onValueChange = { onRateInputChange(it) }, - label = { Text(stringResource(id = R.string.settings_usd_to_cny_rate_label)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onSave) { - Text(stringResource(id = R.string.settings_save)) - } - } - } - } -} - -@Composable -private fun TokenUsageModelCard( - modelName: String, - inputTokens: Long, - cachedInputTokens: Long, - outputTokens: Long, - requestCount: Int, - cost: Double, - inputPrice: Double, - outputPrice: Double, - billingMode: BillingMode, - pricePerRequest: Double, - currency: PricingCurrency, - onClick: () -> Unit, - onResetClick: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onClick - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = modelName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) - AssistChip( - onClick = { }, - label = { - Text( - text = when (billingMode) { - BillingMode.TOKEN -> stringResource(id = R.string.settings_billing_mode_token) - BillingMode.COUNT -> stringResource(id = R.string.settings_billing_mode_count) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = when (billingMode) { - BillingMode.TOKEN -> MaterialTheme.colorScheme.secondaryContainer - BillingMode.COUNT -> MaterialTheme.colorScheme.tertiaryContainer - } - ), - modifier = Modifier.height(24.dp) - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onResetClick) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(id = R.string.settings_reset_model_counts), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp) - ) - } - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(id = R.string.settings_edit_pricing), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_request_count), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$requestCount", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = stringResource(id = R.string.settings_input_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$inputTokens", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - if (cachedInputTokens > 0L) { - Text( - text = stringResource(R.string.settings_cached_tokens, cachedInputTokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary - ) - } - if (billingMode == BillingMode.TOKEN) { - Text( - text = formatPricePerMillion(inputPrice, currency), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(id = R.string.settings_output_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$outputTokens", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - if (billingMode == BillingMode.TOKEN) { - Text( - text = formatPricePerMillion(outputPrice, currency), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(id = R.string.settings_total_cost), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = formatCurrencyAmount(cost, currency), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - if (billingMode == BillingMode.COUNT) { - Text( - text = stringResource( - id = R.string.settings_per_request_cost_with_currency, - currency.symbol, - pricePerRequest - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -private fun convertToCny(amount: Double, currency: PricingCurrency, usdToCnyRate: Double): Double { - return when (currency) { - PricingCurrency.CNY -> amount - PricingCurrency.USD -> amount * usdToCnyRate - } -} - -private fun convertCnyToPricingCurrency( - amount: Double, - currency: PricingCurrency, - usdToCnyRate: Double -): Double { - return when (currency) { - PricingCurrency.CNY -> amount - PricingCurrency.USD -> amount / usdToCnyRate - } -} - -private fun formatCurrencyAmount(amount: Double, currency: PricingCurrency): String { - return "${currency.symbol}${String.format(Locale.US, "%.2f", amount)}" -} - -private fun formatPricePerMillion(price: Double, currency: PricingCurrency): String { - return "${currency.symbol}${String.format(Locale.US, "%.2f", price)}/1M" -} - -private fun formatEditablePrice(price: Double): String { - return String.format(Locale.US, "%.6f", price).trimEnd('0').trimEnd('.') -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt new file mode 100644 index 000000000..b74b631d7 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt @@ -0,0 +1,581 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.stats.TokenStatsGranularity +import com.ai.assistance.operit.data.stats.TokenStatsTrendBucket +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.ceil +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 统计图表(阶段 4):纯 Compose Canvas 实现,不引入重型图表依赖。 + * + * 交互契约(避免与页面滚动互抢): + * - 点击与**水平拖动**才选中/切换桶详情([detectTapGestures] + + * [detectHorizontalDragGestures],与 Rainytoken 参考实现一致); + * - 垂直手势不消费,LazyColumn 纵向滚动不受影响; + * - 桶详情以图表下方的 tooltip 卡片呈现(无悬浮层,不遮挡内容)。 + * + * 空桶由阶段 3 聚合器补齐(buckets 已含全零桶),图表直接绘制;全部为 0 + * 时由调用方传入 [emptyText] 显示空提示。unknown 不当作 0:调用方通过 + * [unknownNote] 在 tooltip 里给出“部分数据未知”提示。 + */ + +/** 堆叠柱状图:每桶若干堆叠分量(值 + 颜色)。 */ +@Composable +internal fun TokenStatsStackedBarChart( + modifier: Modifier = Modifier, + buckets: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + formatValue: (Double) -> String, + emptyText: String, + chartLabel: String = "", + stackSelector: (TokenStatsTrendBucket) -> List>, + stackLabels: (TokenStatsTrendBucket) -> List, + unknownNote: (TokenStatsTrendBucket) -> String? = { null }, + legendItems: List> = emptyList(), +) { + if (buckets.isEmpty()) { + ChartEmptyText(emptyText, modifier) + return + } + val colors = LocalTokenStatsColors.current + var selectedIndex by remember(buckets) { mutableIntStateOf(buckets.lastIndex) } + val density = LocalDensity.current + val d = density.density + val chartHPx = 160f * d + val labelHPx = 20f * d + + // 无障碍文案预取:semantics 块不是 Composable,不能在块内解析资源(P1-8) + val summaryTemplate = stringResource(R.string.token_stats_chart_summary) + val bucketPositionTemplate = stringResource(R.string.token_stats_chart_bucket_of) + val prevBucketLabel = stringResource(R.string.token_stats_chart_prev_bucket) + val nextBucketLabel = stringResource(R.string.token_stats_chart_next_bucket) + + val maxVal = buckets.maxOf { bucket -> stackSelector(bucket).sumOf { it.first } }.coerceAtLeast(0.0) + val refTop = niceCeil(maxVal) + val refHalf = refTop / 2.0 + val scale = refTop + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val availPx = maxWidth.value * d + val barAreaPx = availPx / buckets.size + val barW = barAreaPx * 0.65f + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .semantics(mergeDescendants = true) { + val selected = buckets[selectedIndex] + val stacks = stackSelector(selected) + val positionText = + String.format(bucketPositionTemplate, selectedIndex + 1, buckets.size) + val summary = String.format( + summaryTemplate, + chartLabel, + bucketTimeLabel(selected.bucketStartMs, granularity, zone), + positionText, + formatValue(stacks.sumOf { it.first }), + ) + val rows = stacks.mapIndexedNotNull { index, (value, color) -> + val label = stackLabels(selected).getOrNull(index) ?: "" + if (value > 0.0 || label.isNotEmpty()) { + "${label.ifEmpty { "" }} ${formatValue(value)}".trim() + } else { + null + } + } + contentDescription = chartAccessibilityDescription(summary, rows) + stateDescription = positionText + role = Role.Image + customActions = listOf( + CustomAccessibilityAction(prevBucketLabel) { + previousBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + CustomAccessibilityAction(nextBucketLabel) { + nextBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + ) + } + .focusable() + .pointerInput(buckets) { + detectTapGestures { offset -> + val idx = (offset.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + .pointerInput(buckets) { + detectHorizontalDragGestures { change, _ -> + change.consume() + val idx = (change.position.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + ) { + val chartH = chartHPx + buckets.forEachIndexed { i, bucket -> + val x = i * barAreaPx + (barAreaPx - barW) / 2 + var yBase = chartH + stackSelector(bucket).forEach { (value, color) -> + val h = (value / scale * chartH).toFloat().coerceAtLeast(0f) + drawRect(color, Offset(x, yBase - h), Size(barW, h)) + yBase -= h + } + val (label, show) = bucketLabel(bucket.bucketStartMs, granularity, zone, i, buckets.size) + if (show) { + drawContext.canvas.nativeCanvas.drawText( + label, x + barW / 2, chartH + labelHPx - 4f * d, + android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 10f * d * density.fontScale + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + // 参考线(满刻度与半刻度)+ 数值标签 + val refY = chartH - (refTop / scale * chartH).toFloat() + val refHalfY = chartH - (refHalf / scale * chartH).toFloat() + drawLine(colors.chartGrid, Offset(0f, refY), Offset(size.width, refY), strokeWidth = 0.5f * d) + drawLine(colors.chartGrid, Offset(0f, refHalfY), Offset(size.width, refHalfY), strokeWidth = 0.5f * d) + val paint = android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 8f * d * density.fontScale + textAlign = android.graphics.Paint.Align.LEFT + } + drawContext.canvas.nativeCanvas.drawText(formatValue(refTop), 2f * d, refY - 2f * d, paint) + drawContext.canvas.nativeCanvas.drawText(formatValue(refHalf), 2f * d, refHalfY - 2f * d, paint) + } + } + + if (legendItems.isNotEmpty()) { + ChartLegend(legendItems) + } + + val selected = buckets[selectedIndex] + val stacks = stackSelector(selected) + ChartTooltip( + title = bucketTimeLabel(selected.bucketStartMs, granularity, zone), + rows = stacks.mapIndexedNotNull { index, (value, color) -> + val label = stackLabels(selected).getOrNull(index) ?: "" + if (value > 0.0 || label.isNotEmpty()) { + Triple(color, label, formatValue(value)) + } else { + null + } + }, + total = formatValue(stacks.sumOf { it.first }), + unknownNote = unknownNote(selected), + ) + } + } +} + +/** 折线图:每桶一个值;无有效样本的桶不画点、线段断开。 */ +@Composable +internal fun TokenStatsLineChart( + modifier: Modifier = Modifier, + buckets: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + formatValue: (Double) -> String, + emptyText: String, + chartLabel: String = "", + valueSelector: (TokenStatsTrendBucket) -> Double?, + unknownNote: (TokenStatsTrendBucket) -> String? = { null }, +) { + if (buckets.isEmpty()) { + ChartEmptyText(emptyText, modifier) + return + } + val colors = LocalTokenStatsColors.current + var selectedIndex by remember(buckets) { mutableIntStateOf(buckets.lastIndex) } + val density = LocalDensity.current + val d = density.density + val chartHPx = 140f * d + val labelHPx = 20f * d + + // 无障碍文案预取:semantics 块不是 Composable,不能在块内解析资源(P1-8) + val summaryTemplate = stringResource(R.string.token_stats_chart_summary) + val bucketPositionTemplate = stringResource(R.string.token_stats_chart_bucket_of) + val prevBucketLabel = stringResource(R.string.token_stats_chart_prev_bucket) + val nextBucketLabel = stringResource(R.string.token_stats_chart_next_bucket) + + val knownValues = buckets.mapNotNull(valueSelector) + val maxVal = (knownValues.maxOrNull() ?: 0.0).coerceAtLeast(0.0) + val refTop = niceCeil(maxVal) + val refHalf = refTop / 2.0 + val scale = refTop + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val availPx = maxWidth.value * d + val barAreaPx = availPx / buckets.size + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .semantics(mergeDescendants = true) { + val selected = buckets[selectedIndex] + val value = valueSelector(selected) + val positionText = + String.format(bucketPositionTemplate, selectedIndex + 1, buckets.size) + val summary = String.format( + summaryTemplate, + chartLabel, + bucketTimeLabel(selected.bucketStartMs, granularity, zone), + positionText, + if (value == null) "" else formatValue(value), + ) + contentDescription = chartAccessibilityDescription(summary, emptyList()) + stateDescription = positionText + role = Role.Image + customActions = listOf( + CustomAccessibilityAction(prevBucketLabel) { + previousBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + CustomAccessibilityAction(nextBucketLabel) { + nextBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + ) + } + .focusable() + .pointerInput(buckets) { + detectTapGestures { offset -> + val idx = (offset.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + .pointerInput(buckets) { + detectHorizontalDragGestures { change, _ -> + change.consume() + val idx = (change.position.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + ) { + val points = buckets.mapIndexed { i, bucket -> + val value = valueSelector(bucket) + if (value == null) { + null + } else { + Offset(i * barAreaPx + barAreaPx / 2, chartHPx - (value / scale * chartHPx).toFloat()) + } + } + // 分段连线:null 断段;每段只连接**相邻**有效点(P2 修复, + // 此前一直从段首重复连线导致斜率错误) + lineSegments(points).forEach { (start, end) -> + drawLine(colors.chartAccent, start, end, strokeWidth = 2f * d) + } + points.forEachIndexed { i, point -> + if (point != null) { + drawCircle(colors.chartAccent, radius = 3f * d, center = point) + } + } + buckets.forEachIndexed { i, bucket -> + val (label, show) = bucketLabel(bucket.bucketStartMs, granularity, zone, i, buckets.size) + if (show) { + drawContext.canvas.nativeCanvas.drawText( + label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d, + android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 10f * d * density.fontScale + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + val refY = chartHPx - (refTop / scale * chartHPx).toFloat() + val refHalfY = chartHPx - (refHalf / scale * chartHPx).toFloat() + drawLine(colors.chartGrid, Offset(0f, refY), Offset(size.width, refY), strokeWidth = 0.5f * d) + drawLine(colors.chartGrid, Offset(0f, refHalfY), Offset(size.width, refHalfY), strokeWidth = 0.5f * d) + val paint = android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 8f * d * density.fontScale + textAlign = android.graphics.Paint.Align.LEFT + } + drawContext.canvas.nativeCanvas.drawText(formatValue(refTop), 2f * d, refY - 2f * d, paint) + drawContext.canvas.nativeCanvas.drawText(formatValue(refHalf), 2f * d, refHalfY - 2f * d, paint) + } + } + + val selected = buckets[selectedIndex] + val value = valueSelector(selected) + ChartTooltip( + title = bucketTimeLabel(selected.bucketStartMs, granularity, zone), + rows = value?.let { listOf(Triple(colors.chartAccent, "", formatValue(it))) } ?: emptyList(), + total = if (value == null) null else formatValue(value), + unknownNote = unknownNote(selected), + ) + } + } +} + +/** tooltip 卡片(图表下方,不遮挡内容)。 */ +@Composable +private fun ChartTooltip( + title: String, + rows: List>, + total: String?, + unknownNote: String?, +) { + val colors = LocalTokenStatsColors.current + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.tooltipContainer), + ) { + Column(Modifier.padding(8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = colors.tooltipContent, + ) + total?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = colors.chartAccent, + ) + } + rows.forEach { (color, label, value) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(8.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = if (label.isNotEmpty()) "$label $value" else value, + style = MaterialTheme.typography.bodySmall, + color = colors.tooltipContent, + ) + } + } + unknownNote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ChartLegend(items: List>) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.Center, + ) { + items.forEachIndexed { i, (label, color) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.width(3.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (i < items.size - 1) Spacer(Modifier.width(12.dp)) + } + } +} + +@Composable +private fun ChartEmptyText(text: String, modifier: Modifier = Modifier) { + Box(modifier = modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** 桶起点时间标签(本地时区对齐,与聚合器同语义)。 */ +private fun bucketTimeLabel(startMs: Long, granularity: TokenStatsGranularity, zone: ZoneId): String { + val zdt = Instant.ofEpochMilli(startMs).atZone(zone) + return when (granularity) { + TokenStatsGranularity.TEN_MINUTES, TokenStatsGranularity.HOURLY -> + DateTimeFormatter.ofPattern("HH:mm").format(zdt) + TokenStatsGranularity.DAILY -> + DateTimeFormatter.ofPattern("MM/dd").format(zdt) + } +} + +/** 底部时间轴标签:首尾 + 均匀抽稀(约 6 个),避免手机宽度拥挤。 */ +private fun bucketLabel( + startMs: Long, + granularity: TokenStatsGranularity, + zone: ZoneId, + index: Int, + total: Int, +): Pair { + if (total <= 1) return bucketTimeLabel(startMs, granularity, zone) to true + val stride = ceil(total / 6.0).toInt().coerceAtLeast(1) + val show = index == 0 || index == total - 1 || index % stride == 0 + return bucketTimeLabel(startMs, granularity, zone) to show +} + +/** 向上取整到“漂亮”刻度(9→10、883→1000、150M→200M),与参考实现一致。 */ +internal fun niceCeil(value: Double): Double { + if (value <= 0.0) return 1.0 + val exp = log10(value).toInt() + val magnitude = 10.0.pow(exp.toDouble()) + val normalized = value / magnitude + val nice = + when { + normalized <= 1.0 -> 1.0 + normalized <= 1.15 -> 1.15 + normalized <= 1.25 -> 1.25 + normalized <= 1.5 -> 1.5 + normalized <= 2.0 -> 2.0 + normalized <= 2.5 -> 2.5 + normalized <= 3.0 -> 3.0 + normalized <= 4.0 -> 4.0 + normalized <= 5.0 -> 5.0 + normalized <= 7.5 -> 7.5 + else -> 10.0 + } + return nice * magnitude +} + +/** Token 数量紧凑格式:1.2K / 3.4M。 */ +internal fun formatCompactCount(value: Long): String = + when { + value >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", value / 1_000_000.0) + value >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", value / 1_000.0) + else -> "$value" + } + +/** 千分位格式(图表 tooltip 明细用)。 */ +internal fun formatCountWithComma(value: Long): String = + String.format(java.util.Locale.US, "%,d", value) + +/** 时长格式:<1s 用毫秒,否则秒(1 位小数)。 */ +internal fun formatDuration(ms: Double): String = + if (ms < 1_000.0) { + String.format(java.util.Locale.US, "%.0fms", ms) + } else { + String.format(java.util.Locale.US, "%.1fs", ms / 1_000.0) + } + +// ==== 图表无障碍模型(P1-8,纯函数,供 JVM 测试) ==== + +/** 无障碍“上一桶”目标索引;已在最前或无桶返回 null(边界禁用)。 */ +internal fun previousBucketIndex(current: Int, count: Int): Int? = + if (count <= 1 || current <= 0) null else current - 1 + +/** 无障碍“下一桶”目标索引;已在最后或无桶返回 null(边界禁用)。 */ +internal fun nextBucketIndex(current: Int, count: Int): Int? = + if (count <= 1 || current >= count - 1) null else current + 1 + +/** + * 图表无障碍描述(TalkBack 朗读):[summary] 已由调用方按资源拼好(图表名、 + * 当前桶时间、第 n/m 桶、合计),[rows] 为“标签 值”明细行;无行时只读摘要。 + */ +internal fun chartAccessibilityDescription(summary: String, rows: List): String = + if (rows.isEmpty()) summary else "$summary:${rows.joinToString(",")}" + +/** + * 折线分段(P2):null 断段;每段连接**相邻**有效点(而非从段首重复连线)。 + * 返回线段对列表,供 Canvas 绘制与纯 JVM 测试共用。 + */ +internal fun lineSegments(points: List): List> { + val segments = ArrayList>() + var previous: Offset? = null + for (point in points) { + if (point == null) { + previous = null + } else { + previous?.let { segments += it to point } + previous = point + } + } + return segments +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt new file mode 100644 index 000000000..144db9a08 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt @@ -0,0 +1,131 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.ai.assistance.operit.ui.theme.RainyPink +import com.ai.assistance.operit.ui.theme.RainyPinkHover +import com.ai.assistance.operit.ui.theme.RainyRose +import com.ai.assistance.operit.ui.theme.RainySakura +import com.ai.assistance.operit.ui.theme.RainyWarning + +/** + * 统计页颜色接口(阶段 4):Rainy 粉色风格集中在这里,组件不散落硬编码色值。 + * 后续若增加统计页自定义配色,只需替换 [tokenStatsColors] 的取值来源, + * 组件签名不变。 + */ +data class TokenStatsColors( + /** Token 分类堆叠:未缓存输入。 */ + val uncachedInput: Color, + /** Token 分类堆叠:缓存读取。 */ + val cachedInput: Color, + /** Token 分类堆叠:缓存写入。 */ + val cacheWrite: Color, + /** Token 分类堆叠:输出。 */ + val output: Color, + /** Token 分类堆叠:推理。 */ + val reasoning: Color, + /** 折线图强调色。 */ + val chartAccent: Color, + /** 图表网格参考线。 */ + val chartGrid: Color, + /** 图表坐标轴标签。 */ + val chartLabel: Color, + /** 图表 tooltip 容器。 */ + val tooltipContainer: Color, + /** 图表 tooltip 文字。 */ + val tooltipContent: Color, + /** 费用堆叠按模型取色(12 色 Material 色阶,模型多时循环)。 */ + val modelPalette: List, + /** unknown 提示色(未知 ≠ 0)。 */ + val unknownHint: Color, + /** “默认估算”徽标容器。 */ + val estimatedBadgeContainer: Color, + /** “默认估算”徽标文字。 */ + val estimatedBadgeContent: Color, + /** 生命周期累计总览卡片容器。 */ + val summaryCardContainer: Color, + /** 生命周期累计总览卡片文字。 */ + val summaryCardContent: Color, +) + +/** 组件默认入口:从当前 [MaterialTheme] 派生,明暗自适应。 */ +@Composable +fun tokenStatsColors(): TokenStatsColors { + val scheme = MaterialTheme.colorScheme + // 按实际背景亮度判断明暗(自定义主题/背景图下依然正确) + val dark = scheme.background.luminance() < 0.5f + return if (dark) darkTokenStatsColors(scheme) else lightTokenStatsColors(scheme) +} + +private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = + TokenStatsColors( + uncachedInput = RainyRose, + cachedInput = RainyPink, + cacheWrite = RainySakura, + output = Color(0xFFC2185B), + reasoning = Color(0xFFF48FB1), + chartAccent = RainyPink, + chartGrid = scheme.outlineVariant, + chartLabel = scheme.onSurfaceVariant, + tooltipContainer = scheme.surfaceVariant, + tooltipContent = scheme.onSurfaceVariant, + modelPalette = MODEL_PALETTE, + unknownHint = RainyWarning, + estimatedBadgeContainer = scheme.tertiaryContainer, + estimatedBadgeContent = scheme.onTertiaryContainer, + summaryCardContainer = scheme.primaryContainer, + summaryCardContent = scheme.onPrimaryContainer, + ) + +private fun darkTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = + TokenStatsColors( + uncachedInput = RainyPinkHover, + cachedInput = RainyPink, + cacheWrite = RainySakura, + output = Color(0xFFF06292), + reasoning = Color(0xFFF8BBD0), + chartAccent = RainyPink, + chartGrid = scheme.outlineVariant, + chartLabel = scheme.onSurfaceVariant, + tooltipContainer = scheme.surfaceVariant, + tooltipContent = scheme.onSurfaceVariant, + modelPalette = MODEL_PALETTE, + unknownHint = RainyWarning, + estimatedBadgeContainer = scheme.tertiaryContainer, + estimatedBadgeContent = scheme.onTertiaryContainer, + summaryCardContainer = scheme.primaryContainer, + summaryCardContent = scheme.onPrimaryContainer, + ) + +/** 模型费用堆叠色板(与 Rainytoken 参考实现一致,12 色足够区分常见模型数)。 */ +private val MODEL_PALETTE = + listOf( + Color(0xFFF44336), // Red + Color(0xFFE91E63), // Pink + Color(0xFF9C27B0), // Purple + Color(0xFF673AB7), // Deep Purple + Color(0xFF3F51B5), // Indigo + Color(0xFF2196F3), // Blue + Color(0xFF00BCD4), // Cyan + Color(0xFF009688), // Teal + Color(0xFF4CAF50), // Green + Color(0xFFFF9800), // Orange + Color(0xFF795548), // Brown + Color(0xFF607D8B), // Blue Grey + ) + +/** 页面级 CompositionLocal:由 [TokenStatsColorsProvider] 提供。 */ +val LocalTokenStatsColors = staticCompositionLocalOf { + error("TokenStatsColors not provided") +} + +/** 在子树内提供统计页颜色。 */ +@Composable +fun TokenStatsColorsProvider(content: @Composable () -> Unit) { + val colors = tokenStatsColors() + CompositionLocalProvider(LocalTokenStatsColors provides colors, content = content) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt new file mode 100644 index 000000000..2905087cf --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -0,0 +1,1074 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.CurrencyYen +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsCostMode +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsDurationAggregate +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview +import com.ai.assistance.operit.data.stats.TokenStatsPreset +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.data.stats.TokenStatsTokenAggregate +import java.time.ZoneId +import java.util.Locale + +// ==== 通用格式 ==== + +/** 金额:符号 + 4 位小数(图表/明细统一)。 */ +internal fun formatMoney(amount: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.4f", amount)}" + +/** 每百万 token 单价。 */ +internal fun formatPricePerMillion(price: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.4f", price)}/1M" + +/** 按次单价。 */ +internal fun formatPricePerRequest(price: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.4f", price)}/次" + +internal fun formatCount(value: Long): String = String.format(Locale.US, "%,d", value) + +// ==== 生命周期累计总览(不受筛选) ==== + +@Composable +internal fun TokenStatsLifetimeCard( + overview: TokenStatsLifetimeOverview, + currency: PricingCurrency, + manualRate: Double, + rateIsEstimated: Boolean, +) { + val colors = LocalTokenStatsColors.current + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = colors.summaryCardContainer), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.token_stats_lifetime_total), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = colors.summaryCardContent, + modifier = Modifier.weight(1f), + ) + if (rateIsEstimated) { + EstimatedBadge( + text = stringResource(R.string.token_stats_rate_default_hint, manualRate), + textColor = colors.summaryCardContent, + ) + } + } + + Spacer(Modifier.height(8.dp)) + + val eventTotals = overview.eventTotals + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + BigNumber( + label = stringResource(R.string.settings_total_requests), + value = formatCount(overview.combinedRequests), + color = colors.summaryCardContent, + ) + BigNumber( + label = stringResource(R.string.token_stats_tokens_total), + value = formatCompactCount(knownTokenSum(eventTotals)), + color = colors.summaryCardContent, + ) + BigNumber( + label = stringResource(R.string.settings_total_cost), + value = formatMoney(eventTotals.cost.knownAmount, currency), + color = colors.chartAccent, + alignEnd = true, + ) + } + + if (eventTotals.cost.unknownContributionCount > 0L) { + UnknownHint( + text = stringResource( + R.string.token_stats_unknown_cost, + eventTotals.cost.unknownContributionCount, + ), + color = colors.unknownHint, + ) + } + if (eventTotals.cost.unknownContributionCount == 0L && + eventTotals.cost.rateIsEstimated + ) { + Text( + text = stringResource(R.string.token_stats_rate_applied_hint, manualRate), + style = MaterialTheme.typography.bodySmall, + color = colors.summaryCardContent.copy(alpha = 0.8f), + ) + } + + Spacer(Modifier.height(12.dp)) + + TokenComponentLines(totals = eventTotals, textColor = colors.summaryCardContent) + + // 旧数据 baseline(估算口径,明确标注) + val baseline = overview.baselineTotals + if (baseline.identityCount > 0L) { + Spacer(Modifier.height(12.dp)) + HorizontalDivider(color = colors.summaryCardContent.copy(alpha = 0.2f)) + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.token_stats_baseline_estimate), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = colors.summaryCardContent, + modifier = Modifier.weight(1f), + ) + EstimatedBadge( + text = stringResource(R.string.token_stats_baseline_badge), + textColor = colors.summaryCardContent, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.token_stats_baseline_rows, + baseline.identityCount, + ), + style = MaterialTheme.typography.bodySmall, + color = colors.summaryCardContent.copy(alpha = 0.8f), + ) + Spacer(Modifier.height(8.dp)) + BaselineLine( + label = stringResource(R.string.settings_total_requests), + value = formatCount(baseline.requests), + color = colors.summaryCardContent, + ) + BaselineLine( + label = stringResource(R.string.settings_total_cost), + value = formatMoney(baseline.cost.knownAmount, currency), + color = colors.summaryCardContent, + ) + if (baseline.cost.unknownContributionCount > 0L) { + UnknownHint( + text = stringResource( + R.string.token_stats_unknown_cost, + baseline.cost.unknownContributionCount, + ), + color = colors.unknownHint, + ) + } + } + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.BigNumber( + label: String, + value: String, + color: androidx.compose.ui.graphics.Color, + alignEnd: Boolean = false, +) { + Column( + horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start, + modifier = Modifier.weight(1f), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = color.copy(alpha = 0.8f), + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color, + ) + } +} + +@Composable +internal fun EstimatedBadge(text: String, textColor: androidx.compose.ui.graphics.Color) { + val colors = LocalTokenStatsColors.current + Surface( + shape = MaterialTheme.shapes.small, + color = colors.estimatedBadgeContainer, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun UnknownHint(text: String, color: androidx.compose.ui.graphics.Color) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + modifier = Modifier.padding(top = 4.dp), + ) +} + +@Composable +private fun TokenComponentLines( + totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, + textColor: androidx.compose.ui.graphics.Color, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + TokenLine( + label = stringResource(R.string.token_stats_token_uncached), + aggregate = totals.uncachedInput, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_cached), + aggregate = totals.cachedInput, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_cache_write), + aggregate = totals.cacheWrite, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_output), + aggregate = totals.output, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_reasoning), + aggregate = totals.reasoning, + textColor = textColor, + ) + } +} + +@Composable +private fun TokenLine( + label: String, + aggregate: TokenStatsTokenAggregate, + textColor: androidx.compose.ui.graphics.Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = textColor.copy(alpha = 0.85f), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (aggregate.unknownEventCount > 0L) { + Text( + text = stringResource( + R.string.token_stats_unknown_part_suffix, + aggregate.unknownEventCount, + ), + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.unknownHint, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = formatCompactCount(aggregate.knownSum), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = textColor, + ) + } + } +} + +@Composable +private fun BaselineLine( + label: String, + value: String, + color: androidx.compose.ui.graphics.Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = color.copy(alpha = 0.85f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = color, + ) + } +} + +/** 已知 token 分量合计(uncached+cached+cacheWrite+output+reasoning,未知不算 0)。 */ +internal fun knownTokenSum( + totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, +): Long = + listOf( + totals.uncachedInput.knownSum, + totals.cachedInput.knownSum, + totals.cacheWrite.knownSum, + totals.output.knownSum, + totals.reasoning.knownSum, + ).sum() + +// ==== 筛选栏 ==== + +/** 时间预设(10 预设 + 自定义)与模型/分类/状态/口径/币种筛选。 */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TokenStatsFilterBar( + selectedPreset: TokenStatsPreset, + selectedModels: Set, + availableModels: List, + knownModelNames: Map, + selectedCategories: Set?, + selectedStatuses: Set?, + costMode: TokenStatsCostMode, + targetCurrency: PricingCurrency, + onSelectPreset: (TokenStatsPreset) -> Unit, + onCustomRange: () -> Unit, + onToggleModel: (String) -> Unit, + onSelectAllModels: () -> Unit, + onToggleCategory: (TokenStatCategory) -> Unit, + onClearAllCategories: () -> Unit, + onToggleStatus: (TokenStatStatus) -> Unit, + onClearAllStatuses: () -> Unit, + onSetCostMode: (TokenStatsCostMode) -> Unit, + onSetCurrency: (PricingCurrency) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + // 时间预设:横向滚动,手机不拥挤 + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TokenStatsPreset.entries.forEach { preset -> + FilterChip( + selected = selectedPreset == preset, + onClick = { + if (preset == TokenStatsPreset.CUSTOM) onCustomRange() else onSelectPreset(preset) + }, + label = { + Text( + when (preset) { + TokenStatsPreset.CUSTOM -> stringResource(R.string.token_stats_custom_range) + else -> stringResource(preset.labelRes()) + } + ) + }, + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // 第二行:模型/分类/状态多选 + 口径/币种(窄屏横向滚动) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ModelFilterDropdown( + selectedModels, + availableModels, + knownModelNames, + onToggleModel, + onSelectAllModels, + ) + CategoryFilterDropdown(selectedCategories, onToggleCategory, onClearAllCategories) + StatusFilterDropdown(selectedStatuses, onToggleStatus, onClearAllStatuses) + + FilterChip( + selected = costMode == TokenStatsCostMode.HISTORICAL, + onClick = { onSetCostMode(TokenStatsCostMode.HISTORICAL) }, + label = { Text(stringResource(R.string.token_stats_mode_historical)) }, + ) + FilterChip( + selected = costMode == TokenStatsCostMode.REVALUED, + onClick = { onSetCostMode(TokenStatsCostMode.REVALUED) }, + label = { Text(stringResource(R.string.token_stats_mode_revalued)) }, + ) + FilterChip( + selected = targetCurrency == PricingCurrency.CNY, + onClick = { onSetCurrency(PricingCurrency.CNY) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.CurrencyYen, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.token_stats_currency_cny)) + } + }, + ) + FilterChip( + selected = targetCurrency == PricingCurrency.USD, + onClick = { onSetCurrency(PricingCurrency.USD) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.AttachMoney, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.token_stats_currency_usd)) + } + }, + ) + } + } + } +} + +private fun TokenStatsPreset.labelRes(): Int = + when (this) { + TokenStatsPreset.LAST_5H -> R.string.token_stats_preset_5h + TokenStatsPreset.LAST_12H -> R.string.token_stats_preset_12h + TokenStatsPreset.LAST_24H -> R.string.token_stats_preset_24h + TokenStatsPreset.TODAY -> R.string.token_stats_preset_today + TokenStatsPreset.YESTERDAY -> R.string.token_stats_preset_yesterday + TokenStatsPreset.LAST_7D -> R.string.token_stats_preset_7d + TokenStatsPreset.LAST_30D -> R.string.token_stats_preset_30d + TokenStatsPreset.THIS_MONTH -> R.string.token_stats_preset_this_month + TokenStatsPreset.LAST_MONTH -> R.string.token_stats_preset_last_month + TokenStatsPreset.CUSTOM -> R.string.token_stats_custom_range + } + +@Composable +private fun ModelFilterDropdown( + selectedModels: Set, + availableModels: List, + knownModelNames: Map, + onToggleModel: (String) -> Unit, + onSelectAllModels: () -> Unit, +) { + // 可选项 = 当前范围可用模型 + 已被选中但被筛选出当前结果的模型(P1-5) + val options: List> = remember(availableModels, selectedModels, knownModelNames) { + val byId = availableModels.associateBy { it.displayModelId } + buildList { + availableModels.forEach { add(it.displayModelId to it.displayName) } + selectedModels.forEach { id -> + if (id !in byId) add(id to (knownModelNames[id] ?: id)) + } + } + } + FilterDropdown( + label = if (selectedModels.isEmpty()) { + stringResource(R.string.token_stats_filter_all_models) + } else { + stringResource(R.string.token_stats_filter_models_count, selectedModels.size) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_models), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + onSelectAllModels() + dismiss() + }, + ) + options.forEach { (modelId, displayName) -> + val checked = selectedModels.isEmpty() || modelId in selectedModels + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = { onToggleModel(modelId) }, + ) + Text( + displayName, + modifier = Modifier.padding(start = 4.dp), + maxLines = 1, + ) + } + }, + onClick = { onToggleModel(modelId) }, + ) + } + } +} + +@Composable +private fun CategoryFilterDropdown( + selected: Set?, + onToggle: (TokenStatCategory) -> Unit, + onClearAll: () -> Unit, +) { + FilterDropdown( + label = if (selected == null) { + stringResource(R.string.token_stats_filter_all_categories) + } else { + stringResource(R.string.token_stats_filter_categories_count, selected.size) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_categories), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + if (selected != null) onClearAll() + dismiss() + }, + ) + TokenStatCategory.entries.forEach { category -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected?.contains(category) == true, + onCheckedChange = { onToggle(category) }, + ) + Text( + stringResource(category.labelRes()), + modifier = Modifier.padding(start = 4.dp), + ) + } + }, + onClick = { onToggle(category) }, + ) + } + } +} + +@Composable +private fun StatusFilterDropdown( + selected: Set?, + onToggle: (TokenStatStatus) -> Unit, + onClearAll: () -> Unit, +) { + FilterDropdown( + label = if (selected == null) { + stringResource(R.string.token_stats_filter_all_statuses) + } else { + stringResource(R.string.token_stats_filter_statuses_count, selected.size) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_statuses), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + if (selected != null) onClearAll() + dismiss() + }, + ) + TokenStatStatus.entries.forEach { status -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected?.contains(status) == true, + onCheckedChange = { onToggle(status) }, + ) + Text( + stringResource(status.labelRes()), + modifier = Modifier.padding(start = 4.dp), + ) + } + }, + onClick = { onToggle(status) }, + ) + } + } +} + +@Composable +private fun FilterDropdown( + label: String, + content: @Composable (dismiss: () -> Unit) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + FilterChip( + selected = false, + onClick = { expanded = true }, + label = { Text(label) }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + content { expanded = false } + } + } +} + +internal fun TokenStatCategory.labelRes(): Int = + when (this) { + TokenStatCategory.CHAT -> R.string.token_stats_category_chat + TokenStatCategory.SUBAGENT -> R.string.token_stats_category_subagent + TokenStatCategory.SUMMARY -> R.string.token_stats_category_summary + TokenStatCategory.TITLE -> R.string.token_stats_category_title + TokenStatCategory.MEMORY -> R.string.token_stats_category_memory + TokenStatCategory.CHARACTER_GENERATION -> R.string.token_stats_category_character + TokenStatCategory.CONNECTION_TEST -> R.string.token_stats_category_connection_test + TokenStatCategory.OTHER -> R.string.token_stats_category_other + } + +internal fun TokenStatStatus.labelRes(): Int = + when (this) { + TokenStatStatus.COMPLETED -> R.string.token_stats_status_completed + TokenStatStatus.CANCELLED -> R.string.token_stats_status_cancelled + TokenStatStatus.TIMEOUT -> R.string.token_stats_status_timeout + TokenStatStatus.FAILED -> R.string.token_stats_status_failed + } + +// ==== 图表卡片 ==== + +@Composable +internal fun TokenStatsChartCard( + title: String, + summary: String, + modifier: Modifier = Modifier, + headerExtra: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + Card(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = summary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = LocalTokenStatsColors.current.chartAccent, + ) + } + Spacer(Modifier.height(8.dp)) + headerExtra() + content() + } + } +} + +// ==== 模型卡片 ==== + +@Composable +internal fun TokenStatsModelCardsSection( + models: List, + currency: PricingCurrency, + costMode: TokenStatsCostMode, + zone: ZoneId, + onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, + onReset: (TokenStatsDisplayModelBreakdown) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + models.forEach { model -> + TokenStatsModelCard( + model = model, + currency = currency, + costMode = costMode, + zone = zone, + onGroupManage = { onGroupManage(model) }, + onReset = { onReset(model) }, + ) + } + } +} + +@Composable +internal fun TokenStatsModelCard( + model: TokenStatsDisplayModelBreakdown, + currency: PricingCurrency, + costMode: TokenStatsCostMode, + zone: ZoneId, + onGroupManage: () -> Unit, + onReset: () -> Unit, +) { + val colors = LocalTokenStatsColors.current + var expanded by remember(model.displayModelId) { mutableStateOf(false) } + val canReset = model.identities.map { it.provider to it.model }.distinct().size == 1 + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { expanded = !expanded }) { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.token_stats_model_expand), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource( + R.string.token_stats_model_identities_count, + model.identities.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatMoney(model.totals.cost.knownAmount, currency), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = colors.chartAccent, + ) + Text( + text = stringResource(R.string.settings_request_count_label, model.totals.requests), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (canReset) { + IconButton(onClick = onReset) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.settings_reset_model_counts), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + } + } + IconButton(onClick = onGroupManage) { + Icon( + imageVector = Icons.Filled.Groups, + contentDescription = stringResource(R.string.token_stats_group_manage), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + + if (expanded) { + Spacer(Modifier.height(8.dp)) + model.identities.forEach { identity -> + TokenStatsIdentityRow(identity = identity, currency = currency, costMode = costMode) + Spacer(Modifier.height(6.dp)) + } + } + } + } +} + +@Composable +private fun TokenStatsIdentityRow( + identity: com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown, + currency: PricingCurrency, + costMode: TokenStatsCostMode, +) { + val colors = LocalTokenStatsColors.current + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp), + ) { + Text( + text = "${identity.provider} · ${identity.model}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + if (identity.configId.isNotEmpty()) { + Text( + text = stringResource(R.string.token_stats_config_id, identity.configId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val totals = identity.totals + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${stringResource(R.string.token_stats_token_uncached)} ${formatCompactCount(totals.uncachedInput.knownSum)}" + + " · ${stringResource(R.string.token_stats_token_cached)} ${formatCompactCount(totals.cachedInput.knownSum)}" + + " · ${stringResource(R.string.token_stats_token_output)} ${formatCompactCount(totals.output.knownSum)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatMoney(totals.cost.knownAmount, currency), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + if (totals.uncachedInput.unknownEventCount > 0L || + totals.cachedInput.unknownEventCount > 0L || + totals.output.unknownEventCount > 0L + ) { + Text( + text = stringResource( + R.string.token_stats_unknown_parts, + totals.uncachedInput.unknownEventCount + + totals.cachedInput.unknownEventCount + + totals.output.unknownEventCount, + ), + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + if (totals.cost.unknownContributionCount > 0L) { + Text( + text = stringResource(R.string.token_stats_unknown_cost, totals.cost.unknownContributionCount), + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + + // 单价:历史口径 = 事件快照;重估口径 = 当前解析价格 + identity.pricing?.let { pricing -> + val priceText = + if (!pricing.known) { + stringResource(R.string.token_stats_unknown_pricing) + } else { + buildPricingText(pricing, currency) + } + Text( + text = "${stringResource(R.string.token_stats_price_label)} $priceText", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** 单价的展示文本(含计费方式与来源标签)。 */ +@Composable +private fun buildPricingText( + pricing: com.ai.assistance.operit.data.stats.TokenStatsPricingInfo, + currency: PricingCurrency, +): String { + val displayCurrency = pricing.currency + val modeText = + if (pricing.billingMode == com.ai.assistance.operit.data.model.BillingMode.TOKEN) { + val parts = buildList { + pricing.inputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } + pricing.cachedInputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } + pricing.cacheWritePricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } + pricing.outputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } + } + if (parts.isEmpty()) stringResource(R.string.token_stats_unknown_pricing) else parts.joinToString(" · ") + } else { + pricing.pricePerRequest?.let { formatPricePerRequest(it, displayCurrency) } + ?: stringResource(R.string.token_stats_unknown_pricing) + } + val sourceText = + when (pricing.source) { + com.ai.assistance.operit.data.stats.PricingSource.DEFAULT -> + stringResource(R.string.token_stats_pricing_source_builtin) + com.ai.assistance.operit.data.stats.PricingSource.PROVIDER_MODEL_OVERRIDE -> + stringResource(R.string.token_stats_pricing_source_override) + com.ai.assistance.operit.data.stats.PricingSource.CONFIG_OVERRIDE -> + stringResource(R.string.token_stats_pricing_source_config) + com.ai.assistance.operit.data.stats.PricingSource.LEGACY_OVERRIDE -> + stringResource(R.string.token_stats_pricing_source_legacy) + com.ai.assistance.operit.data.stats.PricingSource.UNKNOWN -> + stringResource(R.string.token_stats_unknown_pricing) + } + return "$modeText($sourceText)" +} + +// ==== 汇率与币种设置卡 ==== + +@Composable +internal fun TokenStatsRateCard( + manualRate: Double, + rateIsEstimated: Boolean, + currency: PricingCurrency, + onSaveRate: (Double) -> Boolean, + onSetCurrency: (PricingCurrency) -> Unit, +) { + val colors = LocalTokenStatsColors.current + var rateInput by remember { mutableStateOf(formatRateInput(manualRate)) } + // 汇率外部变化(如从 DataStore 重新加载)时同步输入框 + LaunchedEffect(manualRate) { + rateInput = formatRateInput(manualRate) + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.settings_exchange_rate_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (rateIsEstimated) { + EstimatedBadge( + text = stringResource(R.string.token_stats_rate_default_badge), + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + Text( + text = stringResource(R.string.settings_exchange_rate_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = rateInput, + onValueChange = { rateInput = it }, + label = { Text(stringResource(R.string.settings_usd_to_cny_rate_label)) }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal, + ), + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + val parsed = rateInput.toDoubleOrNull() + if (parsed == null || !onSaveRate(parsed)) { + // 非法输入保持原值并提示(Toast 由调用方统一处理) + rateInput = formatRateInput(manualRate) + } + }, + ) { + Text(stringResource(R.string.settings_save)) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.token_stats_currency), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + FilterChip( + selected = currency == PricingCurrency.CNY, + onClick = { onSetCurrency(PricingCurrency.CNY) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.CurrencyYen, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.token_stats_currency_cny)) + } + }, + ) + Spacer(Modifier.width(8.dp)) + FilterChip( + selected = currency == PricingCurrency.USD, + onClick = { onSetCurrency(PricingCurrency.USD) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.AttachMoney, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.token_stats_currency_usd)) + } + }, + ) + } + if (rateIsEstimated) { + Text( + text = stringResource(R.string.token_stats_rate_default_hint, manualRate), + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + } + } +} + +private fun formatRateInput(rate: Double): String = + String.format(Locale.US, "%.4f", rate).trimEnd('0').trimEnd('.') + +/** 性能聚合的平均值格式化(无有效样本显示“无数据”而非 0)。 */ +@Composable +internal fun durationSummaryText(aggregate: TokenStatsDurationAggregate): String { + if (!aggregate.hasData) return stringResource(R.string.token_stats_perf_no_data) + val avg = formatDuration(aggregate.averageMs) + return stringResource(R.string.token_stats_perf_avg, avg) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt new file mode 100644 index 000000000..afe3ab198 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -0,0 +1,603 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.CurrencyYen +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +// ==== 自定义时间范围(两步日期选择,设备时区自然日边界) ==== + +/** + * DatePicker 返回所选日期当日的 **UTC 0 点**;按 UTC 日历解析出日期本身 + * (P1-6:若用设备时区解析,西半球(如 New York)会因 UTC 日期尚在前一日 + * 20:00 而回退一天,导致选择 8/7 实际落在 8/6)。 + */ +internal fun datePickerMillisToLocalDate(utcMidnightMs: Long): java.time.LocalDate = + Instant.ofEpochMilli(utcMidnightMs).atZone(java.time.ZoneOffset.UTC).toLocalDate() + +/** + * 由“开始日 + 结束日(**包含**结束日当天)”构造半开区间范围: + * `[startDay 0 点, endDay+1 天 0 点)`。同日合法(一天范围)。 + * 结束日早于开始日抛 [IllegalArgumentException]。 + */ +internal fun customRangeInclusiveEnd( + startDate: java.time.LocalDate, + endDate: java.time.LocalDate, + zone: ZoneId, +): com.ai.assistance.operit.data.stats.TokenStatsTimeRange { + require(!endDate.isBefore(startDate)) { "end date must not be before start date" } + val startMs = startDate.atStartOfDay(zone).toInstant().toEpochMilli() + val endMs = endDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + return com.ai.assistance.operit.data.stats.TokenStatsTimeRanges.customRange(startMs, endMs) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CustomRangeDialog( + zone: ZoneId, + onConfirm: (startMs: Long, endMs: Long) -> Boolean, + onDismiss: () -> Unit, +) { + var step by remember { mutableIntStateOf(0) } + var startDate by remember { mutableStateOf(null) } + + // 步骤切换时重建 picker(rememberDatePickerState 只取首帧初始值); + // DatePicker 的毫秒语义是“UTC 当日 0 点”,初始值同样按 UTC 日历生成。 + val pickerState = + androidx.compose.runtime.key(step, startDate) { + rememberDatePickerState( + initialSelectedDateMillis = startDate?.atStartOfDay(java.time.ZoneOffset.UTC) + ?.toInstant()?.toEpochMilli() + ) + } + + val title = + if (step == 0) { + stringResource(R.string.token_stats_custom_range_pick_start) + } else { + stringResource(R.string.token_stats_custom_range_pick_end) + } + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + val selected = pickerState.selectedDateMillis + if (selected != null) { + val date = datePickerMillisToLocalDate(selected) + if (step == 0) { + startDate = date + step = 1 + } else { + val start = startDate ?: return@TextButton + // 结束日包含当天:+1 天 0 点作为半开区间终点(P1-6), + // 同日合法;结束早于开始产生的非法边界由 onConfirm + // (VM 校验)拒绝并提示。 + val startMs = start.atStartOfDay(zone).toInstant().toEpochMilli() + val endMs = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + if (onConfirm(startMs, endMs)) { + onDismiss() + } + } + } + }, + ) { + Text(stringResource(R.string.token_stats_custom_range_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { if (step == 0) onDismiss() else step = 0 }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) { + DatePicker(state = pickerState) + } +} + +// ==== 价格覆盖新增/编辑 ==== + +@Composable +internal fun PriceOverrideDialog( + existing: TokenStatPriceOverrideEntity?, + onSave: (TokenStatsPriceOverrideDraft) -> Unit, + onDelete: (() -> Unit)?, + onDismiss: () -> Unit, +) { + var scope by remember(existing) { + mutableStateOf( + existing?.let { PriceOverrideScope.fromNameOrNull(it.scope) } ?: PriceOverrideScope.PROVIDER_MODEL + ) + } + var provider by remember(existing) { mutableStateOf(existing?.provider.orEmpty()) } + var model by remember(existing) { mutableStateOf(existing?.model.orEmpty()) } + var configId by remember(existing) { mutableStateOf(existing?.configId.orEmpty()) } + var billingMode by remember(existing) { + mutableStateOf( + existing?.let { BillingMode.fromString(it.billingMode) } ?: BillingMode.TOKEN + ) + } + var currency by remember(existing) { + mutableStateOf( + existing?.let { + if (it.pricingCurrency.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD + } ?: PricingCurrency.CNY + ) + } + var inputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.inputPricePerMillion)) } + var cachedInputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.cachedInputPricePerMillion)) } + var cacheWritePrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.cacheWritePricePerMillion)) } + var outputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.outputPricePerMillion)) } + var pricePerRequest by remember(existing) { mutableStateOf(formatEditablePrice(existing?.pricePerRequest)) } + var inlineError by remember { mutableStateOf(null) } + val pricingInvalidText = stringResource(R.string.token_stats_pricing_invalid) + // P1-7:编辑已有覆盖时业务键(scope/provider/model/configId)只读, + // 只允许修改价格/币种/计费方式,防止键被改掉产生第二行或误覆盖。 + val editing = existing != null + + val priceFields = + if (billingMode == BillingMode.TOKEN) { + listOf(inputPrice, cachedInputPrice, cacheWritePrice, outputPrice) + } else { + listOf(pricePerRequest) + } + val allParsed = priceFields.all { + it.isBlank() || it.toDoubleOrNull() != null + } + // CONFIG 作用域必须填写配置 ID(P1-7);新增时同样强制 + val configIdValid = scope != PriceOverrideScope.CONFIG || configId.isNotBlank() + + val builtinReference = + if (scope == PriceOverrideScope.PROVIDER_MODEL && provider.isNotBlank() && model.isNotBlank()) { + DefaultModelPricingCollect.getDefaultPricing("$provider:$model") + } else { + null + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource( + if (existing == null) R.string.token_stats_pricing_add + else R.string.token_stats_pricing_edit + ) + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = scope == PriceOverrideScope.PROVIDER_MODEL, + onClick = { if (!editing) scope = PriceOverrideScope.PROVIDER_MODEL }, + enabled = !editing, + label = { Text(stringResource(R.string.token_stats_pricing_scope_provider)) }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = scope == PriceOverrideScope.CONFIG, + onClick = { if (!editing) scope = PriceOverrideScope.CONFIG }, + enabled = !editing, + label = { Text(stringResource(R.string.token_stats_pricing_scope_config)) }, + modifier = Modifier.weight(1f), + ) + } + + OutlinedTextField( + value = provider, + onValueChange = { if (!editing) provider = it }, + label = { Text(stringResource(R.string.token_stats_pricing_provider_label)) }, + singleLine = true, + enabled = !editing, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = model, + onValueChange = { if (!editing) model = it }, + label = { Text(stringResource(R.string.token_stats_pricing_model_label)) }, + singleLine = true, + enabled = !editing, + modifier = Modifier.fillMaxWidth(), + ) + if (scope == PriceOverrideScope.CONFIG) { + OutlinedTextField( + value = configId, + onValueChange = { if (!editing) configId = it }, + label = { Text(stringResource(R.string.token_stats_pricing_config_label)) }, + singleLine = true, + enabled = !editing, + modifier = Modifier.fillMaxWidth(), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = billingMode == BillingMode.TOKEN, + onClick = { billingMode = BillingMode.TOKEN }, + label = { Text(stringResource(R.string.settings_billing_mode_token)) }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = billingMode == BillingMode.COUNT, + onClick = { billingMode = BillingMode.COUNT }, + label = { Text(stringResource(R.string.settings_billing_mode_count)) }, + modifier = Modifier.weight(1f), + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = currency == PricingCurrency.CNY, + onClick = { currency = PricingCurrency.CNY }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.CurrencyYen, null, modifier = Modifier.padding(end = 2.dp)) + Text(stringResource(R.string.token_stats_currency_cny)) + } + }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = currency == PricingCurrency.USD, + onClick = { currency = PricingCurrency.USD }, + label = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.AttachMoney, null, modifier = Modifier.padding(end = 2.dp)) + Text(stringResource(R.string.token_stats_currency_usd)) + } + }, + modifier = Modifier.weight(1f), + ) + } + + HorizontalDivider() + + if (billingMode == BillingMode.TOKEN) { + PriceField( + label = stringResource(R.string.token_stats_pricing_input), + value = inputPrice, + onChange = { inputPrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_cached), + value = cachedInputPrice, + onChange = { cachedInputPrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_cache_write), + value = cacheWritePrice, + onChange = { cacheWritePrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_output), + value = outputPrice, + onChange = { outputPrice = it }, + ) + } else { + PriceField( + label = stringResource(R.string.token_stats_pricing_per_request), + value = pricePerRequest, + onChange = { pricePerRequest = it }, + ) + } + + builtinReference?.let { defaults -> + val referenceText = + buildString { + append("${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M") + append(" · ") + append("${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M") + if (defaults.billingMode == BillingMode.COUNT) { + append(" · ${stringResource(R.string.settings_billing_mode_count)}") + } + } + Text( + text = stringResource(R.string.token_stats_pricing_reference, referenceText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + inlineError?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + }, + confirmButton = { + TextButton( + enabled = provider.isNotBlank() && model.isNotBlank() && allParsed && configIdValid, + onClick = { + val parse = { raw: String -> raw.trim().toDoubleOrNull() } + val draft = + TokenStatsPriceOverrideDraft( + scope = scope, + provider = provider, + model = model, + configId = configId.ifBlank { null }, + billingMode = billingMode, + currency = currency, + inputPricePerMillion = parse(inputPrice), + cachedInputPricePerMillion = parse(cachedInputPrice), + cacheWritePricePerMillion = parse(cacheWritePrice), + outputPricePerMillion = parse(outputPrice), + pricePerRequest = parse(pricePerRequest), + ) + runCatching { onSave(draft) } + .onSuccess { onDismiss() } + .onFailure { + inlineError = pricingInvalidText + } + }, + ) { + Text(stringResource(R.string.settings_save)) + } + }, + dismissButton = { + Row { + if (existing != null && onDelete != null) { + TextButton( + onClick = { + onDelete() + onDismiss() + }, + ) { + Text( + stringResource(R.string.token_stats_pricing_delete), + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.weight(1f)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + } + }, + ) +} + +@Composable +private fun PriceField( + label: String, + value: String, + onChange: (String) -> Unit, +) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun formatEditablePrice(value: Double?): String = + value?.let { + String.format(Locale.US, "%.6f", it).trimEnd('0').trimEnd('.') + } ?: "" + +// ==== 分组管理(别名/合并) ==== + +/** + * 分组管理对话框(阶段 4 P1 修复):[groupInfo] 与 [otherGroups] 必须来自 + * 独立于统计筛选的完整分组元数据([TokenStatsGroupModelInfo])——当前筛选范围 + * 的明细只包含有事件的身份/分组,作为成员或目标会把无事件组成员漏掉。 + */ +@Composable +internal fun GroupManageDialog( + groupInfo: TokenStatsGroupModelInfo, + otherGroups: List, + onRename: (String) -> Unit, + onCreateAndMerge: (String) -> Unit, + onMergeInto: (String) -> Unit, + onRestoreDefault: () -> Unit, + onDismiss: () -> Unit, +) { + var renameInput by remember(groupInfo.displayModelId) { mutableStateOf(groupInfo.displayName) } + var newGroupInput by remember { mutableStateOf("") } + var mergeTarget by remember { mutableStateOf(null) } + var confirmRestore by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.token_stats_group_manage)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = groupInfo.displayName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource( + R.string.token_stats_group_members, + groupInfo.memberIdentityIds.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + HorizontalDivider() + + // 重命名 + Text( + text = stringResource(R.string.token_stats_group_rename), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = renameInput, + onValueChange = { renameInput = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + enabled = renameInput.isNotBlank(), + onClick = { + onRename(renameInput.trim()) + onDismiss() + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(R.string.settings_save)) + } + + HorizontalDivider() + + // 新建分组并合并 + Text( + text = stringResource(R.string.token_stats_group_new), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = newGroupInput, + onValueChange = { newGroupInput = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + enabled = newGroupInput.isNotBlank(), + onClick = { + onCreateAndMerge(newGroupInput.trim()) + onDismiss() + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(R.string.token_stats_group_create)) + } + + HorizontalDivider() + + // 合并到其他分组 + Text( + text = stringResource(R.string.token_stats_group_merge), + style = MaterialTheme.typography.titleSmall, + ) + var targetExpanded by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = mergeTarget?.let { id -> + otherGroups.firstOrNull { it.displayModelId == id }?.displayName ?: id + } ?: "", + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(stringResource(R.string.token_stats_group_merge_into)) }, + modifier = Modifier.weight(1f), + ) + Box { + TextButton(onClick = { targetExpanded = true }) { + Text(stringResource(R.string.token_stats_group_pick)) + } + DropdownMenu(expanded = targetExpanded, onDismissRequest = { targetExpanded = false }) { + otherGroups.forEach { group -> + DropdownMenuItem( + text = { + Text( + text = group.displayName, + maxLines = 1, + ) + }, + onClick = { + mergeTarget = group.displayModelId + targetExpanded = false + }, + ) + } + } + } + } + TextButton( + enabled = mergeTarget != null, + onClick = { + onMergeInto(mergeTarget!!) + onDismiss() + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringResource(R.string.token_stats_group_merge_confirm)) + } + + HorizontalDivider() + + // 恢复默认分组(两次点击确认) + TextButton( + onClick = { + if (confirmRestore) { + onRestoreDefault() + onDismiss() + } else { + confirmRestore = true + } + }, + modifier = Modifier.align(Alignment.End), + ) { + Text( + text = stringResource( + if (confirmRestore) R.string.token_stats_group_restore_confirm + else R.string.token_stats_group_restore + ), + color = if (confirmRestore) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt new file mode 100644 index 000000000..dbee85771 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -0,0 +1,955 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.ui.components.CustomScaffold +import java.time.ZoneId + +/** 性能卡指标切换。 */ +internal enum class PerfMetric { TTFT, GENERATION } + +/** + * Token 统计完整页面(阶段 4)。 + * 沿用 Operit 设置入口与页面框架(Settings → Token使用统计), + * 升级旧累计页面为账本统计:生命周期总览 + 时间/模型/分类/状态筛选 + + * 四张图表卡 + 模型明细 + 汇率/币种/价格覆盖/分组管理设置。 + */ +@Composable +fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { + val context = LocalContext.current + // P1-3:VM 由路由级 ViewModelStore 管理(AppContent 为该 route 提供 + // LocalViewModelStoreOwner,键 = screenKey)——配置变化保留实例, + // 路由出栈/替换/清栈时 store.clear() 触发 onCleared,viewModelScope + // 取消;Factory 只持有 applicationContext。 + val viewModel: TokenUsageStatisticsViewModel = + viewModel(factory = TokenUsageStatisticsViewModel.Factory(context)) + val state by viewModel.state.collectAsState() + val actionMessage by viewModel.actionMessage.collectAsState() + + // 瞬态 UI 状态:可存 rememberSaveable 的在配置变化后保留(P1-3); + // 筛选已在 VM state 中,天然跨配置变化保留。 + var showCustomRange by rememberSaveable { mutableStateOf(false) } + var showResetAllDialog by rememberSaveable { mutableStateOf(false) } + var resetModel by remember { mutableStateOf(null) } + var pricingTarget by remember { mutableStateOf(null) } + var groupTarget by remember { mutableStateOf(null) } + var perfMetric by rememberSaveable { mutableStateOf(PerfMetric.TTFT) } + + LaunchedEffect(actionMessage) { + actionMessage?.let { message -> + Toast.makeText(context, message.text, Toast.LENGTH_SHORT).show() + viewModel.consumeActionMessage() + } + } + + TokenStatsColorsProvider { + CustomScaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { showResetAllDialog = true }, + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = stringResource(id = R.string.settings_reset_all_counts), + ) + } + }, + ) { paddingValues -> + val content: @Composable () -> Unit = { + when { + state.loading && (state.range == null || state.lifetime == null) -> { + LoadingState() + } + state.errorMessage != null && state.range == null -> { + ErrorState( + message = state.errorMessage.orEmpty(), + onRetry = viewModel::load, + ) + } + else -> { + TokenStatsPageContent( + state = state, + viewModel = viewModel, + zone = viewModel.zone, + perfMetric = perfMetric, + onTogglePerfMetric = { perfMetric = it }, + onCustomRange = { showCustomRange = true }, + onResetModel = { resetModel = it }, + onEditPricing = { pricingTarget = PricingTarget.Edit(it) }, + onAddPricing = { pricingTarget = PricingTarget.New }, + onGroupManage = { groupTarget = it }, + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + content() + } + } + } + + if (showCustomRange) { + CustomRangeDialog( + zone = viewModel.zone, + onConfirm = { start, end -> viewModel.setCustomRange(start, end) }, + onDismiss = { showCustomRange = false }, + ) + } + + pricingTarget?.let { target -> + PriceOverrideDialog( + existing = (target as? PricingTarget.Edit)?.override, + onSave = { draft -> + // P1-7:编辑走键校验入口(业务键只读),新增走 upsert + val existingOverride = (target as? PricingTarget.Edit)?.override + if (existingOverride == null) { + viewModel.upsertPriceOverride(draft) + } else { + viewModel.updatePriceOverride(existingOverride, draft) + } + }, + onDelete = (target as? PricingTarget.Edit)?.let { edit -> + { + val override = edit.override + PriceOverrideScope.fromNameOrNull(override.scope)?.let { scope -> + viewModel.deletePriceOverride( + scope = scope, + provider = override.provider, + model = override.model, + configId = override.configId, + ) + } + } + }, + onDismiss = { pricingTarget = null }, + ) + } + + groupTarget?.let { model -> + // P1 修复:成员与目标来自独立于统计筛选的完整分组元数据(state.groupModels, + // 全量身份/展示模型表)——范围明细只含当前筛选下有事件的身份,直接用它 + // 做合并会把无事件成员漏掉;无事件的目标组也不可选出。 + val group = + state.groupModels.firstOrNull { it.displayModelId == model.displayModelId } + ?: TokenStatsGroupModelInfo( + displayModelId = model.displayModelId, + displayName = model.displayName, + memberIdentityIds = model.identities.map { it.identityId }, + ) + GroupManageDialog( + groupInfo = group, + otherGroups = state.groupModels.filter { it.displayModelId != group.displayModelId }, + onRename = { name -> viewModel.renameDisplayGroup(group.displayModelId, name) }, + onCreateAndMerge = { name -> + viewModel.createGroupAndMerge(name, group.memberIdentityIds) + }, + onMergeInto = { targetId -> + viewModel.mergeIntoGroup(group.memberIdentityIds, targetId) + }, + onRestoreDefault = { viewModel.restoreDefaultGroup(group.displayModelId) }, + onDismiss = { groupTarget = null }, + ) + } + + if (showResetAllDialog) { + val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) + AlertDialog( + onDismissRequest = { showResetAllDialog = false }, + title = { Text(stringResource(R.string.settings_reset_confirmation)) }, + text = { Text(stringResource(R.string.settings_reset_warning)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.resetAllStatistics { error -> + if (error != null) { + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + showResetAllDialog = false + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_reset)) + } + }, + dismissButton = { + TextButton(onClick = { showResetAllDialog = false }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } + + resetModel?.let { model -> + AlertDialog( + onDismissRequest = { resetModel = null }, + title = { Text(stringResource(R.string.settings_reset_model_confirmation)) }, + text = { Text(stringResource(R.string.settings_reset_model_warning, model.displayName)) }, + confirmButton = { + TextButton( + onClick = { + viewModel.resetDisplayModel(model.displayModelId) { error -> + if (error != null) { + Toast.makeText(context, error, Toast.LENGTH_SHORT).show() + } + } + resetModel = null + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.settings_reset)) + } + }, + dismissButton = { + TextButton(onClick = { resetModel = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } +} + +private sealed interface PricingTarget { + data object New : PricingTarget + data class Edit(val override: TokenStatPriceOverrideEntity) : PricingTarget +} + +@Composable +private fun LoadingState() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + androidx.compose.material3.CircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.token_stats_retry)) + } + } + } +} + +@Composable +private fun TokenStatsPageContent( + state: TokenStatsUiState, + viewModel: TokenUsageStatisticsViewModel, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, + onCustomRange: () -> Unit, + onResetModel: (TokenStatsDisplayModelBreakdown) -> Unit, + onEditPricing: (TokenStatPriceOverrideEntity) -> Unit, + onAddPricing: () -> Unit, + onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, +) { + val lifetime = state.lifetime ?: return + val hasAnyData = lifetime.eventTotals.requests > 0L || lifetime.baselineTotals.identityCount > 0L + val context = LocalContext.current + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + TokenStatsLifetimeCard( + overview = lifetime, + currency = state.targetCurrency, + manualRate = state.manualRate, + rateIsEstimated = state.rateIsEstimated, + ) + } + + item { + TokenStatsFilterBar( + selectedPreset = state.selectedPreset, + selectedModels = state.selectedModels, + availableModels = state.availableDisplayModels, + knownModelNames = state.knownModelNames, + selectedCategories = state.selectedCategories, + selectedStatuses = state.selectedStatuses, + costMode = state.costMode, + targetCurrency = state.targetCurrency, + onSelectPreset = viewModel::selectPreset, + onCustomRange = onCustomRange, + onToggleModel = viewModel::toggleModel, + onSelectAllModels = viewModel::selectAllModels, + onToggleCategory = viewModel::toggleCategory, + onClearAllCategories = viewModel::clearCategories, + onToggleStatus = viewModel::toggleStatus, + onClearAllStatuses = viewModel::clearStatuses, + onSetCostMode = viewModel::setCostMode, + onSetCurrency = viewModel::setTargetCurrency, + ) + } + + val range = state.range + if (range == null) { + item { NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) } + } else if (!hasAnyData) { + item { + EmptyStateCard() + } + } else { + if (range.eventCount == 0L) { + item { + NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) + } + } else { + item { + TokenStatsChartsSection( + range = range, + currency = state.targetCurrency, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.settings_model_details), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.token_stats_model_count, range.displayModels.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TokenStatsModelCardsSection( + models = range.displayModels, + currency = state.targetCurrency, + costMode = state.costMode, + zone = zone, + onGroupManage = onGroupManage, + onReset = onResetModel, + ) + } + } + } + } + + item { + val rateInvalidText = stringResource(R.string.token_stats_rate_invalid) + TokenStatsRateCard( + manualRate = state.manualRate, + rateIsEstimated = state.rateIsEstimated, + currency = state.targetCurrency, + onSaveRate = { rate -> + val ok = viewModel.setManualRate(rate) + if (!ok) { + Toast.makeText(context, rateInvalidText, Toast.LENGTH_SHORT).show() + } + ok + }, + onSetCurrency = viewModel::setTargetCurrency, + ) + } + + item { + TokenStatsPricingSection( + range = range, + overrides = state.overrides, + onAdd = onAddPricing, + onEdit = onEditPricing, + onDelete = { override -> + val scope = + PriceOverrideScope.fromNameOrNull(override.scope) + ?: return@TokenStatsPricingSection + viewModel.deletePriceOverride( + scope = scope, + provider = override.provider, + model = override.model, + configId = override.configId, + ) + }, + ) + } + + item { + Spacer(Modifier.height(96.dp)) + } + } +} + +@Composable +private fun EmptyStateCard() { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Analytics, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.token_stats_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.token_stats_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun NoDataCard(text: String) { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp), + ) + } +} + +// ==== 四张图表卡(手机纵向;宽屏 2x2) ==== + +@Composable +private fun TokenStatsChartsSection( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val wide = maxWidth > 700.dp + if (wide) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CostChartCard(range = range, currency = currency, zone = zone) + TokenChartCard(range = range, currency = currency, zone = zone) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + RequestChartCard(range = range, currency = currency, zone = zone) + PerformanceChartCard( + range = range, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + CostChartCard(range = range, currency = currency, zone = zone) + RequestChartCard(range = range, currency = currency, zone = zone) + TokenChartCard(range = range, currency = currency, zone = zone) + PerformanceChartCard( + range = range, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + } + } +} + +@Composable +private fun CostChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, +) { + val colors = LocalTokenStatsColors.current + val models = range.displayModels + val colorFor: (String) -> androidx.compose.ui.graphics.Color = { modelId -> + val index = models.indexOfFirst { it.displayModelId == modelId } + colors.modelPalette[index.coerceAtLeast(0) % colors.modelPalette.size] + } + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val unknownCostTemplate = stringResource(R.string.token_stats_unknown_cost) + val chartTitle = stringResource(R.string.token_stats_chart_cost) + + TokenStatsChartCard( + title = chartTitle, + summary = formatMoney(range.summary.cost.knownAmount, currency), + ) { + if (range.summary.cost.unknownContributionCount > 0L) { + RangeUnknownHint( + stringResource(R.string.token_stats_unknown_cost, range.summary.cost.unknownContributionCount) + ) + } + TokenStatsStackedBarChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatMoney(it, currency) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + stackSelector = { bucket -> + models.mapNotNull { model -> + val cost = bucket.byModel[model.displayModelId]?.cost ?: return@mapNotNull null + if (cost.knownAmount <= 0.0) null else cost.knownAmount to colorFor(model.displayModelId) + } + }, + stackLabels = { bucket -> + models.mapNotNull { model -> + val cost = bucket.byModel[model.displayModelId]?.cost ?: return@mapNotNull null + if (cost.knownAmount <= 0.0) null else model.displayName + } + }, + unknownNote = { bucket -> + val unknown = bucket.totals.cost.unknownContributionCount + if (unknown > 0L) String.format(unknownCostTemplate, unknown) else null + }, + legendItems = models.take(8).map { it.displayName to colorFor(it.displayModelId) }, + ) + } +} + +@Composable +private fun RequestChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, +) { + val chartTitle = stringResource(R.string.token_stats_chart_requests) + TokenStatsChartCard( + title = chartTitle, + summary = formatCount(range.summary.requests), + ) { + TokenStatsLineChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatCount(it.toLong()) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + valueSelector = { it.totals.requests.toDouble() }, + ) + } +} + +@Composable +private fun TokenChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, +) { + val colors = LocalTokenStatsColors.current + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val outputLabel = stringResource(R.string.token_stats_token_output) + val cacheWriteLabel = stringResource(R.string.token_stats_token_cache_write) + val cachedLabel = stringResource(R.string.token_stats_token_cached) + val uncachedLabel = stringResource(R.string.token_stats_token_uncached) + val reasoningLabel = stringResource(R.string.token_stats_token_reasoning) + val unknownPartsTemplate = stringResource(R.string.token_stats_unknown_parts) + val chartTitle = stringResource(R.string.token_stats_chart_tokens) + + val totalUnknown = + range.summary.uncachedInput.unknownEventCount + + range.summary.cachedInput.unknownEventCount + + range.summary.output.unknownEventCount + + TokenStatsChartCard( + title = chartTitle, + summary = formatCompactCount( + range.summary.uncachedInput.knownSum + + range.summary.cachedInput.knownSum + + range.summary.cacheWrite.knownSum + + range.summary.output.knownSum + + range.summary.reasoning.knownSum + ), + ) { + if (totalUnknown > 0L) { + RangeUnknownHint(stringResource(R.string.token_stats_unknown_parts, totalUnknown)) + } + TokenStatsStackedBarChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatCompactCount(it.toLong()) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + stackSelector = { bucket -> + listOf( + bucket.totals.output.knownSum.toDouble() to colors.output, + bucket.totals.cacheWrite.knownSum.toDouble() to colors.cacheWrite, + bucket.totals.cachedInput.knownSum.toDouble() to colors.cachedInput, + bucket.totals.uncachedInput.knownSum.toDouble() to colors.uncachedInput, + bucket.totals.reasoning.knownSum.toDouble() to colors.reasoning, + ) + }, + stackLabels = { + listOf(outputLabel, cacheWriteLabel, cachedLabel, uncachedLabel, reasoningLabel) + }, + unknownNote = { bucket -> + val unknown = + bucket.totals.uncachedInput.unknownEventCount + + bucket.totals.cachedInput.unknownEventCount + + bucket.totals.output.unknownEventCount + if (unknown > 0L) String.format(unknownPartsTemplate, unknown) else null + }, + legendItems = listOf( + uncachedLabel to colors.uncachedInput, + cachedLabel to colors.cachedInput, + cacheWriteLabel to colors.cacheWrite, + outputLabel to colors.output, + reasoningLabel to colors.reasoning, + ), + ) + } +} + +@Composable +private fun PerformanceChartCard( + range: TokenStatsRangeData, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, +) { + val colors = LocalTokenStatsColors.current + val aggregate = + if (perfMetric == PerfMetric.TTFT) range.performance.ttft + else range.performance.generationDuration + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val perfNoDataText = stringResource(R.string.token_stats_perf_no_data) + val durationUnknownTemplate = stringResource(R.string.token_stats_duration_unknown) + val chartTitle = stringResource(R.string.token_stats_chart_performance) + + TokenStatsChartCard( + title = chartTitle, + summary = durationSummaryText(aggregate), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = perfMetric == PerfMetric.TTFT, + onClick = { onTogglePerfMetric(PerfMetric.TTFT) }, + label = { Text(stringResource(R.string.token_stats_perf_ttft)) }, + ) + FilterChip( + selected = perfMetric == PerfMetric.GENERATION, + onClick = { onTogglePerfMetric(PerfMetric.GENERATION) }, + label = { Text(stringResource(R.string.token_stats_perf_generation)) }, + ) + } + Spacer(Modifier.height(8.dp)) + TokenStatsLineChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatDuration(it) }, + emptyText = perfNoDataText, + chartLabel = chartTitle, + valueSelector = { bucket -> + val agg = + if (perfMetric == PerfMetric.TTFT) bucket.performance.ttft + else bucket.performance.generationDuration + if (agg.hasData) agg.averageMs else null + }, + unknownNote = { bucket -> + val agg = + if (perfMetric == PerfMetric.TTFT) bucket.performance.ttft + else bucket.performance.generationDuration + when { + !agg.hasData -> perfNoDataText + agg.unknownCount > 0L -> String.format(durationUnknownTemplate, agg.unknownCount) + else -> null + } + }, + ) + } +} + +@Composable +private fun RangeUnknownHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.unknownHint, + modifier = Modifier.padding(bottom = 4.dp), + ) +} + +// ==== 价格覆盖管理区 ==== + +@Composable +private fun TokenStatsPricingSection( + range: TokenStatsRangeData?, + overrides: List, + onAdd: () -> Unit, + onEdit: (TokenStatPriceOverrideEntity) -> Unit, + onDelete: (TokenStatPriceOverrideEntity) -> Unit, +) { + var showBuiltin by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.token_stats_pricing_manage), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.token_stats_pricing_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onAdd) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.token_stats_pricing_add), + ) + } + } + + // 内置默认(只读) + val countBillingText = stringResource(R.string.settings_billing_mode_count) + val providerModels = + range?.displayModels + ?.flatMap { it.identities } + ?.map { "${it.provider}:${it.model}" } + ?.distinct() + .orEmpty() + if (providerModels.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.token_stats_pricing_builtin), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { showBuiltin = !showBuiltin }) { + Icon( + imageVector = if (showBuiltin) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = stringResource(R.string.token_stats_model_expand), + modifier = Modifier.size(20.dp), + ) + } + } + if (showBuiltin) { + providerModels.take(12).forEach { providerModel -> + val defaults = + com.ai.assistance.operit.data.collects.DefaultModelPricingCollect + .getDefaultPricing(providerModel) + Text( + text = buildString { + append(providerModel) + append(" ") + append("${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M") + append(" · ") + append("${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M") + if (defaults.billingMode == com.ai.assistance.operit.data.model.BillingMode.COUNT) { + append(" · $countBillingText") + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (providerModels.size > 12) { + Text( + text = stringResource(R.string.token_stats_more_count, providerModels.size - 12), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (overrides.isEmpty()) { + Text( + text = stringResource(R.string.token_stats_pricing_none), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + overrides.forEach { override -> + PriceOverrideRow( + override = override, + onEdit = { onEdit(override) }, + onDelete = { onDelete(override) }, + ) + } + } + } + } +} + +@Composable +private fun PriceOverrideRow( + override: TokenStatPriceOverrideEntity, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val scopeText = + if (override.scope == PriceOverrideScope.CONFIG.name) { + stringResource(R.string.token_stats_pricing_scope_config) + } else { + stringResource(R.string.token_stats_pricing_scope_provider) + } + val currency = + if (override.pricingCurrency.equals("CNY", ignoreCase = true)) { + com.ai.assistance.operit.data.collects.PricingCurrency.CNY + } else { + com.ai.assistance.operit.data.collects.PricingCurrency.USD + } + val prices = buildList { + override.inputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, currency)}") } + override.cachedInputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, currency)}") } + override.cacheWritePricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cache_write)} ${formatPricePerMillion(it, currency)}") } + override.outputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, currency)}") } + override.pricePerRequest?.let { add("${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, currency)}") } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "$scopeText · ${override.provider}:${override.model}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + if (override.configId.isNotEmpty()) { + Text( + text = stringResource(R.string.token_stats_config_id, override.configId), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = if (prices.isEmpty()) { + stringResource(R.string.token_stats_unknown_pricing) + } else { + prices.joinToString(" · ") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // 触摸目标保持 IconButton 默认 48dp(P1-8:不低于 48dp) + IconButton(onClick = onEdit) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.token_stats_pricing_edit), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.token_stats_pricing_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt new file mode 100644 index 000000000..b9d40124f --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -0,0 +1,624 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.data.stats.ApiPreferencesTokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenCostCurrency +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsCostMode +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview +import com.ai.assistance.operit.data.stats.TokenStatsPreset +import com.ai.assistance.operit.data.stats.TokenStatsQueryParams +import com.ai.assistance.operit.data.stats.TokenStatsQueryService +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager +import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange +import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges +import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection +import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.util.AppLogger +import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** 页面 UI 状态(阶段 4)。 */ +data class TokenStatsUiState( + val loading: Boolean = true, + val errorMessage: String? = null, + /** + * 每次查询完成的单调版本号:UI/测试可用它等待“这次操作触发的查询已落定” + * (Room 在后台线程恢复协程,loading 标志本身有竞态窗口)。 + */ + val refreshVersion: Long = 0, + /** 生命周期累计总览(不受筛选影响)。 */ + val lifetime: TokenStatsLifetimeOverview? = null, + /** 当前时间范围的完整查询结果(受筛选影响)。 */ + val range: TokenStatsRangeData? = null, + /** 当前时间预设(首次自动回退后为回退结果)。 */ + val selectedPreset: TokenStatsPreset = TokenStatsPreset.LAST_5H, + /** 自定义范围的显式边界;非 CUSTOM 预设时为 null。 */ + val customRange: TokenStatsTimeRange? = null, + /** true = 用户手动选择过时间(不再自动回退)。 */ + val userChoseTime: Boolean = false, + val targetCurrency: PricingCurrency = PricingCurrency.CNY, + val costMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL, + val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, + /** true = 汇率是默认估算值(用户未设置),界面必须明显标注。 */ + val rateIsEstimated: Boolean = true, + /** 展示模型筛选;empty = 全部。 */ + val selectedModels: Set = emptySet(), + /** 业务分类筛选;null = 全部。 */ + val selectedCategories: Set? = null, + /** 请求状态筛选;null = 全部。 */ + val selectedStatuses: Set? = null, + /** + * 模型筛选下拉的可选项(P1-5):与当前范围同时间/分类/状态/口径筛选但 + * **不应用模型筛选**,因此选中某模型后其他模型仍可选。 + */ + val availableDisplayModels: List = emptyList(), + /** 已知展示模型 id → 名称(P1-5:被筛选出当前结果但仍选中的模型可显示)。 */ + val knownModelNames: Map = emptyMap(), + /** 全部价格覆盖(供管理区展示)。 */ + val overrides: List = emptyList(), + /** + * 完整展示分组元数据(阶段 4 P1 修复):与统计筛选无关的分组成员/合并目标 + * 来源——范围明细只含当前筛选下有事件的身份/分组,分组操作(合并成员、 + * 目标组列表)必须用完整归属,否则无事件成员被漏移、无事件目标组不可选。 + */ + val groupModels: List = emptyList(), +) + +/** 一次性操作结果消息(Toast):错误或成功提示,消费后清除。 */ +data class TokenStatsActionMessage( + val text: String, + val isError: Boolean = false, +) + +/** + * 统计页 ViewModel(阶段 4)。 + * + * - 时间选择:`settings` 中**从未选择**时,每次进入按 5h→12h→24h→7d→30d + * 自动回退到最近有数据的预设;用户手动选择后持久化,此后不再自动跳转。 + * - 筛选/币种/费用口径/汇率变更都触发重新查询;查询走 + * [TokenStatsQueryService](同事务快照 + IO 线程)。 + * - 依赖注入缝(测试):[settings] 替换为内存假实现、[dao] 传入测试 Room + * DAO、[nowMs]/[zone] 固定时间;生产默认全部使用真实实现。 + */ +@android.annotation.SuppressLint("StaticFieldLeak") +class TokenUsageStatisticsViewModel( + private val context: Context, + private val settings: TokenStatsSettingsStore = ApiPreferencesTokenStatsSettingsStore(context), + /** 页面时区(图表时间标签与自定义范围边界),生产 = 系统默认。 */ + val zone: ZoneId = ZoneId.systemDefault(), + private val nowMs: () -> Long = { System.currentTimeMillis() }, + dao: TokenStatsDao? = null, + /** + * 错误文案解析(测试注入,避免 JVM 上不可 mock 的 Context.getString); + * 生产默认走真实 Context。 + */ + private val stringResolver: (Int) -> String = { context.applicationContext.getString(it) }, + /** + * 协程调度器(测试注入非 Main 调度器,避免 JVM 上 Room 后台恢复与 + * TestMainDispatcher 冲突);生产默认 = Main.immediate(与 viewModelScope 一致)。 + */ + private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) : ViewModel() { + + // 只保存 applicationContext(进程级单例,无泄漏风险;与 CustomEmojiViewModel 同模式) + private val appContext: Context = context.applicationContext + private val tag = "TokenUsageStatisticsViewModel" + + private val statsDao: TokenStatsDao = + dao ?: AppDatabase.getDatabase(appContext).tokenStatsDao() + + private val manager = TokenStatsSettingsManager(statsDao) + + private val _state = MutableStateFlow(TokenStatsUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _actionMessage = MutableStateFlow(null) + val actionMessage: StateFlow = _actionMessage.asStateFlow() + + /** 丢弃过期加载结果(与 Rainytoken 参考实现同思路的 generation 防竞态)。 */ + private var loadGeneration = 0 + + /** 当前加载任务:新一轮 [load] 先取消旧任务,旧任务不得写 state(P1-4)。 */ + private var loadJob: Job? = null + + /** 已知展示模型 id → 最近一次查询所见名称(P1-5,永不清除,只增补)。 */ + private val knownModelNames = mutableMapOf() + + init { + load() + } + + fun consumeActionMessage() { + _actionMessage.value = null + } + + // ==== 查询 ==== + + fun load() { + loadJob?.cancel() + val generation = ++loadGeneration + // 筛选状态同步快照:偏好读取挂起期间用户可能已改筛选并触发新 load, + // 旧任务一律用本快照构造 params,不混入新状态(P1-4)。 + val filterSnapshot = _state.value + loadJob = viewModelScope.launch(dispatcher) { + try { + // 偏好全部读取为不可变本地快照:任何 _state.update 之前先核对 + // generation,旧 load 即使恢复也不污染共享 state(P1-4)。 + val rateInfo = settings.loadRateWithEstimate() + val currency = settings.loadTargetCurrency() + val mode = settings.loadCostMode() + val savedSelection = settings.loadTimeSelection() + val selectionWasManual = settings.loadSelectionWasManual() + + val preset: TokenStatsPreset + val customRange: TokenStatsTimeRange? + val userChoseTime: Boolean + if (savedSelection != null) { + // 已有选择(用户手选或上次自动回退):直接复用,不再探测(P1-2) + preset = savedSelection.preset + customRange = savedSelection.customRangeOrNull() + userChoseTime = selectionWasManual + } else { + // 首次进入(无任何持久化状态):自动回退并持久化(manual=false), + // 下次进入的 VM 直接复用,不再探测(P1-2)。 + val suggested = + TokenStatsQueryService.initialPresetWithData(appContext, zone, nowMs()) + settings.saveTimeSelection(TokenStatsTimeSelection(suggested), manual = false) + preset = suggested + customRange = null + userChoseTime = false + } + + val params = TokenStatsQueryParams( + targetCurrency = currency, + manualRate = rateInfo.first, + rateIsEstimated = rateInfo.second, + mode = mode, + displayModelIds = filterSnapshot.selectedModels.ifEmpty { null }, + categories = filterSnapshot.selectedCategories, + statuses = filterSnapshot.selectedStatuses, + ) + val range: TokenStatsTimeRange? = + when { + preset == TokenStatsPreset.CUSTOM && customRange != null -> customRange + preset == TokenStatsPreset.CUSTOM -> null + else -> TokenStatsTimeRanges.rangeFor(preset, nowMs(), zone) + } + + if (generation != loadGeneration) return@launch + _state.update { it.copy(loading = true, errorMessage = null) } + + // 并发查询(P1-5):范围结果 + 模型菜单可用项同范围但不应用模型 + // 筛选;无模型筛选时可用项直接复用主结果,不产生重复查询。 + val result = coroutineScope { + val lifetimeD = async(dispatcher) { + TokenStatsQueryService.lifetimeOverview(appContext, params) + } + val rangeD = async(dispatcher) { + range?.let { + TokenStatsQueryService.rangeData(appContext, it, params, zone) + } + } + val availableD = async(dispatcher) { + if (params.displayModelIds == null || range == null) { + null + } else { + TokenStatsQueryService.rangeData( + appContext, + range, + params.copy(displayModelIds = null), + zone, + ) + } + } + val overridesD = async(dispatcher) { manager.allPriceOverrides() } + // 分组元数据与统计筛选无关(P1 修复):并发读取完整分组归属, + // 供分组管理对话框的成员与目标列表使用 + val groupsD = async(dispatcher) { manager.groupModels() } + val rangeData = rangeD.await() + QueryLoadResult( + lifetime = lifetimeD.await(), + range = rangeData, + available = availableD.await() ?: rangeData, + overrides = overridesD.await(), + groups = groupsD.await(), + ) + } + + if (generation != loadGeneration) return@launch + rememberModelNames(result.range?.displayModels.orEmpty()) + rememberModelNames(result.available?.displayModels.orEmpty()) + _state.update { + it.copy( + loading = false, + errorMessage = null, + lifetime = result.lifetime, + range = result.range, + availableDisplayModels = result.available?.displayModels.orEmpty(), + knownModelNames = knownModelNames.toMap(), + targetCurrency = currency, + manualRate = rateInfo.first, + rateIsEstimated = rateInfo.second, + costMode = mode, + selectedPreset = preset, + customRange = customRange, + userChoseTime = userChoseTime, + overrides = result.overrides, + groupModels = result.groups, + refreshVersion = it.refreshVersion + 1, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == loadGeneration) { + _state.update { + it.copy( + loading = false, + errorMessage = stringResolver(R.string.token_stats_load_failed), + ) + } + } + runCatching { AppLogger.e(tag, "统计页加载失败", e) } + } + } + } + + /** 记录最近一次查询所见模型名(供被筛选出当前结果但仍选中的模型显示)。 */ + private fun rememberModelNames(models: List) { + models.forEach { knownModelNames[it.displayModelId] = it.displayName } + } + + // ==== 时间选择 ==== + + /** 用户手动选择预设:持久化(manual=true)后锁定(不再自动回退)。 */ + fun selectPreset(preset: TokenStatsPreset) { + if (preset == TokenStatsPreset.CUSTOM) return + viewModelScope.launch(dispatcher) { + settings.saveTimeSelection(TokenStatsTimeSelection(preset), manual = true) + load() + } + } + + /** + * 设置自定义范围(半开区间 [startMs, endMs))。 + * 校验:end > start 且时长不超过 [MAX_CUSTOM_RANGE_DAYS] 天; + * 非法时不持久化、不触发查询,返回 false 并由 [actionMessage] 说明原因。 + */ + fun setCustomRange(startMs: Long, endMs: Long): Boolean { + if (endMs <= startMs) { + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_custom_range_invalid), + isError = true, + ) + return false + } + val range = TokenStatsTimeRanges.customRange(startMs, endMs) + if (range.durationMs > MAX_CUSTOM_RANGE_DAYS * TokenStatsTimeRanges.DAY_MS) { + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_custom_range_too_long), + isError = true, + ) + return false + } + viewModelScope.launch(dispatcher) { + settings.saveTimeSelection( + TokenStatsTimeSelection( + preset = TokenStatsPreset.CUSTOM, + customStartMs = range.startMs, + customEndMs = range.endMs, + ), + manual = true, + ) + load() + } + return true + } + + // ==== 筛选 ==== + + fun toggleModel(displayModelId: String) { + _state.update { state -> + val newSet = state.selectedModels.toMutableSet() + if (!newSet.add(displayModelId)) newSet.remove(displayModelId) + state.copy(selectedModels = newSet) + } + load() + } + + fun selectAllModels() { + _state.update { it.copy(selectedModels = emptySet()) } + load() + } + + fun toggleCategory(category: TokenStatCategory) { + _state.update { state -> + val current = state.selectedCategories + val newSet = (current?.toMutableSet() ?: mutableSetOf()) + if (!newSet.add(category)) newSet.remove(category) + state.copy(selectedCategories = newSet.ifEmpty { null }) + } + load() + } + + fun toggleStatus(status: TokenStatStatus) { + _state.update { state -> + val current = state.selectedStatuses + val newSet = (current?.toMutableSet() ?: mutableSetOf()) + if (!newSet.add(status)) newSet.remove(status) + state.copy(selectedStatuses = newSet.ifEmpty { null }) + } + load() + } + + /** “全部分类”:一次置空并只触发一次查询(P2,避免逐项 toggle 的多次 load)。 */ + fun clearCategories() { + _state.update { it.copy(selectedCategories = null) } + load() + } + + /** “全部状态”:一次置空并只触发一次查询(P2,避免逐项 toggle 的多次 load)。 */ + fun clearStatuses() { + _state.update { it.copy(selectedStatuses = null) } + load() + } + + // ==== 口径/币种/汇率 ==== + + fun setCostMode(mode: TokenStatsCostMode) { + viewModelScope.launch(dispatcher) { + settings.saveCostMode(mode) + load() + } + } + + fun setTargetCurrency(currency: PricingCurrency) { + viewModelScope.launch(dispatcher) { + settings.saveTargetCurrency(currency) + load() + } + } + + /** 手动汇率:非正或非有限值拒绝(不改持久化、不重查),返回 false。 */ + fun setManualRate(rate: Double): Boolean { + if (!rate.isFinite() || rate <= 0.0) return false + viewModelScope.launch(dispatcher) { + settings.saveRate(rate) + load() + } + return true + } + + // ==== 价格覆盖 ==== + + fun upsertPriceOverride(draft: TokenStatsPriceOverrideDraft) { + viewModelScope.launch(dispatcher) { + runCatching { manager.upsertPriceOverride(draft) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_pricing_save_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "保存价格覆盖失败", e) } + } + } + } + + /** 编辑已有价格覆盖:业务键只读,仅更新价格/币种/计费方式(P1-7)。 */ + fun updatePriceOverride(existing: TokenStatPriceOverrideEntity, draft: TokenStatsPriceOverrideDraft) { + viewModelScope.launch(dispatcher) { + runCatching { manager.updatePriceOverride(existing, draft) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_pricing_save_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "更新价格覆盖失败", e) } + } + } + } + + fun deletePriceOverride( + scope: PriceOverrideScope, + provider: String, + model: String, + configId: String?, + ) { + viewModelScope.launch(dispatcher) { + runCatching { manager.deletePriceOverride(scope, provider, model, configId) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_pricing_delete_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "删除价格覆盖失败", e) } + } + } + } + + // ==== 分组 / 别名 ==== + + fun renameDisplayGroup(displayModelId: String, displayName: String) { + viewModelScope.launch(dispatcher) { + runCatching { manager.renameDisplayGroup(displayModelId, displayName) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_group_rename_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "重命名分组失败", e) } + } + } + } + + /** 新建分组并把指定身份移入;成功返回新分组 id,失败返回 null。 */ + fun createGroupAndMerge(groupName: String, identityIds: List) { + viewModelScope.launch(dispatcher) { + runCatching { manager.createGroupAndMove(groupName, identityIds) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_group_create_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "创建分组失败", e) } + } + } + } + + /** 把指定身份合并到已有分组。 */ + fun mergeIntoGroup(identityIds: List, targetDisplayModelId: String) { + viewModelScope.launch(dispatcher) { + runCatching { manager.moveIdentitiesToGroup(identityIds, targetDisplayModelId) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_group_merge_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "合并分组失败", e) } + } + } + } + + /** 恢复默认规范分组:组内每个身份按其自身模型名归回默认组。 */ + fun restoreDefaultGroup(displayModelId: String) { + viewModelScope.launch(dispatcher) { + runCatching { manager.restoreDefaultGroups(displayModelId) } + .onSuccess { load() } + .onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_group_restore_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "恢复默认分组失败", e) } + } + } + } + + // ==== 旧重置入口(阶段 5 的完整删除语义不在本阶段实现,保持现有行为正确) ==== + + /** 重置全部统计(旧计数 + 新账本),失败时返回错误消息。 */ + fun resetAllStatistics(onResult: (String?) -> Unit) { + viewModelScope.launch(dispatcher) { + val ok = runCatching { + ApiPreferences.getInstance(appContext).resetAllProviderModelTokenCounts() + }.getOrDefault(false) + onResult( + if (ok) { + null + } else { + stringResolver(R.string.settings_token_stats_reset_failed) + } + ) + load() + } + } + + /** + * 重置指定展示分组:仅当组内所有身份属于同一 provider:model 时支持 + * (与旧入口的 provider:model 语义一致);跨 provider/模型合并组 + * 返回错误消息,不扩展阶段 5 才实现的删除语义。 + */ + fun resetDisplayModel(displayModelId: String, onResult: (String?) -> Unit) { + viewModelScope.launch(dispatcher) { + val identities = + runCatching { statsDao.getAllIdentities() }.getOrDefault(emptyList()) + .filter { it.displayModelId == displayModelId } + val providerModels = identities.map { it.provider to it.model }.distinct() + if (providerModels.size != 1) { + onResult(stringResolver(R.string.token_stats_reset_merged_unsupported)) + return@launch + } + val (provider, model) = providerModels.single() + val ok = runCatching { + ApiPreferences.getInstance(appContext) + .resetProviderModelTokenCounts("$provider:$model") + }.getOrDefault(false) + onResult( + if (ok) { + null + } else { + stringResolver(R.string.settings_token_stats_reset_failed) + } + ) + load() + } + } + + /** + * 生产构造(P1-3):只持有 applicationContext;VM 由路由级 ViewModelStore + * 管理(AppContent 按 screenKey 提供 owner)——配置变化保留实例, + * 路由出栈/替换/清栈时 store.clear() 触发 onCleared,viewModelScope + * 取消,正在进行的 load 一并取消。 + */ + class Factory(context: Context) : ViewModelProvider.Factory { + private val appContext: Context = context.applicationContext + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + TokenUsageStatisticsViewModel(appContext) as T + } + + companion object { + /** 自定义范围时长上限(天):与聚合器 10k 桶上限留出余量。 */ + const val MAX_CUSTOM_RANGE_DAYS = 3 * 366L + } +} + +/** 一次 load 的并发查询结果(P1-5):模型菜单可用项可能复用主范围结果。 */ +private data class QueryLoadResult( + val lifetime: TokenStatsLifetimeOverview, + val range: TokenStatsRangeData?, + val available: TokenStatsRangeData?, + val overrides: List, + val groups: List, +) + +/** 保存范围时用:无效自定义边界返回 null(防御损坏状态)。 */ +internal fun TokenStatsTimeSelection.customRangeOrNull(): TokenStatsTimeRange? { + if (preset != TokenStatsPreset.CUSTOM) return null + val start = customStartMs ?: return null + val end = customEndMs ?: return null + if (end <= start) return null + return TokenStatsTimeRanges.customRange(start, end) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt index 89f5f733e..4c71c9541 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt @@ -41,6 +41,7 @@ import com.ai.assistance.operit.ui.main.screens.Screen import com.ai.assistance.operit.ui.main.navigation.AppRouterGateway import com.ai.assistance.operit.ui.main.navigation.AppRouterState import com.ai.assistance.operit.ui.main.navigation.AppRouteDiscoveryGateway +import com.ai.assistance.operit.ui.main.navigation.screenKeysAliveOnStack import com.ai.assistance.operit.ui.main.navigation.NavigationEntrySpec import com.ai.assistance.operit.ui.main.navigation.NavigationSurface import com.ai.assistance.operit.ui.main.navigation.RouteEntrySource @@ -116,6 +117,12 @@ fun OperitApp( val currentRouteEntry = routerState.currentEntry val currentScreen = AppRouteCatalog.resolveScreen(navigationModel, currentRouteEntry) ?: Screen.AiChat val selectedItem = currentScreen.navItem + // 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据: + // AppContent 在转场完成时只保留这些键的 owner) + val aliveScreenKeys: Set = + screenKeysAliveOnStack(routerState.backStack) { entry -> + AppRouteCatalog.resolveScreen(navigationModel, entry) + } val pluginSidebarEntries = remember(navigationModel) { navigationModel.navigationEntries.filter { @@ -532,7 +539,8 @@ fun OperitApp( onGoBack = ::requestGoBack, isNavigatingBack = isNavigatingBack, topBarActions = { topBarActions() }, - topBarTitleContent = topBarTitleContent + topBarTitleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } else { // Phone layout @@ -563,7 +571,8 @@ fun OperitApp( onGoBack = ::requestGoBack, isNavigatingBack = isNavigatingBack, topBarActions = { topBarActions() }, - topBarTitleContent = topBarTitleContent + topBarTitleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt index 2bdbf070d..d71f61d72 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt @@ -53,6 +53,8 @@ import com.ai.assistance.operit.ui.main.NavigationTransitionSource import com.ai.assistance.operit.ui.main.TopBarTitleContent import com.ai.assistance.operit.ui.main.navigation.RouteEntry import com.ai.assistance.operit.ui.main.navigation.LocalRouteInstanceId +import com.ai.assistance.operit.ui.main.navigation.ScreenRouteViewModelStoreOwnerManager +import com.ai.assistance.operit.ui.main.navigation.retainedRouteKeysOnContentAttach import com.ai.assistance.operit.ui.main.screens.Screen import com.ai.assistance.operit.ui.common.composedsl.ToolPkgComposeDslToolScreen import com.ai.assistance.operit.ui.theme.LocalThemePreferenceSnapshot @@ -74,6 +76,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.ui.zIndex import androidx.compose.animation.core.tween import androidx.compose.ui.graphics.graphicsLayer @@ -153,7 +157,9 @@ fun AppContent( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, actions: @Composable RowScope.() -> Unit = {}, - titleContent: TopBarTitleContent? = null + titleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { // Get background image state val context = LocalContext.current @@ -214,16 +220,29 @@ fun AppContent( val screenCache = remember { mutableStateMapOf Unit>() } val screenKeepAliveCache = remember { mutableStateMapOf() } val screenStateHolder = rememberSaveableStateHolder() + // 路由级 ViewModelStore 管理器:自身是 Activity 级 ViewModel(跨配置变化 + // 保留 owner/VM);路由出栈/替换/清栈时清理对应 store(P1:离页查询不保留)。 + val routeViewModelStoreManager: ScreenRouteViewModelStoreOwnerManager = viewModel() val currentScreenKey = remember(currentRouteEntry.instanceId, currentScreen) { - if (currentScreen.keepAlive) { - currentScreen.stableScreenKey() ?: currentRouteEntry.instanceId - } else { - currentRouteEntry.instanceId - } + currentScreen.screenKey(currentRouteEntry.instanceId) } var currentScreenSoftInputMode by remember(currentScreenKey) { mutableStateOf(null) } var currentScreenUsesImePadding by remember(currentScreenKey) { mutableStateOf(false) } + + // 全新组合(配置变化/跨 600dp 布局重建):screenCache 已随组合重置, + // 但 Activity 级 manager/routerState 保留的导航栈仍存活,只保留当前页会 + // 误清 backStack 其他路由的 owner(P1)。首次组合(attach)时按 + // aliveScreenKeys + 当前键同步一次。key 必须用 Unit:只在组合进入时执行 + // 一次,不得随 alive 变化重启——pop 后 alive 立即更新,若按它重启会在 + // 退出动画完成前清理仍渲染的离页 owner。pop/replace/clear 的清理只由 + // 转场完成分支 retainOnly(aliveRouteKeys()) 执行(含过渡/keepAlive 时机); + // Phone/Tablet 切换会销毁本组合,新组合 attach 时重新执行并用新传入 alive。 + LaunchedEffect(Unit) { + routeViewModelStoreManager.retainOnly( + retainedRouteKeysOnContentAttach(currentScreenKey, aliveScreenKeys) + ) + } val effectiveSoftInputMode = currentScreenSoftInputMode ?: manifestSoftInputMode @@ -421,16 +440,32 @@ fun AppContent( uiModuleId = screenSnapshot.uiModuleId, fallbackTitle = screenSnapshot.title ) - else -> - screenSnapshot.Content( - navController = navController, - navigateTo = onScreenChange, - onGoBack = onGoBack, - hasBackgroundImage = hasBackgroundImage, - onLoading = onLoading, - onError = onError, - onGestureConsumed = if (screenSnapshot is Screen.AiChat) onGestureConsumed else { _ -> } - ) + else -> { + val content: @Composable () -> Unit = { + screenSnapshot.Content( + navController = navController, + navigateTo = onScreenChange, + onGoBack = onGoBack, + hasBackgroundImage = hasBackgroundImage, + onLoading = onLoading, + onError = onError, + onGestureConsumed = if (screenSnapshot is Screen.AiChat) onGestureConsumed else { _ -> } + ) + } + if (screenSnapshot.usesRouteViewModelStore) { + // 路由级 ViewModelStore(P1):配置变化保留实例, + // 路由出栈/替换/清栈时 store.clear() 触发 onCleared + val routeOwner = + routeViewModelStoreManager.ownerFor(currentScreenKey) + CompositionLocalProvider( + LocalViewModelStoreOwner provides routeOwner + ) { + content() + } + } else { + content() + } + } } } } @@ -461,6 +496,13 @@ fun AppContent( else -> null } + // 计算当前仍应存活的路由键:导航栈 + 仍渲染的 keepAlive 缓存 + + // 当前键(在转场完成时执行,避免清理仍在渲染的过渡页) + fun aliveRouteKeys(): Set = + aliveScreenKeys + + screenKeepAliveCache.filterValues { it }.keys + + currentScreenKey + LaunchedEffect(currentScreenKey) { val fromKey = lastObservedCurrentKey if (currentScreenKey == fromKey) return@LaunchedEffect @@ -483,6 +525,8 @@ fun AppContent( screenKeepAliveCache.remove(removalKey) screenStateHolder.removeState(removalKey) } + // 路由已离开栈:清理其 ViewModelStore(pop/replace/清栈) + routeViewModelStoreManager.retainOnly(aliveRouteKeys()) pendingRemovalKey = null return@LaunchedEffect } @@ -509,6 +553,8 @@ fun AppContent( screenStateHolder.removeState(keyToRemove) } } + // 动画结束、旧路由已离开栈:清理其 ViewModelStore + routeViewModelStoreManager.retainOnly(aliveRouteKeys()) pendingRemovalKey = null } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt index 846953251..51ca2ab43 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt @@ -78,7 +78,9 @@ fun PhoneLayout( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, topBarActions: @Composable RowScope.() -> Unit = {}, - topBarTitleContent: TopBarTitleContent? = null + topBarTitleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { // 使用 updateTransition 来创建更复杂的动画 val transition = updateTransition(drawerState.targetValue, label = "drawer_transition") @@ -242,7 +244,8 @@ fun PhoneLayout( onGoBack = onGoBack, isNavigatingBack = isNavigatingBack, actions = topBarActions, - titleContent = topBarTitleContent + titleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt index 33878f51a..d748b6c38 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt @@ -64,7 +64,9 @@ fun TabletLayout( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, topBarActions: @Composable RowScope.() -> Unit = {}, - topBarTitleContent: TopBarTitleContent? = null + topBarTitleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { val drawerAppearance = rememberNavigationDrawerAppearance() val sidebarWidthAnimationDurationMillis = 280 @@ -199,7 +201,8 @@ fun TabletLayout( onGoBack = onGoBack, isNavigatingBack = isNavigatingBack, actions = topBarActions, - titleContent = topBarTitleContent + titleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt new file mode 100644 index 000000000..c9036e98b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt @@ -0,0 +1,92 @@ +package com.ai.assistance.operit.ui.main.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.ai.assistance.operit.ui.main.screens.Screen + +/** + * 单个路由(AppContent 的 screenKey)的 [ViewModelStoreOwner]。 + * + * 实例由 [ScreenRouteViewModelStoreOwnerManager] 持有:配置变化期间复用同一 + * 实例(owner 本身不随组合重建),路由真正从导航栈移除时 manager 调用 + * [ViewModelStore.clear],触发该 store 内所有 ViewModel 的 onCleared 并 + * 取消其 viewModelScope。 + * + * 与 ViewModelStore 的约定一致:全部操作必须在主线程执行。 + */ +class ScreenRouteViewModelStoreOwner internal constructor() : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() +} + +/** + * 路由级 ViewModelStore 映射(键 = AppContent 的 screenKey)。 + * + * 本类自身作为 Activity 级 ViewModel 存在(由 AppContent 通过 + * `viewModel()` 获取):跨配置变化保留全部 owner 与已挂载的 ViewModel; + * Activity 销毁时 [onCleared] 全清。导航宿主在路由出栈/替换/清栈的 + * 动画完成后调用 [remove] / [retainOnly],触发对应 owner 的 + * [ViewModelStore.clear]。 + * + * 必须仅在主线程使用(ViewModelStore 语义)。 + */ +class ScreenRouteViewModelStoreOwnerManager : ViewModel() { + + private val owners = mutableMapOf() + + /** 获取(必要时创建)screenKey 的 route owner;配置变化期间复用同一实例。 */ + fun ownerFor(screenKey: String): ScreenRouteViewModelStoreOwner = + owners.getOrPut(screenKey) { ScreenRouteViewModelStoreOwner() } + + /** 移除并清理 screenKey 的 owner:其 store 内所有 ViewModel 收到 onCleared。 */ + fun remove(screenKey: String) { + owners.remove(screenKey)?.let { it.viewModelStore.clear() } + } + + /** + * 仅保留 [aliveScreenKeys] 中的 owner,其余全部移除并清理。 + * 用于 replace / clear stack 等不经过 back 转场动画的栈变化。 + */ + fun retainOnly(aliveScreenKeys: Set) { + owners.keys.filter { it !in aliveScreenKeys }.forEach { remove(it) } + } + + /** 清理全部 owner(Activity 销毁或全新组合时的兜底)。 */ + fun clearAll() { + owners.values.forEach { it.viewModelStore.clear() } + owners.clear() + } + + override fun onCleared() { + clearAll() + } +} + +/** 路由实例在当前导航栈中对应的 screenKey(与 AppContent 的键规则一致)。 */ +fun routeScreenKey(entry: RouteEntry, resolveScreen: (RouteEntry) -> Screen?): String? = + resolveScreen(entry)?.screenKey(entry.instanceId) + +/** 导航栈中仍存活的路由 screenKey 集合(路由级 ViewModelStore 清理依据)。 */ +fun screenKeysAliveOnStack( + stack: List, + resolveScreen: (RouteEntry) -> Screen?, +): Set = + stack.mapNotNull { routeScreenKey(it, resolveScreen) }.toSet() + +/** + * AppContent 全新组合/重建(配置变化、跨 600dp Phone/Tablet 布局切换等)时 + * 应保留的路由键:当前页 + 导航栈中仍存活的路由。 + * + * 仅由 AppContent 首次组合(attach,LaunchedEffect(Unit))调用一次;导航 + * 变化(pop/replace/clear)不得复用本函数,否则会在退出动画完成前清理 + * 仍渲染的离页 owner,其清理由转场完成的 retainOnly(aliveRouteKeys()) 负责。 + * + * 全新组合时 screenCache/keepAlive 缓存已重置,唯一仍在渲染的只有当前页; + * 导航栈(Activity 保留的 routerState/manager)仍存活,栈内的 keepAlive + * 路由键已包含在 [aliveScreenKeys] 中,无需额外补充。离栈的过渡/keepAlive + * 缓存键由转场完成的 retainOnly(aliveRouteKeys()) 负责清理,与本集合无关。 + */ +fun retainedRouteKeysOnContentAttach( + currentScreenKey: String, + aliveScreenKeys: Set, +): Set = aliveScreenKeys + currentScreenKey diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index 621553f9f..fd87ae87f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -64,8 +64,8 @@ import com.ai.assistance.operit.ui.features.settings.screens.SpeechServicesSetti import com.ai.assistance.operit.ui.features.settings.screens.ThemeSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.ToolPermissionSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.MnnModelDownloadScreen -import com.ai.assistance.operit.ui.features.settings.screens.TokenUsageStatisticsScreen import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesSettingsScreen +import com.ai.assistance.operit.ui.features.tokenstats.TokenUsageStatisticsScreen import com.ai.assistance.operit.ui.features.token.TokenConfigWebViewScreen import com.ai.assistance.operit.ui.features.toolbox.screens.AppPermissionsToolScreen import com.ai.assistance.operit.ui.features.toolbox.screens.FileManagerToolScreen @@ -106,10 +106,25 @@ sealed class Screen( // 是否参与 AppContent 的跨页淡入淡出。 // 某些包含实时渲染视图的页面在转场中保留上一页会产生明显残影。 open val participatesInCrossfadeTransition: Boolean = true, - open val keepAlive: Boolean = false + open val keepAlive: Boolean = false, + /** + * 是否使用路由级 ViewModelStore(AppContent 按 screenKey 通过 + * [com.ai.assistance.operit.ui.main.navigation.ScreenRouteViewModelStoreOwnerManager] + * 管理):配置变化保留实例,路由出栈/替换/清栈时 store.clear() 触发 + * onCleared(viewModelScope 取消)。 + * 默认 false 保持 Activity 级语义,防止影响其他页面。 + */ + open val usesRouteViewModelStore: Boolean = false ) { open fun stableScreenKey(): String? = null + /** + * AppContent 使用的屏幕键:keepAlive 屏幕用 [stableScreenKey](同路由 + * 实例复用),否则用路由实例 id(每次进入独立)。 + */ + fun screenKey(routeInstanceId: String): String = + if (keepAlive) stableScreenKey() ?: routeInstanceId else routeInstanceId + // 屏幕内容渲染函数 @Composable open fun Content( @@ -1100,7 +1115,11 @@ sealed class Screen( } data object TokenUsageStatistics : - Screen(navItem = NavItem.Settings, titleRes = R.string.settings_token_usage_stats) { + Screen( + navItem = NavItem.Settings, + titleRes = R.string.settings_token_usage_stats, + usesRouteViewModelStore = true + ) { @Composable override fun Content( navController: NavController, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index df6db9935..dc234d19b 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3621,6 +3621,127 @@ Cached Tokens $%.2f/1M Cost + Lifetime Total (all time) + Total Tokens + Default estimate + Default estimate: 1 USD = %1$s CNY (not set manually) + Converted at 1 USD = %1$s CNY + Rate must be a positive finite number + Legacy data estimate + Estimated + %1$d legacy rows (no time distribution; excluded from trends) + %1$d request(s) with unknown cost + %1$d request(s) with partially unknown data + unknown %1$d + Uncached input + Cache read + Cache write + Output + Reasoning + 5h + 12h + 24h + Today + Yesterday + 7d + 30d + This month + Last month + Custom + Pick start date + Pick end date + OK + End date must be after start date + Custom range must not exceed 3 years + All models + %1$d models + All categories + %1$d categories + All statuses + %1$d statuses + Historical bill + Revalued + Total currency + CNY + USD + Chat + Subagent + Summary + Title + Memory + Character generation + Connection test + Other + Completed + Cancelled + Timeout + Failed + Cost Trend + Request Trend + Token Trend + Performance Trend + TTFT + Generation time + Avg %1$s + No valid duration samples + %1$d invalid samples + No data in this range + No statistics yet + Model call statistics will appear here + Failed to load statistics. Please retry. + Retry + %1$d models + %1$d identities + Expand/Collapse + Config: %1$s + Price + Pricing unknown + Built-in default + Provider/Model override + Config override + Legacy price + Price Overrides + Built-in default → Provider/Model → Config, layered + Built-in defaults (read-only) + No custom price overrides yet + Add override + Edit override + Delete + Provider/Model + API Config + Provider + Model + API config ID + Input price (per million) + Cache read price (per million) + Cache write price (per million) + Output price (per million) + Price per request + Built-in default: %1$s + Prices must be non-negative finite numbers + Failed to save price override + Failed to delete price override + %1$d more + Manage group + %1$d identities in group + Rename group + Create group and merge + Create + Merge into another group + Target group + Pick + Merge + Restore default group + Tap again to confirm restore + Failed to rename group + Failed to create group + Failed to merge group + Failed to restore default group + This group spans multiple providers/models; reset is not supported here + Previous time bucket + Next time bucket + Bucket %1$d of %2$d + %1$s, %2$s, %3$s, total %4$s Male diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8337674e..17bada36f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3534,6 +3534,129 @@ 缓存Token ¥%.2f/1M 费用 + + + 生命周期累计(全部时间) + 总Token + 默认估算 + 默认估算:1 USD = %1$s CNY(未手动设置) + 按 1 USD = %1$s CNY 换算 + 汇率必须为正的有限数值 + 旧数据估算 + 估算 + %1$d 行旧版累计数据(无时间分布,不进趋势图) + 其中 %1$d 个请求费用未知 + %1$d 个请求部分数据未知 + 未知 %1$d + 未缓存输入 + 缓存读取 + 缓存写入 + 输出 + 推理 + 近5小时 + 近12小时 + 近24小时 + 今天 + 昨天 + 近7天 + 近30天 + 本月 + 上月 + 自定义 + 选择开始日期 + 选择结束日期 + 确定 + 结束日期需晚于开始日期 + 自定义范围不能超过 3 年 + 全部模型 + %1$d 个模型 + 全部分类 + %1$d 个分类 + 全部状态 + %1$d 个状态 + 历史账单 + 当前重估 + 总计币种 + CNY + USD + 聊天 + 子代理 + 总结 + 标题 + 记忆 + 角色生成 + 连接测试 + 其他 + 完成 + 取消 + 超时 + 失败 + 费用趋势 + 请求趋势 + Token 趋势 + 性能趋势 + 首Token延迟 + 生成时长 + 平均 %1$s + 无有效时长样本 + %1$d 个样本无效 + 该范围暂无数据 + 暂无统计数据 + 模型调用统计将在此显示 + 统计加载失败,请重试 + 重试 + %1$d 个模型 + %1$d 个身份 + 展开/收起 + 配置:%1$s + 单价 + 定价未知 + 内置默认 + Provider/模型覆盖 + API 配置覆盖 + 旧系统价格 + 价格覆盖 + 内置默认 → Provider/模型 → API 配置,逐层覆盖 + 内置默认(只读) + 暂无自定义价格覆盖 + 新增覆盖 + 编辑覆盖 + 删除 + 按 Provider/模型 + 按 API 配置 + Provider + 模型 + API 配置 ID + 输入价格(每百万) + 缓存读取价格(每百万) + 缓存写入价格(每百万) + 输出价格(每百万) + 每次请求价格 + 内置默认:%1$s + 价格必须为非负有限数值 + 价格覆盖保存失败 + 价格覆盖删除失败 + 其他 %1$d 个 + 分组管理 + 组内 %1$d 个身份 + 重命名分组 + 新建分组并合并 + 创建 + 合并到其他分组 + 目标分组 + 选择 + 合并 + 恢复默认分组 + 再次点击确认恢复 + 重命名分组失败 + 创建分组失败 + 合并分组失败 + 恢复默认分组失败 + 该分组包含多个 Provider/模型,暂不支持此处重置 + 上一个时间桶 + 下一个时间桶 + 第 %1$d / %2$d 桶 + %1$s,%2$s,%3$s,合计 %4$s diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt new file mode 100644 index 000000000..a33bbf1bc --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt @@ -0,0 +1,492 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever + +/** + * 统计页设置管理测试(阶段 4,真实 Room + JVM SQLite): + * 价格覆盖的非负有限校验/新增/编辑/删除,以及分组/别名的安全事务 + * (身份只走 UPDATE,绝不 REPLACE 级联删除事件;恢复默认分组)。 + */ +class TokenStatsSettingsManagerTest { + + private lateinit var tempDir: File + private lateinit var database: AppDatabase + private lateinit var dao: TokenStatsDao + private lateinit var manager: TokenStatsSettingsManager + + @Before + fun setUp() { + tempDir = kotlin.io.path.createTempDirectory("token-settings-test").toFile() + val context = mockContext(tempDir) + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .allowMainThreadQueries() + .build() + dao = database.tokenStatsDao() + manager = TokenStatsSettingsManager(dao) + } + + @After + fun tearDown() { + database.close() + } + + private fun mockContext(filesDir: File): Context { + val context = Mockito.mock(Context::class.java) + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + private suspend fun seedIdentity( + identityId: String, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o", + displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), + ) { + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + dao.upsertDisplayModel( + com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), + displayName = model, + ) + ) + } + + private suspend fun seedEvent(identityId: String, eventId: String) { + dao.insertEvent( + TokenStatEventEntity( + eventId = eventId, + statIdentityId = identityId, + category = TokenStatCategory.CHAT.name, + status = TokenStatStatus.COMPLETED.name, + acceptedGeneration = 0L, + startedAtMs = 1_000_000L, + endedAtMs = 1_001_000L, + firstTokenAtMs = null, + uncachedInputTokens = 10L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = null, + outputTokens = 5L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.001, + diagnosticsJson = null, + ) + ) + } + + // ==== 价格覆盖 ==== + + @Test + fun `price override rejects negative and non-finite values without writing`() = runBlocking { + val negative = + runCatching { + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = -1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + } + assertTrue("negative price must be rejected", negative.isFailure) + assertEquals(0, dao.getAllPriceOverrides().size) + + val nan = + runCatching { + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = Double.NaN, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + } + assertTrue("NaN price must be rejected", nan.isFailure) + assertEquals(0, dao.getAllPriceOverrides().size) + } + + @Test + fun `price override upsert replaces same business key and normalizes fields`() = runBlocking { + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = " OPENAI ", + model = " Gpt-4o ", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + // 同业务组合(规范化后相同)再次写入 → REPLACE 覆盖,仍只有一行 + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "openai", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 3.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 4.0, + pricePerRequest = null, + ) + val all = dao.getAllPriceOverrides() + assertEquals(1, all.size) + val row = all.single() + assertEquals("openai", row.provider) + assertEquals("gpt-4o", row.model) + assertEquals("", row.configId) + assertEquals(3.0, row.inputPricePerMillion!!, 0.0) + assertEquals(4.0, row.outputPricePerMillion!!, 0.0) + + // 删除 + manager.deletePriceOverride(PriceOverrideScope.PROVIDER_MODEL, "openai", "gpt-4o", null) + assertTrue(dao.getAllPriceOverrides().isEmpty()) + } + + @Test + fun `config scope override keeps configId and is independent from provider scope`() = runBlocking { + manager.upsertPriceOverride( + scope = PriceOverrideScope.CONFIG, + provider = "OPENAI", + model = "gpt-4o", + configId = " cfg-9 ", + billingMode = BillingMode.COUNT, + pricingCurrency = PricingCurrency.CNY, + inputPricePerMillion = null, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = null, + pricePerRequest = 0.01, + ) + val row = dao.getAllPriceOverrides().single() + assertEquals("cfg-9", row.configId) + assertEquals(PriceOverrideScope.CONFIG.name, row.scope) + assertEquals(BillingMode.COUNT.name, row.billingMode) + assertEquals(0.01, row.pricePerRequest!!, 0.0) + + // 删除时同样按规范化组合匹配 + manager.deletePriceOverride(PriceOverrideScope.CONFIG, "OPENAI", "gpt-4o", "cfg-9") + assertTrue(dao.getAllPriceOverrides().isEmpty()) + } + + @Test + fun `edit keeps business key unchanged and rejects key changes`() = runBlocking { + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + val existing = dao.getAllPriceOverrides().single() + + // 同业务键编辑(大小写/空白差异经规范化后一致)→ 更新成功,仍只有一行 + manager.updatePriceOverride( + existing, + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = " openai ", + model = "Gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 3.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 4.0, + pricePerRequest = null, + ), + ) + assertEquals(1, dao.getAllPriceOverrides().size) + assertEquals(3.0, dao.getAllPriceOverrides().single().inputPricePerMillion!!, 0.0) + + // 改 provider → 拒绝,行不变 + val keyChanged = + runCatching { + manager.updatePriceOverride( + existing, + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "anthropic", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 5.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 5.0, + pricePerRequest = null, + ), + ) + } + assertTrue("changed provider must be rejected", keyChanged.isFailure) + assertEquals(1, dao.getAllPriceOverrides().size) + + // 改 scope(CONFIG 且带新 configId)→ 拒绝,行不变 + val scopeChanged = + runCatching { + manager.updatePriceOverride( + existing, + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.CONFIG, + provider = "openai", + model = "gpt-4o", + configId = "cfg-2", + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 5.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 5.0, + pricePerRequest = null, + ), + ) + } + assertTrue("changed scope must be rejected", scopeChanged.isFailure) + assertEquals(1, dao.getAllPriceOverrides().size) + } + + @Test + fun `config scope with blank configId is rejected without writing`() = runBlocking { + val blank = runCatching { + manager.upsertPriceOverride( + scope = PriceOverrideScope.CONFIG, + provider = "OPENAI", + model = "gpt-4o", + configId = " ", + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + } + assertTrue("blank configId must be rejected", blank.isFailure) + assertEquals(0, dao.getAllPriceOverrides().size) + + // 编辑路径同样拒绝:CONFIG 空 configId 的草稿在规范化阶段即失败 + val nullConfig = runCatching { + manager.upsertPriceOverride( + scope = PriceOverrideScope.CONFIG, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 2.0, + pricePerRequest = null, + ) + } + assertTrue("null configId must be rejected", nullConfig.isFailure) + assertEquals(0, dao.getAllPriceOverrides().size) + } + + // ==== 分组 / 别名 ==== + + @Test + fun `moving identities to a group preserves events and never replaces identity rows`() = + runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-2", configId = "cfg-2", model = "gpt-4o") + seedEvent("id-1", "e1") + seedEvent("id-2", "e2") + + manager.createGroupAndMove("我的组", listOf("id-1", "id-2")) + val groupId = + dao.getAllDisplayModels() + .first { it.displayName == "我的组" && it.displayModelId.startsWith("custom-group-") } + .displayModelId + + // 身份只被 UPDATE:displayModelId 变更,identityId 不变 + val identities = dao.getAllIdentities().associateBy { it.identityId } + assertEquals(groupId, identities.getValue("id-1").displayModelId) + assertEquals(groupId, identities.getValue("id-2").displayModelId) + + // 事件未被级联删除(无 REPLACE) + assertEquals(2, dao.getAllEvents().size) + assertEquals(2, dao.getAllEvents().count { it.statIdentityId in setOf("id-1", "id-2") }) + } + + @Test + fun `rename only changes displayName and keeps identities attached`() = runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedEvent("id-1", "e1") + val defaultId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + + manager.renameDisplayGroup(defaultId, "GPT-4 主力") + val group = dao.getDisplayModel(defaultId) + assertNotNull(group) + assertEquals("GPT-4 主力", group!!.displayName) + assertEquals(defaultId, dao.getIdentity("id-1")!!.displayModelId) + assertEquals(1, dao.getAllEvents().size) + } + + @Test + fun `restore default groups moves each identity back to its own normalized model group`() = + runBlocking { + seedIdentity("id-a", model = "gpt-4o") + seedIdentity("id-b", configId = "cfg-2", model = "claude-3-5-sonnet") + seedEvent("id-a", "e1") + seedEvent("id-b", "e2") + + // 先把两个身份手动合并到 gpt-4o 组 + manager.moveIdentitiesToGroup( + listOf("id-a", "id-b"), + TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), + ) + manager.restoreDefaultGroups(TokenStatIdentityResolver.displayModelIdFor("gpt-4o")) + + val identities = dao.getAllIdentities().associateBy { it.identityId } + assertEquals( + TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), + identities.getValue("id-a").displayModelId, + ) + assertEquals( + TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet"), + identities.getValue("id-b").displayModelId, + ) + // 事件完整保留 + assertEquals(2, dao.getAllEvents().size) + } + + @Test + fun `move to existing group is idempotent and blank names are rejected`() = runBlocking { + seedIdentity("id-1", model = "gpt-4o") + val defaultId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + manager.moveIdentitiesToGroup(listOf("id-1"), defaultId) + manager.moveIdentitiesToGroup(listOf("id-1"), defaultId) + assertEquals(defaultId, dao.getIdentity("id-1")!!.displayModelId) + + val blankRename = runCatching { manager.renameDisplayGroup(defaultId, " ") } + assertTrue("blank display name must be rejected", blankRename.isFailure) + manager.renameDisplayGroup(defaultId, " 新名字 ") + assertEquals("新名字", dao.getDisplayModel(defaultId)!!.displayName) + + val emptyMerge = runCatching { manager.moveIdentitiesToGroup(emptyList(), defaultId) } + assertTrue("empty identity list must be rejected", emptyMerge.isFailure) + } + + // ==== P1 修复:分组元数据与统计筛选无关 ==== + + @Test + fun `group models expose complete membership even when only one member has events`() = + runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") + // 只有 A 有事件:范围明细只能看到 A,但分组操作必须拿到完整成员 + seedEvent("id-1", "e1") + manager.createGroupAndMove("组合", listOf("id-1", "id-2")) + val groupId = + dao.getAllDisplayModels() + .first { it.displayName == "组合" && it.displayModelId.startsWith("custom-group-") } + .displayModelId + + val groups = manager.groupModels() + val merged = groups.first { it.displayModelId == groupId } + assertEquals("组合", merged.displayName) + assertEquals(setOf("id-1", "id-2"), merged.memberIdentityIds.toSet()) + + // 默认分组(成员被移出后变空)仍在列表中:空组是合法合并目标 + val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + val emptyGroup = groups.first { it.displayModelId == gptId } + assertTrue(emptyGroup.memberIdentityIds.isEmpty()) + } + + @Test + fun `group models include groups without display row and keep fallback name`() = + runBlocking { + // 身份引用的 displayModelId 没有对应展示行:组名回退到 displayModelId + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = "id-3", + configId = "cfg-3", + provider = "ANTHROPIC", + model = "sonnet", + displayModelId = "orphan-group", + ) + ) + val groups = manager.groupModels() + val orphan = groups.first { it.displayModelId == "orphan-group" } + assertEquals("orphan-group", orphan.displayName) + assertEquals(listOf("id-3"), orphan.memberIdentityIds) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt new file mode 100644 index 000000000..39615f961 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt @@ -0,0 +1,70 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 图表纯逻辑测试(P1-8 无障碍模型 + P2 折线分段): + * 只测可抽出的纯函数,不触碰 Compose UI / 仪器测试。 + */ +class TokenStatsChartsTest { + + // ==== 无障碍上一/下一桶(P1-8,边界禁用) ==== + + @Test + fun `previous and next bucket indices respect bounds`() { + // 上一桶 + assertEquals(0, previousBucketIndex(1, 3)) + assertEquals(1, previousBucketIndex(2, 3)) + assertNull(previousBucketIndex(0, 3)) // 已在最前 → 禁用 + assertNull(previousBucketIndex(1, 1)) // 单桶 → 禁用 + assertNull(previousBucketIndex(0, 0)) // 无桶 → 禁用 + + // 下一桶 + assertEquals(1, nextBucketIndex(0, 3)) + assertEquals(2, nextBucketIndex(1, 3)) + assertNull(nextBucketIndex(2, 3)) // 已在最后 → 禁用 + assertNull(nextBucketIndex(0, 1)) // 单桶 → 禁用 + assertNull(nextBucketIndex(0, 0)) // 无桶 → 禁用 + } + + @Test + fun `chart accessibility description reads summary and rows`() { + // 无明细行:只读摘要 + assertEquals( + "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", + chartAccessibilityDescription("费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", emptyList()), + ) + // 有明细行:摘要 + 行 + assertEquals( + "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000:输出 ¥0.6000,输入 ¥0.4000", + chartAccessibilityDescription( + "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", + listOf("输出 ¥0.6000", "输入 ¥0.4000"), + ), + ) + } + + // ==== 折线分段(P2:连接相邻点,null 断段) ==== + + @Test + fun `line segments connect adjacent points instead of segment start`() { + val p0 = Offset(0f, 0f) + val p1 = Offset(10f, 10f) + val p2 = Offset(20f, 5f) + // 回归:旧实现会得到 [p0->p1, p0->p2],正确实现是 [p0->p1, p1->p2] + assertEquals(listOf(p0 to p1, p1 to p2), lineSegments(listOf(p0, p1, p2))) + } + + @Test + fun `line segments break at null points`() { + val p0 = Offset(0f, 0f) + val p1 = Offset(10f, 10f) + val p2 = Offset(20f, 5f) + assertEquals(listOf(p1 to p2), lineSegments(listOf(p0, null, p1, p2))) + // 全空 → 无线段 + assertEquals(emptyList>(), lineSegments(listOf(null, null))) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt new file mode 100644 index 000000000..e6c74845c --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt @@ -0,0 +1,75 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 自定义范围日期选择纯逻辑测试(P1-6): + * - DatePicker 毫秒按 **UTC 日历**解析日期(西时区不再回退一天); + * - 结束日期**包含当天**(+1 天 0 点作为半开区间终点),同日合法; + * - DST 自然日跨度为 23/25 小时由 java.time 日历运算保证。 + */ +class TokenStatsDatePickerTest { + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val newYork = ZoneId.of("America/New_York") + + private fun utcMidnightMs(date: String): Long = + LocalDate.parse(date).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toEpochMilli() + + @Test + fun `date picker millis parse as UTC calendar in east and west zones`() { + // 回归:西时区(New York)若用设备时区解析,UTC 8/7 0 点会被看成 + // 8/6 20:00 而回退一天;按 UTC 日历解析必须是 8/7 + assertEquals(LocalDate.of(2026, 8, 7), datePickerMillisToLocalDate(utcMidnightMs("2026-08-07"))) + // 东时区(Shanghai)同样按 UTC 日历解析 + assertEquals(LocalDate.of(2026, 8, 7), datePickerMillisToLocalDate(utcMidnightMs("2026-08-07"))) + } + + @Test + fun `inclusive end date makes same day selection a valid one day range`() { + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 8, 7), LocalDate.of(2026, 8, 7), shanghai) + assertEquals( + LocalDate.of(2026, 8, 7).atStartOfDay(shanghai).toInstant().toEpochMilli(), + range.startMs, + ) + assertEquals( + LocalDate.of(2026, 8, 8).atStartOfDay(shanghai).toInstant().toEpochMilli(), + range.endMs, + ) + assertEquals(TokenStatsTimeRanges.DAY_MS, range.durationMs) + } + + @Test + fun `cross day selection spans all selected days`() { + // 8/7 → 8/9(含结束日)= 3 个自然日:终点为 8/10 0 点 + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 8, 7), LocalDate.of(2026, 8, 9), newYork) + assertEquals(3L * TokenStatsTimeRanges.DAY_MS, range.durationMs) + assertEquals( + LocalDate.of(2026, 8, 10).atStartOfDay(newYork).toInstant().toEpochMilli(), + range.endMs, + ) + } + + @Test + fun `dst spring forward day is 23 hours`() { + // 美东 2026-03-08 春季拨快 1 小时:单日范围正好 23 小时 + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 3, 8), LocalDate.of(2026, 3, 8), newYork) + assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, range.durationMs) + } + + @Test + fun `end before start is rejected`() { + val failure = runCatching { + customRangeInclusiveEnd(LocalDate.of(2026, 8, 9), LocalDate.of(2026, 8, 7), shanghai) + } + assertTrue("end before start must be rejected", failure.isFailure) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt new file mode 100644 index 000000000..0a8e80538 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -0,0 +1,909 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver +import com.ai.assistance.operit.data.stats.TokenCostCurrency +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsCostMode +import com.ai.assistance.operit.data.stats.TokenStatsPreset +import com.ai.assistance.operit.data.stats.TokenStatsQueryService +import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection +import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import java.io.File +import java.time.ZoneId +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever + +/** + * 统计页 ViewModel 逻辑测试(阶段 4,JVM + 真实 Room + 内存假偏好存储): + * 首次自动回退与手选锁定、自定义范围校验、筛选刷新、设置保存与持久化、 + * 价格覆盖与分组操作后刷新。不触碰 DataStore/真机。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TokenUsageStatisticsViewModelTest { + + private lateinit var tempDir: File + private lateinit var database: AppDatabase + private lateinit var dao: TokenStatsDao + private lateinit var context: Context + private lateinit var settings: FakeSettingsStore + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val nowMs = localMs("2026-08-07T15:00:00", shanghai) + + private fun localMs(dateTime: String, zone: ZoneId): Long = + java.time.LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + tempDir = kotlin.io.path.createTempDirectory("token-vm-test").toFile() + context = mockContext(tempDir) + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .allowMainThreadQueries() + .build() + dao = database.tokenStatsDao() + settings = FakeSettingsStore() + TokenStatsQueryService.databaseProvider = { database } + TokenStatsQueryService.legacyPricesProvider = { emptyMap() } + TokenStatsQueryService.queryDispatcher = UnconfinedTestDispatcher() + } + + @After + fun tearDown() { + TokenStatsQueryService.databaseProvider = null + TokenStatsQueryService.legacyPricesProvider = null + TokenStatsQueryService.queryDispatcher = Dispatchers.IO + database.close() + Dispatchers.resetMain() + } + + private fun mockContext(filesDir: File): Context { + val context = Mockito.mock(Context::class.java) + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + private fun newViewModel(): TokenUsageStatisticsViewModel = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + // JVM 测试:Room 在后台线程恢复协程,非 Main 调度器 + 假文案, + // 避免 TestMainDispatcher/不可 mock 的 Context.getString + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + ) + + /** + * 等待异步查询落定:Room 在 arch 后台线程恢复协程,不能靠虚拟时间推进; + * 用 refreshVersion 单调递增判断“本次操作触发的查询已完成”。 + */ + private fun awaitRefresh(viewModel: TokenUsageStatisticsViewModel, fromVersion: Long) { + val deadline = System.currentTimeMillis() + 15_000 + while (viewModel.state.value.refreshVersion <= fromVersion) { + if (System.currentTimeMillis() > deadline) { + fail("timed out waiting for refresh (loading=${viewModel.state.value.loading})") + } + Thread.sleep(10) + } + } + + private fun awaitActionMessage(viewModel: TokenUsageStatisticsViewModel) { + val deadline = System.currentTimeMillis() + 15_000 + while (viewModel.actionMessage.value == null) { + if (System.currentTimeMillis() > deadline) { + fail("timed out waiting for action message") + } + Thread.sleep(10) + } + } + + private suspend fun seedIdentity( + identityId: String, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o", + ) { + val displayModelId = TokenStatIdentityResolver.displayModelIdFor(model) + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), + displayName = model, + ) + ) + } + + private fun event( + id: String, + identityId: String, + startedAtMs: Long, + status: String = TokenStatStatus.COMPLETED.name, + category: String = TokenStatCategory.CHAT.name, + cost: Double? = 0.01, + ): TokenStatEventEntity = + TokenStatEventEntity( + eventId = id, + statIdentityId = identityId, + category = category, + status = status, + acceptedGeneration = 0L, + startedAtMs = startedAtMs, + endedAtMs = startedAtMs + 1_000L, + firstTokenAtMs = startedAtMs + 200L, + uncachedInputTokens = 100L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = null, + outputTokens = 50L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.5, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 6.0, + pricePerRequest = null, + pricingSource = com.ai.assistance.operit.data.stats.PricingSource.DEFAULT.name, + costInPricingCurrency = cost, + diagnosticsJson = null, + ) + + // ==== 首次自动回退 ==== + + @Test + fun `initial fallback picks first preset with data and persists it as auto`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 + dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + assertEquals(TokenStatsPreset.LAST_7D, viewModel.state.value.selectedPreset) + assertFalse(viewModel.state.value.userChoseTime) + // 自动回退结果已持久化(manual=false):下次进入直接复用,不再探测(P1-2) + assertNotNull(settings.savedSelection) + assertEquals(TokenStatsPreset.LAST_7D, settings.savedSelection!!.preset) + assertFalse(settings.savedManual) + assertEquals(1, settings.timeSelectionSaveCount) + } + + @Test + fun `second viewmodel reuses persisted auto fallback without probing`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 + dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) + } + val vm1 = newViewModel() + awaitRefresh(vm1, 0) + assertEquals(TokenStatsPreset.LAST_7D, vm1.state.value.selectedPreset) + assertEquals(1, settings.timeSelectionSaveCount) + + // 清空事件:若第二个 VM 重新探测会回退到 5h(无任何数据);必须复用 + // 已保存的自动选择且不再次保存(P1-2 跨 VM 验证) + runBlocking { dao.deleteAllEvents() } + val vm2 = newViewModel() + awaitRefresh(vm2, 0) + assertEquals(TokenStatsPreset.LAST_7D, vm2.state.value.selectedPreset) + assertFalse(vm2.state.value.userChoseTime) + assertEquals(1, settings.timeSelectionSaveCount) + assertEquals(0L, vm2.state.value.range?.eventCount) + } + + @Test + fun `user selection locks time and disables auto fallback`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + // 数据在 30 小时前:5h/12h/24h 全空(自动回退会选 7d),但用户已选择 24h + dao.insertEvent(event("e1", "id-1", nowMs - 30 * 3600_000L)) + settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) + settings.savedManual = true + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + // 用户选择过 → 不自动回退 + assertEquals(TokenStatsPreset.LAST_24H, viewModel.state.value.selectedPreset) + assertTrue(viewModel.state.value.userChoseTime) + assertTrue(viewModel.state.value.range?.eventCount == 0L) + // 已有选择 → 本次 load 不再保存 + assertEquals(0, settings.timeSelectionSaveCount) + } + + // ==== 自定义范围 ==== + + @Test + fun `custom range rejects invalid and accepts valid bounds`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 2 * 3600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val start = nowMs - 24 * 3600_000L + + // end <= start → 拒绝,不改变选择 + val rejected = viewModel.setCustomRange(start, start) + assertFalse(rejected) + assertNotNull(viewModel.actionMessage.value) + assertEquals(TokenStatsPreset.LAST_5H, viewModel.state.value.selectedPreset) + + // 超过 3 年 → 拒绝 + val tooLong = + viewModel.setCustomRange( + start - TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS * 24 * 3600_000L - 1, + start, + ) + assertFalse(tooLong) + + // 合法范围 → 应用并查询 + val versionBefore = viewModel.state.value.refreshVersion + val accepted = viewModel.setCustomRange(start, nowMs) + assertTrue(accepted) + awaitRefresh(viewModel, versionBefore) + assertEquals(TokenStatsPreset.CUSTOM, viewModel.state.value.selectedPreset) + assertTrue(viewModel.state.value.userChoseTime) + assertNotNull(viewModel.state.value.customRange) + assertEquals(1L, viewModel.state.value.range?.eventCount) + // 持久化(manual=true:用户手动选择) + assertNotNull(settings.savedSelection) + assertEquals(TokenStatsPreset.CUSTOM, settings.savedSelection!!.preset) + assertTrue(settings.savedManual) + } + + // ==== 筛选刷新 ==== + + @Test + fun `model filter refreshes range data`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L), + event("e2", "id-2", nowMs - 3_600_000L), + ) + ) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + assertEquals(2, viewModel.state.value.range?.displayModels?.size) + + val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + val v1 = viewModel.state.value.refreshVersion + viewModel.toggleModel(gptId) + awaitRefresh(viewModel, v1) + val filtered = viewModel.state.value.range + assertEquals(1, filtered?.displayModels?.size) + assertEquals(gptId, filtered?.displayModels?.single()?.displayModelId) + assertEquals(1L, filtered?.eventCount) + + // 再点一次 → 全部 + val v2 = viewModel.state.value.refreshVersion + viewModel.toggleModel(gptId) + awaitRefresh(viewModel, v2) + assertEquals(2, viewModel.state.value.range?.displayModels?.size) + } + + @Test + fun `category and status filters refresh range data`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L), + event( + "e2", "id-1", nowMs - 2 * 3_600_000L, + category = TokenStatCategory.CONNECTION_TEST.name, + ), + event( + "e3", "id-1", nowMs - 3 * 3_600_000L, + status = TokenStatStatus.FAILED.name, + ), + ) + ) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + assertEquals(3L, viewModel.state.value.range?.eventCount) + + val v1 = viewModel.state.value.refreshVersion + viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) + awaitRefresh(viewModel, v1) + val byCategory = viewModel.state.value.range + assertEquals(1L, byCategory?.eventCount) + assertEquals( + setOf(TokenStatCategory.CONNECTION_TEST), + byCategory?.categories?.map { it.category }?.toSet(), + ) + + val v2 = viewModel.state.value.refreshVersion + viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) + awaitRefresh(viewModel, v2) + assertEquals(3L, viewModel.state.value.range?.eventCount) + + val v3 = viewModel.state.value.refreshVersion + viewModel.toggleStatus(TokenStatStatus.FAILED) + awaitRefresh(viewModel, v3) + assertEquals(1L, viewModel.state.value.range?.eventCount) + } + + // ==== P1-5:模型下拉选项不受模型筛选影响 ==== + + @Test + fun `model dropdown options stay complete after selecting a model`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L, category = TokenStatCategory.CHAT.name), + event( + "e2", "id-2", nowMs - 3_600_000L, + category = TokenStatCategory.CONNECTION_TEST.name, + ), + ) + ) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + val claudeId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") + assertEquals(2, viewModel.state.value.availableDisplayModels.size) + + // 选 A(gpt)后:结果只剩 A,但下拉选项仍含 B(P1-5) + val v1 = viewModel.state.value.refreshVersion + viewModel.toggleModel(gptId) + awaitRefresh(viewModel, v1) + assertEquals( + setOf(gptId), + viewModel.state.value.range?.displayModels?.map { it.displayModelId }?.toSet(), + ) + assertEquals( + setOf(gptId, claudeId), + viewModel.state.value.availableDisplayModels.map { it.displayModelId }.toSet(), + ) + + // 再选 B → AB 同时选中,结果恢复两个模型 + val v2 = viewModel.state.value.refreshVersion + viewModel.toggleModel(claudeId) + awaitRefresh(viewModel, v2) + assertEquals(setOf(gptId, claudeId), viewModel.state.value.selectedModels) + assertEquals(2, viewModel.state.value.range?.displayModels?.size) + + // B 被分类筛选出当前结果(也离开 available)后,仍保留在选项中可显示 + val v3 = viewModel.state.value.refreshVersion + viewModel.toggleCategory(TokenStatCategory.CHAT) + awaitRefresh(viewModel, v3) + val range = viewModel.state.value.range + assertEquals( + setOf(gptId), + range?.displayModels?.map { it.displayModelId }?.toSet(), + ) + assertFalse(viewModel.state.value.availableDisplayModels.any { it.displayModelId == claudeId }) + assertEquals("claude-3-5-sonnet", viewModel.state.value.knownModelNames[claudeId]) + assertTrue(claudeId in viewModel.state.value.selectedModels) + } + + // ==== P2:全选/清空只触发一次查询 ==== + + @Test + fun `clearing all categories or statuses triggers exactly one load`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L), + event( + "e2", "id-1", nowMs - 2 * 3_600_000L, + category = TokenStatCategory.CONNECTION_TEST.name, + ), + event( + "e3", "id-1", nowMs - 3 * 3_600_000L, + status = TokenStatStatus.FAILED.name, + ), + ) + ) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + // 先选中两个分类(各自一次查询),再一键清空:必须只再查询一次 + val v0 = viewModel.state.value.refreshVersion + viewModel.toggleCategory(TokenStatCategory.CHAT) + awaitRefresh(viewModel, v0) + val v1 = viewModel.state.value.refreshVersion + viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) + awaitRefresh(viewModel, v1) + assertEquals(2, viewModel.state.value.selectedCategories!!.size) + + val beforeClear = viewModel.state.value.refreshVersion + viewModel.clearCategories() + awaitRefresh(viewModel, beforeClear) + assertEquals(beforeClear + 1, viewModel.state.value.refreshVersion) + assertNull(viewModel.state.value.selectedCategories) + + // 状态同理 + val v2 = viewModel.state.value.refreshVersion + viewModel.toggleStatus(TokenStatStatus.COMPLETED) + awaitRefresh(viewModel, v2) + val v3 = viewModel.state.value.refreshVersion + viewModel.toggleStatus(TokenStatStatus.FAILED) + awaitRefresh(viewModel, v3) + assertEquals(2, viewModel.state.value.selectedStatuses!!.size) + + val beforeClearStatus = viewModel.state.value.refreshVersion + viewModel.clearStatuses() + awaitRefresh(viewModel, beforeClearStatus) + assertEquals(beforeClearStatus + 1, viewModel.state.value.refreshVersion) + assertNull(viewModel.state.value.selectedStatuses) + } + + // ==== P1-4:旧 load 不得污染共享 state ==== + + @Test + fun `stale load cannot overwrite newer load result`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val gated = GatedSettingsStore() + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = gated, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + ) + // 第一次 load 卡在偏好读取(构造期间已挂起,尚未写任何 state) + runBlocking { withTimeout(5_000) { gated.firstLoadStarted.await() } } + + // 第二次 load:汇率已改 → 完成后 state 必须是最新参数 + gated.savedRate = 7.5 + gated.rateEstimated = false + val v = vm.state.value.refreshVersion + vm.load() + awaitRefresh(vm, v) + assertEquals(7.5, vm.state.value.manualRate, 0.0) + assertFalse(vm.state.value.rateIsEstimated) + val versionAfterSecond = vm.state.value.refreshVersion + + // 释放 gate:旧 load 已被取消(Job cancel),不得再写 state + gated.gate.complete(Unit) + val deadline = System.currentTimeMillis() + 5_000 + while (vm.state.value.refreshVersion != versionAfterSecond) { + if (System.currentTimeMillis() > deadline) { + fail("stale load overwrote newer state") + } + Thread.sleep(10) + } + assertEquals(7.5, vm.state.value.manualRate, 0.0) + assertFalse(vm.state.value.rateIsEstimated) + assertFalse(vm.state.value.loading) + } + + // ==== P1-3:生命周期 ==== + + @Test + fun `viewmodel clear cancels pending load before it writes state`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val dispatcher = StandardTestDispatcher() + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = dispatcher, + ) + // load 已入队但未执行;ViewModelStore.clear() 触发 onCleared → + // viewModelScope 取消 → 任务不运行、不写 state、不执行首次回退持久化 + val store = androidx.lifecycle.ViewModelStore() + store.put("token-stats", vm) + store.clear() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(0L, vm.state.value.refreshVersion) + assertTrue(vm.state.value.loading) // 初始值,未被 load 改写 + assertEquals(0, settings.timeSelectionSaveCount) + } + + // ==== 设置保存 ==== + + @Test + fun `currency cost mode and rate changes persist and refresh`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + assertEquals(PricingCurrency.CNY, viewModel.state.value.targetCurrency) + assertTrue(viewModel.state.value.rateIsEstimated) + assertEquals(TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, viewModel.state.value.manualRate, 0.0) + + val v1 = viewModel.state.value.refreshVersion + viewModel.setTargetCurrency(PricingCurrency.USD) + awaitRefresh(viewModel, v1) + assertEquals(PricingCurrency.USD, viewModel.state.value.targetCurrency) + assertEquals(PricingCurrency.USD, settings.savedCurrency) + + val v2 = viewModel.state.value.refreshVersion + viewModel.setCostMode(TokenStatsCostMode.REVALUED) + awaitRefresh(viewModel, v2) + assertEquals(TokenStatsCostMode.REVALUED, viewModel.state.value.costMode) + assertEquals(TokenStatsCostMode.REVALUED, settings.savedMode) + + // 手动汇率:合法保存后不再标记估算 + val v3 = viewModel.state.value.refreshVersion + assertTrue(viewModel.setManualRate(7.35)) + awaitRefresh(viewModel, v3) + assertEquals(7.35, viewModel.state.value.manualRate, 0.0) + assertFalse(viewModel.state.value.rateIsEstimated) + assertEquals(7.35, settings.savedRate, 0.0) + + // 非法汇率:拒绝且不持久化 + assertFalse(viewModel.setManualRate(-1.0)) + assertEquals(7.35, settings.savedRate, 0.0) + } + + // ==== 价格覆盖与分组操作 ==== + + @Test + fun `price override save updates overrides and negative value fails with message`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + val v1 = viewModel.state.value.refreshVersion + viewModel.upsertPriceOverride( + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 8.0, + pricePerRequest = null, + ) + ) + awaitRefresh(viewModel, v1) + assertEquals(1, viewModel.state.value.overrides.size) + + viewModel.upsertPriceOverride( + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = -2.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 8.0, + pricePerRequest = null, + ) + ) + awaitActionMessage(viewModel) + assertTrue(viewModel.actionMessage.value!!.isError) + assertEquals(1, viewModel.state.value.overrides.size) + } + + @Test + fun `editing price override keeps business key and only updates values`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + val draft = + TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 2.0, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 8.0, + pricePerRequest = null, + ) + val v1 = viewModel.state.value.refreshVersion + viewModel.upsertPriceOverride(draft) + awaitRefresh(viewModel, v1) + val existing = viewModel.state.value.overrides.single() + + // 同键编辑 → 更新成功,仍只有一行(P1-7) + val v2 = viewModel.state.value.refreshVersion + viewModel.updatePriceOverride(existing, draft.copy(outputPricePerMillion = 9.0)) + awaitRefresh(viewModel, v2) + assertEquals(1, viewModel.state.value.overrides.size) + assertEquals(9.0, viewModel.state.value.overrides.single().outputPricePerMillion!!, 0.0) + + // 改业务键 → manager 拒绝:行不变 + 错误消息(P1-7) + val v3 = viewModel.state.value.refreshVersion + viewModel.updatePriceOverride(existing, draft.copy(provider = "ANTHROPIC")) + awaitActionMessage(viewModel) + assertTrue(viewModel.actionMessage.value!!.isError) + assertEquals(1, viewModel.state.value.overrides.size) + assertEquals("openai", viewModel.state.value.overrides.single().provider) + assertEquals(v3, viewModel.state.value.refreshVersion) + } + + @Test + fun `group rename and create reflect in range display models`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1") + seedIdentity("id-2", configId = "cfg-2") + dao.insertEvents( + listOf( + event("e1", "id-1", nowMs - 3_600_000L), + event("e2", "id-2", nowMs - 3_600_000L), + ) + ) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + + val v1 = viewModel.state.value.refreshVersion + viewModel.renameDisplayGroup(gptId, "GPT-4 主力") + awaitRefresh(viewModel, v1) + assertEquals("GPT-4 主力", viewModel.state.value.range?.displayModels?.single()?.displayName) + + val v2 = viewModel.state.value.refreshVersion + viewModel.createGroupAndMerge("组合模型", listOf("id-1", "id-2")) + awaitRefresh(viewModel, v2) + val models = viewModel.state.value.range?.displayModels.orEmpty() + assertEquals(1, models.size) + assertEquals("组合模型", models.single().displayName) + assertEquals(2, models.single().identities.size) + } + + // ==== P1 修复:分组元数据与统计筛选无关 ==== + + @Test + fun `group metadata stays complete when range only shows one member`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") + // 只有 A 在 5h 预设范围内有事件;B 的事件在 8 天前(不在范围) + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + dao.insertEvent(event("e2", "id-2", nowMs - 8L * 24 * 3600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + val claudeId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") + + // 范围明细(筛选结果)只含当前范围有事件的身份/分组:只看到 A + assertEquals(1, viewModel.state.value.range?.displayModels?.size) + assertEquals(gptId, viewModel.state.value.range?.displayModels?.single()?.displayModelId) + + // 完整分组元数据含两个分组与各自的完整成员(不受事件/筛选影响) + val groups = viewModel.state.value.groupModels.associateBy { it.displayModelId } + assertEquals(setOf(gptId, claudeId), groups.keys) + assertEquals(listOf("id-1"), groups.getValue(gptId).memberIdentityIds) + assertEquals(listOf("id-2"), groups.getValue(claudeId).memberIdentityIds) + + // 对话框按完整成员 id 执行合并(UI 从 groupModels 取):两个身份都被移动 + val v1 = viewModel.state.value.refreshVersion + viewModel.createGroupAndMerge("组合", listOf("id-1", "id-2")) + awaitRefresh(viewModel, v1) + val groupId = kotlinx.coroutines.runBlocking { + dao.getAllDisplayModels().first { it.displayName == "组合" }.displayModelId + } + assertEquals( + setOf(groupId), + kotlinx.coroutines.runBlocking { + dao.getAllIdentities().map { it.displayModelId }.toSet() + }, + ) + // 刷新后的元数据同步:新组合组含完整成员;原默认组行保留但已无成员 + // (空组仍是合法目标,见 manager 语义) + val groupsAfter = viewModel.state.value.groupModels + assertEquals( + setOf("id-1", "id-2"), + groupsAfter.first { it.displayModelId == groupId }.memberIdentityIds.toSet(), + ) + assertTrue( + groupsAfter.filter { it.displayModelId != groupId } + .all { it.memberIdentityIds.isEmpty() } + ) + } + + @Test + fun `group without events in range is still available as merge target`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", model = "gpt-4o") + seedIdentity("id-3", configId = "cfg-3", model = "gemini-2.0-flash") + // 只有 gpt 有事件;gemini 组完全无事件 + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val geminiId = TokenStatIdentityResolver.displayModelIdFor("gemini-2.0-flash") + + // 范围明细看不到 gemini(无事件),但完整元数据里有 → 对话框可列为目标 + assertFalse( + viewModel.state.value.range?.displayModels?.any { it.displayModelId == geminiId } + ?: true + ) + assertTrue(viewModel.state.value.groupModels.any { it.displayModelId == geminiId }) + + // 把 A 合并进无事件的目标组:操作成功,归属变更 + val v1 = viewModel.state.value.refreshVersion + viewModel.mergeIntoGroup(listOf("id-1"), geminiId) + awaitRefresh(viewModel, v1) + assertEquals( + geminiId, + kotlinx.coroutines.runBlocking { dao.getIdentity("id-1")!!.displayModelId }, + ) + } +} + +/** 内存假实现:验证持久化调用与首次回退语义(无用户选择 = null)。 */ +private class FakeSettingsStore : TokenStatsSettingsStore { + var savedRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE + var rateEstimated: Boolean = true + var savedCurrency: PricingCurrency = PricingCurrency.CNY + var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL + var savedSelection: TokenStatsTimeSelection? = null + var savedManual: Boolean = false + /** saveTimeSelection 调用次数(P1-2:第二个 VM 不得再次保存/探测)。 */ + var timeSelectionSaveCount: Int = 0 + + override suspend fun loadRateWithEstimate(): Pair = + savedRate to rateEstimated + + override suspend fun saveRate(rate: Double) { + savedRate = rate + rateEstimated = false + } + + override suspend fun loadTargetCurrency(): PricingCurrency = savedCurrency + + override suspend fun saveTargetCurrency(currency: PricingCurrency) { + savedCurrency = currency + } + + override suspend fun loadCostMode(): TokenStatsCostMode = savedMode + + override suspend fun saveCostMode(mode: TokenStatsCostMode) { + savedMode = mode + } + + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection + + override suspend fun loadSelectionWasManual(): Boolean = savedManual + + override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { + savedSelection = selection + savedManual = manual + timeSelectionSaveCount++ + } +} + +/** + * 可控制挂起的偏好存储(P1-4):第一次 [loadRateWithEstimate] 挂起在 [gate] 上 + * (期间不写任何 state),用于验证旧 load 被取消后不得覆盖新 load 的结果。 + */ +private class GatedSettingsStore : TokenStatsSettingsStore { + var savedRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE + var rateEstimated: Boolean = true + var savedCurrency: PricingCurrency = PricingCurrency.CNY + var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL + var savedSelection: TokenStatsTimeSelection? = null + var savedManual: Boolean = false + val firstLoadStarted = CompletableDeferred() + val gate = CompletableDeferred() + private var rateReads = 0 + + override suspend fun loadRateWithEstimate(): Pair { + rateReads++ + if (rateReads == 1) { + firstLoadStarted.complete(Unit) + gate.await() + } + return savedRate to rateEstimated + } + + override suspend fun saveRate(rate: Double) { + savedRate = rate + rateEstimated = false + } + + override suspend fun loadTargetCurrency(): PricingCurrency = savedCurrency + + override suspend fun saveTargetCurrency(currency: PricingCurrency) { + savedCurrency = currency + } + + override suspend fun loadCostMode(): TokenStatsCostMode = savedMode + + override suspend fun saveCostMode(mode: TokenStatsCostMode) { + savedMode = mode + } + + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection + + override suspend fun loadSelectionWasManual(): Boolean = savedManual + + override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { + savedSelection = selection + savedManual = manual + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt new file mode 100644 index 000000000..a7eeb11cd --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt @@ -0,0 +1,417 @@ +package com.ai.assistance.operit.ui.main.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewModelScope +import com.ai.assistance.operit.ui.main.screens.Screen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * 路由级 ViewModelStore 管理测试(阶段 4 P1,纯 JVM,无仪器): + * - 配置变化不 pop:同一 manager(Activity VM 保留)复用同一 owner/VM; + * - pop(路由出栈):remove 触发 onCleared 与 viewModelScope 取消; + * - 两个 screenKey 互不影响; + * - replace/clear stack:retainOnly 只保留存活键; + * - Activity 销毁:clearAll 全清; + * - 真实导航栈(AppRouterState push/pop/resetTo)驱动的 alive 键与清理; + * - AppContent 首次组合(attach)只同步一次(LaunchedEffect(Unit)):pop 后 + * 转场完成前不得清理仍渲染的离页 owner,只在转场完成分支 retainOnly 后清理。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ScreenRouteViewModelStoreOwnerManagerTest { + + @Before + fun setUp() { + // viewModelScope 使用 Main.immediate;Unconfined 保证取消回调同步执行 + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** 记录 onCleared 与 viewModelScope 取消的跟踪 VM(须可被 ViewModelProvider 反射实例化)。 */ + class TrackableViewModel : ViewModel() { + var onClearedCalled = false + var scopeCancelled = false + + init { + viewModelScope.launch { + try { + awaitCancellation() + } finally { + scopeCancelled = true + } + } + } + + override fun onCleared() { + onClearedCalled = true + } + } + + private fun trackableVM(owner: ViewModelStoreOwner): TrackableViewModel = + ViewModelProvider(owner)[TrackableViewModel::class.java] + + private fun awaitScopeCancelled(vm: TrackableViewModel) { + val deadline = System.currentTimeMillis() + 5_000 + while (!vm.scopeCancelled) { + if (System.currentTimeMillis() > deadline) { + fail("timed out waiting for viewModelScope cancellation") + } + Thread.sleep(10) + } + } + + // ==== 配置变化:不 pop 复用同 owner/VM ==== + + @Test + fun configurationChange_withoutPop_reusesSameOwnerAndViewModel() { + val manager = ScreenRouteViewModelStoreOwnerManager() + + val ownerFirst = manager.ownerFor("stats-key") + val vmFirst = trackableVM(ownerFirst) + + // 模拟配置变化:同一 manager 实例(Activity VM 保留),同 screenKey → 同 owner + val ownerSecond = manager.ownerFor("stats-key") + assertSame(ownerFirst, ownerSecond) + // 同 owner → 同 store → 同 VM 实例 + assertSame(vmFirst, trackableVM(ownerSecond)) + + assertFalse(vmFirst.onClearedCalled) + assertFalse(vmFirst.scopeCancelled) + } + + // ==== pop:remove → onCleared + viewModelScope 取消 ==== + + @Test + fun pop_removeOwner_clearsViewModelAndCancelsScope() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val owner = manager.ownerFor("stats-key") + val vm = trackableVM(owner) + + manager.remove("stats-key") + + assertTrue(vm.onClearedCalled) + awaitScopeCancelled(vm) + + // 再次进入同路由:新 owner + 新 VM(旧 store 已清理) + val newOwner = manager.ownerFor("stats-key") + assertNotSame(owner, newOwner) + assertNotSame(vm, trackableVM(newOwner)) + } + + @Test + fun remove_unknownKey_isNoOp() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vm = trackableVM(manager.ownerFor("stats-key")) + + manager.remove("no-such-key") + + assertFalse(vm.onClearedCalled) + assertFalse(vm.scopeCancelled) + } + + // ==== 两个 screenKey 互不影响 ==== + + @Test + fun twoKeys_areIndependent() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmStats = trackableVM(manager.ownerFor("stats-key")) + val vmOther = trackableVM(manager.ownerFor("other-key")) + + manager.remove("stats-key") + + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmOther.onClearedCalled) + assertFalse(vmOther.scopeCancelled) + } + + // ==== replace / clear stack:retainOnly ==== + + @Test + fun retainOnly_removesNonAliveKeys_keepsAliveKeys() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmStats = trackableVM(manager.ownerFor("stats-key")) + val vmHome = trackableVM(manager.ownerFor("home-key")) + + // 抽屉导航 resetTo:栈只剩 home + manager.retainOnly(setOf("home-key")) + + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } + + // ==== Activity 销毁:clearAll 全清 ==== + + @Test + fun clearAll_clearsEveryOwner() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmA = trackableVM(manager.ownerFor("key-a")) + val vmB = trackableVM(manager.ownerFor("key-b")) + + manager.clearAll() + + assertTrue(vmA.onClearedCalled) + assertTrue(vmB.onClearedCalled) + awaitScopeCancelled(vmA) + awaitScopeCancelled(vmB) + + // 全清后同键再进:新 owner(旧实例不再复用) + assertNotSame(vmA, trackableVM(manager.ownerFor("key-a"))) + } + + // ==== 真实导航栈驱动(AppRouterState push/pop/resetTo)==== + + private val resolveScreen: (RouteEntry) -> Screen? = { entry -> + when (entry.routeId) { + "home" -> Screen.AiChat + "settings" -> Screen.AiChat + "stats" -> Screen.AiChat + "other" -> Screen.AiChat + else -> null + } + } + + @Test + fun realNavigationStack_drivesRouteViewModelCleanup() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + // 模拟 AppContent 转场完成时的同步调用 + fun syncAlive() = manager.retainOnly(screenKeysAliveOnStack(router.backStack, resolveScreen)) + + val homeKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val homeVm = trackableVM(manager.ownerFor(homeKey)) + syncAlive() + assertFalse(homeVm.onClearedCalled) + + // 推入 stats(Settings → TokenUsageStatistics) + router.navigate(routeId = "stats") + val statsKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val statsVm = trackableVM(manager.ownerFor(statsKey)) + syncAlive() + assertFalse(homeVm.onClearedCalled) + assertFalse(statsVm.onClearedCalled) + + // pop(返回):stats 离开栈 → 清理(离页查询不再保留) + router.pop() + syncAlive() + assertTrue(statsVm.onClearedCalled) + awaitScopeCancelled(statsVm) + assertFalse(homeVm.onClearedCalled) + + // 再次进入 stats:新路由实例 → 新 screenKey → 新 owner/VM + router.navigate(routeId = "stats") + val statsKey2 = routeScreenKey(router.currentEntry, resolveScreen)!! + assertNotEquals(statsKey, statsKey2) + val statsVm2 = trackableVM(manager.ownerFor(statsKey2)) + assertNotSame(statsVm, statsVm2) + + // resetTo(replace/clear stack,如抽屉导航):旧栈全部清理 + router.resetTo(RouteEntry(routeId = "home")) + syncAlive() + assertTrue(statsVm2.onClearedCalled) + awaitScopeCancelled(statsVm2) + assertTrue(homeVm.onClearedCalled) + // 新 home 实例存活 + val newHomeKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val newHomeVm = trackableVM(manager.ownerFor(newHomeKey)) + assertFalse(newHomeVm.onClearedCalled) + } + + // ==== keepAlive 路由:stableScreenKey 复用 ==== + + @Test + fun keepAliveRoute_usesStableScreenKeyAndReusesOwner() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val resolveKeepAlive: (RouteEntry) -> Screen? = { + Screen.ToolPkgComposeDsl( + containerPackageName = "pkg", + uiModuleId = "mod", + title = "t", + keepAlive = true, + ) + } + + val entry1 = RouteEntry(routeId = "toolpkg") + val key1 = routeScreenKey(entry1, resolveKeepAlive)!! + assertEquals("toolpkg_keepalive:pkg:mod", key1) + val vm = trackableVM(manager.ownerFor(key1)) + + // 同 routeId 再次进入:stableScreenKey 相同 → 复用同一 owner/VM + val entry2 = RouteEntry(routeId = "toolpkg") + val key2 = routeScreenKey(entry2, resolveKeepAlive)!! + assertEquals(key1, key2) + assertSame(vm, trackableVM(manager.ownerFor(key2))) + assertFalse(vm.onClearedCalled) + } + + // ==== AppContent 重建(配置变化/跨 600dp):attach 同步 = alive + current ==== + // attach 同步只在首次组合执行一次(LaunchedEffect(Unit)):pop 后 alive + // 立即更新,但退出动画未完成、离页仍在渲染,不得触发 retainOnly; + // 清理只发生在模拟的转场完成分支 retainOnly(aliveRouteKeys()) 之后。 + + @Test + fun retainedRouteKeysOnContentAttach_unionsCurrentWithAliveAndDeduplicates() { + // 当前页不在 alive 中:并入 alive + assertEquals( + setOf("stats-a", "other", "stats-b", "current"), + retainedRouteKeysOnContentAttach( + currentScreenKey = "current", + aliveScreenKeys = setOf("stats-a", "other", "stats-b") + ) + ) + // 当前页已在 alive 中:不重复,结果不变 + assertEquals( + setOf("stats-a", "other"), + retainedRouteKeysOnContentAttach( + currentScreenKey = "stats-a", + aliveScreenKeys = setOf("stats-a", "other") + ) + ) + // alive 为空(栈解析未就绪)时仍保留当前页 + assertEquals( + setOf("current"), + retainedRouteKeysOnContentAttach(currentScreenKey = "current", aliveScreenKeys = emptySet()) + ) + } + + @Test + fun appContentRecreation_attachSyncOnce_preservesFullStack_popClearsOnlyAfterTransitionCompleted() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + // 模拟 AppContent 首次组合(attach)时的单次同步:alive + 当前键 + // (对应 LaunchedEffect(Unit):只在组合进入时执行,不随导航变化重启) + fun attachSync() { + manager.retainOnly( + retainedRouteKeysOnContentAttach( + currentScreenKey = routeScreenKey(router.currentEntry, resolveScreen)!!, + aliveScreenKeys = screenKeysAliveOnStack(router.backStack, resolveScreen) + ) + ) + } + // 模拟 AppContent 转场完成分支的清理:alive = 导航栈 + keepAlive 缓存 + + // 当前键;本测试路由均非 keepAlive,缓存部分为空 + fun transitionCleanup() { + manager.retainOnly( + screenKeysAliveOnStack(router.backStack, resolveScreen) + + routeScreenKey(router.currentEntry, resolveScreen)!! + ) + } + + // 两个不同 TokenStats 路由实例(中间夹 other)同栈: + // 跨 600dp 重建前旧组合已访问过 backStack [home, statsA, other, statsB] + router.navigate(routeId = "stats") + router.navigate(routeId = "other") + router.navigate(routeId = "stats") + val homeKey = routeScreenKey(router.backStack[0], resolveScreen)!! + val statsAKey = routeScreenKey(router.backStack[1], resolveScreen)!! + val otherKey = routeScreenKey(router.backStack[2], resolveScreen)!! + val statsBKey = routeScreenKey(router.backStack[3], resolveScreen)!! + + // 重建前旧组合已为这些路由创建 owner/VM(manager 是 Activity 级,跨重建保留) + val vmHome = trackableVM(manager.ownerFor(homeKey)) + val vmStatsA = trackableVM(manager.ownerFor(statsAKey)) + val vmOther = trackableVM(manager.ownerFor(otherKey)) + val vmStatsB = trackableVM(manager.ownerFor(statsBKey)) + + // 首次组合 attach 只同步一次:保留 alive + 当前键 → 全部存活 + // (回归:旧逻辑只保留当前键会误清 backStack 其他 opt-in owner) + attachSync() + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + assertFalse(vmStatsB.onClearedCalled) + + // pop statsB:alive 已更新但退出动画未完成,不得调用 retainOnly → + // statsB 仍在渲染,owner/VM 必须存活(P1 回归:attach 若按 alive + // 变化重启会在此立即清理 statsB) + router.pop() + assertFalse(vmStatsB.onClearedCalled) + assertFalse(vmStatsB.scopeCancelled) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + + // 调用转场完成清理后:仅 statsB 清理,其余存活 + transitionCleanup() + assertTrue(vmStatsB.onClearedCalled) + awaitScopeCancelled(vmStatsB) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + + // pop other:转场完成前不清理,转场完成后才清理 + router.pop() + assertFalse(vmOther.onClearedCalled) + assertFalse(vmOther.scopeCancelled) + transitionCleanup() + assertTrue(vmOther.onClearedCalled) + awaitScopeCancelled(vmOther) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + + // pop statsA:逐次同前,home 始终存活 + router.pop() + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmStatsA.scopeCancelled) + transitionCleanup() + assertTrue(vmStatsA.onClearedCalled) + awaitScopeCancelled(vmStatsA) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } + + @Test + fun layoutSwitch_newAppContentAttach_usesNewAliveKeys() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + router.navigate(routeId = "stats") + val homeKey = routeScreenKey(router.backStack[0], resolveScreen)!! + val statsKey = routeScreenKey(router.backStack[1], resolveScreen)!! + val vmHome = trackableVM(manager.ownerFor(homeKey)) + val vmStats = trackableVM(manager.ownerFor(statsKey)) + + // 旧组合(如 Phone 布局)attach:保留 [home, stats] + 当前键 + manager.retainOnly(retainedRouteKeysOnContentAttach(statsKey, setOf(homeKey, statsKey))) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStats.onClearedCalled) + + // pop stats 后转场未完成时发生 Phone → Tablet 切换:旧 AppContent + // 销毁,新 AppContent attach 重新执行(LaunchedEffect(Unit))并使用 + // 新传入的 alive(pop 后的栈 [home] + 当前键)。stats 在新组合中不再 + // 渲染(screenCache/keepAlive 缓存已重置),attach 同步即清理其 owner。 + router.pop() + manager.retainOnly( + retainedRouteKeysOnContentAttach( + currentScreenKey = routeScreenKey(router.currentEntry, resolveScreen)!!, + aliveScreenKeys = screenKeysAliveOnStack(router.backStack, resolveScreen) + ) + ) + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } +} From 50259f7f6731b0b21df7a41969dcea70ee42b848 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:11:52 +0800 Subject: [PATCH 05/28] feat(stats): add safe statistics deletion (cherry picked from commit 9b614d1050cef9c3bdc4c80ca66b9bd2299840ec) --- .../31.json | 1413 +++++++++++++++++ .../llmprovider/TokenTrackingAIService.kt | 12 +- .../operit/data/dao/TokenStatsDao.kt | 353 +++- .../assistance/operit/data/db/AppDatabase.kt | 64 + .../data/model/TokenStatCleanupItemEntity.kt | 34 + .../model/TokenStatCleanupOperationEntity.kt | 49 + .../data/model/TokenStatRangeCutoffEntity.kt | 25 + .../data/model/TokenStatResetCutoffEntity.kt | 7 + .../operit/data/preferences/ApiPreferences.kt | 180 ++- .../data/stats/TokenBaselineImportRunner.kt | 130 +- .../operit/data/stats/TokenStatsLedger.kt | 67 +- .../tokenstats/TokenStatsComponents.kt | 34 +- .../tokenstats/TokenUsageStatisticsScreen.kt | 186 ++- .../TokenUsageStatisticsViewModel.kt | 129 +- app/src/main/res/values-en/strings.xml | 18 +- app/src/main/res/values/strings.xml | 18 +- .../llmprovider/TokenTrackingAIServiceTest.kt | 81 + .../ApiPreferencesResetFailureTest.kt | 17 + .../stats/TokenBaselineImportRunnerTest.kt | 220 ++- .../data/stats/TokenStatsCleanupOutboxTest.kt | 532 +++++++ .../data/stats/TokenStatsDeletionTest.kt | 628 ++++++++ .../stats/TokenStatsQueryServiceRoomTest.kt | 2 +- .../data/stats/TokenStatsRoomMigrationTest.kt | 188 ++- .../stats/TokenStatsSettingsManagerTest.kt | 2 +- .../TokenUsageStatisticsViewModelTest.kt | 339 +++- 25 files changed, 4455 insertions(+), 273 deletions(-) create mode 100644 app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt diff --git a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json new file mode 100644 index 000000000..399795e78 --- /dev/null +++ b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json @@ -0,0 +1,1413 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "f518d4399dae2579dd24e4ddc77d0517", + "entities": [ + { + "tableName": "chats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `currentWindowSize` INTEGER NOT NULL, `group` TEXT, `folderId` TEXT, `displayOrder` INTEGER NOT NULL, `workspace` TEXT, `workspaceEnv` TEXT, `parentChatId` TEXT, `chatKind` TEXT NOT NULL DEFAULT 'NORMAL', `characterCardName` TEXT, `characterGroupId` TEXT, `locked` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastMessageAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`folderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentWindowSize", + "columnName": "currentWindowSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "group", + "columnName": "group", + "affinity": "TEXT" + }, + { + "fieldPath": "folderId", + "columnName": "folderId", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workspace", + "columnName": "workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "workspaceEnv", + "columnName": "workspaceEnv", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatId", + "columnName": "parentChatId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatKind", + "columnName": "chatKind", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'NORMAL'" + }, + { + "fieldPath": "characterCardName", + "columnName": "characterCardName", + "affinity": "TEXT" + }, + { + "fieldPath": "characterGroupId", + "columnName": "characterGroupId", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageAt", + "columnName": "lastMessageAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chats_folderId", + "unique": false, + "columnNames": [ + "folderId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_folderId` ON `${TABLE_NAME}` (`folderId`)" + }, + { + "name": "index_chats_chatKind", + "unique": false, + "columnNames": [ + "chatKind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_chatKind` ON `${TABLE_NAME}` (`chatKind`)" + }, + { + "name": "index_chats_parentChatId_chatKind", + "unique": false, + "columnNames": [ + "parentChatId", + "chatKind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_parentChatId_chatKind` ON `${TABLE_NAME}` (`parentChatId`, `chatKind`)" + } + ], + "foreignKeys": [ + { + "table": "chat_folders", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "folderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "chat_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `parentFolderId` TEXT, `displayOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentFolderId", + "columnName": "parentFolderId", + "affinity": "TEXT" + }, + { + "fieldPath": "displayOrder", + "columnName": "displayOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_chat_folders_parentFolderId_displayOrder", + "unique": false, + "columnNames": [ + "parentFolderId", + "displayOrder" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_folders_parentFolderId_displayOrder` ON `${TABLE_NAME}` (`parentFolderId`, `displayOrder`)" + } + ], + "foreignKeys": [ + { + "table": "chat_folders", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parentFolderId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `sender` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `roleName` TEXT NOT NULL, `selectedVariantIndex` INTEGER NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "roleName", + "columnName": "roleName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectedVariantIndex", + "columnName": "selectedVariantIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelName", + "columnName": "modelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sentAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputDurationMs", + "columnName": "outputDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "waitDurationMs", + "columnName": "waitDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayMode", + "columnName": "displayMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "isFavorite", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "messageId" + ] + }, + "indices": [ + { + "name": "index_messages_chatId", + "unique": false, + "columnNames": [ + "chatId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `${TABLE_NAME}` (`chatId`)" + }, + { + "name": "index_messages_chatId_timestamp", + "unique": false, + "columnNames": [ + "chatId", + "timestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` ON `${TABLE_NAME}` (`chatId`, `timestamp`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "message_variants", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `messageTimestamp` INTEGER NOT NULL, `variantIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, `roleName` TEXT NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "variantId", + "columnName": "variantId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatId", + "columnName": "chatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageTimestamp", + "columnName": "messageTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "variantIndex", + "columnName": "variantIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "roleName", + "columnName": "roleName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modelName", + "columnName": "modelName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sentAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputDurationMs", + "columnName": "outputDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "waitDurationMs", + "columnName": "waitDurationMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "variantId" + ] + }, + "indices": [ + { + "name": "index_message_variants_chatId_messageTimestamp", + "unique": false, + "columnNames": [ + "chatId", + "messageTimestamp" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`)" + }, + { + "name": "index_message_variants_chatId_messageTimestamp_variantIndex", + "unique": true, + "columnNames": [ + "chatId", + "messageTimestamp", + "variantIndex" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp_variantIndex` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`, `variantIndex`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "subagent_runs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentChatId` TEXT NOT NULL, `childChatId` TEXT NOT NULL, `parentToolCallId` TEXT, `agentProfileId` TEXT NOT NULL, `title` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `startedAt` INTEGER, `completedAt` INTEGER, `error` TEXT, `agentConfigSnapshot` TEXT, `modelConfigIdSnapshot` TEXT, `modelIndexSnapshot` INTEGER, `toolInvocationCount` INTEGER NOT NULL, `archivedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`parentChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`childChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatId", + "columnName": "parentChatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childChatId", + "columnName": "childChatId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentToolCallId", + "columnName": "parentToolCallId", + "affinity": "TEXT" + }, + { + "fieldPath": "agentProfileId", + "columnName": "agentProfileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "completedAt", + "columnName": "completedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT" + }, + { + "fieldPath": "agentConfigSnapshot", + "columnName": "agentConfigSnapshot", + "affinity": "TEXT" + }, + { + "fieldPath": "modelConfigIdSnapshot", + "columnName": "modelConfigIdSnapshot", + "affinity": "TEXT" + }, + { + "fieldPath": "modelIndexSnapshot", + "columnName": "modelIndexSnapshot", + "affinity": "INTEGER" + }, + { + "fieldPath": "toolInvocationCount", + "columnName": "toolInvocationCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archivedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_subagent_runs_parentChatId", + "unique": false, + "columnNames": [ + "parentChatId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId` ON `${TABLE_NAME}` (`parentChatId`)" + }, + { + "name": "index_subagent_runs_childChatId", + "unique": true, + "columnNames": [ + "childChatId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subagent_runs_childChatId` ON `${TABLE_NAME}` (`childChatId`)" + }, + { + "name": "index_subagent_runs_parentChatId_parentToolCallId", + "unique": false, + "columnNames": [ + "parentChatId", + "parentToolCallId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId_parentToolCallId` ON `${TABLE_NAME}` (`parentChatId`, `parentToolCallId`)" + }, + { + "name": "index_subagent_runs_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "chats", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "parentChatId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "chats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "childChatId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_stat_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `statIdentityId` TEXT NOT NULL, `category` TEXT NOT NULL, `status` TEXT NOT NULL, `acceptedGeneration` INTEGER NOT NULL, `startedAtMs` INTEGER NOT NULL, `endedAtMs` INTEGER NOT NULL, `firstTokenAtMs` INTEGER, `uncachedInputTokens` INTEGER, `cachedInputTokens` INTEGER, `cacheWriteTokens` INTEGER, `totalInputTokens` INTEGER, `outputTokens` INTEGER, `reasoningTokens` INTEGER, `reasoningIncludedInOutput` INTEGER, `cacheWriteSeparateBilling` INTEGER, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL, `pricingSource` TEXT NOT NULL, `costInPricingCurrency` REAL, `diagnosticsJson` TEXT, PRIMARY KEY(`eventId`), FOREIGN KEY(`statIdentityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "statIdentityId", + "columnName": "statIdentityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acceptedGeneration", + "columnName": "acceptedGeneration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startedAtMs", + "columnName": "startedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAtMs", + "columnName": "endedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstTokenAtMs", + "columnName": "firstTokenAtMs", + "affinity": "INTEGER" + }, + { + "fieldPath": "uncachedInputTokens", + "columnName": "uncachedInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "cacheWriteTokens", + "columnName": "cacheWriteTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalInputTokens", + "columnName": "totalInputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "reasoningTokens", + "columnName": "reasoningTokens", + "affinity": "INTEGER" + }, + { + "fieldPath": "reasoningIncludedInOutput", + "columnName": "reasoningIncludedInOutput", + "affinity": "INTEGER" + }, + { + "fieldPath": "cacheWriteSeparateBilling", + "columnName": "cacheWriteSeparateBilling", + "affinity": "INTEGER" + }, + { + "fieldPath": "billingMode", + "columnName": "billingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputPricePerMillion", + "columnName": "inputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cachedInputPricePerMillion", + "columnName": "cachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cacheWritePricePerMillion", + "columnName": "cacheWritePricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "outputPricePerMillion", + "columnName": "outputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "pricePerRequest", + "columnName": "pricePerRequest", + "affinity": "REAL" + }, + { + "fieldPath": "pricingSource", + "columnName": "pricingSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "costInPricingCurrency", + "columnName": "costInPricingCurrency", + "affinity": "REAL" + }, + { + "fieldPath": "diagnosticsJson", + "columnName": "diagnosticsJson", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "eventId" + ] + }, + "indices": [ + { + "name": "index_token_stat_events_statIdentityId_startedAtMs", + "unique": false, + "columnNames": [ + "statIdentityId", + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_statIdentityId_startedAtMs` ON `${TABLE_NAME}` (`statIdentityId`, `startedAtMs`)" + }, + { + "name": "index_token_stat_events_startedAtMs", + "unique": false, + "columnNames": [ + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` ON `${TABLE_NAME}` (`startedAtMs`)" + }, + { + "name": "index_token_stat_events_category_startedAtMs", + "unique": false, + "columnNames": [ + "category", + "startedAtMs" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` ON `${TABLE_NAME}` (`category`, `startedAtMs`)" + } + ], + "foreignKeys": [ + { + "table": "token_stat_identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "statIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "token_stat_identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `configId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `displayModelId` TEXT NOT NULL, PRIMARY KEY(`identityId`))", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configId", + "columnName": "configId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayModelId", + "columnName": "displayModelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_token_stat_identities_configId_provider_model", + "unique": true, + "columnNames": [ + "configId", + "provider", + "model" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_identities_configId_provider_model` ON `${TABLE_NAME}` (`configId`, `provider`, `model`)" + }, + { + "name": "index_token_stat_identities_displayModelId", + "unique": false, + "columnNames": [ + "displayModelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` ON `${TABLE_NAME}` (`displayModelId`)" + } + ] + }, + { + "tableName": "token_stat_display_models", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`displayModelId` TEXT NOT NULL, `normalizedModel` TEXT NOT NULL, `displayName` TEXT NOT NULL, PRIMARY KEY(`displayModelId`))", + "fields": [ + { + "fieldPath": "displayModelId", + "columnName": "displayModelId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedModel", + "columnName": "normalizedModel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "displayModelId" + ] + }, + "indices": [ + { + "name": "index_token_stat_display_models_normalizedModel", + "unique": true, + "columnNames": [ + "normalizedModel" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_display_models_normalizedModel` ON `${TABLE_NAME}` (`normalizedModel`)" + } + ] + }, + { + "tableName": "token_stat_price_overrides", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scope` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `configId` TEXT NOT NULL, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL)", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "configId", + "columnName": "configId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billingMode", + "columnName": "billingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputPricePerMillion", + "columnName": "inputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cachedInputPricePerMillion", + "columnName": "cachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "cacheWritePricePerMillion", + "columnName": "cacheWritePricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "outputPricePerMillion", + "columnName": "outputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "pricePerRequest", + "columnName": "pricePerRequest", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_token_stat_price_overrides_scope_provider_model_configId", + "unique": true, + "columnNames": [ + "scope", + "provider", + "model", + "configId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_price_overrides_scope_provider_model_configId` ON `${TABLE_NAME}` (`scope`, `provider`, `model`, `configId`)" + } + ] + }, + { + "tableName": "token_stat_baselines", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `requestCount` INTEGER NOT NULL, `pricingCurrency` TEXT NOT NULL, `costInPricingCurrency` REAL, `isEstimated` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `importedAtMs` INTEGER NOT NULL, `frozenBillingMode` TEXT NOT NULL, `frozenInputPricePerMillion` REAL, `frozenCachedInputPricePerMillion` REAL, `frozenOutputPricePerMillion` REAL, `frozenPricePerRequest` REAL, PRIMARY KEY(`identityId`), FOREIGN KEY(`identityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputTokens", + "columnName": "inputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cachedInputTokens", + "columnName": "cachedInputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outputTokens", + "columnName": "outputTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestCount", + "columnName": "requestCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pricingCurrency", + "columnName": "pricingCurrency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "costInPricingCurrency", + "columnName": "costInPricingCurrency", + "affinity": "REAL" + }, + { + "fieldPath": "isEstimated", + "columnName": "isEstimated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importedAtMs", + "columnName": "importedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frozenBillingMode", + "columnName": "frozenBillingMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frozenInputPricePerMillion", + "columnName": "frozenInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenCachedInputPricePerMillion", + "columnName": "frozenCachedInputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenOutputPricePerMillion", + "columnName": "frozenOutputPricePerMillion", + "affinity": "REAL" + }, + { + "fieldPath": "frozenPricePerRequest", + "columnName": "frozenPricePerRequest", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "foreignKeys": [ + { + "table": "token_stat_identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "token_stat_restore_generations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` TEXT NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", + "fields": [ + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appliedAtMs", + "columnName": "appliedAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "generation" + ] + } + }, + { + "tableName": "token_stat_reset_cutoffs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `generation` INTEGER NOT NULL, PRIMARY KEY(`kind`, `provider`, `model`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind", + "provider", + "model" + ] + } + }, + { + "tableName": "token_stat_range_cutoffs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` INTEGER NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", + "fields": [ + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "startMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "endMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "generation" + ] + } + }, + { + "tableName": "token_stat_cleanup_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`operationId` TEXT NOT NULL, `scope` TEXT NOT NULL, `targetRef` TEXT NOT NULL, `deleteBaselines` INTEGER NOT NULL, `status` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, PRIMARY KEY(`operationId`))", + "fields": [ + { + "fieldPath": "operationId", + "columnName": "operationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scope", + "columnName": "scope", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "targetRef", + "columnName": "targetRef", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deleteBaselines", + "columnName": "deleteBaselines", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "operationId" + ] + } + }, + { + "tableName": "token_stat_cleanup_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`operationId` TEXT NOT NULL, `identityId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, PRIMARY KEY(`operationId`, `identityId`), FOREIGN KEY(`operationId`) REFERENCES `token_stat_cleanup_operations`(`operationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "operationId", + "columnName": "operationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "operationId", + "identityId" + ] + }, + "indices": [ + { + "name": "index_token_stat_cleanup_items_operationId", + "unique": false, + "columnNames": [ + "operationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_cleanup_items_operationId` ON `${TABLE_NAME}` (`operationId`)" + } + ], + "foreignKeys": [ + { + "table": "token_stat_cleanup_operations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "operationId" + ], + "referencedColumns": [ + "operationId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f518d4399dae2579dd24e4ddc77d0517')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt index f0ed5e791..869593a48 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -190,7 +190,17 @@ class TokenTrackingAIService( ) } val (provider, model) = TokenStatIdentityResolver.splitProviderModel(delegate.providerModel) - val acceptedGeneration = TokenStatsLedger.currentResetGeneration(appContext) + // P1-1:请求接受边界在**同一事务**内原子确保身份存在并读取 generation——删除展示 + // 分组要么看见该身份(写 IDENTITY tombstone,删除前接受的事件被跳过),要么请求 + // 拿到 ≥ tombstone 的新 generation(删除后请求正常入账)。首次请求的身份绝不可能 + // 绕过分组删除 tombstone 复活旧事件。 + val acceptedGeneration = + TokenStatsLedger.ensureIdentityAndCaptureGeneration( + appContext, + configId, + provider, + model, + ) return TokenStatRequestContext( eventId = "evt_${UUID.randomUUID().toString().replace("-", "")}", category = category ?: TokenStatCategory.OTHER, diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index 9df6d9e98..b4327fc6c 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -6,16 +6,20 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatCleanupItemEntity +import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.model.TokenStatRangeCutoffEntity import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver import com.ai.assistance.operit.data.stats.TokenStatsGroupMetadataSnapshot import com.ai.assistance.operit.data.stats.TokenStatsLifetimeRead import com.ai.assistance.operit.data.stats.TokenStatsQuerySnapshot import androidx.room.Transaction +import java.util.UUID import kotlinx.coroutines.flow.Flow /** @@ -228,6 +232,36 @@ abstract class TokenStatsDao { @Query("DELETE FROM token_stat_events WHERE statIdentityId = :identityId") abstract suspend fun deleteEventsByIdentity(identityId: String): Int + /** 按成员身份批量删除事件(仅由事务方法分块调用,IN 数量受 [MAX_IN_VALUES] 限制)。 */ + @Query("DELETE FROM token_stat_events WHERE statIdentityId IN (:identityIds)") + protected abstract suspend fun deleteEventsByIdentitiesQuery(identityIds: List): Int + + /** 按成员身份批量删除 baseline(仅由事务方法分块调用)。 */ + @Query("DELETE FROM token_stat_baselines WHERE identityId IN (:identityIds)") + protected abstract suspend fun deleteBaselinesByIdentitiesQuery(identityIds: List): Int + + /** 分块删除指定身份集合的事件(同一调用方事务内执行,结果计数累加)。 */ + protected suspend fun deleteEventsByIdentities(identityIds: List): Int { + var deleted = 0 + for (chunk in identityIds.distinct().chunked(MAX_IN_VALUES)) { + deleted += deleteEventsByIdentitiesQuery(chunk) + } + return deleted + } + + /** 分块删除指定身份集合的 baseline。 */ + protected suspend fun deleteBaselinesByIdentities(identityIds: List): Int { + var deleted = 0 + for (chunk in identityIds.distinct().chunked(MAX_IN_VALUES)) { + deleted += deleteBaselinesByIdentitiesQuery(chunk) + } + return deleted + } + + /** 删除半开区间 [startMs, endMs) 内的事件(走 startedAtMs 索引;仅事务方法调用)。 */ + @Query("DELETE FROM token_stat_events WHERE startedAtMs >= :startMs AND startedAtMs < :endMs") + protected abstract suspend fun deleteEventsInRange(startMs: Long, endMs: Long): Int + @Query( "DELETE FROM token_stat_events WHERE statIdentityId IN " + "(SELECT identityId FROM token_stat_identities " + @@ -272,6 +306,23 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_identities") abstract suspend fun getAllIdentities(): List + /** + * 请求接受边界原子操作(P1-1):身份不存在时创建(INSERT IGNORE,绝不 REPLACE)、 + * 默认展示分组补齐、读取当前 generation,全部在**同一事务**内完成。展示分组删除 + * 与请求开始按 SQLite 事务串行化(写事务原子性):删除要么看见该身份并写 IDENTITY + * tombstone(删除前接受的事件被跳过),要么请求捕获 ≥ tombstone 的新 generation + * (删除后请求正常入账)——首次请求的身份不再可能绕过分组删除 tombstone 复活旧事件。 + */ + @Transaction + open suspend fun ensureIdentityAndCaptureGenerationTx( + identity: TokenStatIdentityEntity, + displayModel: TokenStatDisplayModelEntity, + ): Long { + insertIdentityIfAbsent(identity) + upsertDisplayModel(displayModel) + return currentResetGeneration() + } + // ==== 展示模型分组 ==== @Insert(onConflict = OnConflictStrategy.REPLACE) @@ -475,9 +526,105 @@ abstract class TokenStatsDao { abstract suspend fun deleteAllBaselines(): Int + // ==== legacy cleanup outbox(阶段 5 P1 闭环) ==== + // 跨存储删除的线性化点:operation/items 与 tombstone/删除在**同一 Room 事务** + // 提交(见 deleteDisplayModelEventsTx / resetModelTx / deleteAllStatisticsTx)。 + // drain 顺序固定:Room 读 PENDING → DataStore apply(marker 幂等)→ Room ACK; + // 失败保持 PENDING 下次重试。历史行不删除(导入 fence 与备份 lineage)。 + + @Query( + "SELECT * FROM token_stat_cleanup_operations " + + "WHERE status = 'PENDING' ORDER BY createdAtMs ASC, operationId ASC" + ) + abstract suspend fun getPendingCleanupOperations(): List + + @Query( + "SELECT * FROM token_stat_cleanup_operations " + + "ORDER BY createdAtMs ASC, operationId ASC" + ) + abstract suspend fun getAllCleanupOperations(): List + + @Query("SELECT * FROM token_stat_cleanup_items WHERE operationId = :operationId") + abstract suspend fun getCleanupItems(operationId: String): List + + @Query("SELECT COUNT(*) FROM token_stat_cleanup_operations WHERE status = 'PENDING'") + abstract suspend fun countPendingCleanupOperations(): Int + + /** + * drain ACK:只把仍为 PENDING 的 operation 标记 APPLIED(@return 0 = 已由 + * 其他排空完成,幂等安全)。 + */ + @Query( + "UPDATE token_stat_cleanup_operations SET status = 'APPLIED' " + + "WHERE operationId = :operationId AND status = 'PENDING'" + ) + abstract suspend fun ackCleanupOperation(operationId: String): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertCleanupOperation( + operation: TokenStatCleanupOperationEntity + ): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract suspend fun insertCleanupItems( + items: List + ): List + + /** + * 在删除事务内创建 PENDING cleanup operation(+ 不可变 items 快照)。 + * 只供本类事务方法调用;items 为空表示 ALL kind 或无需逐项清理。 + */ + protected suspend fun createCleanupOperation( + scope: String, + targetRef: String, + deleteBaselines: Boolean, + items: List>, + ): TokenStatCleanupOperationEntity { + val operation = + TokenStatCleanupOperationEntity( + operationId = UUID.randomUUID().toString(), + scope = scope, + targetRef = targetRef, + deleteBaselines = deleteBaselines, + status = TokenStatCleanupOperationEntity.STATUS_PENDING, + createdAtMs = System.currentTimeMillis(), + ) + insertCleanupOperation(operation) + if (items.isNotEmpty()) { + insertCleanupItems( + items.map { (identityId, provider, model) -> + TokenStatCleanupItemEntity( + operationId = operation.operationId, + identityId = identityId, + provider = provider, + model = model, + ) + } + ) + } + return operation + } + + /** + * 导入 fence(P1 闭环):当前快照(含同时读取的 applied marker ID 集合)能否 + * 安全用于 baseline 导入。返回 true 当且仅当: + * 1. 不存在 PENDING cleanup operation(未排空的清理不得被导入覆盖); + * 2. Room 中**全部** cleanup operation ID 都包含在该快照的 marker 集合里—— + * 否则快照早于某次 legacy cleanup 完成,直接应用会复活已删除的 baseline。 + * 必须在 Room 事务内调用(与快照读取后的写入线性化)。 + */ + @Transaction + open suspend fun cleanupFenceSatisfied(markerOperationIds: Set): Boolean { + if (countPendingCleanupOperations() > 0) return false + return getAllCleanupOperations().all { it.operationId in markerOperationIds } + } + // ==== 重置 tombstone(reset cutoff) ==== // reset 与 spool 排空的一致同步边界:tombstone 与删除在同一事务提交, // 排空插入在同一事务内检查,SQLite 事务串行化杜绝并发复活(P1-3)。 + // 阶段 5 扩展:IDENTITY kind(按展示分组删除,精确到身份)与 + // token_stat_range_cutoffs 表(时间范围删除)共用同一 generation 计数器, + // 删除后新接受的事件(acceptedGeneration ≥ cutoff)永不误伤。 @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract suspend fun upsertResetCutoff(cutoff: TokenStatResetCutoffEntity) @@ -491,19 +638,129 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") abstract suspend fun modelResetCutoffs(): List + /** IDENTITY tombstone(阶段 5 展示分组删除):provider 空串、model 列 = identityId。 */ + @Query( + "SELECT * FROM token_stat_reset_cutoffs " + + "WHERE kind = 'IDENTITY' AND provider = '' AND model = :identityId LIMIT 1" + ) + protected abstract suspend fun identityResetCutoff(identityId: String): TokenStatResetCutoffEntity? + @Query("DELETE FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") protected abstract suspend fun deleteModelResetCutoffs() - @Query("SELECT COALESCE(MAX(generation), 0) FROM token_stat_reset_cutoffs") + @Query("DELETE FROM token_stat_reset_cutoffs WHERE kind = 'IDENTITY'") + protected abstract suspend fun deleteIdentityResetCutoffs() + + /** + * 统一 generation 计数器:跨 reset_cutoffs 与 range_cutoffs 两表取最大值。 + * 阶段 5 必须统一:范围删除 tombstone 与 reset tombstone 共用单调序列, + * 否则“删除当前范围”后新请求捕获的 acceptedGeneration 可能低于范围 tombstone, + * 导致新事件被误判为删除前事件而跳过入账。 + */ + @Query( + "SELECT COALESCE(MAX(generation), 0) FROM (" + + "SELECT generation FROM token_stat_reset_cutoffs " + + "UNION ALL " + + "SELECT generation FROM token_stat_range_cutoffs" + + ")" + ) abstract suspend fun currentResetGeneration(): Long + // ==== 阶段 5:时间范围删除 tombstone ==== + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun upsertRangeCutoff(cutoff: TokenStatRangeCutoffEntity) + + @Query("SELECT * FROM token_stat_range_cutoffs") + abstract suspend fun rangeCutoffs(): List + + @Query("DELETE FROM token_stat_range_cutoffs") + protected abstract suspend fun deleteAllRangeCutoffs() + + /** + * 删除时间范围 [startMs, endMs) 内的事件:写入 RANGE tombstone 并与删除 + * 同一事务提交。**绝不触碰 baseline**(baseline 无时间分布,只有按模型/全部 + * 删除且用户确认后才删除)。身份/展示分组/价格覆盖一律保留。 + * @return 删除的事件数。 + */ + @Transaction + open suspend fun deleteRangeEventsTx(startMs: Long, endMs: Long): Int { + require(endMs > startMs) { "range end must be after start" } + val generation = Math.addExact(currentResetGeneration(), 1L) + upsertRangeCutoff( + TokenStatRangeCutoffEntity( + generation = generation, + startMs = startMs, + endMs = endMs, + ) + ) + return deleteEventsInRange(startMs, endMs) + } + + /** + * 按展示分组删除(阶段 5 + P1 闭环):事务内从 **identity 全表**解析组成员(不依赖 + * 任何统计筛选),为该组全部成员写 IDENTITY tombstone(精确到身份,同一 + * provider:model 的其他分组不受影响),再按成员删除事件;[deleteBaselines] + * 为 true 时同步删除这些成员的 baseline,并**在同一事务内**为其中 configId + * 为空串的 legacy 成员持久化 cleanup operation + items(不可变快照,供 + * DataStore 累计键排空;为 false 时 baseline 与旧键一律保留、不建 operation)。 + * 身份行/展示分组/价格覆盖不删除(保持“只清计数、保留配置”语义)。 + * 任何读取失败都会让整个事务回滚(不产生 tombstone/operation 半状态)。 + */ + @Transaction + open suspend fun deleteDisplayModelEventsTx( + displayModelId: String, + deleteBaselines: Boolean, + ): TokenStatDisplayGroupDeletionResult { + require(displayModelId.isNotBlank()) { "displayModelId must not be blank" } + val members = getAllIdentities().filter { it.displayModelId == displayModelId } + if (members.isEmpty()) return TokenStatDisplayGroupDeletionResult(0, null) + val memberIds = members.map { it.identityId } + val generation = Math.addExact(currentResetGeneration(), 1L) + for (identityId in memberIds) { + upsertResetCutoff( + TokenStatResetCutoffEntity( + kind = TokenStatResetCutoffEntity.KIND_IDENTITY, + provider = "", + model = identityId, + generation = generation, + ) + ) + } + val deleted = deleteEventsByIdentities(memberIds) + var operation: TokenStatCleanupOperationEntity? = null + if (deleteBaselines) { + deleteBaselinesByIdentities(memberIds) + val legacyMembers = members.filter { it.configId == "" } + if (legacyMembers.isNotEmpty()) { + operation = + createCleanupOperation( + scope = TokenStatCleanupOperationEntity.SCOPE_DISPLAY_GROUP, + targetRef = displayModelId, + deleteBaselines = true, + items = + legacyMembers.map { + Triple(it.identityId, it.provider, it.model) + }, + ) + } + } + return TokenStatDisplayGroupDeletionResult(deleted, operation) + } + /** - * 全量重置:写入 FULL tombstone 并与删除(事件按 startedAtMs 过滤、baseline - * 全清)同一事务提交。排空并发插入要么在本事务前(被本事务删除),要么在 - * 本事务后(被 tombstone 在插入事务内跳过),不可能复活。 + * 全部删除(阶段 5 + P1 闭环):写入 FULL tombstone 并与删除同一事务提交; + * [deleteBaselines] 为 true 时同时清空全部 baseline,并创建 ALL kind 的 + * cleanup operation(无 items,排空时清除全部旧累计键——不触碰价格等配置); + * 为 false 时 baseline 与旧累计键一律保留、不建 operation。 + * 顺带清理 MODEL/IDENTITY/RANGE tombstone(与既有 + * [resetAllStatisticsTx] 的卫生语义一致:FULL 覆盖所有更早的删除边界, + * 事件只按 FULL generation 判断,旧边界不再需要)。 */ @Transaction - open suspend fun resetAllStatisticsTx() { + open suspend fun deleteAllStatisticsTx( + deleteBaselines: Boolean, + ): TokenStatAllDeletionResult { val generation = Math.addExact(currentResetGeneration(), 1L) upsertResetCutoff( TokenStatResetCutoffEntity( @@ -514,16 +771,43 @@ abstract class TokenStatsDao { ) ) deleteModelResetCutoffs() - deleteAllEvents() - deleteAllBaselines() + deleteIdentityResetCutoffs() + deleteAllRangeCutoffs() + val deleted = deleteAllEvents() + var operation: TokenStatCleanupOperationEntity? = null + if (deleteBaselines) { + deleteAllBaselines() + operation = + createCleanupOperation( + scope = TokenStatCleanupOperationEntity.SCOPE_ALL, + targetRef = "", + deleteBaselines = true, + items = emptyList(), + ) + } + return TokenStatAllDeletionResult(deleted, operation) } /** - * 按模型重置:写入 MODEL tombstone(每 provider/model REPLACE 覆盖,取最近 - * 时刻)并与删除同一事务提交;覆盖该模型下所有配置实例身份。 + * 全量重置(既有语义,阶段 1 确认):事件 + 全部 baseline 一并删除,并创建 + * ALL kind 的 legacy cleanup operation(P1 闭环:跨存储窗口统一走 outbox)。 + * 阶段 5 保留为旧重置流程的别名;新删除流程直接使用 + * [deleteAllStatisticsTx](可单独选择是否删除 baseline)。 + * @return 创建的 cleanup operation(null 表示无需清理——本路径恒删除 baseline)。 + */ + @Transaction + open suspend fun resetAllStatisticsTx(): TokenStatCleanupOperationEntity? = + deleteAllStatisticsTx(deleteBaselines = true).cleanupOperation + + /** + * 按模型重置(P1 闭环):写入 MODEL tombstone(每 provider/model REPLACE 覆盖, + * 取最近时刻)并与删除同一事务提交;覆盖该模型下所有配置实例身份,并为其中 + * configId 为空串的 legacy 成员持久化 cleanup operation + items(精确到成员, + * 不误清其他模型/配置身份对应的旧键)。 + * @return 创建的 cleanup operation(null = 无 legacy 成员,无需清旧键)。 */ @Transaction - open suspend fun resetModelTx(provider: String, model: String) { + open suspend fun resetModelTx(provider: String, model: String): TokenStatCleanupOperationEntity? { val generation = Math.addExact(currentResetGeneration(), 1L) upsertResetCutoff( TokenStatResetCutoffEntity( @@ -535,12 +819,34 @@ abstract class TokenStatsDao { ) deleteEventsByProviderModel(provider, model) deleteBaselinesByProviderModel(provider, model) + // 与 delete*ByProviderModel 相同(非规范化)匹配口径:只登记实际被删的 legacy 成员 + val legacyMembers = + getAllIdentities().filter { + it.configId == "" && it.provider == provider && it.model == model + } + return if (legacyMembers.isEmpty()) { + null + } else { + createCleanupOperation( + scope = TokenStatCleanupOperationEntity.SCOPE_MODEL, + targetRef = "$provider:$model", + deleteBaselines = true, + items = legacyMembers.map { Triple(it.identityId, it.provider, it.model) }, + ) + } } /** * 排空路径的事件插入入口:tombstone 检查与插入在同一事务内。 - * @return false = 事件被 reset tombstone 覆盖(跳过;调用方视为已处理, + * @return false = 事件被 reset/删除 tombstone 覆盖(跳过;调用方视为已处理, * 段可删除,不重放);true = 已插入。 + * + * 阶段 5 检查链(与删除矩阵一一对应): + * - FULL:全量删除/重置后不接受任何更早接受的事件; + * - IDENTITY:按展示分组删除后不接受该身份更早接受的事件(精确到身份); + * - MODEL:按 provider:model 重置后不接受该模型更早接受的事件; + * - RANGE:范围删除后不接受 startedAtMs 落在已删范围且更早接受的事件。 + * 统一 generation 计数(两表 UNION)保证“接受于删除前”判断不依赖墙钟。 */ @Transaction open suspend fun insertEventIfNotResetCovered(event: TokenStatEventEntity): Boolean { @@ -549,6 +855,8 @@ abstract class TokenStatsDao { val identity = getIdentity(event.statIdentityId) ?: error("identity missing for event ${event.eventId} (ensureIdentity must run first)") + val identityCutoff = identityResetCutoff(event.statIdentityId) + if (identityCutoff != null && event.acceptedGeneration < identityCutoff.generation) return false val models = modelResetCutoffs() for (cutoff in models) { if (event.acceptedGeneration < cutoff.generation && @@ -560,6 +868,14 @@ abstract class TokenStatsDao { return false } } + for (cutoff in rangeCutoffs()) { + if (event.acceptedGeneration < cutoff.generation && + event.startedAtMs >= cutoff.startMs && + event.startedAtMs < cutoff.endMs + ) { + return false + } + } insertEvent(event) return true } @@ -567,3 +883,18 @@ abstract class TokenStatsDao { } + +/** + * 按展示分组删除的结果:删除的事件数与(baseline=yes 且组内存在 legacy 成员时) + * 在同一事务内创建的 cleanup operation(否则为 null)。 + */ +data class TokenStatDisplayGroupDeletionResult( + val deletedEvents: Int, + val cleanupOperation: TokenStatCleanupOperationEntity?, +) + +/** 全部删除的结果:删除的事件数与(baseline=yes 时)ALL kind cleanup operation。 */ +data class TokenStatAllDeletionResult( + val deletedEvents: Int, + val cleanupOperation: TokenStatCleanupOperationEntity?, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 43e2601e6..6410984d7 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -15,10 +15,13 @@ import com.ai.assistance.operit.data.model.ChatEntity import com.ai.assistance.operit.data.model.MessageEntity import com.ai.assistance.operit.data.model.MessageVariantEntity import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatCleanupItemEntity +import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.model.TokenStatRangeCutoffEntity import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity /** 应用数据库,包含聊天表和消息表 */ @Database( @@ -32,6 +35,9 @@ import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity TokenStatEventEntity::class, TokenStatBaselineEntity::class, TokenStatResetCutoffEntity::class, + TokenStatRangeCutoffEntity::class, + TokenStatCleanupOperationEntity::class, + TokenStatCleanupItemEntity::class, ], version = 21, exportSchema = false @@ -426,6 +432,64 @@ abstract class AppDatabase : RoomDatabase() { } } } + try { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_range_cutoffs` ( + `generation` INTEGER NOT NULL, + `startMs` INTEGER NOT NULL, + `endMs` INTEGER NOT NULL, + PRIMARY KEY(`generation`) + ) + """.trimIndent() + ) + } catch (_: Exception) { + // 表已存在(幂等重放),忽略 + } + try { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_cleanup_operations` ( + `operationId` TEXT NOT NULL, + `scope` TEXT NOT NULL, + `targetRef` TEXT NOT NULL, + `deleteBaselines` INTEGER NOT NULL, + `status` TEXT NOT NULL, + `createdAtMs` INTEGER NOT NULL, + PRIMARY KEY(`operationId`) + ) + """.trimIndent() + ) + } catch (_: Exception) { + // 表已存在(幂等重放),忽略 + } + try { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `token_stat_cleanup_items` ( + `operationId` TEXT NOT NULL, + `identityId` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + PRIMARY KEY(`operationId`, `identityId`), + FOREIGN KEY(`operationId`) + REFERENCES `token_stat_cleanup_operations`(`operationId`) + ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) + } catch (_: Exception) { + // 表已存在(幂等重放),忽略 + } + try { + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_token_stat_cleanup_items_operationId` " + + "ON `token_stat_cleanup_items` (`operationId`)" + ) + } catch (_: Exception) { + // 索引已存在(幂等重放),忽略 + } + // 定义从版本2到3的迁移 diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt new file mode 100644 index 000000000..f23c9a39d --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt @@ -0,0 +1,34 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * legacy cleanup operation 的不可变成员快照(阶段 5 P1 闭环)。 + * + * 在创建 operation 的**同一个 Room 删除事务**内从 identity 全表快照解析, + * 只包含 configId 为空串的旧系统迁移身份;provider/model 为不可变快照 + * (成员后续移动/删除不影响已登记的清理目标)。外键级联保证 operation + * 删除时 items 跟随清理(生产流程保留历史,不主动删除)。 + */ +@Entity( + tableName = "token_stat_cleanup_items", + primaryKeys = ["operationId", "identityId"], + foreignKeys = [ + ForeignKey( + entity = TokenStatCleanupOperationEntity::class, + parentColumns = ["operationId"], + childColumns = ["operationId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [Index(value = ["operationId"])], +) +data class TokenStatCleanupItemEntity( + @ColumnInfo(name = "operationId") val operationId: String, + @ColumnInfo(name = "identityId") val identityId: String, + @ColumnInfo(name = "provider") val provider: String, + @ColumnInfo(name = "model") val model: String, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt new file mode 100644 index 000000000..e98cb355d --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt @@ -0,0 +1,49 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * 旧 DataStore 累计键清理 outbox operation(阶段 5 P1 闭环)。 + * + * 跨存储删除的**唯一线性化点**是 Room 删除事务:同一事务内解析展示组成员 + * 快照、写 tombstone、删事件、删 baseline,并且**只对旧系统迁移身份** + * (configId 为空串,其累计键按 provider:model 共享且是 baseline 的迁移源) + * 持久化本表 operation + [TokenStatCleanupItemEntity] 不可变快照 + * (operationId + identityId + provider + model)。事务读取失败整体回滚, + * 不会产生半删除或丢失清理信号。 + * + * 状态机(PENDING → APPLIED): + * - PENDING:Room 删除已提交,DataStore 累计键尚未清理(或尚未确认); + * - APPLIED:drain 已在 DataStore 单次 edit 内清键并写入 marker 后 ACK。 + * 失败保持 PENDING,下次冷启动(baseline 导入之前 / pending restore 之前) + * 与删除后立即重试排空。历史行不删除:作为导入 fence(快照 marker 校验) + * 与备份 lineage 使用。 + * + * scope: + * - [SCOPE_DISPLAY_GROUP]:按展示分组删除(targetRef = displayModelId), + * items 精确到该组 configId 为空的成员; + * - [SCOPE_MODEL]:按 provider:model 重置(targetRef = provider:model), + * items 精确到匹配的 configId 为空成员; + * - [SCOPE_ALL]:全量删除(targetRef 为空串),无 items, + * apply 时清除全部旧累计键(不触碰价格等配置)。 + */ +@Entity(tableName = "token_stat_cleanup_operations") +data class TokenStatCleanupOperationEntity( + @PrimaryKey @ColumnInfo(name = "operationId") val operationId: String, + @ColumnInfo(name = "scope") val scope: String, + @ColumnInfo(name = "targetRef") val targetRef: String, + @ColumnInfo(name = "deleteBaselines") val deleteBaselines: Boolean, + @ColumnInfo(name = "status") val status: String, + @ColumnInfo(name = "createdAtMs") val createdAtMs: Long, +) { + companion object { + const val SCOPE_DISPLAY_GROUP = "DISPLAY_GROUP" + const val SCOPE_MODEL = "MODEL" + const val SCOPE_ALL = "ALL" + + const val STATUS_PENDING = "PENDING" + const val STATUS_APPLIED = "APPLIED" + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt new file mode 100644 index 000000000..65e27dab6 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt @@ -0,0 +1,25 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * 统计时间范围删除 tombstone(阶段 5)—— 范围删除与 spool 排空的一致同步边界。 + * + * - 每次“删除时间范围事件”在同一 Room 事务内写入一行(generation 为主键: + * 由 [com.ai.assistance.operit.data.dao.TokenStatsDao.currentResetGeneration] + * 跨 reset/range 两表统一递增,任意两次删除/重置都不会撞 generation)。 + * - 排空插入事件时([com.ai.assistance.operit.data.dao.TokenStatsDao.insertEventIfNotResetCovered]), + * `acceptedGeneration < generation && startedAtMs ∈ [startMs, endMs)` 的事件跳过, + * 不复活已删除范围的数据;删除后新接受的事件(acceptedGeneration ≥ generation) + * 即使落在同一范围内也正常入账。 + * - 范围删除**只**删除有时间戳的事件,绝不触碰 baseline/身份/分组/价格覆盖。 + * - 行数 = 用户范围删除次数(每行 24 字节级),全量重置时随其他 tombstone 一并清理。 + */ +@Entity(tableName = "token_stat_range_cutoffs") +data class TokenStatRangeCutoffEntity( + @PrimaryKey @ColumnInfo(name = "generation") val generation: Long, + @ColumnInfo(name = "startMs") val startMs: Long, + @ColumnInfo(name = "endMs") val endMs: Long, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt index 6a98bccb0..117c5d787 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt @@ -32,5 +32,12 @@ data class TokenStatResetCutoffEntity( companion object { const val KIND_FULL = "FULL" const val KIND_MODEL = "MODEL" + + /** + * 按展示分组删除的身份级 tombstone(阶段 5):provider 为空串、 + * model 列存 identityId。精确到身份——同一 provider:model 的其他分组 + * 不受影响;排空插入时事件 identityId 匹配且 acceptedGeneration 更低则跳过。 + */ + const val KIND_IDENTITY = "IDENTITY" } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index ae58d3ce7..eeda99e5b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -224,6 +224,26 @@ class ApiPreferences private constructor(private val context: Context) { const val DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions" const val DEFAULT_MODEL_NAME = "deepseek-v4-flash" + // legacy cleanup applied marker 键前缀(P1 闭环):marker 与累计键清理在 + // 同一次 DataStore.edit 内原子完成;marker 已存在时同 operation 重试为 + // 幂等 no-op。marker ID 集合与 baseline 快照同一次读取,供导入 fence 校验 + // (见 legacyStatsSnapshotWithMarkers)。 + val LEGACY_CLEANUP_MARKER_PREFIX = "legacy_cleanup_applied_" + + fun legacyCleanupMarkerKey(operationId: String): Preferences.Key = + booleanPreferencesKey("$LEGACY_CLEANUP_MARKER_PREFIX$operationId") + + /** 移除指定键名的累计计数键(实例方法与 outbox 纯变更函数共用)。 */ + internal fun removeTokenCountKeysForMutation( + preferences: MutablePreferences, + vararg keyNames: String, + ) { + val names = keyNames.toSet() + preferences.asMap().keys + .filter { it.name in names } + .forEach { preferences.remove(it) } + } + private const val TAG = "ApiPreferences" } @@ -661,25 +681,73 @@ class ApiPreferences private constructor(private val context: Context) { } } + /** + * legacy cleanup applied marker 键前缀(P1 闭环):marker 与累计键清理在 + * **同一次** DataStore.edit 内原子完成;marker 已存在时同 operation 重试 + * 为幂等 no-op。marker ID 集合与 baseline 快照同一次读取,供导入 fence 校验 + * (见 [legacyStatsSnapshotWithMarkers])。 + */ + val LEGACY_CLEANUP_MARKER_PREFIX = "legacy_cleanup_applied_" + + fun legacyCleanupMarkerKey(operationId: String): Preferences.Key = + booleanPreferencesKey("$LEGACY_CLEANUP_MARKER_PREFIX$operationId") + + /** 读取全部已应用的 legacy cleanup marker operationId 集合(导入 fence 用)。 */ + suspend fun appliedLegacyCleanupMarkerIds(): Set { + val preferences = context.apiDataStore.data.first() + return appliedMarkerIdsFrom(preferences) + } + + /** + * 应用一次 legacy cleanup(P1 闭环 drain 的 DataStore 侧): + * 单次 DataStore.edit 内,若该 operation 的 applied marker 不存在,则精准清除 + * 累计键并写入 marker;marker 已存在则幂等 no-op(崩溃后重放不二次清键)。 + * [providerModels] 为 null 表示 ALL kind:清除全部旧累计键 + * (token_input_ / token_cached_input_ / token_output_ / request_count_ 前缀), + * **绝不触碰价格/计费方式等配置键**与 marker 键。取消向上传播。 + */ + suspend fun applyLegacyCleanup(operationId: String, providerModels: List?) { + require(operationId.isNotBlank()) { "operationId must not be blank" } + context.apiDataStore.edit { preferences -> + applyLegacyCleanupMutation(preferences, operationId, providerModels) + } + } + + private fun appliedMarkerIdsFrom(preferences: Preferences): Set = + preferences.asMap().keys.asSequence() + .map { it.name } + .filter { it.startsWith(LEGACY_CLEANUP_MARKER_PREFIX) } + .map { it.removePrefix(LEGACY_CLEANUP_MARKER_PREFIX) } + .toSet() + + /** + * 旧累计统计快照 + **同一次读取**的 applied marker ID 集合(P1 闭环导入 fence): + * baseline 快照与 marker 集合来自同一个 DataStore 读取,Room 事务内校验全部 + * cleanup operation ID 均包含在该 marker 集合(且无 PENDING)后才允许导入, + * 杜绝“先读旧快照 → cleanup 完成 → 旧快照写回”复活已删除的 baseline。 + */ + suspend fun legacyStatsSnapshotWithMarkers(): LegacyStatsSnapshotRead { + val preferences = context.apiDataStore.data.first() + return LegacyStatsSnapshotRead( + snapshot = + com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( + preferences.asMap().mapKeys { it.key.name } + ), + cleanupMarkerIds = appliedMarkerIdsFrom(preferences), + ) + } + /** * 重置所有供应商:模型的token计数,并同步清空新统计账本(事件 + baseline)。 - * @return true = 旧计数与新账本均清零成功;false = 旧计数已清零但新账本清理失败 + * P1 闭环:顺序改为 **Room 先删(同一事务写 FULL tombstone + 删除 + 创建 + * ALL cleanup operation)→ 排空 DataStore 累计键(marker 幂等)**,消除 + * 旧的“先清 DataStore 再删新账本”跨存储窗口(新账本删除失败时旧计数不会被 + * 静默清掉;排空失败时 operation 保持 PENDING 由下次启动重试)。 + * @return true = 旧计数与新账本均清零成功;false = 任一步失败 * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 * 协程取消(CancellationException)不在此吞掉,向上传播。 */ suspend fun resetAllProviderModelTokenCounts(): Boolean { - context.apiDataStore.edit { preferences -> - val keysToRemove = mutableListOf>() - preferences.asMap().forEach { (key, _) -> - val keyName = key.name - if (keyName.startsWith("token_input_") || keyName.startsWith("token_output_") || keyName.startsWith("token_cached_input_") || keyName.startsWith("request_count_")) { - keysToRemove.add(key) - } - } - keysToRemove.forEach { key -> - preferences.remove(key) - } - } return try { com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator .resetAllStatistics(context) @@ -694,24 +762,14 @@ class ApiPreferences private constructor(private val context: Context) { /** * 重置指定供应商:模型的token计数,并同步清空该模型在新账本中的事件与 baseline - * (所有配置实例身份,见 TokenStatsResetCoordinator)。 - * @return true = 旧计数与新账本均清零成功;false = 旧计数已清零但新账本清理失败 + * (所有配置实例身份,见 TokenStatsResetCoordinator)。P1 闭环:顺序与 + * [resetAllProviderModelTokenCounts] 一致(Room 先删 + 创建精确 items 的 + * cleanup operation → 排空 DataStore 累计键)。 + * @return true = 旧计数与新账本均清零成功;false = 任一步失败 * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 * 协程取消(CancellationException)不在此吞掉,向上传播。 */ suspend fun resetProviderModelTokenCounts(providerModel: String): Boolean { - context.apiDataStore.edit { preferences -> - removeTokenCountKeys( - preferences, - getTokenInputKey(providerModel).name, - getTokenCachedInputKey(providerModel).name, - getTokenOutputKey(providerModel).name - ) - preferences[getTokenInputKey(providerModel)] = 0L - preferences[getTokenCachedInputKey(providerModel)] = 0L - preferences[getTokenOutputKey(providerModel)] = 0L - preferences[getRequestCountKey(providerModel)] = 0 - } return try { com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator .resetStatisticsForProviderModel(context, providerModel) @@ -724,17 +782,6 @@ class ApiPreferences private constructor(private val context: Context) { } } - /** - * 旧累计统计快照(迁移来源)。新统计系统只把这里作为一次性迁移读取, - * 不再作为第二套账本写入点。 - */ - suspend fun legacyStatsSnapshot(): com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot { - val preferences = context.apiDataStore.data.first() - return com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( - preferences.asMap().mapKeys { it.key.name } - ) - } - /** * 读取指定 provider:model 的旧系统用户价格设置(阶段 2 事件记录用)。 * 旧约定:价格键缺失或为 0 视为未设置(0 与“未设置”不可区分), @@ -790,12 +837,8 @@ class ApiPreferences private constructor(private val context: Context) { return settings.takeIf { it.hasAnyUserSetting() } } - private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) { - val names = keyNames.toSet() - preferences.asMap().keys - .filter { it.name in names } - .forEach { preferences.remove(it) } - } + private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) = + removeTokenCountKeysForMutation(preferences, *keyNames) private fun readTokenCount(preferences: Preferences, keyName: String): Long { val values = preferences.asMap().entries @@ -1104,3 +1147,52 @@ class ApiPreferences private constructor(private val context: Context) { } } } + +/** + * 单次 DataStore.edit 内的 legacy cleanup 变更(P1 闭环,纯函数): + * - marker 已存在 → 严格 no-op(崩溃后重放不二次清键,也不写任何值); + * - [providerModels] == null(ALL kind)→ 清除全部旧累计键 + * (token_input_/token_cached_input_/token_output_/request_count_ 前缀), + * 价格/计费方式等配置键与 marker 键一律保留; + * - 否则只清除这些 provider:model 的累计键与 request_count; + * 之后写入 operation marker(与清理同一次 edit 原子提交)。 + * 独立为纯函数以便 Windows JVM 测试直接验证键级语义(DataStore.edit 只是薄壳)。 + */ +internal fun applyLegacyCleanupMutation( + preferences: MutablePreferences, + operationId: String, + providerModels: List?, +) { + require(operationId.isNotBlank()) { "operationId must not be blank" } + val markerKey = ApiPreferences.legacyCleanupMarkerKey(operationId) + if (preferences[markerKey] == true) return + if (providerModels == null) { + val keysToRemove = + preferences.asMap().keys.filter { key -> + key.name.startsWith("token_input_") || + key.name.startsWith("token_cached_input_") || + key.name.startsWith("token_output_") || + key.name.startsWith("request_count_") + } + keysToRemove.forEach { preferences.remove(it) } + } else { + providerModels.distinct().forEach { providerModel -> + ApiPreferences.removeTokenCountKeysForMutation( + preferences, + ApiPreferences.getTokenInputKey(providerModel).name, + ApiPreferences.getTokenCachedInputKey(providerModel).name, + ApiPreferences.getTokenOutputKey(providerModel).name, + ) + preferences.remove(ApiPreferences.getRequestCountKey(providerModel)) + } + } + preferences[markerKey] = true +} + +/** + * baseline 快照 + 同一次 DataStore 读取的 applied marker ID 集合(导入 fence 用)。 + */ +data class LegacyStatsSnapshotRead( + val snapshot: com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot, + val cleanupMarkerIds: Set, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt index 62beda157..86c2cdbca 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt @@ -5,6 +5,7 @@ import androidx.room.withTransaction import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.util.AppLogger @@ -19,6 +20,14 @@ import kotlinx.coroutines.CancellationException * 快照缺失的模型保持原样(不删除——偏好文件可能暂时缺失;显式删除 * 走 [TokenStatsResetCoordinator] 的用户重置路径)。普通启动的空快照安全 no-op * (见 [runImport] 的空快照守卫)。 + * + * P1 闭环(legacy cleanup outbox fence):两个导入入口在读取快照**之前**先排空 + * pending cleanup operation([TokenStatsResetCoordinator.drainPendingCleanupWith], + * Room 事务之外),并让快照携带**同一次读取**的 applied marker ID 集合;导入事务 + * 内经 [TokenStatsDao.cleanupFenceSatisfied] 校验(无 PENDING 且全部 operation ID + * 都在快照 markers 中)才应用快照,否则 no-op 等待下次启动重试——杜绝“先读旧 + * 快照 → cleanup 完成 → 旧快照写回”复活已删除的 baseline。受控补导被 fence 拒绝 + * 时**不记录 generation、不消费 marker**,保证信号不丢失。 */ object TokenBaselineImportRunner { @@ -42,22 +51,28 @@ object TokenBaselineImportRunner { } } + // ==== 恢复生命周期:pending 标记 ==== + // ==== 导入 ==== - internal suspend fun runImport(appContext: Context, forceReplace: Boolean) { - // 普通启动守卫:空快照直接返回,不触碰数据库(取消/空源都安全,绝不删除)。 - // 注意:受控补导(consumePendingLocked)不走此入口,空快照也以 - // forceReplace 语义执行删除计划。 - val snapshot = ApiPreferences.getInstance(appContext).legacyStatsSnapshot() - if (snapshot.providerModels.isEmpty()) return + internal suspend fun runImport(appContext: Context, forceReplace: Boolean) { val injected = databaseProvider val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) val dao = database.tokenStatsDao() + // 普通启动守卫前先排空 pending legacy cleanup(删除后未完成的 DataStore + // 清理),再读快照:保证读到的是清理后的最新状态;排空失败抛异常由 + // ensureMigrated 捕获,下次启动重试(不吞)。 + TokenStatsResetCoordinator.drainPendingCleanupWith(appContext, dao) + val read = ApiPreferences.getInstance(appContext).legacyStatsSnapshotWithMarkers() + // 普通启动守卫:空快照直接返回,不触碰数据库(取消/空源都安全,绝不删除)。 + // 注意:受控补导(consumePendingLocked)不走此入口,空快照也以 + // forceReplace 语义执行删除计划。 + if (read.snapshot.providerModels.isEmpty()) return if (injected != null) { - runImport(appContext, dao, snapshot, forceReplace) + runImport(appContext, dao, read.snapshot, read.cleanupMarkerIds, forceReplace) } else { database.withTransaction { - runImport(appContext, dao, snapshot, forceReplace) + runImport(appContext, dao, read.snapshot, read.cleanupMarkerIds, forceReplace) } } } @@ -66,8 +81,20 @@ object TokenBaselineImportRunner { appContext: Context, dao: TokenStatsDao, snapshot: LegacyTokenStatsSnapshot, + cleanupMarkerIds: Set, forceReplace: Boolean, ) { + // 导入 fence(P1 闭环):Room 侧无 PENDING cleanup 且**全部** cleanup + // operation ID 都包含在本快照的 marker 集合中,才允许应用该快照—— + // 否则快照早于某次 legacy cleanup(或清理尚未排空),应用会复活已删除 + // 的 baseline。拒绝时 no-op,下次启动重试。 + if (!dao.cleanupFenceSatisfied(cleanupMarkerIds)) { + AppLogger.w( + TAG, + "legacy cleanup 未排空或快照 marker 过期,跳过本次 baseline 导入(下次启动重试)" + ) + return + } val existingBaselines = dao.getAllBaselines().associateBy { it.identityId } val existingIdentities = dao.getAllIdentities().associateBy { it.identityId } val existingDisplayModels = dao.getAllDisplayModels().associateBy { it.displayModelId } @@ -174,6 +201,21 @@ object TokenBaselineImportRunner { * ([TokenStatsDao.insertEventIfNotResetCovered]),并发中已接受但未入 Room 的 * 事件不会复活;重置后触发 [TokenStatSpool.replay] 让排空丢弃被覆盖的行。 * + * 阶段 5 删除入口(与旧重置共用同一 spool 一致性机制): + * - [deleteEventsInRange]:只删时间范围内的事件(RANGE tombstone),绝不碰 baseline; + * - [deleteDisplayModel]:按**完整展示分组**删除事件,可单独选择是否删除该组 + * baseline(IDENTITY tombstone 精确到身份,不误伤同 provider:model 的其他分组); + * - [deleteAllEvents]:删除全部事件,可单独选择是否删除全部 baseline + * (FULL tombstone)。 + * + * 跨存储 legacy cleanup(P1 闭环):删除事务是唯一线性化点——删除 baseline 的 + * 入口([deleteDisplayModel]/[deleteAllEvents]/[resetAllStatistics]/ + * [resetStatisticsForProviderModel])在同一事务内为 configId 为空的 legacy 成员 + * 持久化 PENDING cleanup operation/items;事务提交后在 **Room 事务之外**调用 + * [drainPendingCleanup]:Room 读 PENDING → DataStore 单次 edit 精准清键并写 + * marker → Room ACK APPLIED。DataStore 失败抛异常、operation 保持 PENDING, + * 由冷启动(baseline 导入之前 / pending restore 之前)与下次删除入口重试。 + * * [daoProvider] 为测试注入缝:生产代码始终为 null,走 [AppDatabase] 的真实事务; * 测试注入时由测试自行验证调用语义(生产原子性由 DAO @Transaction 保证)。 */ @@ -181,8 +223,11 @@ object TokenStatsResetCoordinator { internal var daoProvider: ((Context) -> TokenStatsDao)? = null + private const val TAG = "TokenStatsReset" + suspend fun resetAllStatistics(context: Context) { withDao(context) { dao -> dao.resetAllStatisticsTx() } + drainPendingCleanup(context.applicationContext) TokenStatSpool.replay(context.applicationContext) } @@ -190,9 +235,78 @@ object TokenStatsResetCoordinator { val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) if (model.isBlank()) return withDao(context) { dao -> dao.resetModelTx(provider, model) } + drainPendingCleanup(context.applicationContext) + TokenStatSpool.replay(context.applicationContext) + } + + /** 删除时间范围 [startMs, endMs) 内的事件;baseline 一律保留(阶段 5)。 */ + suspend fun deleteEventsInRange(context: Context, startMs: Long, endMs: Long) { + withDao(context) { dao -> dao.deleteRangeEventsTx(startMs, endMs) } TokenStatSpool.replay(context.applicationContext) } + /** + * 按展示分组删除(阶段 5):组成员在 DAO 事务内从 identity 全表解析; + * [deleteBaselines] 为 true 时同时删除该组成员的 baseline,并在同一事务内 + * 为其中 legacy 成员持久化 cleanup operation(事务外立即排空 DataStore)。 + */ + suspend fun deleteDisplayModel( + context: Context, + displayModelId: String, + deleteBaselines: Boolean, + ) { + withDao(context) { dao -> dao.deleteDisplayModelEventsTx(displayModelId, deleteBaselines) } + drainPendingCleanup(context.applicationContext) + TokenStatSpool.replay(context.applicationContext) + } + + /** 删除全部事件;[deleteBaselines] 为 true 时同时删除全部 baseline(阶段 5)。 */ + suspend fun deleteAllEvents(context: Context, deleteBaselines: Boolean) { + withDao(context) { dao -> dao.deleteAllStatisticsTx(deleteBaselines) } + drainPendingCleanup(context.applicationContext) + TokenStatSpool.replay(context.applicationContext) + } + + /** + * 排空 pending legacy cleanup(P1 闭环):固定顺序 + * **Room 读 PENDING operations/items → DataStore apply(marker 幂等)→ + * Room ACK APPLIED**,单 operation 依次完成。 + * - DataStore apply 失败抛异常且 operation 保持 PENDING(下次启动/下次删除 + * 重试;marker 已在时重放为幂等 no-op); + * - 协程取消([CancellationException])向上传播,不吞; + * - 不占用任何 Room 事务等待 DataStore(本函数在删除事务之外调用)。 + */ + suspend fun drainPendingCleanup(context: Context) { + val appContext = context.applicationContext + val injected = daoProvider + val dao = + injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext).tokenStatsDao() + drainPendingCleanupWith(appContext, dao) + } + + /** + * 指定 DAO 的排空(runner/coordinator 共用):见 [drainPendingCleanup]。 + * runner 用它复用自己解析的数据库实例(测试注入路径)。 + */ + internal suspend fun drainPendingCleanupWith(appContext: Context, dao: TokenStatsDao) { + val pending = dao.getPendingCleanupOperations() + if (pending.isEmpty()) return + val prefs = ApiPreferences.getInstance(appContext) + for (operation in pending) { + val providerModels = + if (operation.scope == TokenStatCleanupOperationEntity.SCOPE_ALL) { + null + } else { + dao.getCleanupItems(operation.operationId) + .map { "${it.provider}:${it.model}" } + } + prefs.applyLegacyCleanup(operation.operationId, providerModels) + if (dao.ackCleanupOperation(operation.operationId) == 0) { + AppLogger.w(TAG, "cleanup operation ${operation.operationId} 已由其他排空完成") + } + } + } + private suspend fun withDao( context: Context, block: suspend (TokenStatsDao) -> Unit, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt index aae778d56..1048438d9 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt @@ -57,6 +57,28 @@ object TokenStatsLedger { return database.tokenStatsDao().currentResetGeneration() } + /** + * 请求接受边界(P1-1 修复):在**同一 Room 事务**内确保身份存在(INSERT IGNORE + + * 默认展示分组补齐)并读取当前 generation。展示分组删除与请求开始因此按事务原子 + * 串行化:删除要么看见该身份并写 IDENTITY tombstone(删除前接受的事件被跳过), + * 要么请求捕获 ≥ tombstone 的新 generation(删除后请求正常入账)——首次请求的 + * 身份绝不可能绕过分组删除 tombstone 复活旧事件。 + * @throws CancellationException 协程取消向上传播。 + */ + internal suspend fun ensureIdentityAndCaptureGeneration( + context: Context, + configId: String, + provider: String, + model: String, + ): Long { + val appContext = context.applicationContext + val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + return database.tokenStatsDao().ensureIdentityAndCaptureGenerationTx( + identityEntityFor(configId, provider, model), + displayModelEntityFor(model), + ) + } + /** * 记录一个请求事件。写入失败(非取消)只记录日志,不影响原响应/取消传播。 * @throws CancellationException 协程取消时向上传播,不吞掉。 @@ -260,31 +282,34 @@ object TokenStatsLedger { dao: TokenStatsDao, request: TokenStatRequestContext, ): TokenStatIdentityEntity { - val identity = - TokenStatIdentityEntity( - identityId = - TokenStatIdentityResolver.identityId( - request.configId, - request.provider, - request.model, - ), - configId = request.configId, - provider = request.provider, - model = request.model, - displayModelId = - TokenStatIdentityResolver.displayModelIdFor(request.model), - ) + val identity = identityEntityFor(request.configId, request.provider, request.model) dao.insertIdentityIfAbsent(identity) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = identity.displayModelId, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(request.model), - displayName = request.model, - ) - ) + dao.upsertDisplayModel(displayModelEntityFor(request.model)) return identity } + /** 身份行构造(请求边界与落账共用,与既有 [ensureIdentity] 规范化完全一致)。 */ + private fun identityEntityFor( + configId: String, + provider: String, + model: String, + ): TokenStatIdentityEntity = + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId(configId, provider, model), + configId = configId, + provider = provider, + model = model, + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + ) + + /** 默认展示分组行构造(默认规范化模型名分组,与既有 [ensureIdentity] 完全一致)。 */ + private fun displayModelEntityFor(model: String): TokenStatDisplayModelEntity = + TokenStatDisplayModelEntity( + displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), + normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), + displayName = model, + ) + /** 脱敏诊断字段:来源标签、是否观察到 usage、上报次数、attempt 数;无正文/凭据。 */ private fun buildDiagnosticsJson(request: TokenStatRequestContext): String? { val usage = request.aggregatedUsage() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index 2905087cf..4bae25e83 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -384,6 +384,7 @@ internal fun TokenStatsFilterBar( targetCurrency: PricingCurrency, onSelectPreset: (TokenStatsPreset) -> Unit, onCustomRange: () -> Unit, + onDeleteRange: () -> Unit, onToggleModel: (String) -> Unit, onSelectAllModels: () -> Unit, onToggleCategory: (TokenStatCategory) -> Unit, @@ -418,6 +419,14 @@ internal fun TokenStatsFilterBar( }, ) } + // 删除当前时间范围:只删有时间戳的事件,不触碰 baseline(阶段 5) + IconButton(onClick = onDeleteRange) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.token_stats_delete_range), + tint = MaterialTheme.colorScheme.error, + ) + } } Spacer(Modifier.height(8.dp)) @@ -727,7 +736,7 @@ internal fun TokenStatsModelCardsSection( costMode: TokenStatsCostMode, zone: ZoneId, onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, - onReset: (TokenStatsDisplayModelBreakdown) -> Unit, + onDelete: (TokenStatsDisplayModelBreakdown) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { models.forEach { model -> @@ -737,7 +746,7 @@ internal fun TokenStatsModelCardsSection( costMode = costMode, zone = zone, onGroupManage = { onGroupManage(model) }, - onReset = { onReset(model) }, + onDelete = { onDelete(model) }, ) } } @@ -750,11 +759,10 @@ internal fun TokenStatsModelCard( costMode: TokenStatsCostMode, zone: ZoneId, onGroupManage: () -> Unit, - onReset: () -> Unit, + onDelete: () -> Unit, ) { val colors = LocalTokenStatsColors.current var expanded by remember(model.displayModelId) { mutableStateOf(false) } - val canReset = model.identities.map { it.provider to it.model }.distinct().size == 1 Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { @@ -793,15 +801,15 @@ internal fun TokenStatsModelCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (canReset) { - IconButton(onClick = onReset) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.settings_reset_model_counts), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp), - ) - } + // 阶段 5:删除对完整展示分组生效(可跨 provider/模型合并组), + // 不再限制单 provider:model;危险操作在对话框两步确认。 + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(R.string.token_stats_delete_model), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) } IconButton(onClick = onGroupManage) { Icon( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index dbee85771..d9ebb4a7d 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -20,7 +20,6 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -79,8 +78,13 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { // 瞬态 UI 状态:可存 rememberSaveable 的在配置变化后保留(P1-3); // 筛选已在 VM state 中,天然跨配置变化保留。 var showCustomRange by rememberSaveable { mutableStateOf(false) } - var showResetAllDialog by rememberSaveable { mutableStateOf(false) } - var resetModel by remember { mutableStateOf(null) } + var showDeleteRangeDialog by rememberSaveable { mutableStateOf(false) } + // 全部删除两步确认:第一步危险确认,第二步 baseline 选择(阶段 5) + var showDeleteAllConfirm by rememberSaveable { mutableStateOf(false) } + var showDeleteAllBaseline by rememberSaveable { mutableStateOf(false) } + // 模型删除两步确认:目标模型 + baseline 选择 + var deleteModel by remember { mutableStateOf(null) } + var showDeleteModelBaseline by rememberSaveable { mutableStateOf(false) } var pricingTarget by remember { mutableStateOf(null) } var groupTarget by remember { mutableStateOf(null) } var perfMetric by rememberSaveable { mutableStateOf(PerfMetric.TTFT) } @@ -96,13 +100,13 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { CustomScaffold( floatingActionButton = { FloatingActionButton( - onClick = { showResetAllDialog = true }, + onClick = { showDeleteAllConfirm = true }, containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer, ) { Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(id = R.string.settings_reset_all_counts), + imageVector = Icons.Default.Delete, + contentDescription = stringResource(id = R.string.token_stats_delete_all_title), ) } }, @@ -126,7 +130,8 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { perfMetric = perfMetric, onTogglePerfMetric = { perfMetric = it }, onCustomRange = { showCustomRange = true }, - onResetModel = { resetModel = it }, + onDeleteRange = { showDeleteRangeDialog = true }, + onDeleteModel = { deleteModel = it }, onEditPricing = { pricingTarget = PricingTarget.Edit(it) }, onAddPricing = { pricingTarget = PricingTarget.New }, onGroupManage = { groupTarget = it }, @@ -208,66 +213,177 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { ) } - if (showResetAllDialog) { - val resetFailedMessage = stringResource(id = R.string.settings_token_stats_reset_failed) + // ==== 阶段 5 删除对话框 ==== + // 危险操作明确确认:范围删除单步确认(绝不触碰 baseline); + // 模型/全部删除两步确认(第一步危险确认 → 第二步选择是否同时删除 baseline)。 + + if (showDeleteRangeDialog) { AlertDialog( - onDismissRequest = { showResetAllDialog = false }, - title = { Text(stringResource(R.string.settings_reset_confirmation)) }, - text = { Text(stringResource(R.string.settings_reset_warning)) }, + onDismissRequest = { showDeleteRangeDialog = false }, + title = { Text(stringResource(R.string.token_stats_delete_range_title)) }, + text = { Text(stringResource(R.string.token_stats_delete_range_message)) }, confirmButton = { TextButton( onClick = { - viewModel.resetAllStatistics { error -> - if (error != null) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - showResetAllDialog = false + viewModel.deleteRangeEvents() + showDeleteRangeDialog = false }, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.settings_reset)) + Text(stringResource(R.string.token_stats_delete_confirm)) } }, dismissButton = { - TextButton(onClick = { showResetAllDialog = false }) { + TextButton(onClick = { showDeleteRangeDialog = false }) { Text(stringResource(R.string.settings_cancel)) } }, ) } - resetModel?.let { model -> + if (showDeleteAllConfirm) { AlertDialog( - onDismissRequest = { resetModel = null }, - title = { Text(stringResource(R.string.settings_reset_model_confirmation)) }, - text = { Text(stringResource(R.string.settings_reset_model_warning, model.displayName)) }, + onDismissRequest = { showDeleteAllConfirm = false }, + title = { Text(stringResource(R.string.token_stats_delete_all_title)) }, + text = { Text(stringResource(R.string.token_stats_delete_all_message)) }, confirmButton = { TextButton( onClick = { - viewModel.resetDisplayModel(model.displayModelId) { error -> - if (error != null) { - Toast.makeText(context, error, Toast.LENGTH_SHORT).show() - } - } - resetModel = null + showDeleteAllConfirm = false + showDeleteAllBaseline = true }, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.settings_reset)) + Text(stringResource(R.string.token_stats_delete_confirm)) } }, dismissButton = { - TextButton(onClick = { resetModel = null }) { + TextButton(onClick = { showDeleteAllConfirm = false }) { Text(stringResource(R.string.settings_cancel)) } }, ) } + + if (showDeleteAllBaseline) { + val baselineRows = state.lifetime?.baselineTotals?.identityCount ?: 0L + DeleteBaselineDialog( + title = stringResource(R.string.token_stats_delete_baseline_title), + message = stringResource(R.string.token_stats_delete_baseline_message_all, baselineRows), + onEventsOnly = { + showDeleteAllBaseline = false + viewModel.deleteAllStatistics(deleteBaselines = false) + }, + onEventsAndBaseline = { + showDeleteAllBaseline = false + viewModel.deleteAllStatistics(deleteBaselines = true) + }, + onDismiss = { showDeleteAllBaseline = false }, + ) + } + + deleteModel?.let { model -> + // 成员数取完整分组元数据(state.groupModels),与统计筛选无关(P1 修复) + val groupMembers = + state.groupModels.firstOrNull { it.displayModelId == model.displayModelId } + ?.memberIdentityIds?.size ?: model.identities.size + if (showDeleteModelBaseline) { + DeleteBaselineDialog( + title = stringResource(R.string.token_stats_delete_baseline_title), + message = stringResource( + R.string.token_stats_delete_baseline_message_model, + model.displayName, + ), + onEventsOnly = { + showDeleteModelBaseline = false + viewModel.deleteDisplayModel(model.displayModelId, deleteBaselines = false) + deleteModel = null + }, + onEventsAndBaseline = { + showDeleteModelBaseline = false + viewModel.deleteDisplayModel(model.displayModelId, deleteBaselines = true) + deleteModel = null + }, + onDismiss = { showDeleteModelBaseline = false }, + ) + } else { + AlertDialog( + onDismissRequest = { deleteModel = null }, + title = { Text(stringResource(R.string.token_stats_delete_model_title)) }, + text = { + Text( + stringResource( + R.string.token_stats_delete_model_message, + model.displayName, + groupMembers, + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteModelBaseline = true + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.token_stats_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { deleteModel = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } + } +} + +/** + * 删除的第二步:是否同时删除迁移的旧统计 baseline(阶段 5)。 + * 选择“仅删除事件”只删事件并保留 baseline;选择“删除事件与 baseline” + * 才删对应/全部 baseline;取消不做任何删除。 + */ +@Composable +private fun DeleteBaselineDialog( + title: String, + message: String, + onEventsOnly: () -> Unit, + onEventsAndBaseline: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton( + onClick = onEventsAndBaseline, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.token_stats_delete_events_and_baseline)) + } + }, + dismissButton = { + Row { + TextButton(onClick = onEventsOnly) { + Text(stringResource(R.string.token_stats_delete_events_only)) + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + } + }, + ) } private sealed interface PricingTarget { @@ -307,7 +423,8 @@ private fun TokenStatsPageContent( perfMetric: PerfMetric, onTogglePerfMetric: (PerfMetric) -> Unit, onCustomRange: () -> Unit, - onResetModel: (TokenStatsDisplayModelBreakdown) -> Unit, + onDeleteRange: () -> Unit, + onDeleteModel: (TokenStatsDisplayModelBreakdown) -> Unit, onEditPricing: (TokenStatPriceOverrideEntity) -> Unit, onAddPricing: () -> Unit, onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, @@ -343,6 +460,7 @@ private fun TokenStatsPageContent( targetCurrency = state.targetCurrency, onSelectPreset = viewModel::selectPreset, onCustomRange = onCustomRange, + onDeleteRange = onDeleteRange, onToggleModel = viewModel::toggleModel, onSelectAllModels = viewModel::selectAllModels, onToggleCategory = viewModel::toggleCategory, @@ -401,7 +519,7 @@ private fun TokenStatsPageContent( costMode = state.costMode, zone = zone, onGroupManage = onGroupManage, - onReset = onResetModel, + onDelete = onDeleteModel, ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index b9d40124f..63dbfb105 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -29,6 +29,7 @@ import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator import com.ai.assistance.operit.util.AppLogger import java.time.ZoneId import kotlinx.coroutines.CancellationException @@ -60,6 +61,12 @@ data class TokenStatsUiState( val selectedPreset: TokenStatsPreset = TokenStatsPreset.LAST_5H, /** 自定义范围的显式边界;非 CUSTOM 预设时为 null。 */ val customRange: TokenStatsTimeRange? = null, + /** + * 当前查询实际使用的时间范围(阶段 5 删除入口):与展示/查询完全同界—— + * CUSTOM 用自定义边界,其余预设用 [TokenStatsTimeRanges.rangeFor] 实时计算。 + * 删除当前范围必须与用户所见范围一致,不能在 UI 侧另行计算。 + */ + val currentRange: TokenStatsTimeRange? = null, /** true = 用户手动选择过时间(不再自动回退)。 */ val userChoseTime: Boolean = false, val targetCurrency: PricingCurrency = PricingCurrency.CNY, @@ -268,6 +275,7 @@ class TokenUsageStatisticsViewModel( costMode = mode, selectedPreset = preset, customRange = customRange, + currentRange = range, userChoseTime = userChoseTime, overrides = result.overrides, groupModels = result.groups, @@ -535,53 +543,104 @@ class TokenUsageStatisticsViewModel( } } - // ==== 旧重置入口(阶段 5 的完整删除语义不在本阶段实现,保持现有行为正确) ==== + // ==== 阶段 5:删除(范围/模型/全部,危险操作由 UI 两步确认) ==== + // 删除后统一 load() 全量重查:生命周期、范围、图表与模型明细全部刷新, + // 不留任何缓存旧数据。删除语义(baseline 只随“全部/模型 + 用户确认”删除): + // - 范围删除:只删有时间戳的事件(RANGE tombstone),绝不触碰 baseline; + // - 模型删除:完整展示分组(identity 全表解析成员,不依赖当前筛选), + // baseline 是否删除由 UI 第二步确认;确认删除时经 outbox 清理旧 DataStore + // 累计键(否则下次启动迁移会按旧快照把已删 baseline 重新导入); + // - 全部删除:FULL tombstone + 全部事件;baseline 是否删除由 UI 第二步 + // 确认,确认时走 resetAllProviderModelTokenCounts(Room 先删 + ALL cleanup + // operation 排空旧计数,保持已确认语义)。 + // 所有删 baseline 的路径都满足 P1 闭环:删除事务是唯一线性化点,operation + // 持久化在同一事务,DataStore 清理在事务外排空且 marker 幂等。 - /** 重置全部统计(旧计数 + 新账本),失败时返回错误消息。 */ - fun resetAllStatistics(onResult: (String?) -> Unit) { + /** + * 删除当前时间范围的事件([TokenStatsUiState.currentRange],与显示同界)。 + * 只删事件,绝不删除 baseline;失败时通过 [actionMessage] 提示。 + */ + fun deleteRangeEvents() { + val range = _state.value.currentRange ?: return viewModelScope.launch(dispatcher) { - val ok = runCatching { - ApiPreferences.getInstance(appContext).resetAllProviderModelTokenCounts() - }.getOrDefault(false) - onResult( - if (ok) { - null - } else { - stringResolver(R.string.settings_token_stats_reset_failed) - } - ) - load() + runCatching { + TokenStatsResetCoordinator.deleteEventsInRange(appContext, range.startMs, range.endMs) + }.onSuccess { load() }.onFailure { e -> + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_delete_range_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "删除时间范围统计失败", e) } + } } } /** - * 重置指定展示分组:仅当组内所有身份属于同一 provider:model 时支持 - * (与旧入口的 provider:model 语义一致);跨 provider/模型合并组 - * 返回错误消息,不扩展阶段 5 才实现的删除语义。 + * 删除指定展示分组的全部事件(完整组成员,DAO 事务内从 identity 全表解析)。 + * [deleteBaselines] 为 true 时同时删除该组成员的 baseline,并清理这些成员 + * 中**确实对应 legacy 身份**(configId 为空串)的 provider:model 旧 DataStore + * 累计键(防迁移重导复活);为 false 时 baseline 与旧键一律保留。身份行/分组/ + * 价格覆盖不删除。 + * + * P1 闭环:成员解析、tombstone、删除与 cleanup operation 持久化全部在 DAO + * **同一事务**内线性化(不再 VM 事务外预读),事务提交后由 + * [TokenStatsResetCoordinator] 立即排空 DataStore 累计键(marker 幂等, + * 失败保持 PENDING 由下次启动重试并向上报错)。 */ - fun resetDisplayModel(displayModelId: String, onResult: (String?) -> Unit) { + fun deleteDisplayModel(displayModelId: String, deleteBaselines: Boolean) { viewModelScope.launch(dispatcher) { - val identities = - runCatching { statsDao.getAllIdentities() }.getOrDefault(emptyList()) - .filter { it.displayModelId == displayModelId } - val providerModels = identities.map { it.provider to it.model }.distinct() - if (providerModels.size != 1) { - onResult(stringResolver(R.string.token_stats_reset_merged_unsupported)) - return@launch + try { + TokenStatsResetCoordinator.deleteDisplayModel(appContext, displayModelId, deleteBaselines) + load() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_delete_model_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "删除模型统计失败", e) } } - val (provider, model) = providerModels.single() - val ok = runCatching { - ApiPreferences.getInstance(appContext) - .resetProviderModelTokenCounts("$provider:$model") - }.getOrDefault(false) - onResult( + } + } + + /** + * 删除全部统计事件。 [deleteBaselines] 为 true 时走既有 + * [ApiPreferences.resetAllProviderModelTokenCounts](旧 DataStore 累计键 + + * 新账本事件与 baseline 一并清空);为 false 时只删新账本事件,baseline 与 + * 旧累计键保留。失败时通过 [actionMessage] 提示。 + */ + fun deleteAllStatistics(deleteBaselines: Boolean) { + viewModelScope.launch(dispatcher) { + try { + val ok = + if (deleteBaselines) { + ApiPreferences.getInstance(appContext).resetAllProviderModelTokenCounts() + } else { + TokenStatsResetCoordinator.deleteAllEvents(appContext, deleteBaselines = false) + true + } if (ok) { - null + load() } else { - stringResolver(R.string.settings_token_stats_reset_failed) + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.settings_token_stats_reset_failed), + isError = true, + ) } - ) - load() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _actionMessage.value = + TokenStatsActionMessage( + text = stringResolver(R.string.token_stats_delete_all_failed), + isError = true, + ) + runCatching { AppLogger.e(tag, "删除全部统计失败", e) } + } } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index dc234d19b..6faf356d9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3737,7 +3737,23 @@ Failed to create group Failed to merge group Failed to restore default group - This group spans multiple providers/models; reset is not supported here + Delete current time range + Delete Range Statistics + This will delete all events in the current time range (all models and categories). The migrated legacy baseline is not affected. This action cannot be undone. + Failed to delete range statistics. Please try again. + Delete this model + Delete Model Statistics + This will delete all events of %1$s (%2$d identities, all configs). This action cannot be undone. + Failed to delete model statistics. Please try again. + Delete All Statistics + This will delete statistics events of all models and categories. This action cannot be undone. + Failed to delete statistics. Please try again. + Delete Legacy Baseline? + Also delete the migrated legacy baseline (%1$d rows)? Choosing \"Events only\" keeps the baseline. + Also delete the migrated legacy baseline of %1$s? Choosing \"Events only\" keeps the baseline. + Events only + Events and baseline + Delete Previous time bucket Next time bucket Bucket %1$d of %2$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 17bada36f..dc1708be1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3652,7 +3652,23 @@ 创建分组失败 合并分组失败 恢复默认分组失败 - 该分组包含多个 Provider/模型,暂不支持此处重置 + 删除当前时间范围 + 删除时间范围统计 + 将删除当前时间范围内的全部事件(所有模型与分类),迁移的旧统计 baseline 不受影响。此操作不可恢复。 + 删除时间范围统计失败,请重试 + 删除该模型 + 删除模型统计 + 将删除 %1$s 的全部事件(%2$d 个身份,含所有配置),此操作不可恢复。 + 删除模型统计失败,请重试 + 删除全部统计 + 将删除全部模型与分类的统计事件,此操作不可恢复。 + 删除全部统计失败,请重试 + 删除旧统计 baseline? + 是否同时删除迁移的旧统计 baseline(%1$d 行)?选择“仅删除事件”会保留 baseline。 + 是否同时删除 %1$s 迁移的旧统计 baseline?选择“仅删除事件”会保留 baseline。 + 仅删除事件 + 删除事件与 baseline + 确认删除 上一个时间桶 下一个时间桶 第 %1$d / %2$d 桶 diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index 3657ac0c1..e973f556e 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -8,6 +8,7 @@ import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ModelParameter +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver import com.ai.assistance.operit.data.stats.PricingSource @@ -15,6 +16,7 @@ import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer import com.ai.assistance.operit.data.stats.TokenPriceResolver import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.data.stats.TokenStatStatus import com.ai.assistance.operit.data.stats.TokenStatsLedger @@ -203,6 +205,9 @@ class TokenTrackingAIServiceTest { private fun tracked(fake: FakeAiService): TokenTrackingAIService = TokenTrackingAIService(delegate = fake, context = context, configId = "cfg-1") + private fun tracked(fake: FakeAiService, configId: String): TokenTrackingAIService = + TokenTrackingAIService(delegate = fake, context = context, configId = configId) + private fun usage(): ProviderUsageSnapshot = ProviderUsageSnapshot( uncachedInputTokens = 800L, @@ -253,6 +258,9 @@ class TokenTrackingAIServiceTest { val release = CountDownLatch(1) val hangingDao = mock() whenever(hangingDao.currentResetGeneration()).thenReturn(0L) + // P1-1:请求接受边界在同一事务内建身份+取 generation——本测试聚焦 restore + // 对 wedged insert 的有界失败,边界直接返回 generation,不触碰挂起门闩 + whenever(hangingDao.ensureIdentityAndCaptureGenerationTx(any(), any())).thenReturn(0L) whenever(hangingDao.getAllPriceOverrides()).thenReturn(emptyList()) whenever(hangingDao.insertIdentityIfAbsent(any())).thenAnswer { gateIgnoringInterrupts(release) @@ -905,6 +913,79 @@ class TokenTrackingAIServiceTest { } } + @Test + fun `first request identity cannot bypass group deletion tombstone`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val dao = database.tokenStatsDao() + // cfg-a 已在默认展示组 deepseek-chat(FakeAiService 的 provider:model) + val identityA = TokenStatIdentityResolver.identityId("cfg-a", "DEEPSEEK", "deepseek-chat") + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityA, + configId = "cfg-a", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + ) + // cfg-b 首次请求:sendMessage 的接受边界原子创建身份并捕获 generation 0 + val fake = + FakeAiService { onUsage -> + stream { emit("hello"); onUsage?.invoke(usage(), 1) } + } + val stream = tracked(fake, configId = "cfg-b").sendMessage(context = context) + + // 请求进行中删除默认展示组:成员解析必须看见边界已创建的身份并写 tombstone + dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = false) + assertEquals(1L, dao.currentResetGeneration()) + + stream.collect { } + // 事件接受于删除前:排空被 IDENTITY tombstone 跳过,绝不复活 + assertEquals("old event must not resurrect", 0, dao.countEvents()) + assertNotNull( + "identity must exist (created atomically at the request boundary)", + dao.getIdentityByTriple("cfg-b", "DEEPSEEK", "deepseek-chat"), + ) + } + } + + @Test + fun `request after group deletion records normally with newer generation`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + val dao = database.tokenStatsDao() + val identityA = TokenStatIdentityResolver.identityId("cfg-a", "DEEPSEEK", "deepseek-chat") + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityA, + configId = "cfg-a", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) + ) + dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = false) + assertEquals(1L, dao.currentResetGeneration()) + + // 删除后的新请求:边界捕获 ≥ tombstone 的新 generation + val fake = + FakeAiService { onUsage -> + stream { emit("hello"); onUsage?.invoke(usage(), 1) } + } + val stream = tracked(fake, configId = "cfg-b").sendMessage(context = context) + stream.collect { } + + val events = dao.getAllEvents() + assertEquals(1, events.size) + val event = events.single() + assertEquals( + TokenStatIdentityResolver.identityId("cfg-b", "DEEPSEEK", "deepseek-chat"), + event.statIdentityId, + ) + assertEquals("post-deletion request must carry the new generation", 1L, event.acceptedGeneration) + } + } + + @Test fun `claude same attempt incremental usage keeps full snapshot and cost`() = runBlocking { database.tokenStatsDao().upsertPriceOverride( diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt index 3de2c7398..c16902ed2 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt @@ -105,6 +105,8 @@ class ApiPreferencesResetFailureTest { val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() val prefs = constructApiPreferences(contextWithFiles(tempDir)) val dao = mock() + // P1 闭环:coordinator 删除后立即排空 pending cleanup——mock 无 operation + whenever(dao.getPendingCleanupOperations()).thenReturn(emptyList()) TokenStatsResetCoordinator.daoProvider = { dao } try { @@ -122,6 +124,7 @@ class ApiPreferencesResetFailureTest { val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() val prefs = constructApiPreferences(contextWithFiles(tempDir)) val dao = mock() + whenever(dao.getPendingCleanupOperations()).thenReturn(emptyList()) TokenStatsResetCoordinator.daoProvider = { dao } try { @@ -136,6 +139,20 @@ class ApiPreferencesResetFailureTest { } } + @Test + fun `apply legacy cleanup is a safe single write and marker is readable`() = runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() + val prefs = constructApiPreferences(contextWithFiles(tempDir)) + // Windows DataStore 约束(本 JVM 环境同一文件只能“首写”一次):本测试的唯一 + // 真实写入。apply 的键级精准语义由 TokenStatsCleanupOutboxTest 的纯变更函数 + // 与真实排空测试覆盖;这里验证 edit 薄壳 + marker 读取(导入 fence 数据源)。 + prefs.applyLegacyCleanup("op-1", listOf("OPENAI:gpt-4o")) + assertEquals(setOf("op-1"), prefs.appliedLegacyCleanupMarkerIds()) + assertEquals(0L, prefs.getInputTokensForProviderModel("OPENAI:gpt-4o")) + assertTrue(prefs.legacyStatsSnapshotWithMarkers().cleanupMarkerIds == setOf("op-1")) + assertTrue(prefs.legacyStatsSnapshotWithMarkers().snapshot.providerModels.isEmpty()) + } + @Test fun `reset all propagates cancellation instead of swallowing it`() { Mockito.mockStatic(AppLogger::class.java).use { diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt index 7dbe57682..5604a7d87 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before @@ -31,14 +33,12 @@ import org.mockito.kotlin.whenever /** * 导入器测试: * 1. 取消传播:ensureMigrated 的 catch(Exception) 不得吞掉 CancellationException。 - * 2. 冻结价格 + 计数跟踪 + 恢复生命周期(真实 ApiPreferences 快照路径 + 真实 + * 2. 冻结价格 + 计数跟踪(真实 ApiPreferences 快照路径 + 真实 * Room 数据库): * - 无自定义价格迁移也冻结;计数不变时普通 setter/快照变化不重估 baseline; * - 计数变化(真实累计 setter 增长 / 用户 reset 降低)用行内冻结价格重估, * 整体替换为快照绝对值,不产生负增量;冻结价格列永不被普通启动替换; - * consumePendingRestore)才受控补导一次(替换冻结价格);部分字段恢复不触发 - * 任何启发式,直到 completion hook 才统一处理;相同 generation 重复消费幂等; - * - 受控补导不删除/不覆盖活动 DataStore 文件、不级联删除事件。 + * - legacy cleanup outbox fence:导入前排空 pending cleanup,旧快照被拒。 * * DataStore 隔离:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建一个 * DataStore 实例(绑定首个访问它的 Context),且每个文件的 DataStore 写入在 @@ -195,19 +195,27 @@ class TokenBaselineImportRunnerTest { @Test fun `cancellation propagates through import runner instead of being swallowed`() = runBlocking { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - val prefs = mock() - whenever(prefs.legacyStatsSnapshot()) - .thenThrow(CancellationException("import cancelled")) - injectApiPreferences(prefs) + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + TokenBaselineImportRunner.databaseProvider = { database } try { - TokenBaselineImportRunner.ensureMigrated(context) - fail("expected CancellationException to propagate") - } catch (e: CancellationException) { - assertEquals("import cancelled", e.message) + val context = mock() + whenever(context.applicationContext).thenReturn(context) + val prefs = mock() + // P1 闭环:快照读取已改为带 marker 的单次读取(fence 数据源) + whenever(prefs.legacyStatsSnapshotWithMarkers()) + .thenThrow(CancellationException("import cancelled")) + injectApiPreferences(prefs) + try { + TokenBaselineImportRunner.ensureMigrated(context) + fail("expected CancellationException to propagate") + } catch (e: CancellationException) { + assertEquals("import cancelled", e.message) + } } finally { injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() } } @@ -344,13 +352,19 @@ class TokenBaselineImportRunnerTest { val dao = database.tokenStatsDao() assertEquals(2, dao.countBaselines()) - // 显式重置 B:独立删除路径,只删 B 的 baseline + // 显式重置 B:独立删除路径,只删 B 的 baseline;P1 闭环会在删除事务后 + // 排空 B 的 legacy 累计键(该文件的唯一真实写入)——Windows 先读入 + // 缓存再移除磁盘文件,使排空 edit 的 rename 目标不存在。 + prefs.getInputTokensForProviderModel(providerModelB) + check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) TokenStatsResetCoordinator.daoProvider = { dao } try { - TokenStatsResetCoordinator.resetStatisticsForProviderModel( - ctx, - providerModelB, - ) + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatsResetCoordinator.resetStatisticsForProviderModel( + ctx, + providerModelB, + ) + } } finally { TokenStatsResetCoordinator.daoProvider = null } @@ -361,6 +375,9 @@ class TokenBaselineImportRunnerTest { val identityA = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") assertEquals(1_000_000L, dao.getBaseline(identityA)!!.inputTokens) + // 排空确认:B 的旧键已清除且 marker 已写(A 键保留) + assertEquals(0L, prefs.getInputTokensForProviderModel(providerModelB)) + assertEquals(1_000_000L, prefs.getInputTokensForProviderModel(providerModel)) } finally { injectApiPreferences(null) TokenBaselineImportRunner.databaseProvider = null @@ -494,4 +511,169 @@ class TokenBaselineImportRunnerTest { database.close() } } + + @Test + fun `cold start drains pending cleanup before import and deleted baseline never resurrects`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenBaselineImportRunner.databaseProvider = { database } + try { + // Room:legacy A 在 group-x(将随删除移除 baseline),legacy B 保留 + seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") + seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") + dao.upsertBaseline(legacyBaseline(identityIdA)) + // DataStore:A 与 B 都有累计计数 + val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerA)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L + prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L + prefs[ApiPreferences.getTokenOutputKey(providerModelB)] = 1_000_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val prefs = constructApiPreferences(ctx) + injectApiPreferences(prefs) + // Windows DataStore:读入缓存后移除磁盘文件,使排空的 edit 成为唯一真实写入 + prefs.getInputTokensForProviderModel(providerA) + check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) + + // 删除事务:A 的 baseline 删除 + PENDING operation(items 精确到 A) + val op = + dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + .cleanupOperation!! + assertEquals(1, dao.countPendingCleanupOperations()) + assertEquals(0, dao.countBaselines()) + + // 冷启动导入:先排空(A 键清除 + marker),再以清理后的快照导入 B + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + assertEquals("A baseline must stay deleted", null, dao.getBaseline(identityIdA)) + assertNotNull("B baseline must be imported from the remaining snapshot", dao.getBaseline(identityIdB)) + assertEquals(0, dao.countPendingCleanupOperations()) + assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) + assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) + assertEquals(2_000_000L, prefs.getInputTokensForProviderModel(providerModelB)) + + // 再次冷启动:幂等——B 不重复、A 不复活 + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + assertEquals(1, dao.countBaselines()) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + @Test + fun `import skips entirely while pending cleanup cannot drain`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenBaselineImportRunner.databaseProvider = { database } + try { + seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") + seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") + dao.upsertBaseline(legacyBaseline(identityIdA)) + val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! + + // DataStore 排空失败:ensureMigrated 捕获并跳过本次导入(不应用旧快照) + val failingPrefs = mock() + whenever(failingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) + .thenAnswer { throw java.io.IOException("datastore down") } + injectApiPreferences(failingPrefs) + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + assertEquals("import must not run while cleanup is pending", 0, dao.countBaselines()) + assertEquals(1, dao.countPendingCleanupOperations()) + + // 排空恢复后(模拟下次启动):清理完成、B 导入、A 保持删除 + val realPrefs = constructApiPreferences(ctx) + injectApiPreferences(realPrefs) + realPrefs.getInputTokensForProviderModel(providerA) + check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + assertNull(dao.getBaseline(identityIdA)) + assertNotNull(dao.getBaseline(identityIdB)) + assertEquals(0, dao.countPendingCleanupOperations()) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + + @Test + fun `stale snapshot missing the applied marker is rejected by the fence`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenBaselineImportRunner.databaseProvider = { database } + try { + seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") + // operation 已 APPLIED(清理完成)但快照读取发生在其 marker 写入之前: + // 模拟“先读旧快照 → cleanup 完成 → 旧快照写回”窗口 + val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() + val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + // Room:先建 op 再手动 ACK(模拟已完成清理、快照仍旧) + seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") + val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! + dao.ackCleanupOperation(op.operationId) + assertEquals(0, dao.countPendingCleanupOperations()) + + val stalePrefs = mock() + whenever(stalePrefs.legacyStatsSnapshotWithMarkers()) + .thenReturn( + com.ai.assistance.operit.data.preferences.LegacyStatsSnapshotRead( + snapshot = + com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( + mapOf( + ApiPreferences.getTokenInputKey(providerModelB).name to 2_000_000L, + ) + ), + cleanupMarkerIds = emptySet(), + ) + ) + injectApiPreferences(stalePrefs) + Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigrated(ctx) + } + assertEquals( + "stale snapshot without the applied marker must be rejected", + 0, + dao.countBaselines(), + ) + } finally { + injectApiPreferences(null) + TokenBaselineImportRunner.databaseProvider = null + database.close() + } + } + } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt new file mode 100644 index 000000000..668337604 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt @@ -0,0 +1,532 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.room.Room +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.data.preferences.applyLegacyCleanupMutation +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * legacy cleanup outbox 排空协议测试(阶段 5 P1 闭环): + * + * - 删除事务(唯一线性化点)提交后,coordinator 在 Room 事务外排空: + * Room 读 PENDING → DataStore 单次 edit 精准清键 + 写 marker → Room ACK APPLIED; + * - DataStore 失败(含取消)→ operation 保持 PENDING、异常/取消传播,重启排空重试; + * - marker 已存在 → 幂等 no-op(崩溃后重放不二次清键、不写值;新累计值保留); + * - ALL kind 清全部累计键但不触碰价格/计费方式等配置键与 marker 键; + * - 重启排空(无 PENDING)不触碰 DataStore。 + * + * Windows DataStore 约束:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建 + * 一个 DataStore 实例,且同一文件每次真实写入在 Windows 上不稳定(rename 目标 + * 已存在时失败)。因此每个测试阶段最多一次真实写入:先用独立 DataStore 实例生成 + * 种子文件([seedPreferencesFile]),复制到阶段目录([restorePreferencesInto]), + * 被测流程(排空的 apply)是阶段文件的唯一真实写入;失败路径用注入的 + * ApiPreferences mock 在边界模拟 DataStore 故障(协调器协议用真实 DAO 验证), + * 键级幂等语义由纯函数 [applyLegacyCleanupMutation] 直接验证——不绕过生产协议。 + */ +class TokenStatsCleanupOutboxTest { + + private val providerA = "DEEPSEEK:deepseek-chat" + private val providerB = "OPENAI:gpt-4o" + + @Before + fun isolate() { + clearApiDataStoreSingleton() + injectApiPreferences(null) + TokenStatsResetCoordinator.daoProvider = null + TokenBaselineImportRunner.databaseProvider = null + } + + // ==== DataStore 单例隔离与种子文件(与 TokenBaselineImportRunnerTest 同套技术) ==== + + private fun clearApiDataStoreSingleton() { + val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") + val delegateField = facade.getDeclaredField("apiDataStore\$delegate") + delegateField.isAccessible = true + val delegate = delegateField.get(null) + val instanceField = + delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } + instanceField.set(delegate, null) + } + + private fun injectApiPreferences(instance: ApiPreferences?) { + val field = + ApiPreferences::class.java + .getDeclaredField("INSTANCE") + .apply { isAccessible = true } + field.set(null, instance) + } + + private fun constructApiPreferences(context: Context): ApiPreferences { + val constructor = + ApiPreferences::class.java + .getDeclaredConstructor(Context::class.java) + .apply { isAccessible = true } + return constructor.newInstance(context) + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.noBackupFilesDir).thenReturn(File(filesDir, "no_backup")) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + private fun openDatabase(filesDir: File): AppDatabase = + Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations( + AppDatabase.MIGRATION_20_21, + AppDatabase.MIGRATION_20_21, + AppDatabase.MIGRATION_20_21, + ) + .allowMainThreadQueries() + .build() + + private fun seedPreferencesFile(seedFile: File, block: (MutablePreferences) -> Unit) { + seedFile.parentFile?.mkdirs() + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val store = + PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { seedFile }, + ) + runBlocking { store.edit { block(it) } } + } + + private fun restorePreferencesInto(filesDir: File, seedFile: File) { + val target = File(filesDir, "datastore/api_settings.preferences_pb") + target.parentFile?.mkdirs() + seedFile.copyTo(target, overwrite = true) + } + + private suspend fun seedLegacyIdentity( + dao: TokenStatsDao, + identityId: String, + configId: String, + provider: String, + model: String, + displayModelId: String, + ) { + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = displayModelId, + displayName = displayModelId, + ) + ) + } + + /** + * Windows DataStore 约束(与 VM 测试同款技术):DataStore 1.0.0 以 renameTo 原子 + * 替换,Windows 上目标文件已存在时替换失败。先用一次读把种子文件内容载入 + * DataStore 内存缓存(后续 edit 基于缓存状态),再移除磁盘文件,使被测流程的 + * edit(rename 目标不存在)成为该文件的唯一真实写入——不绕过生产协议。 + */ + private suspend fun primeDatastoreForWrite(phase: File, prefs: ApiPreferences) { + prefs.getInputTokensForProviderModel("DEEPSEEK:deepseek-chat") + check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) + } + + // ==== 排空协议 ==== + + @Test + fun `drain applies precise legacy keys for display group operation and acks`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + // Room:group-x 含 legacy A 与配置身份 cfg-C;group-y 含 legacy B + seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") + seedLegacyIdentity(dao, "x-cfg", "cfg-c", "OPENAI", "gpt-4o", "group-x") + seedLegacyIdentity(dao, "y-legacy", "", "OPENAI", "gpt-4o", "group-y") + + // DataStore:A 与 B 都有累计键,A 另有价格键与 request_count + val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + prefs[ApiPreferences.getTokenCachedInputKey(providerA)] = 200_000L + prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L + prefs[ApiPreferences.getRequestCountKey(providerA)] = 7 + prefs[ApiPreferences.getModelInputPriceKey(providerA)] = 2.0f + prefs[ApiPreferences.getTokenInputKey(providerB)] = 3_000_000L + prefs[ApiPreferences.getTokenOutputKey(providerB)] = 800_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val prefs = constructApiPreferences(ctx) + injectApiPreferences(prefs) + primeDatastoreForWrite(phase, prefs) + + // 删除事务创建 operation(items 精确到 legacy A) + val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + val op = result.cleanupOperation!! + assertEquals(1, dao.getCleanupItems(op.operationId).size) + + // 排空(该文件的唯一真实写入) + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + + // DataStore:A 键精准清除,B 键与价格配置保留,marker 已写 + assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) + assertEquals(0L, prefs.getOutputTokensForProviderModel(providerA)) + assertEquals(0, prefs.getRequestCountForProviderModel(providerA)) + assertEquals("other model counts must survive", 3_000_000L, prefs.getInputTokensForProviderModel(providerB)) + assertEquals("price config must never be cleared", 2.0, prefs.getModelInputPrice(providerA), 1e-9) + assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) + // Room:operation APPLIED、items 保留(lineage) + assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) + assertEquals(1, dao.getCleanupItems(op.operationId).size) + assertEquals(0, dao.countPendingCleanupOperations()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `datastore failure keeps operation pending and restart drain completes it`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") + val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! + + // 第一次排空:DataStore 边界失败(模拟 edit 抛 IOException)→ 传播、保持 PENDING + val failingPrefs = mock() + whenever(failingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) + .thenAnswer { throw IOException("datastore down") } + injectApiPreferences(failingPrefs) + try { + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + fail("drain must propagate the DataStore failure") + } catch (e: IOException) { + assertEquals("datastore down", e.message) + } + assertEquals( + "failed apply must keep the operation pending", + TokenStatCleanupOperationEntity.STATUS_PENDING, + dao.getPendingCleanupOperations().single().status, + ) + + // 模拟重启:注入真实 prefs(该文件的首个真实写入),排空重试成功 + val realPrefs = constructApiPreferences(ctx) + injectApiPreferences(realPrefs) + primeDatastoreForWrite(phase, realPrefs) + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + val applied = dao.getAllCleanupOperations().single() + assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, applied.status) + assertEquals(0L, ApiPreferences.getInstance(ctx).getInputTokensForProviderModel(providerA)) + assertEquals(setOf(op.operationId), ApiPreferences.getInstance(ctx).appliedLegacyCleanupMarkerIds()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `cancellation propagates through drain and operation stays pending`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") + val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! + + val cancellingPrefs = mock() + whenever(cancellingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) + .thenThrow(CancellationException("drain cancelled")) + injectApiPreferences(cancellingPrefs) + try { + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + fail("drain must propagate CancellationException") + } catch (e: CancellationException) { + assertEquals("drain cancelled", e.message) + } + assertEquals(1, dao.countPendingCleanupOperations()) + assertEquals(0, dao.ackCleanupOperation("never-acked")) + assertEquals(1, dao.countPendingCleanupOperations()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `read failure inside deletion transaction fails without cleanup or drain`() = + runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + // 事务内读取失败(成员解析抛错):整个删除事务失败——不得继续排空、 + // 不得产生任何 operation(生产原子性由 DAO @Transaction 回滚保证) + val failingDao = mock() + whenever(failingDao.deleteDisplayModelEventsTx("group-x", true)) + .thenThrow(RuntimeException("member read failed")) + TokenStatsResetCoordinator.daoProvider = { failingDao } + val ctx = mockContext(kotlin.io.path.createTempDirectory("outbox-phase").toFile()) + val spyPrefs = mock() + injectApiPreferences(spyPrefs) + try { + val failure = runCatching { + TokenStatsResetCoordinator.deleteDisplayModel(ctx, "group-x", deleteBaselines = true) + } + assertTrue("read failure must propagate", failure.isFailure) + verify(spyPrefs, never()).applyLegacyCleanup(any(), any()) + assertEquals( + "failed transaction must leave no pending operation", + 0, + dao.countPendingCleanupOperations(), + ) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `all kind clears every cumulative key and keeps prices and markers`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L + prefs[ApiPreferences.getTokenInputKey(providerB)] = 3_000_000L + prefs[ApiPreferences.getModelInputPriceKey(providerA)] = 2.0f + prefs[ApiPreferences.getBillingModeKey(providerB)] = "COUNT" + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val prefs = constructApiPreferences(ctx) + injectApiPreferences(prefs) + primeDatastoreForWrite(phase, prefs) + + val op = dao.deleteAllStatisticsTx(deleteBaselines = true).cleanupOperation!! + assertEquals(TokenStatCleanupOperationEntity.SCOPE_ALL, op.scope) + assertTrue(dao.getCleanupItems(op.operationId).isEmpty()) + + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + + assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) + assertEquals(0L, prefs.getInputTokensForProviderModel(providerB)) + assertEquals("price config must survive ALL cleanup", 2.0, prefs.getModelInputPrice(providerA), 1e-9) + assertEquals("billing config must survive ALL cleanup", "COUNT", prefs.getBillingModeForProviderModel(providerB).name) + assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) + assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `restart drain with nothing pending never touches datastore`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + val ctx = mockContext(kotlin.io.path.createTempDirectory("outbox-phase").toFile()) + // 无任何 operation + val spyPrefs = mock() + injectApiPreferences(spyPrefs) + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + verify(spyPrefs, never()).applyLegacyCleanup(any(), any()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + @Test + fun `marker present makes retry a strict no-op preserving re added counts`() = runBlocking { + val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() + val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() + val database = openDatabase(dbDir) + val dao = database.tokenStatsDao() + TokenStatsResetCoordinator.daoProvider = { dao } + try { + seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") + val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! + + // 崩溃窗口:marker 已写(apply 完成)但 ACK 未提交;随后新使用重新累计了计数 + val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() + val seedFile = File(seed, "seed.preferences_pb") + seedPreferencesFile(seedFile) { prefs -> + prefs[ApiPreferences.legacyCleanupMarkerKey(op.operationId)] = true + prefs[ApiPreferences.getTokenInputKey(providerA)] = 42_000L + } + restorePreferencesInto(phase, seedFile) + val ctx = mockContext(phase) + val prefs = constructApiPreferences(ctx) + injectApiPreferences(prefs) + + // 重试排空:marker 已存在 → 幂等 no-op(不二次清键、不写值),只 ACK + Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } + assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) + assertEquals( + "fresh usage after the crash must be preserved (no double clear)", + 42_000L, + prefs.getInputTokensForProviderModel(providerA), + ) + assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) + } finally { + TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) + database.close() + } + } + + // ==== 纯变更函数(键级语义,无 I/O) ==== + + @Test + fun `mutation clears exact provider models and writes marker`() = runBlocking { + val prefs = androidx.datastore.preferences.core.preferencesOf( + ApiPreferences.getTokenInputKey(providerA) to 1L, + ApiPreferences.getTokenCachedInputKey(providerA) to 2L, + ApiPreferences.getTokenOutputKey(providerA) to 3L, + ApiPreferences.getRequestCountKey(providerA) to 4, + ApiPreferences.getTokenInputKey(providerB) to 5L, + ApiPreferences.getModelInputPriceKey(providerA) to 2.0f, + ApiPreferences.getBillingModeKey(providerB) to "TOKEN", + ).toMutablePreferences() + + applyLegacyCleanupMutation(prefs, "op-1", listOf(providerA)) + + assertNull(prefs[ApiPreferences.getTokenInputKey(providerA)]) + assertNull(prefs[ApiPreferences.getTokenCachedInputKey(providerA)]) + assertNull(prefs[ApiPreferences.getTokenOutputKey(providerA)]) + assertNull(prefs[ApiPreferences.getRequestCountKey(providerA)]) + assertEquals(5L, prefs[ApiPreferences.getTokenInputKey(providerB)]) + assertEquals(2.0f, prefs[ApiPreferences.getModelInputPriceKey(providerA)]) + assertEquals("TOKEN", prefs[ApiPreferences.getBillingModeKey(providerB)]) + assertEquals(true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-1")]) + } + + @Test + fun `mutation ALL clears cumulative keys and keeps config and markers`() = runBlocking { + val prefs = androidx.datastore.preferences.core.preferencesOf( + ApiPreferences.getTokenInputKey(providerA) to 1L, + ApiPreferences.getTokenOutputKey(providerB) to 3L, + ApiPreferences.getRequestCountKey(providerB) to 4, + ApiPreferences.getModelInputPriceKey(providerA) to 2.0f, + ApiPreferences.legacyCleanupMarkerKey("op-old") to true, + ).toMutablePreferences() + + applyLegacyCleanupMutation(prefs, "op-all", null) + + assertNull(prefs[ApiPreferences.getTokenInputKey(providerA)]) + assertNull(prefs[ApiPreferences.getTokenOutputKey(providerB)]) + assertNull(prefs[ApiPreferences.getRequestCountKey(providerB)]) + assertEquals("price config must survive ALL", 2.0f, prefs[ApiPreferences.getModelInputPriceKey(providerA)]) + assertEquals("old markers must survive ALL", true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-old")]) + assertEquals("new marker must be written", true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-all")]) + } + + @Test + fun `mutation with existing marker is a strict no-op`() = runBlocking { + val prefs = androidx.datastore.preferences.core.preferencesOf( + ApiPreferences.legacyCleanupMarkerKey("op-1") to true, + ApiPreferences.getTokenInputKey(providerA) to 42L, + ).toMutablePreferences() + + applyLegacyCleanupMutation(prefs, "op-1", listOf(providerA)) + applyLegacyCleanupMutation(prefs, "op-1", null) + + assertEquals( + "marker present must never re-clear keys", + 42L, + prefs[ApiPreferences.getTokenInputKey(providerA)], + ) + assertEquals(true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-1")]) + } + + @Test + fun `mutation with blank operationId is rejected`() = runBlocking { + val prefs = androidx.datastore.preferences.core.preferencesOf().toMutablePreferences() + try { + applyLegacyCleanupMutation(prefs, "", listOf(providerA)) + fail("blank operationId must be rejected") + } catch (e: IllegalArgumentException) { + // expected + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt new file mode 100644 index 000000000..b700dc599 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt @@ -0,0 +1,628 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity +import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity +import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity +import com.ai.assistance.operit.data.model.TokenStatEventEntity +import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * 阶段 5 删除矩阵的 DAO 级测试(真实 Room + JVM SQLite 驱动): + * + * - 时间范围删除只删有时间戳的事件,边界 [startMs, endMs) 精确,绝不碰 baseline; + * - 按展示分组删除覆盖完整组成员(跨 provider/model 合并组),IDENTITY tombstone + * 精确到身份,同一 provider:model 的其他分组不受影响; + * baseline 是否删除由参数决定(yes/no 两分支); + * - 全部删除 yes/no 两分支;身份/展示分组/价格覆盖一律保留; + * - RANGE/IDENTITY tombstone 与 spool 排空插入同界:删除前接受的事件不复活, + * 删除后接受的事件正常入账(统一 generation 计数,不依赖墙钟); + * - 删除后查询/计数/聚合结果一致,无缓存旧数据。 + */ +class TokenStatsDeletionTest { + + private lateinit var tempDir: File + private lateinit var database: AppDatabase + private lateinit var dao: TokenStatsDao + + @Before + fun setUp() { + tempDir = kotlin.io.path.createTempDirectory("token-stats-deletion-test").toFile() + val context = mockContext(tempDir) + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .allowMainThreadQueries() + .build() + dao = database.tokenStatsDao() + } + + @After + fun tearDown() { + database.close() + } + + private fun mockContext(filesDir: File): Context { + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.packageName).thenReturn("com.ai.assistance.operit") + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.getDatabasePath(any())).thenAnswer { invocation -> + File(filesDir, invocation.getArgument(0)) + } + return context + } + + private suspend fun seedIdentity( + identityId: String, + configId: String = "cfg-1", + provider: String = "OPENAI", + model: String = "gpt-4o", + displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), + ) { + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = configId, + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = displayModelId, + normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), + displayName = model, + ) + ) + } + + private fun event( + id: String, + identityId: String, + startedAtMs: Long, + generation: Long = 0L, + status: String = TokenStatStatus.COMPLETED.name, + ): TokenStatEventEntity = + TokenStatEventEntity( + eventId = id, + statIdentityId = identityId, + category = TokenStatCategory.CHAT.name, + status = status, + acceptedGeneration = generation, + startedAtMs = startedAtMs, + endedAtMs = startedAtMs + 1_000L, + firstTokenAtMs = startedAtMs + 200L, + uncachedInputTokens = 100L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = null, + outputTokens = 50L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.5, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = 6.0, + pricePerRequest = null, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.01, + diagnosticsJson = null, + ) + + private fun baseline(identityId: String, requestCount: Long = 3L): TokenStatBaselineEntity = + TokenStatBaselineEntity( + identityId = identityId, + inputTokens = 100L * requestCount, + cachedInputTokens = 0L, + outputTokens = 50L * requestCount, + requestCount = requestCount, + pricingCurrency = PricingCurrency.USD.name, + costInPricingCurrency = 0.01 * requestCount, + isEstimated = true, + fingerprint = "fp-$identityId", + importedAtMs = 1L, + frozenBillingMode = BillingMode.TOKEN.name, + frozenInputPricePerMillion = 1.0, + frozenOutputPricePerMillion = 2.0, + ) + + // ==== 时间范围删除 ==== + + @Test + fun `range deletion removes only in range events and never touches baseline`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a") + seedIdentity("id-b", configId = "cfg-b") + dao.upsertBaseline(baseline("id-a")) + dao.upsertBaseline(baseline("id-b")) + + // 半开区间 [10_000, 20_000):startMs 在界内的删,endMs 处与界外的保留 + dao.insertEvent(event("in-1", "id-a", startedAtMs = 10_000L)) + dao.insertEvent(event("in-2", "id-a", startedAtMs = 19_999L)) + dao.insertEvent(event("boundary-end", "id-a", startedAtMs = 20_000L)) + dao.insertEvent(event("before", "id-b", startedAtMs = 9_999L)) + dao.insertEvent(event("after", "id-b", startedAtMs = 20_001L)) + + val deleted = dao.deleteRangeEventsTx(10_000L, 20_000L) + assertEquals(2, deleted) + + assertEquals("boundary-end must survive at endMs", "boundary-end", dao.getEvent("boundary-end")!!.eventId) + assertEquals("before must survive", "before", dao.getEvent("before")!!.eventId) + assertEquals("after must survive", "after", dao.getEvent("after")!!.eventId) + assertNull(dao.getEvent("in-1")) + assertNull(dao.getEvent("in-2")) + assertEquals(3, dao.countEvents()) + + // baseline 绝不因范围删除被触碰 + assertEquals(2, dao.countBaselines()) + assertNotNull(dao.getBaseline("id-a")) + assertNotNull(dao.getBaseline("id-b")) + + // 身份/展示分组/价格覆盖保留(两个身份同模型 → 同一默认展示组一行) + assertNotNull(dao.getIdentity("id-a")) + assertNotNull(dao.getIdentity("id-b")) + assertEquals(1, dao.getAllDisplayModels().size) + + // 删除后查询一致:范围内无事件 + assertFalse(dao.rangeHasEvents(10_000L, 20_000L)) + assertEquals(1, dao.rangeCutoffs().size) + } + + @Test + fun `range deletion rejects invalid boundaries`() = runBlocking { + val failure = runCatching { dao.deleteRangeEventsTx(20_000L, 10_000L) } + assertTrue("end <= start must be rejected", failure.isFailure) + assertEquals(0, dao.rangeCutoffs().size) + } + + @Test + fun `range deletion tombstone skips pre deletion in range events and accepts post deletion ones`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a") + // 删除范围 [10_000, 20_000),删除时 generation 递增为 1 + dao.insertEvent(event("old-in", "id-a", startedAtMs = 12_000L, generation = 0L)) + dao.insertEvent(event("old-out", "id-a", startedAtMs = 30_000L, generation = 0L)) + dao.deleteRangeEventsTx(10_000L, 20_000L) + assertEquals(1, dao.countEvents()) + assertNull(dao.getEvent("old-in")) + assertNotNull(dao.getEvent("old-out")) + + // 删除前接受但尚未入账的排空事件:落在范围内 → 跳过(不复活) + assertFalse(dao.insertEventIfNotResetCovered(event("drain-in", "id-a", startedAtMs = 12_000L, generation = 0L))) + // 删除前接受但范围外 → 正常入账 + assertTrue(dao.insertEventIfNotResetCovered(event("drain-out", "id-a", startedAtMs = 30_000L, generation = 0L))) + // 删除后接受的新事件(generation >= cutoff):即使落在已删范围内也正常入账 + assertTrue(dao.insertEventIfNotResetCovered(event("new-in", "id-a", startedAtMs = 12_001L, generation = 1L))) + + assertEquals(3, dao.countEvents()) + assertNull(dao.getEvent("drain-in")) + assertNotNull(dao.getEvent("drain-out")) + assertNotNull(dao.getEvent("new-in")) + } + + // ==== 按展示分组删除 ==== + + @Test + fun `model deletion covers full group members across providers and preserves other groups`() = + runBlocking { + // 展示组 group-x:两个身份,来自不同 provider:model + seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("x-2", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") + // 其他分组:与 x-1 同 provider:model(验证精确到身份,不误伤) + seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") + + dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L)) + dao.insertEvent(event("e-x2", "x-2", startedAtMs = 2_000L)) + dao.insertEvent(event("e-y1", "y-1", startedAtMs = 3_000L)) + dao.upsertBaseline(baseline("x-1")) + dao.upsertBaseline(baseline("x-2")) + dao.upsertBaseline(baseline("y-1")) + + // 不删 baseline:只删组内事件,组外(含同 provider:model)事件与全部 baseline 保留 + val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) + assertEquals(2, deleted.deletedEvents) + assertNull("non-legacy group with baseline=no creates no cleanup operation", deleted.cleanupOperation) + assertNull(dao.getEvent("e-x1")) + assertNull(dao.getEvent("e-x2")) + assertNotNull("same provider:model in another group must survive", dao.getEvent("e-y1")) + assertEquals(3, dao.countBaselines()) + assertNotNull(dao.getBaseline("x-1")) + + // 身份行保留(只清计数,保留配置/分组) + assertNotNull(dao.getIdentity("x-1")) + assertEquals("group-x", dao.getIdentity("x-1")!!.displayModelId) + } + + @Test + fun `model deletion with baseline removes group member baselines only`() = runBlocking { + seedIdentity("x-1", displayModelId = "group-x") + seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") + dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L)) + dao.insertEvent(event("e-y1", "y-1", startedAtMs = 2_000L)) + dao.upsertBaseline(baseline("x-1")) + dao.upsertBaseline(baseline("y-1")) + + val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + assertEquals(1, deleted.deletedEvents) + assertNull( + "group without legacy members must not create a cleanup operation", + deleted.cleanupOperation, + ) + assertNull(dao.getEvent("e-x1")) + assertNotNull(dao.getEvent("e-y1")) + assertNull("group baseline must be deleted", dao.getBaseline("x-1")) + assertNotNull("other group baseline must survive", dao.getBaseline("y-1")) + assertEquals(1, dao.countBaselines()) + } + + @Test + fun `model deletion writes identity tombstones that do not cover other groups`() = + runBlocking { + seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") + + dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L, generation = 0L)) + dao.insertEvent(event("e-y1", "y-1", startedAtMs = 2_000L, generation = 0L)) + dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) + assertEquals(1, dao.countEvents()) + assertNotNull(dao.getEvent("e-y1")) + + // 删除前接受、删除后排空的同 provider:model 事件: + // 组成员身份 → 跳过;其他分组身份 → 正常入账(IDENTITY 精确到身份) + assertFalse(dao.insertEventIfNotResetCovered(event("drain-x", "x-1", startedAtMs = 3_000L, generation = 0L))) + assertTrue(dao.insertEventIfNotResetCovered(event("drain-y", "y-1", startedAtMs = 3_000L, generation = 0L))) + assertNull(dao.getEvent("drain-x")) + assertNotNull(dao.getEvent("drain-y")) + + // 删除后新接受的事件正常入账 + assertTrue(dao.insertEventIfNotResetCovered(event("new-x", "x-1", startedAtMs = 4_000L, generation = 1L))) + assertNotNull(dao.getEvent("new-x")) + } + + @Test + fun `model deletion on empty group is a no-op`() = runBlocking { + seedIdentity("y-1", displayModelId = "group-y") + dao.insertEvent(event("e-y1", "y-1", startedAtMs = 1_000L)) + + val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + assertEquals(0, deleted.deletedEvents) + assertNull(deleted.cleanupOperation) + assertEquals(1, dao.countEvents()) + assertEquals("empty group must not write tombstones", 0L, dao.currentResetGeneration()) + } + // ==== 请求接受边界原子性(P1-1) ==== + + /** + * 首次请求(身份尚不存在)在**请求接受边界**原子创建身份并捕获 generation 0; + * 随后删除默认展示组:成员解析必须看见边界已创建的身份并写 IDENTITY tombstone, + * 删除前接受的事件在排空/落账时被跳过,绝不复活。 + */ + @Test + fun `request boundary before group deletion is covered by identity tombstone`() = runBlocking { + seedIdentity("id-a", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o") + dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L, generation = 0L)) + + // 请求接受边界:同一事务内创建 cfg-b 身份(同模型默认组)并读取 generation + val generation = + dao.ensureIdentityAndCaptureGenerationTx( + identity = + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("cfg-b", "OPENAI", "gpt-4o"), + configId = "cfg-b", + provider = "OPENAI", + model = "gpt-4o", + displayModelId = "gpt-4o", + ), + displayModel = + TokenStatDisplayModelEntity( + displayModelId = "gpt-4o", + normalizedModel = "gpt-4o", + displayName = "gpt-4o", + ), + ) + assertEquals("first request captures generation 0", 0L, generation) + + // 删除默认展示组:事务内从 identity 全表解析成员 → 必须包含边界刚创建的身份 + val deleted = dao.deleteDisplayModelEventsTx("gpt-4o", deleteBaselines = false) + assertEquals(1, deleted.deletedEvents) + assertEquals(1L, dao.currentResetGeneration()) + + // cfg-b 的旧事件(接受于删除前)排空时被 IDENTITY tombstone 跳过,不复活 + val identityB = dao.getIdentityByTriple("cfg-b", "OPENAI", "gpt-4o")!! + assertFalse( + dao.insertEventIfNotResetCovered( + event("drain-b", identityB.identityId, startedAtMs = 2_000L, generation = 0L) + ) + ) + assertNull(dao.getEvent("drain-b")) + assertEquals(0, dao.countEvents()) + } + + /** 删除先于请求边界:边界捕获 ≥ tombstone 的新 generation,新请求事件正常入账。 */ + @Test + fun `request boundary after group deletion captures newer generation and records normally`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o") + dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L, generation = 0L)) + dao.deleteDisplayModelEventsTx("gpt-4o", deleteBaselines = false) + assertEquals(1L, dao.currentResetGeneration()) + + val generation = + dao.ensureIdentityAndCaptureGenerationTx( + identity = + TokenStatIdentityEntity( + identityId = TokenStatIdentityResolver.identityId("cfg-b", "OPENAI", "gpt-4o"), + configId = "cfg-b", + provider = "OPENAI", + model = "gpt-4o", + displayModelId = "gpt-4o", + ), + displayModel = + TokenStatDisplayModelEntity( + displayModelId = "gpt-4o", + normalizedModel = "gpt-4o", + displayName = "gpt-4o", + ), + ) + assertEquals("boundary after deletion must capture new generation", 1L, generation) + + // 删除后接受的请求事件正常入账(acceptedGeneration >= tombstone) + val identityB = dao.getIdentityByTriple("cfg-b", "OPENAI", "gpt-4o")!! + assertTrue( + dao.insertEventIfNotResetCovered( + event("new-b", identityB.identityId, startedAtMs = 3_000L, generation = generation) + ) + ) + assertNotNull(dao.getEvent("new-b")) + assertEquals(1, dao.countEvents()) + } + + // ==== 全部删除 ==== + + @Test + fun `delete all without baseline keeps baseline and clears events`() = runBlocking { + seedIdentity("id-a", configId = "cfg-a") + seedIdentity("id-b", configId = "cfg-b") + dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) + dao.insertEvent(event("e-b", "id-b", startedAtMs = 2_000L)) + dao.upsertBaseline(baseline("id-a")) + + dao.deleteAllStatisticsTx(deleteBaselines = false) + assertEquals(0, dao.countEvents()) + assertEquals("baseline must survive when not confirmed", 1, dao.countBaselines()) + assertNotNull(dao.getIdentity("id-a")) + assertNotNull(dao.getIdentity("id-b")) + assertEquals(1, dao.getAllDisplayModels().size) + assertNotNull("FULL tombstone must be written", dao.fullResetCutoff()) + } + + @Test + fun `delete all with baseline removes events and baseline and keeps identity config`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a") + dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) + dao.upsertBaseline(baseline("id-a")) + dao.upsertPriceOverride( + scope = "PROVIDER_MODEL", + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 9.0, + outputPricePerMillion = 9.0, + ) + + dao.deleteAllStatisticsTx(deleteBaselines = true) + assertEquals(0, dao.countEvents()) + assertEquals(0, dao.countBaselines()) + // 身份/展示分组/价格覆盖保留(“重置只清计数、保留配置”语义) + assertNotNull(dao.getIdentity("id-a")) + assertEquals(1, dao.getAllDisplayModels().size) + assertEquals(1, dao.getAllPriceOverrides().size) + } + + @Test + fun `full deletion supersedes older tombstones and unified generation never collides`() = + runBlocking { + seedIdentity("x-1", displayModelId = "group-x") + seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") + + // 依次执行三种删除,generation 跨两表统一递增:1(范围)→ 2(模型)→ 3(全部) + dao.insertEvent(event("e-x", "x-1", startedAtMs = 1_000L, generation = 0L)) + dao.insertEvent(event("e-y", "y-1", startedAtMs = 2_000L, generation = 0L)) + dao.deleteRangeEventsTx(500L, 1_500L) + assertEquals(1L, dao.currentResetGeneration()) + dao.deleteDisplayModelEventsTx("group-y", deleteBaselines = true) + assertEquals(2L, dao.currentResetGeneration()) + assertEquals(0, dao.countEvents()) + + dao.deleteAllStatisticsTx(deleteBaselines = true) + assertEquals(3L, dao.currentResetGeneration()) + // 卫生:FULL 之后旧边界(RANGE/MODEL)全部清除,只剩 FULL + assertTrue("range cutoffs must be cleared by full deletion", dao.rangeCutoffs().isEmpty()) + assertTrue("model cutoffs must be cleared by full deletion", dao.modelResetCutoffs().isEmpty()) + assertEquals(3L, dao.fullResetCutoff()!!.generation) + + // FULL 覆盖:更早接受的事件全部跳过(含跨越中间删除的 generation) + assertFalse(dao.insertEventIfNotResetCovered(event("drain-old", "x-1", startedAtMs = 1_000L, generation = 0L))) + assertFalse(dao.insertEventIfNotResetCovered(event("drain-old-2", "y-1", startedAtMs = 2_000L, generation = 1L))) + // 删除后新事件正常 + assertTrue(dao.insertEventIfNotResetCovered(event("new", "x-1", startedAtMs = 3_000L, generation = 3L))) + assertEquals(1, dao.countEvents()) + assertNotNull(dao.getEvent("new")) + } + + // ==== legacy cleanup outbox(阶段 5 P1 闭环) ==== + + @Test + fun `display group deletion with baseline creates cleanup operation with exact legacy items`() = + runBlocking { + // group-x:legacy A(configId="")、配置身份 cfg-B、legacy C —— 只登记 A 和 C + seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("x-cfg-b", configId = "cfg-b", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") + seedIdentity("x-legacy-c", configId = "", provider = "ANTHROPIC", model = "claude-3-5-sonnet", displayModelId = "group-x") + dao.upsertBaseline(baseline("x-legacy-a")) + dao.upsertBaseline(baseline("x-cfg-b")) + dao.upsertBaseline(baseline("x-legacy-c")) + + val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + assertEquals(0, result.deletedEvents) + val op = result.cleanupOperation + assertNotNull("legacy members must produce a cleanup operation", op) + assertEquals(TokenStatCleanupOperationEntity.SCOPE_DISPLAY_GROUP, op!!.scope) + assertEquals("group-x", op.targetRef) + assertEquals(TokenStatCleanupOperationEntity.STATUS_PENDING, op.status) + val items = dao.getCleanupItems(op.operationId) + assertEquals( + "only legacy members are registered as immutable provider:model snapshots", + listOf("OPENAI:gpt-4o", "ANTHROPIC:claude-3-5-sonnet"), + items.map { "${it.provider}:${it.model}" }, + ) + assertEquals(listOf("x-legacy-a", "x-legacy-c"), items.map { it.identityId }) + // baseline 全删(含配置身份),身份/分组保留 + assertNull(dao.getBaseline("x-legacy-a")) + assertNull(dao.getBaseline("x-cfg-b")) + assertNull(dao.getBaseline("x-legacy-c")) + assertEquals(3, dao.getAllIdentities().size) + } + + @Test + fun `display group deletion with baseline=no never creates cleanup operation`() = runBlocking { + seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + dao.upsertBaseline(baseline("x-legacy-a")) + + // baseline=no:即使组内有 legacy 成员也不建 operation、不清共享键 + val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) + assertEquals(0, result.deletedEvents) + assertNull(result.cleanupOperation) + assertEquals(0, dao.countPendingCleanupOperations()) + assertNotNull("baseline must survive when not confirmed", dao.getBaseline("x-legacy-a")) + } + + @Test + fun `member moves linearize with the deletion transaction snapshot`() = runBlocking { + seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("f-legacy", configId = "", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") + seedIdentity("e-legacy", configId = "", provider = "ANTHROPIC", model = "claude-3-5-sonnet", displayModelId = "group-other") + + // 已提交的移入/移出(各自独立事务)——删除事务必须看到提交后的成员归属 + dao.updateIdentityDisplayModel("f-legacy", "group-other") + dao.updateIdentityDisplayModel("e-legacy", "group-x") + + val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + assertEquals(0, result.deletedEvents) + val items = dao.getCleanupItems(result.cleanupOperation!!.operationId) + assertEquals( + "deletion must use the membership snapshot at transaction time", + listOf("e-legacy", "x-legacy-a"), + items.map { it.identityId }.sorted(), + ) + // 删除事务提交后再移入的成员绝不被该 operation 覆盖(快照不可变) + dao.updateIdentityDisplayModel("f-legacy", "group-x") + assertEquals( + listOf("e-legacy", "x-legacy-a"), + dao.getCleanupItems(result.cleanupOperation!!.operationId).map { it.identityId }.sorted(), + ) + } + + @Test + fun `delete all with baseline creates ALL kind operation and without baseline creates none`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a") + dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) + dao.upsertBaseline(baseline("id-a")) + + val noBaseline = dao.deleteAllStatisticsTx(deleteBaselines = false) + assertEquals(1, noBaseline.deletedEvents) + assertNull("baseline=no must not create ALL cleanup", noBaseline.cleanupOperation) + assertEquals(0, dao.countPendingCleanupOperations()) + assertNotNull(dao.getBaseline("id-a")) + + val withBaseline = dao.deleteAllStatisticsTx(deleteBaselines = true) + assertEquals(0, withBaseline.deletedEvents) + val op = withBaseline.cleanupOperation + assertNotNull(op) + assertEquals(TokenStatCleanupOperationEntity.SCOPE_ALL, op!!.scope) + assertEquals(TokenStatCleanupOperationEntity.STATUS_PENDING, op.status) + assertTrue("ALL kind carries no items", dao.getCleanupItems(op.operationId).isEmpty()) + } + + @Test + fun `model reset creates cleanup operation only for legacy members`() = runBlocking { + seedIdentity("m-legacy", configId = "", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "deepseek-chat") + seedIdentity("m-cfg", configId = "cfg-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "deepseek-chat") + seedIdentity("other", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "gpt-4o") + dao.upsertBaseline(baseline("m-legacy")) + dao.upsertBaseline(baseline("m-cfg")) + dao.upsertBaseline(baseline("other")) + + val op = dao.resetModelTx("DEEPSEEK", "deepseek-chat") + assertNotNull(op) + assertEquals(TokenStatCleanupOperationEntity.SCOPE_MODEL, op!!.scope) + assertEquals("DEEPSEEK:deepseek-chat", op.targetRef) + val items = dao.getCleanupItems(op.operationId) + assertEquals("only legacy members are registered", listOf("m-legacy"), items.map { it.identityId }) + assertNull(dao.getBaseline("m-legacy")) + assertNull(dao.getBaseline("m-cfg")) + assertNotNull("other model must survive", dao.getBaseline("other")) + + // 另一模型的 legacy 成员同样被登记(精确到自身 provider:model) + val opOther = dao.resetModelTx("OPENAI", "gpt-4o") + assertNotNull(opOther) + assertEquals( + listOf("other"), + dao.getCleanupItems(opOther!!.operationId).map { it.identityId }, + ) + assertNull(dao.getBaseline("other")) + + // 完全无身份的模型:不建 operation + val opEmpty = dao.resetModelTx("ANTHROPIC", "claude-3-5-sonnet") + assertNull(opEmpty) + } + + @Test + fun `cleanup fence rejects pending or unmarked operations`() = runBlocking { + // 空 outbox:任意 marker 集合都通过 + assertTrue(dao.cleanupFenceSatisfied(emptySet())) + assertTrue(dao.cleanupFenceSatisfied(setOf("any-marker"))) + + // PENDING 存在 → 拒绝(即使 marker 齐全) + seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) + val opId = result.cleanupOperation!!.operationId + assertFalse("PENDING must block the import fence", dao.cleanupFenceSatisfied(setOf(opId))) + + // APPLIED 且 marker 在 → 通过 + assertEquals(1, dao.ackCleanupOperation(opId)) + assertTrue(dao.cleanupFenceSatisfied(setOf(opId))) + // APPLIED 但 marker 缺失(旧快照)→ 拒绝 + assertFalse("missing marker must block the import fence", dao.cleanupFenceSatisfied(emptySet())) + // ACK 幂等:重复 ACK 返回 0 + assertEquals(0, dao.ackCleanupOperation(opId)) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt index 708674816..923b760f3 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt @@ -64,7 +64,7 @@ class TokenStatsQueryServiceRoomTest { database = Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .setDriver(recordingDriver) - .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .addMigrations(AppDatabase.MIGRATION_20_21) .allowMainThreadQueries() .build() } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt index c9a938c8a..fa5c32605 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt @@ -38,6 +38,8 @@ class TokenStatsRoomMigrationTest { val context = mock() whenever(context.applicationContext).thenReturn(context) whenever(context.packageName).thenReturn("com.ai.assistance.operit") + // DataStore 委托在 coordinator 排空时按 filesDir 定位偏好文件(隔离到临时目录) + whenever(context.filesDir).thenReturn(tempDir) // 模拟 Android Context 的数据库目录解析:/ whenever(context.getDatabasePath(any())).thenAnswer { invocation -> File(tempDir, invocation.getArgument(0)) @@ -496,13 +498,18 @@ outputTokens = 500L, // 按 provider/model 重置:所有配置实例的事件 + 全部匹配 baseline 一起清。 // 通过 daoProvider 注入缝把真实 DAO 交给协调器(生产路径用 - // AppDatabase.withTransaction 包同一组删除)。 + // AppDatabase.withTransaction 包同一组删除)。P1 闭环:删除后协调器 + // 排空 legacy cleanup——DataStore 侧注入 mock 隔离(真实键级协议由 + // TokenStatsCleanupOutboxTest 覆盖,此处聚焦 Room 语义与删除矩阵)。 TokenStatsResetCoordinator.daoProvider = { dao } + val prefsMock = mock() + injectApiPreferences(prefsMock) try { TokenStatsResetCoordinator .resetStatisticsForProviderModel(mockContext(tempDir), "DEEPSEEK:deepseek-chat") } finally { TokenStatsResetCoordinator.daoProvider = null + injectApiPreferences(null) } assertEquals(1, dao.countEvents()) @@ -581,58 +588,91 @@ outputTokens = 500L, } } - /** 用导出的 v29 schema JSON 构造一个真实的 v29 数据库文件(含一条事件行)。 */ - private fun buildV29Database(dbPath: String) { - val schemaFile = File(schemaDir, "29.json") - assertTrue("schema export missing: ${schemaFile.absolutePath}", schemaFile.isFile) - val schema = json.decodeFromString(schemaFile.readText()) - assertEquals(29, schema.database.version) + @Test + fun `v20 database migrates to v21 adding diagnostics columns and new tables`() = + runBlocking { + val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() + val dbFile = File(tempDir, "app_database") + buildV20Database(dbFile.absolutePath) - DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> - connection.createStatement().use { statement -> - schema.database.entities.forEach { entity -> - statement.execute(entity.createSql.replace("\${TABLE_NAME}", entity.tableName)) - entity.indices.forEach { index -> - statement.execute(index.createSql.replace("\${TABLE_NAME}", entity.tableName)) - } - } - statement.execute( - "CREATE TABLE IF NOT EXISTS room_master_table " + - "(id INTEGER PRIMARY KEY, identity_hash TEXT NOT NULL)" - ) - statement.execute( - "INSERT OR REPLACE INTO room_master_table (id, identity_hash) " + - "VALUES(42, '${schema.database.identityHash}')" - ) - statement.execute("PRAGMA user_version = 29") - // 旧数据:迁移前插入一条事件,验证迁移后数据保留(含价格快照) - statement.execute( - "INSERT INTO token_stat_identities " + - "(identityId, configId, provider, model, displayModelId) " + - "VALUES ('identity-1', '', 'DEEPSEEK', 'deepseek-chat', 'deepseek-chat')" - ) - statement.execute( - "INSERT INTO token_stat_events " + - "(eventId, statIdentityId, category, status, startedAtMs, endedAtMs, " + - "firstTokenAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + - "outputTokens, reasoningTokens, reasoningIncludedInOutput, billingMode, " + - "pricingCurrency, inputPricePerMillion, cachedInputPricePerMillion, " + - "cacheWritePricePerMillion, outputPricePerMillion, pricePerRequest, " + - "pricingSource, costInPricingCurrency) " + - "VALUES ('evt-v29', 'identity-1', 'CHAT', 'COMPLETED', 1000, 2000, 1200, " + - "800, 200, 100, 500, 50, 1, 'TOKEN', 'USD', 1.0, 0.5, 2.0, 3.0, NULL, " + - "'DEFAULT', 0.0019)" + val database = + Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + + try { + // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo,包括新列/新表) + val dao = database.tokenStatsDao() + val identityId = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = identityId, + configId = "", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", + ) ) + // 事件新列可写:诊断列默认 null,写入后往返一致 + val event = + TokenStatEventEntity( + eventId = "evt-v21", + statIdentityId = identityId, + category = TokenStatCategory.CHAT.name, + status = TokenStatStatus.COMPLETED.name, + startedAtMs = 1000L, + endedAtMs = 2000L, + uncachedInputTokens = 800, + cachedInputTokens = 200, + outputTokens = 500, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 2.0, + pricingSource = PricingSource.DEFAULT.name, + costInPricingCurrency = 0.0019, + totalInputTokens = 1000L, + cacheWriteSeparateBilling = false, + diagnosticsJson = "{\"source\":\"openai_chat_completions\",\"usageObserved\":true}", + ) + dao.insertEvent(event) + val readBack = dao.getEvent("evt-v21")!! + assertEquals(800, readBack.uncachedInputTokens) + assertEquals(1000L, readBack.totalInputTokens) + assertEquals(false, readBack.cacheWriteSeparateBilling) + assertTrue(readBack.diagnosticsJson!!.contains("\"source\":\"openai_chat_completions\"")) + + // v21 新增 reset/range tombstone 与 cleanup outbox 表真实存在 + val tables = queryTables(dbFile.absolutePath) + assertTrue("token_stat_reset_cutoffs", tables.contains("token_stat_reset_cutoffs")) + assertTrue("token_stat_range_cutoffs", tables.contains("token_stat_range_cutoffs")) + assertTrue("token_stat_cleanup_operations", tables.contains("token_stat_cleanup_operations")) + assertTrue("token_stat_cleanup_items", tables.contains("token_stat_cleanup_items")) + + // range 删除 tombstone 可读写 + dao.deleteRangeEventsTx(100L, 200L) + assertEquals(1, dao.rangeCutoffs().size) + assertEquals(1L, dao.currentResetGeneration()) + + // 迁移可重入(CREATE IF NOT EXISTS / ALTER 幂等):以驱动变体再跑一次 + JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> + AppDatabase.MIGRATION_20_21.migrate(connection) + assertEquals(21, userVersion(connection)) + } + } finally { + database.close() } } - } @Test - fun `v29 database migrates to v30 keeping events and adding diagnostics column`() = + fun `v21 cleanup outbox tables enforce foreign key and cascade on operation delete`() = runBlocking { val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() val dbFile = File(tempDir, "app_database") - buildV29Database(dbFile.absolutePath) + buildV20Database(dbFile.absolutePath) val database = Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") @@ -642,36 +682,46 @@ outputTokens = 500L, .build() try { - // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo,包括新列) val dao = database.tokenStatsDao() - val readBack = dao.getEvent("evt-v29") - assertNotNull("migration must preserve legacy event rows", readBack) - assertEquals(800L, readBack!!.uncachedInputTokens) - assertEquals("DEFAULT", readBack.pricingSource) - assertNull("v29 rows have no diagnostics", readBack.diagnosticsJson) - // v30 新增的结构化列对旧行保持 null(未知),与新写入可区分 - assertNull(readBack.totalInputTokens) - assertNull(readBack.cacheWriteSeparateBilling) - - // 新列可写 - dao.insertEvent( - readBack.copy( - eventId = "evt-v30", -totalInputTokens = 1000L, - cacheWriteSeparateBilling = false, - diagnosticsJson = "{\"source\":\"openai_chat_completions\",\"usageObserved\":true}", + // 通过删除事务(真实路径)创建 operation + items + dao.insertIdentityIfAbsent( + TokenStatIdentityEntity( + identityId = "id-legacy", + configId = "", + provider = "DEEPSEEK", + model = "deepseek-chat", + displayModelId = "deepseek-chat", ) ) - val v30Event = dao.getEvent("evt-v30")!! - assertEquals(1000L, v30Event.totalInputTokens) - assertEquals(false, v30Event.cacheWriteSeparateBilling) - assertTrue(v30Event.diagnosticsJson!!.contains("\"source\":\"openai_chat_completions\"")) - - // 迁移可重入(ALTER 幂等):以驱动变体再跑一次 + dao.upsertDisplayModel( + TokenStatDisplayModelEntity( + displayModelId = "deepseek-chat", + normalizedModel = "deepseek-chat", + displayName = "deepseek-chat", + ) + ) + val result = dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = true) + val op = result.cleanupOperation!! + assertEquals(1, dao.getCleanupItems(op.operationId).size) + assertEquals(1, dao.countPendingCleanupOperations()) + // 外键:孤儿 item(operation 不存在)必须被拒绝 JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - AppDatabase.MIGRATION_20_21.migrate(connection) - assertEquals(30, userVersion(connection)) + connection.prepare("PRAGMA journal_mode = OFF").use { it.step() } + connection.prepare("PRAGMA foreign_keys = ON").use { it.step() } + val orphan = + runCatching { + connection.prepare( + "INSERT INTO token_stat_cleanup_items " + + "(operationId, identityId, provider, model) " + + "VALUES ('no-such-op', 'id', 'P', 'M')" + ).use { it.step() } + } + assertTrue("orphan item must violate FK", orphan.isFailure) } + // 级联:删除 operation 后 items 跟随删除 + dao.deleteCleanupOperation(op.operationId) + assertEquals(0, dao.getCleanupItems(op.operationId).size) + assertEquals(0, dao.countPendingCleanupOperations()) } finally { database.close() } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt index a33bbf1bc..7c3438fbe 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt @@ -43,7 +43,7 @@ class TokenStatsSettingsManagerTest { database = Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .addMigrations(AppDatabase.MIGRATION_20_21) .allowMainThreadQueries() .build() dao = database.tokenStatsDao() diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt index 0a8e80538..2ef07dc59 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -7,17 +7,21 @@ import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.BillingMode import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatBaselineEntity import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver import com.ai.assistance.operit.data.stats.TokenCostCurrency import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver import com.ai.assistance.operit.data.stats.TokenStatStatus import com.ai.assistance.operit.data.stats.TokenStatsCostMode +import com.ai.assistance.operit.data.stats.TokenStatsLedger import com.ai.assistance.operit.data.stats.TokenStatsPreset import com.ai.assistance.operit.data.stats.TokenStatsQueryService +import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft @@ -68,12 +72,16 @@ class TokenUsageStatisticsViewModelTest { @Before fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) + // ApiPreferences 是 JVM 级单例:删除“全部 + baseline”路径会走真实 DataStore, + // 每个测试前清空单例,保证绑定到本测试的临时目录(与 ApiPreferencesResetFailureTest + // 的隔离方式一致,避免跨测试共享 DataStore 文件)。 + clearApiPreferencesSingletons() tempDir = kotlin.io.path.createTempDirectory("token-vm-test").toFile() context = mockContext(tempDir) database = Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30) + .addMigrations(AppDatabase.MIGRATION_20_21) .allowMainThreadQueries() .build() dao = database.tokenStatsDao() @@ -81,6 +89,9 @@ class TokenUsageStatisticsViewModelTest { TokenStatsQueryService.databaseProvider = { database } TokenStatsQueryService.legacyPricesProvider = { emptyMap() } TokenStatsQueryService.queryDispatcher = UnconfinedTestDispatcher() + // 阶段 5 删除走 TokenStatsResetCoordinator(spool replay 用注入的数据库) + TokenStatsResetCoordinator.daoProvider = { dao } + TokenStatsLedger.databaseProvider = { database } } @After @@ -88,10 +99,26 @@ class TokenUsageStatisticsViewModelTest { TokenStatsQueryService.databaseProvider = null TokenStatsQueryService.legacyPricesProvider = null TokenStatsQueryService.queryDispatcher = Dispatchers.IO + TokenStatsResetCoordinator.daoProvider = null + TokenStatsLedger.databaseProvider = null database.close() Dispatchers.resetMain() } + /** 清空 `Context.apiDataStore` 委托缓存与 ApiPreferences INSTANCE(跨测试隔离)。 */ + private fun clearApiPreferencesSingletons() { + val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") + val delegateField = facade.getDeclaredField("apiDataStore\$delegate") + delegateField.isAccessible = true + val delegate = delegateField.get(null) + val instanceField = + delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } + instanceField.set(delegate, null) + val prefsInstanceField = + ApiPreferences::class.java.getDeclaredField("INSTANCE").apply { isAccessible = true } + prefsInstanceField.set(null, null) + } + private fun mockContext(filesDir: File): Context { val context = Mockito.mock(Context::class.java) whenever(context.applicationContext).thenReturn(context) @@ -103,6 +130,41 @@ class TokenUsageStatisticsViewModelTest { return context } + /** + * 直接以 DataStore 的 protobuf 文件格式(datastore/.preferences_pb)写入 + * legacy 累计键(token_input_/token_cached_input_/token_output_)。 + * Windows JVM 上同一 DataStore 文件每测试只能经 DataStore 写入一次(见 + * ApiPreferencesResetFailureTest 类注释:二次写入的原子替换 rename 失败),因此 + * 种子键绕过 DataStore 写管线、用库自身 PreferencesProto 编码直接落盘,把唯一 + * 一次真实 DataStore 写入留给被测删除流程(生产路径不受该测试环境约束)。 + * 键名/文件路径与 `preferencesDataStore(name = "api_settings")` 约定一致。 + */ + private fun seedLegacyTokenCountsFile( + filesDir: File, + providerModel: String, + input: Long, + cached: Long, + output: Long, + ) { + val datastoreDir = File(filesDir, "datastore") + check(datastoreDir.mkdirs() || datastoreDir.isDirectory) + val mapBuilder = androidx.datastore.preferences.PreferencesProto.PreferenceMap.newBuilder() + fun putLong(key: String, value: Long) { + mapBuilder.putPreferences( + key, + androidx.datastore.preferences.PreferencesProto.Value.newBuilder() + .setLong(value) + .build(), + ) + } + putLong("token_input_${providerModel.replace(":", "_")}", input) + putLong("token_cached_input_${providerModel.replace(":", "_")}", cached) + putLong("token_output_${providerModel.replace(":", "_")}", output) + File(datastoreDir, "api_settings.preferences_pb").outputStream().use { output -> + mapBuilder.build().writeTo(output) + } + } + private fun newViewModel(): TokenUsageStatisticsViewModel = TokenUsageStatisticsViewModel( context = context, @@ -145,8 +207,8 @@ class TokenUsageStatisticsViewModelTest { configId: String = "cfg-1", provider: String = "OPENAI", model: String = "gpt-4o", + displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), ) { - val displayModelId = TokenStatIdentityResolver.displayModelIdFor(model) dao.insertIdentityIfAbsent( TokenStatIdentityEntity( identityId = identityId, @@ -165,6 +227,26 @@ class TokenUsageStatisticsViewModelTest { ) } + private suspend fun seedBaseline(identityId: String, requestCount: Long = 3L) { + dao.upsertBaseline( + TokenStatBaselineEntity( + identityId = identityId, + inputTokens = 100L * requestCount, + cachedInputTokens = 0L, + outputTokens = 50L * requestCount, + requestCount = requestCount, + pricingCurrency = PricingCurrency.USD.name, + costInPricingCurrency = 0.01 * requestCount, + isEstimated = true, + fingerprint = "fp-$identityId", + importedAtMs = 1L, + frozenBillingMode = BillingMode.TOKEN.name, + frozenInputPricePerMillion = 1.0, + frozenOutputPricePerMillion = 2.0, + ) + ) + } + private fun event( id: String, identityId: String, @@ -207,7 +289,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `initial fallback picks first preset with data and persists it as auto`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) } @@ -225,7 +307,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `second viewmodel reuses persisted auto fallback without probing`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) } @@ -248,7 +330,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `user selection locks time and disables auto fallback`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") // 数据在 30 小时前:5h/12h/24h 全空(自动回退会选 7d),但用户已选择 24h dao.insertEvent(event("e1", "id-1", nowMs - 30 * 3600_000L)) settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) @@ -269,7 +351,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `custom range rejects invalid and accepts valid bounds`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 2 * 3600_000L)) } val viewModel = newViewModel() @@ -342,7 +424,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `category and status filters refresh range data`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvents( listOf( event("e1", "id-1", nowMs - 3_600_000L), @@ -444,7 +526,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `clearing all categories or statuses triggers exactly one load`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvents( listOf( event("e1", "id-1", nowMs - 3_600_000L), @@ -498,7 +580,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `stale load cannot overwrite newer load result`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) } val gated = GatedSettingsStore() @@ -544,7 +626,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `viewmodel clear cancels pending load before it writes state`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) } val dispatcher = StandardTestDispatcher() @@ -574,7 +656,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `currency cost mode and rate changes persist and refresh`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) } val viewModel = newViewModel() @@ -613,7 +695,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `price override save updates overrides and negative value fails with message`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) } val viewModel = newViewModel() @@ -661,7 +743,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `editing price override keeps business key and only updates values`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) } val viewModel = newViewModel() @@ -706,7 +788,7 @@ class TokenUsageStatisticsViewModelTest { @Test fun `group rename and create reflect in range display models`() { kotlinx.coroutines.runBlocking { - seedIdentity("id-1") + seedIdentity("id-1", configId = "cfg-a") seedIdentity("id-2", configId = "cfg-2") dao.insertEvents( listOf( @@ -813,6 +895,235 @@ class TokenUsageStatisticsViewModelTest { kotlinx.coroutines.runBlocking { dao.getIdentity("id-1")!!.displayModelId }, ) } + + // ==== 阶段 5:删除(范围 / 模型 / 全部;删除后页面状态刷新一致) ==== + + @Test + fun `delete current range removes in range events only and refreshes state`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + seedIdentity("id-2", configId = "cfg-b") + dao.insertEvent(event("e-in", "id-1", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-out", "id-1", startedAtMs = nowMs - 7L * 86_400_000L)) + seedBaseline("id-1") + seedBaseline("id-2") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + // 首次回退:5h 内恰有事件 → LAST_5H;currentRange 与查询同界 + assertEquals(TokenStatsPreset.LAST_5H, viewModel.state.value.selectedPreset) + val range = viewModel.state.value.currentRange + assertNotNull(range) + assertTrue(range!!.startMs <= nowMs - 3_600_000L) + assertTrue(nowMs - 3_600_000L < range.endMs) + + val from = viewModel.state.value.refreshVersion + viewModel.deleteRangeEvents() + awaitRefresh(viewModel, from) + + // 删除真实生效:范围内事件消失,范围外保留 + assertNull(dao.getEvent("e-in")) + assertNotNull(dao.getEvent("e-out")) + // baseline 绝不因范围删除被触碰 + assertNotNull(dao.getBaseline("id-1")) + assertNotNull(dao.getBaseline("id-2")) + // 页面状态刷新一致:生命周期只剩 1 条事件、2 行 baseline;范围空数据 + val lifetime = viewModel.state.value.lifetime!! + assertEquals(1L, lifetime.eventTotals.requests) + assertEquals(2L, lifetime.baselineTotals.identityCount) + assertEquals(0L, viewModel.state.value.range!!.eventCount) + } + } + + @Test + fun `delete display model covers full group members and keeps baseline by choice`() { + kotlinx.coroutines.runBlocking { + // 展示组 group-x:跨 provider:model 的两个身份 + 组外同 provider:model 身份 + seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("x-2", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") + seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") + dao.insertEvent(event("e-x1", "x-1", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-x2", "x-2", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-y1", "y-1", startedAtMs = nowMs - 3_600_000L)) + seedBaseline("x-1") + seedBaseline("y-1") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + // 第二步选择“仅删除事件”:组内全部成员事件删除(含无事件组成员身份影响 + // 由 DAO 全表解析验证),baseline 保留,组外同 provider:model 不受影响 + val from = viewModel.state.value.refreshVersion + viewModel.deleteDisplayModel("group-x", deleteBaselines = false) + awaitRefresh(viewModel, from) + + assertNull(dao.getEvent("e-x1")) + assertNull(dao.getEvent("e-x2")) + assertNotNull("same provider:model in another group must survive", dao.getEvent("e-y1")) + assertNotNull(dao.getBaseline("x-1")) + assertNotNull(dao.getBaseline("y-1")) + assertEquals(1L, viewModel.state.value.lifetime!!.eventTotals.requests) + } + } + + @Test + fun `delete display model with baseline removes member baselines and refreshes`() { + kotlinx.coroutines.runBlocking { + seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") + dao.insertEvent(event("e-x1", "x-1", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-y1", "y-1", startedAtMs = nowMs - 3_600_000L)) + seedBaseline("x-1") + seedBaseline("y-1") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val from = viewModel.state.value.refreshVersion + viewModel.deleteDisplayModel("group-x", deleteBaselines = true) + awaitRefresh(viewModel, from) + + assertNull(dao.getEvent("e-x1")) + assertNotNull(dao.getEvent("e-y1")) + assertNull(dao.getBaseline("x-1")) + assertNotNull("other group baseline must survive", dao.getBaseline("y-1")) + assertEquals(1L, viewModel.state.value.lifetime!!.baselineTotals.identityCount) + } + } + + @Test + fun `legacy datastore keys are cleared only when the deleted group contains the legacy identity`() { + kotlinx.coroutines.runBlocking { + // 两组同 provider:model:group-x 只含配置身份 cfg-a;group-y 含 legacy 身份 + // (configId="",旧 DataStore 累计键的 baseline 迁移目标,键按 provider:model 共享) + seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") + seedIdentity("y-legacy", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") + seedBaseline("x-1") + seedBaseline("y-legacy") + val prefs = ApiPreferences.getInstance(context) + // Windows JVM 约束(见 ApiPreferencesResetFailureTest 类注释):DataStore 1.0.0 + // 以 File.renameTo 原子替换,Windows 上目标文件已存在时替换失败——同一文件 + // 每测试只能被 DataStore 写入一次。因此 legacy 键用库自身 PreferencesProto + // 直接落盘(读断言可用),被测流程的唯一真实 DataStore 写入留给排空 + // (applyLegacyCleanup,P1 闭环:删除事务 → 排空清键 + marker)。 + seedLegacyTokenCountsFile(tempDir, "OPENAI:gpt-4o", input = 100L, cached = 10L, output = 50L) + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + // 删除只含 cfg-a 的组(baseline=yes):legacy 身份不在目标组 → 不得清旧键 + val from = viewModel.state.value.refreshVersion + viewModel.deleteDisplayModel("group-x", deleteBaselines = true) + awaitRefresh(viewModel, from) + + assertNull("cfg-a baseline must be deleted", dao.getBaseline("x-1")) + assertNotNull("legacy baseline in other group must survive", dao.getBaseline("y-legacy")) + assertEquals( + "legacy DataStore key must survive a non-legacy group deletion", + 100L, + prefs.getInputTokensForProviderModel("OPENAI:gpt-4o"), + ) + assertEquals(50L, prefs.getOutputTokensForProviderModel("OPENAI:gpt-4o")) + + // 删除含 legacy 身份的组(baseline=yes):其 baseline 随组删除、旧键必须清除。 + // 先移除磁盘文件,使排空(applyLegacyCleanup)成为该文件的首次写入 + // (DataStore 内存状态已在上面读断言时缓存,编辑仍基于含键的状态)。 + check(File(File(tempDir, "datastore"), "api_settings.preferences_pb").delete()) + val from2 = viewModel.state.value.refreshVersion + viewModel.deleteDisplayModel("group-y", deleteBaselines = true) + awaitRefresh(viewModel, from2) + + assertNull(dao.getBaseline("y-legacy")) + assertEquals( + "legacy DataStore key must be cleared with its baseline", + 0L, + prefs.getInputTokensForProviderModel("OPENAI:gpt-4o"), + ) + assertEquals(0L, prefs.getOutputTokensForProviderModel("OPENAI:gpt-4o")) + } + } + + @Test + fun `delete all events only keeps baseline and refreshes`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + seedIdentity("id-2", configId = "cfg-b") + dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-2", "id-2", startedAtMs = nowMs - 3_600_000L)) + seedBaseline("id-1") + seedBaseline("id-2") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val from = viewModel.state.value.refreshVersion + viewModel.deleteAllStatistics(deleteBaselines = false) + awaitRefresh(viewModel, from) + + assertEquals(0, dao.countEvents()) + assertEquals("baseline must survive", 2, dao.countBaselines()) + val lifetime = viewModel.state.value.lifetime!! + assertEquals(0L, lifetime.eventTotals.requests) + assertEquals(2L, lifetime.baselineTotals.identityCount) + assertEquals(0L, viewModel.state.value.range!!.eventCount) + } + } + + @Test + fun `delete all with baseline clears legacy keys and refreshes`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + seedIdentity("id-2", configId = "cfg-b") + dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) + dao.insertEvent(event("e-2", "id-2", startedAtMs = nowMs - 3_600_000L)) + seedBaseline("id-1") + seedBaseline("id-2") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + val from = viewModel.state.value.refreshVersion + viewModel.deleteAllStatistics(deleteBaselines = true) + awaitRefresh(viewModel, from) + + assertEquals(0, dao.countEvents()) + assertEquals(0, dao.countBaselines()) + val lifetime = viewModel.state.value.lifetime!! + assertEquals(0L, lifetime.eventTotals.requests) + assertEquals(0L, lifetime.baselineTotals.identityCount) + } + } + + @Test + fun `delete failures surface error message and keep data intact`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) + seedBaseline("id-1") + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + // 数据库不可用:三种删除都应报错而不是假装成功 + val failures = + listOf( + { viewModel.deleteRangeEvents() }, + { viewModel.deleteDisplayModel("id-1", deleteBaselines = false) }, + { viewModel.deleteAllStatistics(deleteBaselines = false) }, + ) + for (action in failures) { + TokenStatsResetCoordinator.daoProvider = + { _: Context -> throw RuntimeException("db down") } + try { + action() + awaitActionMessage(viewModel) + assertTrue("error message expected", viewModel.actionMessage.value!!.isError) + viewModel.consumeActionMessage() + } finally { + TokenStatsResetCoordinator.daoProvider = { dao } + } + } + // 失败不产生任何删除 + assertEquals(1, dao.countEvents()) + assertEquals(1, dao.countBaselines()) + } + } } /** 内存假实现:验证持久化调用与首次回退语义(无用户选择 = null)。 */ From bb31245e5449b3ab720972e583d20b794f00fc6c Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:19:12 +0800 Subject: [PATCH 06/28] feat(stats): improve token dashboard management (cherry picked from commit cffbb744673b4c730d3bc8c0743c5c9e15cf4209) --- .../operit/data/preferences/ApiPreferences.kt | 24 + .../data/stats/TokenStatsQueryModels.kt | 9 + .../data/stats/TokenStatsSettingsManager.kt | 17 +- .../data/stats/TokenStatsSettingsStore.kt | 11 + .../features/tokenstats/TokenStatsCharts.kt | 6 +- .../features/tokenstats/TokenStatsColors.kt | 31 +- .../tokenstats/TokenStatsComponents.kt | 425 ++++++---- .../features/tokenstats/TokenStatsDialogs.kt | 70 +- .../tokenstats/TokenStatsManagementScreen.kt | 726 ++++++++++++++++++ .../TokenStatsManagementViewModel.kt | 220 ++++++ .../tokenstats/TokenUsageStatisticsScreen.kt | 271 +++---- .../TokenUsageStatisticsViewModel.kt | 11 + .../operit/ui/main/screens/OperitScreens.kt | 47 +- app/src/main/res/values-en/strings.xml | 36 +- app/src/main/res/values/strings.xml | 36 +- .../stats/TokenStatsSettingsManagerTest.kt | 7 + .../tokenstats/TokenStatsChartsTest.kt | 18 + .../TokenStatsManagementViewModelTest.kt | 84 ++ .../TokenUsageStatisticsViewModelTest.kt | 18 + 19 files changed, 1720 insertions(+), 347 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index eeda99e5b..9b32ed69d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -135,6 +135,7 @@ class ApiPreferences private constructor(private val context: Context) { private val STATS_TARGET_CURRENCY = stringPreferencesKey("stats_target_currency") private val STATS_COST_MODE = stringPreferencesKey("stats_cost_mode") + private val STATS_INCLUDE_LEGACY = booleanPreferencesKey("stats_include_legacy") private val STATS_TIME_PRESET = stringPreferencesKey("stats_time_preset") private val STATS_TIME_CUSTOM_START = longPreferencesKey("stats_time_custom_start") private val STATS_TIME_CUSTOM_END = longPreferencesKey("stats_time_custom_end") @@ -817,6 +818,17 @@ class ApiPreferences private constructor(private val context: Context) { } } + /** 恢复内置定价时清除旧系统遗留的 provider:model 价格层。 */ + suspend fun clearLegacyPriceSettings(providerModel: String) { + context.apiDataStore.edit { preferences -> + preferences.remove(getModelInputPriceKey(providerModel)) + preferences.remove(getModelCachedInputPriceKey(providerModel)) + preferences.remove(getModelOutputPriceKey(providerModel)) + preferences.remove(getBillingModeKey(providerModel)) + preferences.remove(getPricePerRequestKey(providerModel)) + } + } + private fun legacyPriceSettingsFrom( preferences: Preferences, providerModel: String @@ -1060,6 +1072,18 @@ class ApiPreferences private constructor(private val context: Context) { } } + /** 旧版累计 baseline 是否加入生命周期累计;缺省开启以保持升级前后的总计连续。 */ + suspend fun getStatsIncludeLegacy(): Boolean { + val preferences = context.apiDataStore.data.first() + return preferences[STATS_INCLUDE_LEGACY] ?: true + } + + suspend fun setStatsIncludeLegacy(include: Boolean) { + context.apiDataStore.edit { preferences -> + preferences[STATS_INCLUDE_LEGACY] = include + } + } + /** * 统计页时间选择(阶段 4):null = 从未有任何选择(首次进入,允许自动回退)。 * CUSTOM 预设必须同时存在合法自定义边界,否则视为未选择(防御损坏状态)。 diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt index af527c927..d9f58b177 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -225,6 +225,15 @@ data class TokenStatsGroupModelInfo( val displayName: String, /** 该分组下的全部身份 id(完整归属,非当前筛选范围所见)。 */ val memberIdentityIds: List, + /** 可供管理界面搜索与辨识的完整身份元数据。 */ + val members: List = emptyList(), +) + +data class TokenStatsGroupMemberInfo( + val identityId: String, + val configId: String, + val provider: String, + val model: String, ) /** 业务分类合计。 */ diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt index 2e96ca780..b1954421b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -164,20 +164,29 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { suspend fun groupModels(): List { val snapshot = dao.loadGroupMetadataSnapshot() val displayNames = snapshot.displayModels.associateBy { it.displayModelId } - val membersByGroup = LinkedHashMap>() + val membersByGroup = LinkedHashMap>() for (identity in snapshot.identities) { membersByGroup.getOrPut(identity.displayModelId) { mutableListOf() } - .add(identity.identityId) + .add( + TokenStatsGroupMemberInfo( + identityId = identity.identityId, + configId = identity.configId, + provider = identity.provider, + model = identity.model, + ) + ) } // 有展示行但无身份的空分组:仍可作为合并目标,必须保留 for (display in snapshot.displayModels) { membersByGroup.putIfAbsent(display.displayModelId, mutableListOf()) } - return membersByGroup.map { (displayModelId, memberIds) -> + return membersByGroup.map { (displayModelId, members) -> + val sortedMembers = members.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() }, { it.configId })) TokenStatsGroupModelInfo( displayModelId = displayModelId, displayName = displayNames[displayModelId]?.displayName ?: displayModelId, - memberIdentityIds = memberIds.sorted(), + memberIdentityIds = sortedMembers.map { it.identityId }, + members = sortedMembers, ) }.sortedWith(compareBy({ it.displayName.lowercase() }, { it.displayModelId })) } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt index 0fc445b2a..14f89186d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt @@ -31,6 +31,11 @@ interface TokenStatsSettingsStore { suspend fun saveCostMode(mode: TokenStatsCostMode) + /** 是否在生命周期累计中加入迁移的旧版 baseline;默认 true。 */ + suspend fun loadIncludeLegacy(): Boolean + + suspend fun saveIncludeLegacy(include: Boolean) + /** * 当前时间选择(首次自动回退结果或用户手选);null = 从未有任何选择, * 进入页面时允许执行首次自动回退并持久化。 @@ -80,6 +85,12 @@ class ApiPreferencesTokenStatsSettingsStore(context: Context) : TokenStatsSettin api.setStatsCostMode(mode) } + override suspend fun loadIncludeLegacy(): Boolean = api.getStatsIncludeLegacy() + + override suspend fun saveIncludeLegacy(include: Boolean) { + api.setStatsIncludeLegacy(include) + } + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = api.getStatsTimeSelection() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt index b74b631d7..b56dc3201 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt @@ -441,6 +441,7 @@ private fun ChartTooltip( @OptIn(ExperimentalLayoutApi::class) @Composable internal fun ChartLegend(items: List>) { + val colors = LocalTokenStatsColors.current FlowRow( modifier = Modifier .fillMaxWidth() @@ -458,7 +459,7 @@ internal fun ChartLegend(items: List>) { Text( text = label, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = colors.chartLabel, ) } if (i < items.size - 1) Spacer(Modifier.width(12.dp)) @@ -468,11 +469,12 @@ internal fun ChartLegend(items: List>) { @Composable private fun ChartEmptyText(text: String, modifier: Modifier = Modifier) { + val colors = LocalTokenStatsColors.current Box(modifier = modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { Text( text = text, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = colors.chartLabel, ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt index 144db9a08..d5ae0961e 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt @@ -7,8 +7,6 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import com.ai.assistance.operit.ui.theme.RainyPink -import com.ai.assistance.operit.ui.theme.RainyPinkHover -import com.ai.assistance.operit.ui.theme.RainyRose import com.ai.assistance.operit.ui.theme.RainySakura import com.ai.assistance.operit.ui.theme.RainyWarning @@ -52,6 +50,15 @@ data class TokenStatsColors( val summaryCardContent: Color, ) +internal val TokenStatsCardContainer = Color.White +internal val TokenStatsCardContent = Color(0xFF202124) +internal val TokenStatsCardMuted = Color(0xFF5F6368) + +// Rainytoken Token 堆叠色板:缓存读取(顶)、未缓存输入(中)、输出(底)。 +internal val RainyTokenCacheRead = Color(0xFFFFD1DC) +internal val RainyTokenUncachedInput = Color(0xFFFF85A2) +internal val RainyTokenOutput = Color(0xFFE91E63) + /** 组件默认入口:从当前 [MaterialTheme] 派生,明暗自适应。 */ @Composable fun tokenStatsColors(): TokenStatsColors { @@ -63,10 +70,10 @@ fun tokenStatsColors(): TokenStatsColors { private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = TokenStatsColors( - uncachedInput = RainyRose, - cachedInput = RainyPink, + uncachedInput = RainyTokenUncachedInput, + cachedInput = RainyTokenCacheRead, cacheWrite = RainySakura, - output = Color(0xFFC2185B), + output = RainyTokenOutput, reasoning = Color(0xFFF48FB1), chartAccent = RainyPink, chartGrid = scheme.outlineVariant, @@ -77,16 +84,16 @@ private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme unknownHint = RainyWarning, estimatedBadgeContainer = scheme.tertiaryContainer, estimatedBadgeContent = scheme.onTertiaryContainer, - summaryCardContainer = scheme.primaryContainer, - summaryCardContent = scheme.onPrimaryContainer, + summaryCardContainer = TokenStatsCardContainer, + summaryCardContent = TokenStatsCardContent, ) private fun darkTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = TokenStatsColors( - uncachedInput = RainyPinkHover, - cachedInput = RainyPink, + uncachedInput = RainyTokenUncachedInput, + cachedInput = RainyTokenCacheRead, cacheWrite = RainySakura, - output = Color(0xFFF06292), + output = RainyTokenOutput, reasoning = Color(0xFFF8BBD0), chartAccent = RainyPink, chartGrid = scheme.outlineVariant, @@ -97,8 +104,8 @@ private fun darkTokenStatsColors(scheme: androidx.compose.material3.ColorScheme) unknownHint = RainyWarning, estimatedBadgeContainer = scheme.tertiaryContainer, estimatedBadgeContent = scheme.onTertiaryContainer, - summaryCardContainer = scheme.primaryContainer, - summaryCardContent = scheme.onPrimaryContainer, + summaryCardContainer = TokenStatsCardContainer, + summaryCardContent = TokenStatsCardContent, ) /** 模型费用堆叠色板(与 Rainytoken 参考实现一致,12 色足够区分常见模型数)。 */ diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index 4bae25e83..977d0a15f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -1,9 +1,9 @@ package com.ai.assistance.operit.ui.features.tokenstats -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -13,15 +13,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.CurrencyYen import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.HelpOutline +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -34,9 +33,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,13 +45,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenCostCalculator +import com.ai.assistance.operit.data.stats.TokenStatsBaselineTotals import com.ai.assistance.operit.data.stats.TokenStatsCostMode import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown import com.ai.assistance.operit.data.stats.TokenStatsDurationAggregate @@ -77,6 +82,35 @@ internal fun formatPricePerRequest(price: Double, currency: PricingCurrency): St internal fun formatCount(value: Long): String = String.format(Locale.US, "%,d", value) +/** 统计页统一白色卡片;局部浅色 scheme 保证深色主题下控件与文字仍清晰。 */ +@Composable +internal fun TokenStatsWhiteCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val scheme = MaterialTheme.colorScheme + MaterialTheme( + colorScheme = + scheme.copy( + surface = TokenStatsCardContainer, + onSurface = TokenStatsCardContent, + surfaceVariant = Color(0xFFF5F5F5), + onSurfaceVariant = TokenStatsCardMuted, + outline = Color(0xFFBDBDBD), + outlineVariant = Color(0xFFE0E0E0), + ), + ) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = TokenStatsCardContainer, + contentColor = TokenStatsCardContent, + ), + content = content, + ) + } +} + // ==== 生命周期累计总览(不受筛选) ==== @Composable @@ -85,11 +119,12 @@ internal fun TokenStatsLifetimeCard( currency: PricingCurrency, manualRate: Double, rateIsEstimated: Boolean, + includeLegacy: Boolean, + onIncludeLegacyChange: (Boolean) -> Unit, ) { val colors = LocalTokenStatsColors.current - Card( + TokenStatsWhiteCard( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = colors.summaryCardContainer), ) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -110,39 +145,72 @@ internal fun TokenStatsLifetimeCard( Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.token_stats_include_legacy), + style = MaterialTheme.typography.bodyMedium, + color = colors.summaryCardContent, + modifier = Modifier.weight(1f), + ) + Switch( + checked = includeLegacy, + onCheckedChange = onIncludeLegacyChange, + ) + } + + Spacer(Modifier.height(8.dp)) + val eventTotals = overview.eventTotals + val baseline = overview.baselineTotals + val unknownCostContributions = + includeLegacyValue( + eventTotals.cost.unknownContributionCount, + baseline.cost.unknownContributionCount, + includeLegacy, + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { BigNumber( label = stringResource(R.string.settings_total_requests), - value = formatCount(overview.combinedRequests), + value = + formatCount( + includeLegacyValue(eventTotals.requests, baseline.requests, includeLegacy) + ), color = colors.summaryCardContent, ) BigNumber( label = stringResource(R.string.token_stats_tokens_total), - value = formatCompactCount(knownTokenSum(eventTotals)), + value = formatCompactCount(knownLifetimeTokenSum(overview, includeLegacy)), color = colors.summaryCardContent, ) BigNumber( label = stringResource(R.string.settings_total_cost), - value = formatMoney(eventTotals.cost.knownAmount, currency), + value = + formatMoney( + eventTotals.cost.knownAmount + + if (includeLegacy) baseline.cost.knownAmount else 0.0, + currency, + ), color = colors.chartAccent, alignEnd = true, ) } - if (eventTotals.cost.unknownContributionCount > 0L) { + if (unknownCostContributions > 0L) { UnknownHint( text = stringResource( R.string.token_stats_unknown_cost, - eventTotals.cost.unknownContributionCount, + unknownCostContributions, ), color = colors.unknownHint, ) } - if (eventTotals.cost.unknownContributionCount == 0L && + if (unknownCostContributions == 0L && eventTotals.cost.rateIsEstimated ) { Text( @@ -157,8 +225,7 @@ internal fun TokenStatsLifetimeCard( TokenComponentLines(totals = eventTotals, textColor = colors.summaryCardContent) // 旧数据 baseline(估算口径,明确标注) - val baseline = overview.baselineTotals - if (baseline.identityCount > 0L) { + if (includeLegacy && baseline.identityCount > 0L) { Spacer(Modifier.height(12.dp)) HorizontalDivider(color = colors.summaryCardContent.copy(alpha = 0.2f)) Spacer(Modifier.height(12.dp)) @@ -185,6 +252,11 @@ internal fun TokenStatsLifetimeCard( color = colors.summaryCardContent.copy(alpha = 0.8f), ) Spacer(Modifier.height(8.dp)) + BaselineLine( + label = stringResource(R.string.token_stats_tokens_total), + value = formatCount(knownBaselineTokenSum(baseline)), + color = colors.summaryCardContent, + ) BaselineLine( label = stringResource(R.string.settings_total_requests), value = formatCount(baseline.requests), @@ -359,14 +431,34 @@ private fun BaselineLine( /** 已知 token 分量合计(uncached+cached+cacheWrite+output+reasoning,未知不算 0)。 */ internal fun knownTokenSum( totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, -): Long = - listOf( +): Long = saturatedTokenSum( totals.uncachedInput.knownSum, totals.cachedInput.knownSum, totals.cacheWrite.knownSum, totals.output.knownSum, totals.reasoning.knownSum, - ).sum() + ) + +/** 旧累计值没有额外 token 分类,只能按输入、缓存输入和输出合计。 */ +internal fun knownBaselineTokenSum(totals: TokenStatsBaselineTotals): Long = + saturatedTokenSum(totals.inputTokens, totals.cachedInputTokens, totals.outputTokens) + +/** 生命周期总 Token 必须同时包含新事件与迁移的旧累计 baseline。 */ +internal fun knownLifetimeTokenSum( + overview: TokenStatsLifetimeOverview, + includeLegacy: Boolean = true, +): Long = + includeLegacyValue( + knownTokenSum(overview.eventTotals), + knownBaselineTokenSum(overview.baselineTotals), + includeLegacy, + ) + +internal fun includeLegacyValue(eventValue: Long, baselineValue: Long, includeLegacy: Boolean): Long = + if (includeLegacy) TokenCostCalculator.saturatedAdd(eventValue, baselineValue) else eventValue + +internal fun saturatedTokenSum(vararg values: Long): Long = + values.fold(0L, TokenCostCalculator::saturatedAdd) // ==== 筛选栏 ==== @@ -394,31 +486,34 @@ internal fun TokenStatsFilterBar( onSetCostMode: (TokenStatsCostMode) -> Unit, onSetCurrency: (PricingCurrency) -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Column(modifier = Modifier.padding(12.dp)) { - // 时间预设:横向滚动,手机不拥挤 + var showCostModeHelp by remember { mutableStateOf(false) } + + // 时间、展示币种和范围删除属于同一层级。 Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - TokenStatsPreset.entries.forEach { preset -> - FilterChip( - selected = selectedPreset == preset, - onClick = { - if (preset == TokenStatsPreset.CUSTOM) onCustomRange() else onSelectPreset(preset) - }, - label = { - Text( - when (preset) { - TokenStatsPreset.CUSTOM -> stringResource(R.string.token_stats_custom_range) - else -> stringResource(preset.labelRes()) - } - ) - }, - ) - } + TimePresetDropdown( + selectedPreset, + onSelectPreset, + onCustomRange, + Modifier.weight(1f), + ) + CurrencyChip( + currency = PricingCurrency.CNY, + selected = targetCurrency == PricingCurrency.CNY, + onClick = { onSetCurrency(PricingCurrency.CNY) }, + ) + CurrencyChip( + currency = PricingCurrency.USD, + selected = targetCurrency == PricingCurrency.USD, + onClick = { onSetCurrency(PricingCurrency.USD) }, + ) // 删除当前时间范围:只删有时间戳的事件,不触碰 baseline(阶段 5) IconButton(onClick = onDeleteRange) { Icon( @@ -431,11 +526,9 @@ internal fun TokenStatsFilterBar( Spacer(Modifier.height(8.dp)) - // 第二行:模型/分类/状态多选 + 口径/币种(窄屏横向滚动) + // 查询维度固定三列,避免重要筛选藏在横向滚动区域。 Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { ModelFilterDropdown( @@ -444,47 +537,69 @@ internal fun TokenStatsFilterBar( knownModelNames, onToggleModel, onSelectAllModels, + Modifier.weight(1f), ) - CategoryFilterDropdown(selectedCategories, onToggleCategory, onClearAllCategories) - StatusFilterDropdown(selectedStatuses, onToggleStatus, onClearAllStatuses) + CategoryFilterDropdown( + selectedCategories, + onToggleCategory, + onClearAllCategories, + Modifier.weight(1f), + ) + StatusFilterDropdown( + selectedStatuses, + onToggleStatus, + onClearAllStatuses, + Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(8.dp)) + // 计价口径独占一行,帮助入口解释它只影响费用计算。 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { FilterChip( selected = costMode == TokenStatsCostMode.HISTORICAL, onClick = { onSetCostMode(TokenStatsCostMode.HISTORICAL) }, label = { Text(stringResource(R.string.token_stats_mode_historical)) }, + modifier = Modifier.weight(1f), ) FilterChip( selected = costMode == TokenStatsCostMode.REVALUED, onClick = { onSetCostMode(TokenStatsCostMode.REVALUED) }, label = { Text(stringResource(R.string.token_stats_mode_revalued)) }, + modifier = Modifier.weight(1f), ) - FilterChip( - selected = targetCurrency == PricingCurrency.CNY, - onClick = { onSetCurrency(PricingCurrency.CNY) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.CurrencyYen, - contentDescription = null, - modifier = Modifier.size(16.dp), + IconButton(onClick = { showCostModeHelp = true }) { + Icon( + imageVector = Icons.Filled.HelpOutline, + contentDescription = stringResource(R.string.token_stats_mode_help_title), + tint = TokenStatsCardMuted, + ) + } + } + + if (showCostModeHelp) { + AlertDialog( + onDismissRequest = { showCostModeHelp = false }, + title = { Text(stringResource(R.string.token_stats_mode_help_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.token_stats_mode_historical_help)) + Text(stringResource(R.string.token_stats_mode_revalued_help)) + Text( + text = stringResource(R.string.token_stats_mode_rate_help), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.token_stats_currency_cny)) } }, - ) - FilterChip( - selected = targetCurrency == PricingCurrency.USD, - onClick = { onSetCurrency(PricingCurrency.USD) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.AttachMoney, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.token_stats_currency_usd)) + confirmButton = { + TextButton(onClick = { showCostModeHelp = false }) { + Text(stringResource(R.string.token_stats_help_got_it)) } }, ) @@ -493,6 +608,21 @@ internal fun TokenStatsFilterBar( } } +@Composable +private fun CurrencyChip( + currency: PricingCurrency, + selected: Boolean, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { + Text(currency.code) + }, + ) +} + private fun TokenStatsPreset.labelRes(): Int = when (this) { TokenStatsPreset.LAST_5H -> R.string.token_stats_preset_5h @@ -507,6 +637,34 @@ private fun TokenStatsPreset.labelRes(): Int = TokenStatsPreset.CUSTOM -> R.string.token_stats_custom_range } +@Composable +private fun TimePresetDropdown( + selected: TokenStatsPreset, + onSelect: (TokenStatsPreset) -> Unit, + onCustomRange: () -> Unit, + modifier: Modifier = Modifier, +) { + FilterDropdown( + label = stringResource(selected.labelRes()), + modifier = modifier, + ) { dismiss -> + TokenStatsPreset.entries.forEach { preset -> + DropdownMenuItem( + text = { + Text( + text = stringResource(preset.labelRes()), + fontWeight = if (preset == selected) FontWeight.Bold else FontWeight.Normal, + ) + }, + onClick = { + dismiss() + if (preset == TokenStatsPreset.CUSTOM) onCustomRange() else onSelect(preset) + }, + ) + } + } +} + @Composable private fun ModelFilterDropdown( selectedModels: Set, @@ -514,6 +672,7 @@ private fun ModelFilterDropdown( knownModelNames: Map, onToggleModel: (String) -> Unit, onSelectAllModels: () -> Unit, + modifier: Modifier = Modifier, ) { // 可选项 = 当前范围可用模型 + 已被选中但被筛选出当前结果的模型(P1-5) val options: List> = remember(availableModels, selectedModels, knownModelNames) { @@ -526,6 +685,7 @@ private fun ModelFilterDropdown( } } FilterDropdown( + modifier = modifier, label = if (selectedModels.isEmpty()) { stringResource(R.string.token_stats_filter_all_models) } else { @@ -571,8 +731,10 @@ private fun CategoryFilterDropdown( selected: Set?, onToggle: (TokenStatCategory) -> Unit, onClearAll: () -> Unit, + modifier: Modifier = Modifier, ) { FilterDropdown( + modifier = modifier, label = if (selected == null) { stringResource(R.string.token_stats_filter_all_categories) } else { @@ -616,8 +778,10 @@ private fun StatusFilterDropdown( selected: Set?, onToggle: (TokenStatStatus) -> Unit, onClearAll: () -> Unit, + modifier: Modifier = Modifier, ) { FilterDropdown( + modifier = modifier, label = if (selected == null) { stringResource(R.string.token_stats_filter_all_statuses) } else { @@ -659,14 +823,22 @@ private fun StatusFilterDropdown( @Composable private fun FilterDropdown( label: String, + modifier: Modifier = Modifier, content: @Composable (dismiss: () -> Unit) -> Unit, ) { var expanded by remember { mutableStateOf(false) } - Box { + Box(modifier = modifier) { FilterChip( selected = false, onClick = { expanded = true }, - label = { Text(label) }, + label = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier.fillMaxWidth(), ) DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { content { expanded = false } @@ -704,25 +876,37 @@ internal fun TokenStatsChartCard( headerExtra: @Composable () -> Unit = {}, content: @Composable () -> Unit, ) { - Card(modifier = modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Text( - text = summary, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = LocalTokenStatsColors.current.chartAccent, - ) + val chartColors = + LocalTokenStatsColors.current.copy( + chartGrid = Color(0xFFE0E0E0), + chartLabel = Color(0xFF5F6368), + tooltipContainer = TokenStatsCardContainer, + tooltipContent = Color(0xFF202124), + unknownHint = Color(0xFF8A4B00), + ) + CompositionLocalProvider(LocalTokenStatsColors provides chartColors) { + TokenStatsWhiteCard( + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = summary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = chartColors.chartAccent, + ) + } + Spacer(Modifier.height(8.dp)) + headerExtra() + content() } - Spacer(Modifier.height(8.dp)) - headerExtra() - content() } } } @@ -764,7 +948,9 @@ internal fun TokenStatsModelCard( val colors = LocalTokenStatsColors.current var expanded by remember(model.displayModelId) { mutableStateOf(false) } - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = { expanded = !expanded }) { @@ -785,7 +971,7 @@ internal fun TokenStatsModelCard( model.identities.size, ), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } Column(horizontalAlignment = Alignment.End) { @@ -798,7 +984,7 @@ internal fun TokenStatsModelCard( Text( text = stringResource(R.string.settings_request_count_label, model.totals.requests), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } // 阶段 5:删除对完整展示分组生效(可跨 provider/模型合并组), @@ -815,7 +1001,7 @@ internal fun TokenStatsModelCard( Icon( imageVector = Icons.Filled.Groups, contentDescription = stringResource(R.string.token_stats_group_manage), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = TokenStatsCardMuted, modifier = Modifier.size(20.dp), ) } @@ -853,7 +1039,7 @@ private fun TokenStatsIdentityRow( Text( text = stringResource(R.string.token_stats_config_id, identity.configId), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } @@ -867,7 +1053,7 @@ private fun TokenStatsIdentityRow( " · ${stringResource(R.string.token_stats_token_cached)} ${formatCompactCount(totals.cachedInput.knownSum)}" + " · ${stringResource(R.string.token_stats_token_output)} ${formatCompactCount(totals.output.knownSum)}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) Text( text = formatMoney(totals.cost.knownAmount, currency), @@ -909,7 +1095,7 @@ private fun TokenStatsIdentityRow( Text( text = "${stringResource(R.string.token_stats_price_label)} $priceText", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } } @@ -957,9 +1143,7 @@ private fun buildPricingText( internal fun TokenStatsRateCard( manualRate: Double, rateIsEstimated: Boolean, - currency: PricingCurrency, onSaveRate: (Double) -> Boolean, - onSetCurrency: (PricingCurrency) -> Unit, ) { val colors = LocalTokenStatsColors.current var rateInput by remember { mutableStateOf(formatRateInput(manualRate)) } @@ -968,7 +1152,9 @@ internal fun TokenStatsRateCard( rateInput = formatRateInput(manualRate) } - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -983,14 +1169,14 @@ internal fun TokenStatsRateCard( if (rateIsEstimated) { EstimatedBadge( text = stringResource(R.string.token_stats_rate_default_badge), - textColor = MaterialTheme.colorScheme.onSurface, + textColor = TokenStatsCardContent, ) } } Text( text = stringResource(R.string.settings_exchange_rate_subtitle), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) Row( @@ -1020,45 +1206,6 @@ internal fun TokenStatsRateCard( } } - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.token_stats_currency), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(end = 8.dp), - ) - FilterChip( - selected = currency == PricingCurrency.CNY, - onClick = { onSetCurrency(PricingCurrency.CNY) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.CurrencyYen, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.token_stats_currency_cny)) - } - }, - ) - Spacer(Modifier.width(8.dp)) - FilterChip( - selected = currency == PricingCurrency.USD, - onClick = { onSetCurrency(PricingCurrency.USD) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Filled.AttachMoney, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.token_stats_currency_usd)) - } - }, - ) - } if (rateIsEstimated) { Text( text = stringResource(R.string.token_stats_rate_default_hint, manualRate), diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt index afe3ab198..f6d6ecbfe 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -146,40 +146,62 @@ internal fun CustomRangeDialog( @Composable internal fun PriceOverrideDialog( existing: TokenStatPriceOverrideEntity?, + initialDraft: TokenStatsPriceOverrideDraft? = null, onSave: (TokenStatsPriceOverrideDraft) -> Unit, onDelete: (() -> Unit)?, onDismiss: () -> Unit, ) { - var scope by remember(existing) { + var scope by remember(existing, initialDraft) { mutableStateOf( - existing?.let { PriceOverrideScope.fromNameOrNull(it.scope) } ?: PriceOverrideScope.PROVIDER_MODEL + existing?.let { PriceOverrideScope.fromNameOrNull(it.scope) } + ?: initialDraft?.scope + ?: PriceOverrideScope.PROVIDER_MODEL ) } - var provider by remember(existing) { mutableStateOf(existing?.provider.orEmpty()) } - var model by remember(existing) { mutableStateOf(existing?.model.orEmpty()) } - var configId by remember(existing) { mutableStateOf(existing?.configId.orEmpty()) } - var billingMode by remember(existing) { + var provider by remember(existing, initialDraft) { + mutableStateOf(existing?.provider ?: initialDraft?.provider.orEmpty()) + } + var model by remember(existing, initialDraft) { + mutableStateOf(existing?.model ?: initialDraft?.model.orEmpty()) + } + var configId by remember(existing, initialDraft) { + mutableStateOf(existing?.configId ?: initialDraft?.configId.orEmpty()) + } + var billingMode by remember(existing, initialDraft) { mutableStateOf( - existing?.let { BillingMode.fromString(it.billingMode) } ?: BillingMode.TOKEN + existing?.let { BillingMode.fromString(it.billingMode) } + ?: initialDraft?.billingMode + ?: BillingMode.TOKEN ) } - var currency by remember(existing) { + var currency by remember(existing, initialDraft) { mutableStateOf( existing?.let { if (it.pricingCurrency.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD - } ?: PricingCurrency.CNY + } ?: initialDraft?.currency ?: PricingCurrency.CNY ) } - var inputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.inputPricePerMillion)) } - var cachedInputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.cachedInputPricePerMillion)) } - var cacheWritePrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.cacheWritePricePerMillion)) } - var outputPrice by remember(existing) { mutableStateOf(formatEditablePrice(existing?.outputPricePerMillion)) } - var pricePerRequest by remember(existing) { mutableStateOf(formatEditablePrice(existing?.pricePerRequest)) } + var inputPrice by remember(existing, initialDraft) { + mutableStateOf(formatEditablePrice(existing?.inputPricePerMillion ?: initialDraft?.inputPricePerMillion)) + } + var cachedInputPrice by remember(existing, initialDraft) { + mutableStateOf(formatEditablePrice(existing?.cachedInputPricePerMillion ?: initialDraft?.cachedInputPricePerMillion)) + } + var cacheWritePrice by remember(existing, initialDraft) { + mutableStateOf(formatEditablePrice(existing?.cacheWritePricePerMillion ?: initialDraft?.cacheWritePricePerMillion)) + } + var outputPrice by remember(existing, initialDraft) { + mutableStateOf(formatEditablePrice(existing?.outputPricePerMillion ?: initialDraft?.outputPricePerMillion)) + } + var pricePerRequest by remember(existing, initialDraft) { + mutableStateOf(formatEditablePrice(existing?.pricePerRequest ?: initialDraft?.pricePerRequest)) + } var inlineError by remember { mutableStateOf(null) } val pricingInvalidText = stringResource(R.string.token_stats_pricing_invalid) // P1-7:编辑已有覆盖时业务键(scope/provider/model/configId)只读, // 只允许修改价格/币种/计费方式,防止键被改掉产生第二行或误覆盖。 val editing = existing != null + val targetLocked = editing || initialDraft != null val priceFields = if (billingMode == BillingMode.TOKEN) { @@ -215,15 +237,15 @@ internal fun PriceOverrideDialog( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = scope == PriceOverrideScope.PROVIDER_MODEL, - onClick = { if (!editing) scope = PriceOverrideScope.PROVIDER_MODEL }, - enabled = !editing, + onClick = { if (!targetLocked) scope = PriceOverrideScope.PROVIDER_MODEL }, + enabled = !targetLocked, label = { Text(stringResource(R.string.token_stats_pricing_scope_provider)) }, modifier = Modifier.weight(1f), ) FilterChip( selected = scope == PriceOverrideScope.CONFIG, - onClick = { if (!editing) scope = PriceOverrideScope.CONFIG }, - enabled = !editing, + onClick = { if (!targetLocked) scope = PriceOverrideScope.CONFIG }, + enabled = !targetLocked, label = { Text(stringResource(R.string.token_stats_pricing_scope_config)) }, modifier = Modifier.weight(1f), ) @@ -231,27 +253,27 @@ internal fun PriceOverrideDialog( OutlinedTextField( value = provider, - onValueChange = { if (!editing) provider = it }, + onValueChange = { if (!targetLocked) provider = it }, label = { Text(stringResource(R.string.token_stats_pricing_provider_label)) }, singleLine = true, - enabled = !editing, + enabled = !targetLocked, modifier = Modifier.fillMaxWidth(), ) OutlinedTextField( value = model, - onValueChange = { if (!editing) model = it }, + onValueChange = { if (!targetLocked) model = it }, label = { Text(stringResource(R.string.token_stats_pricing_model_label)) }, singleLine = true, - enabled = !editing, + enabled = !targetLocked, modifier = Modifier.fillMaxWidth(), ) if (scope == PriceOverrideScope.CONFIG) { OutlinedTextField( value = configId, - onValueChange = { if (!editing) configId = it }, + onValueChange = { if (!targetLocked) configId = it }, label = { Text(stringResource(R.string.token_stats_pricing_config_label)) }, singleLine = true, - enabled = !editing, + enabled = !targetLocked, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt new file mode 100644 index 000000000..d652c7c63 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt @@ -0,0 +1,726 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsGroupMemberInfo +import com.ai.assistance.operit.data.stats.LegacyPriceSettings +import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager + +private enum class ManagementTab { GROUPS, PRICING } + +private data class PricingEditor( + val existing: TokenStatPriceOverrideEntity?, + val draft: TokenStatsPriceOverrideDraft, +) + +@Composable +fun TokenStatsManagementScreen(initialPricingTab: Boolean = false) { + val context = LocalContext.current + val viewModel: TokenStatsManagementViewModel = + viewModel(factory = TokenStatsManagementViewModel.Factory(context)) + val state by viewModel.state.collectAsState() + var selectedTab by rememberSaveable { + mutableStateOf(if (initialPricingTab) ManagementTab.PRICING else ManagementTab.GROUPS) + } + + TokenStatsColorsProvider { + Column(modifier = Modifier.fillMaxSize()) { + TabRow(selectedTabIndex = selectedTab.ordinal) { + Tab( + selected = selectedTab == ManagementTab.GROUPS, + onClick = { selectedTab = ManagementTab.GROUPS }, + text = { Text(stringResource(R.string.token_stats_management_groups_tab)) }, + ) + Tab( + selected = selectedTab == ManagementTab.PRICING, + onClick = { selectedTab = ManagementTab.PRICING }, + text = { Text(stringResource(R.string.token_stats_management_pricing_tab)) }, + ) + } + state.errorMessage?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + when { + state.loading && state.groups.isEmpty() -> Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + selectedTab == ManagementTab.GROUPS -> GroupManagementTab( + groups = state.groups, + configs = state.configs, + onRename = viewModel::renameGroup, + onCreate = viewModel::createGroup, + onMove = viewModel::moveToGroup, + onRestore = viewModel::restoreDefaultGroup, + ) + else -> PricingManagementTab( + models = state.pricingModels, + overrides = state.overrides, + onSave = viewModel::savePriceOverride, + onResetConfig = viewModel::resetPrice, + onRestoreBuiltIn = viewModel::restoreBuiltInPrice, + ) + } + } + } +} + +@Composable +private fun GroupManagementTab( + groups: List, + configs: List, + onRename: (String, String) -> Unit, + onCreate: (String, List) -> Unit, + onMove: (List, String) -> Unit, + onRestore: (String) -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + var selectedIds by remember { mutableStateOf(emptySet()) } + var renameTarget by remember { mutableStateOf(null) } + var showCreate by remember { mutableStateOf(false) } + var showMove by remember { mutableStateOf(false) } + var restoreTarget by remember { mutableStateOf(null) } + val configNames = remember(configs) { configs.associate { it.id to it.name } } + val normalizedQuery = query.trim().lowercase() + val visibleGroups = remember(groups, normalizedQuery, configNames) { + if (normalizedQuery.isEmpty()) groups else groups.filter { group -> + group.displayName.contains(normalizedQuery, ignoreCase = true) || + group.members.any { member -> + member.model.contains(normalizedQuery, ignoreCase = true) || + member.provider.contains(normalizedQuery, ignoreCase = true) || + configNames[member.configId].orEmpty().contains(normalizedQuery, ignoreCase = true) + } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + SearchField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.token_stats_group_search_hint), + ) + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(visibleGroups, key = { it.displayModelId }) { group -> + GroupCard( + group = group, + configNames = configNames, + selectedIds = selectedIds, + onToggleMember = { id -> + selectedIds = selectedIds.toMutableSet().apply { + if (!add(id)) remove(id) + } + }, + onRename = { renameTarget = group }, + onRestore = { restoreTarget = group }, + ) + } + } + if (selectedIds.isNotEmpty()) { + TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth().padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.token_stats_group_selected_count, selectedIds.size), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Medium, + ) + TextButton(onClick = { showCreate = true }) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.token_stats_group_new_short)) + } + Button(onClick = { showMove = true }) { + Text(stringResource(R.string.token_stats_group_move)) + } + } + } + } + } + + renameTarget?.let { group -> + TextInputDialog( + title = stringResource(R.string.token_stats_group_rename), + initialValue = group.displayName, + confirmLabel = stringResource(R.string.settings_save), + onConfirm = { onRename(group.displayModelId, it) }, + onDismiss = { renameTarget = null }, + ) + } + if (showCreate) { + TextInputDialog( + title = stringResource(R.string.token_stats_group_new), + initialValue = "", + confirmLabel = stringResource(R.string.token_stats_group_create), + onConfirm = { + onCreate(it, selectedIds.toList()) + selectedIds = emptySet() + }, + onDismiss = { showCreate = false }, + ) + } + if (showMove) { + ChoiceDialog( + title = stringResource(R.string.token_stats_group_move_title), + groups = groups, + onSelect = { + onMove(selectedIds.toList(), it.displayModelId) + selectedIds = emptySet() + showMove = false + }, + onDismiss = { showMove = false }, + ) + } + restoreTarget?.let { group -> + AlertDialog( + onDismissRequest = { restoreTarget = null }, + title = { Text(stringResource(R.string.token_stats_group_restore)) }, + text = { Text(stringResource(R.string.token_stats_group_restore_message, group.displayName)) }, + confirmButton = { + TextButton(onClick = { + onRestore(group.displayModelId) + restoreTarget = null + }) { Text(stringResource(R.string.token_stats_group_restore_confirm_short)) } + }, + dismissButton = { + TextButton(onClick = { restoreTarget = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } +} + +@Composable +private fun GroupCard( + group: TokenStatsGroupModelInfo, + configNames: Map, + selectedIds: Set, + onToggleMember: (String) -> Unit, + onRename: () -> Unit, + onRestore: () -> Unit, +) { + var expanded by rememberSaveable(group.displayModelId) { mutableStateOf(false) } + val custom = group.displayModelId.startsWith(TokenStatsSettingsManager.CUSTOM_GROUP_ID_PREFIX) + TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { expanded = !expanded }) { + Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, null) + } + Column(modifier = Modifier.weight(1f)) { + Text(group.displayName, fontWeight = FontWeight.Bold) + Text( + stringResource( + if (custom) R.string.token_stats_group_custom_summary + else R.string.token_stats_group_default_summary, + group.members.size, + ), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + } + IconButton(onClick = onRename) { + Icon(Icons.Default.Edit, stringResource(R.string.token_stats_group_rename)) + } + IconButton(onClick = onRestore) { + Icon(Icons.Default.Restore, stringResource(R.string.token_stats_group_restore)) + } + } + if (expanded) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + if (group.members.isEmpty()) { + Text( + stringResource(R.string.token_stats_group_empty), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + } + group.members.forEach { member -> + GroupMemberRow( + member = member, + configName = configNames[member.configId], + checked = member.identityId in selectedIds, + onToggle = { onToggleMember(member.identityId) }, + ) + } + } + } + } +} + +@Composable +private fun GroupMemberRow( + member: TokenStatsGroupMemberInfo, + configName: String?, + checked: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = checked, onCheckedChange = { onToggle() }) + Column(modifier = Modifier.weight(1f)) { + Text(member.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + text = buildString { + append(member.provider) + if (member.configId.isNotEmpty()) { + append(" · ") + append(configName ?: stringResource(R.string.token_stats_config_deleted)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun PricingManagementTab( + models: List, + overrides: List, + onSave: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, + onResetConfig: (TokenStatPriceOverrideEntity) -> Unit, + onRestoreBuiltIn: (TokenStatPriceOverrideEntity?, String?) -> Unit, +) { + var query by rememberSaveable { mutableStateOf("") } + var selectedProvider by rememberSaveable { mutableStateOf(null) } + var editor by remember { mutableStateOf(null) } + var resetConfigTarget by remember { mutableStateOf(null) } + var restoreBuiltInTarget by remember { + mutableStateOf?>(null) + } + val providers = remember(models) { models.map { it.provider }.distinct().sorted() } + val visible = remember(models, query, selectedProvider) { + models.filter { option -> + (selectedProvider == null || option.provider == selectedProvider) && + (query.isBlank() || option.model.contains(query, true) || + option.provider.contains(query, true) || + option.configs.any { it.name.contains(query, true) }) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + SearchField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(R.string.token_stats_pricing_search_hint), + ) + ProviderDropdown( + providers = providers, + selected = selectedProvider, + onSelect = { selectedProvider = it }, + ) + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(visible, key = { "${it.provider}:${it.model}" }) { option -> + PricingModelCard( + option = option, + overrides = overrides, + onEdit = { existing, draft -> editor = PricingEditor(existing, draft) }, + onResetConfig = { resetConfigTarget = it }, + onRestoreBuiltIn = { override, legacyKey -> + restoreBuiltInTarget = override to legacyKey + }, + ) + } + } + } + + editor?.let { target -> + PriceOverrideDialog( + existing = target.existing, + initialDraft = if (target.existing == null) target.draft else null, + onSave = { onSave(target.existing, it) }, + onDelete = null, + onDismiss = { editor = null }, + ) + } + restoreBuiltInTarget?.let { (override, legacyKey) -> + AlertDialog( + onDismissRequest = { restoreBuiltInTarget = null }, + title = { Text(stringResource(R.string.token_stats_pricing_restore_builtin)) }, + text = { Text(stringResource(R.string.token_stats_pricing_restore_message)) }, + confirmButton = { + TextButton(onClick = { + onRestoreBuiltIn(override, legacyKey) + restoreBuiltInTarget = null + }) { Text(stringResource(R.string.token_stats_pricing_restore_confirm)) } + }, + dismissButton = { + TextButton(onClick = { restoreBuiltInTarget = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } + resetConfigTarget?.let { override -> + AlertDialog( + onDismissRequest = { resetConfigTarget = null }, + title = { Text(stringResource(R.string.token_stats_pricing_restore_model)) }, + text = { Text(stringResource(R.string.token_stats_pricing_restore_message)) }, + confirmButton = { + TextButton(onClick = { + onResetConfig(override) + resetConfigTarget = null + }) { Text(stringResource(R.string.token_stats_pricing_restore_confirm)) } + }, + dismissButton = { + TextButton(onClick = { resetConfigTarget = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } +} + +@Composable +private fun PricingModelCard( + option: TokenStatsPricingModelOption, + overrides: List, + onEdit: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, + onResetConfig: (TokenStatPriceOverrideEntity) -> Unit, + onRestoreBuiltIn: (TokenStatPriceOverrideEntity?, String?) -> Unit, +) { + val providerOverride = overrides.firstOrNull { + it.scope == PriceOverrideScope.PROVIDER_MODEL.name && + it.provider.equals(option.provider, true) && it.model.equals(option.model, true) + } + val providerDraft = providerOverride?.toDraft() + ?: option.legacyPricing?.let { legacyDraft(option.provider, option.model, it) } + ?: builtinDraft(option.provider, option.model) + TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(option.model, fontWeight = FontWeight.Bold) + Text(option.provider, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted) + } + Text( + stringResource( + when { + providerOverride != null -> R.string.token_stats_pricing_source_override + option.legacyPricing != null -> R.string.token_stats_pricing_source_legacy + else -> R.string.token_stats_pricing_source_builtin + } + ), + style = MaterialTheme.typography.labelSmall, + color = LocalTokenStatsColors.current.chartAccent, + ) + IconButton(onClick = { onEdit(providerOverride, providerDraft) }) { + Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit)) + } + } + Text( + priceSummary(providerDraft), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + if (providerOverride != null || option.legacyPricing != null) { + TextButton(onClick = { + onRestoreBuiltIn(providerOverride, option.legacyProviderModel) + }) { + Text(stringResource(R.string.token_stats_pricing_restore_builtin)) + } + } + option.configs.forEach { config -> + val configOverride = overrides.firstOrNull { + it.scope == PriceOverrideScope.CONFIG.name && + it.provider.equals(option.provider, true) && + it.model.equals(option.model, true) && it.configId == config.id + } + val draft = configOverride?.toDraft() ?: providerDraft.copy( + scope = PriceOverrideScope.CONFIG, + configId = config.id, + ) + HorizontalDivider() + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(config.name, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) + Text( + stringResource( + if (configOverride == null) R.string.token_stats_pricing_inherits_model + else R.string.token_stats_pricing_source_config + ), + style = MaterialTheme.typography.labelSmall, + color = TokenStatsCardMuted, + ) + Text( + priceSummary(draft), + style = MaterialTheme.typography.labelSmall, + color = TokenStatsCardMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (configOverride != null) { + IconButton(onClick = { onResetConfig(configOverride) }) { + Icon(Icons.Default.Restore, stringResource(R.string.token_stats_pricing_restore_model)) + } + } + IconButton(onClick = { onEdit(configOverride, draft) }) { + Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit)) + } + } + } + } + } +} + +@Composable +private fun ProviderDropdown( + providers: List, + selected: String?, + onSelect: (String?) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { + FilterChip( + selected = selected != null, + onClick = { expanded = true }, + label = { Text(selected ?: stringResource(R.string.token_stats_filter_all_providers)) }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.token_stats_filter_all_providers)) }, + onClick = { onSelect(null); expanded = false }, + ) + providers.forEach { provider -> + DropdownMenuItem( + text = { Text(provider) }, + onClick = { onSelect(provider); expanded = false }, + ) + } + } + } +} + +@Composable +private fun SearchField(value: String, onValueChange: (String) -> Unit, placeholder: String) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + placeholder = { Text(placeholder) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + ) +} + +@Composable +private fun TextInputDialog( + title: String, + initialValue: String, + confirmLabel: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(initialValue) { mutableStateOf(initialValue) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + enabled = value.isNotBlank(), + onClick = { onConfirm(value.trim()); onDismiss() }, + ) { Text(confirmLabel) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } + }, + ) +} + +@Composable +private fun ChoiceDialog( + title: String, + groups: List, + onSelect: (TokenStatsGroupModelInfo) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(groups, key = { it.displayModelId }) { group -> + Row( + modifier = Modifier.fillMaxWidth().clickable { onSelect(group) }.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(group.displayName, fontWeight = FontWeight.Medium) + Text( + stringResource(R.string.token_stats_group_members, group.members.size), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + } + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } + }, + ) +} + +private fun builtinDraft(provider: String, model: String): TokenStatsPriceOverrideDraft { + val defaults = DefaultModelPricingCollect.getDefaultPricing("$provider:$model") + return TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = provider, + model = model, + configId = null, + billingMode = defaults.billingMode, + currency = defaults.currency, + inputPricePerMillion = defaults.inputPricePerMillion, + cachedInputPricePerMillion = defaults.cachedInputPricePerMillion, + outputPricePerMillion = defaults.outputPricePerMillion, + pricePerRequest = defaults.pricePerRequest, + ) +} + +private fun legacyDraft( + provider: String, + model: String, + legacy: LegacyPriceSettings, +): TokenStatsPriceOverrideDraft { + val defaults = DefaultModelPricingCollect.getDefaultPricing("$provider:$model") + val billingMode = legacy.billingMode ?: defaults.billingMode + return TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = provider, + model = model, + configId = null, + billingMode = billingMode, + currency = defaults.currency, + inputPricePerMillion = legacy.inputPricePerMillion ?: defaults.inputPricePerMillion, + cachedInputPricePerMillion = legacy.cachedInputPricePerMillion + ?: defaults.cachedInputPricePerMillion, + outputPricePerMillion = legacy.outputPricePerMillion ?: defaults.outputPricePerMillion, + pricePerRequest = legacy.pricePerRequest ?: defaults.pricePerRequest, + ) +} + +private fun TokenStatPriceOverrideEntity.toDraft() = TokenStatsPriceOverrideDraft( + scope = PriceOverrideScope.fromNameOrNull(scope) ?: PriceOverrideScope.PROVIDER_MODEL, + provider = provider, + model = model, + configId = configId.ifBlank { null }, + billingMode = BillingMode.fromString(billingMode), + currency = if (pricingCurrency.equals("CNY", true)) PricingCurrency.CNY else PricingCurrency.USD, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, +) + +@Composable +private fun priceSummary(draft: TokenStatsPriceOverrideDraft): String { + if (draft.billingMode == BillingMode.COUNT) { + return draft.pricePerRequest?.let { + "${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, draft.currency)}" + } ?: "-" + } + return listOfNotNull( + draft.inputPricePerMillion?.let { + "${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, draft.currency)}" + }, + draft.cachedInputPricePerMillion?.let { + "${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, draft.currency)}" + }, + draft.outputPricePerMillion?.let { + "${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, draft.currency)}" + }, + ).joinToString(" · ").ifEmpty { "-" } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt new file mode 100644 index 000000000..b6811b394 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt @@ -0,0 +1,220 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.ModelConfigSummary +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.model.getModelList +import com.ai.assistance.operit.data.preferences.ModelConfigManager +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.data.stats.LegacyPriceSettings +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class TokenStatsConfigOption( + val id: String, + val name: String, + val provider: String, + val models: List, +) + +data class TokenStatsPricingModelOption( + val provider: String, + val model: String, + val configs: List, + val legacyProviderModel: String? = null, + val legacyPricing: LegacyPriceSettings? = null, +) + +data class TokenStatsManagementState( + val loading: Boolean = true, + val errorMessage: String? = null, + val groups: List = emptyList(), + val overrides: List = emptyList(), + val configs: List = emptyList(), + val pricingModels: List = emptyList(), +) + +class TokenStatsManagementViewModel( + context: Context, + dao: TokenStatsDao? = null, +) : ViewModel() { + private val appContext = context.applicationContext + private val manager = TokenStatsSettingsManager( + dao ?: AppDatabase.getDatabase(appContext).tokenStatsDao() + ) + private val configManager = ModelConfigManager(appContext) + private val apiPreferences = ApiPreferences.getInstance(appContext) + private val _state = MutableStateFlow(TokenStatsManagementState()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _state.update { it.copy(loading = true, errorMessage = null) } + try { + val groups = manager.groupModels() + val overrides = manager.allPriceOverrides() + val legacyPrices = apiPreferences.allLegacyPriceSettings().mapNotNull { (key, value) -> + value?.let { key to it } + }.toMap() + val configs = configManager.getAllConfigSummaries().map(ModelConfigSummary::toTokenStatsOption) + _state.value = TokenStatsManagementState( + loading = false, + groups = groups, + overrides = overrides, + configs = configs, + pricingModels = buildPricingModels(groups, configs, overrides, legacyPrices), + ) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _state.update { + it.copy( + loading = false, + errorMessage = appContext.getString(R.string.token_stats_management_load_failed), + ) + } + } + } + } + + fun renameGroup(groupId: String, name: String) = mutate { + manager.renameDisplayGroup(groupId, name) + } + + fun createGroup(name: String, identityIds: List) = mutate { + manager.createGroupAndMove(name, identityIds) + } + + fun moveToGroup(identityIds: List, targetGroupId: String) = mutate { + manager.moveIdentitiesToGroup(identityIds, targetGroupId) + } + + fun restoreDefaultGroup(groupId: String) = mutate { + manager.restoreDefaultGroups(groupId) + } + + fun savePriceOverride( + existing: TokenStatPriceOverrideEntity?, + draft: TokenStatsPriceOverrideDraft, + ) = mutate { + if (existing == null) manager.upsertPriceOverride(draft) + else manager.updatePriceOverride(existing, draft) + } + + fun resetPrice(override: TokenStatPriceOverrideEntity) = mutate { + val scope = PriceOverrideScope.fromNameOrNull(override.scope) ?: return@mutate + manager.deletePriceOverride(scope, override.provider, override.model, override.configId) + } + + fun restoreBuiltInPrice( + providerOverride: TokenStatPriceOverrideEntity?, + legacyProviderModel: String?, + ) = mutate { + if (providerOverride != null) { + manager.deletePriceOverride( + PriceOverrideScope.PROVIDER_MODEL, + providerOverride.provider, + providerOverride.model, + providerOverride.configId, + ) + } + if (legacyProviderModel != null) { + apiPreferences.clearLegacyPriceSettings(legacyProviderModel) + } + } + + private fun mutate(block: suspend () -> Unit) { + viewModelScope.launch { + try { + block() + load() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + _state.update { + it.copy(errorMessage = appContext.getString(R.string.token_stats_management_save_failed)) + } + } + } + } + + class Factory(context: Context) : ViewModelProvider.Factory { + private val appContext = context.applicationContext + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + TokenStatsManagementViewModel(appContext) as T + } +} + +private fun ModelConfigSummary.toTokenStatsOption() = TokenStatsConfigOption( + id = id, + name = name, + provider = apiProviderTypeId, + models = getModelList(modelName), +) + +internal fun buildPricingModels( + groups: List, + configs: List, + overrides: List, + legacyPrices: Map = emptyMap(), +): List { + val keys = linkedMapOf>() + fun add(provider: String, model: String) { + val key = "${provider.trim().lowercase()}\u0000${model.trim().lowercase()}" + keys.putIfAbsent(key, provider to model) + } + groups.flatMap { it.members }.forEach { add(it.provider, it.model) } + configs.forEach { config -> config.models.forEach { add(config.provider, it) } } + overrides.forEach { add(it.provider, it.model) } + legacyPrices.keys.forEach { providerModel -> + val separator = providerModel.indexOf(':') + if (separator > 0) add(providerModel.substring(0, separator), providerModel.substring(separator + 1)) + } + return keys.values.map { (provider, model) -> + val matchingConfigs = configs.filter { config -> + config.provider.equals(provider, ignoreCase = true) && + config.models.any { it.equals(model, ignoreCase = true) } + } + val missingConfigIds = overrides.asSequence().filter { + it.scope == PriceOverrideScope.CONFIG.name && + it.provider.equals(provider, true) && it.model.equals(model, true) + }.map { it.configId }.filter { id -> matchingConfigs.none { it.id == id } }.distinct() + TokenStatsPricingModelOption( + provider = provider, + model = model, + configs = matchingConfigs + missingConfigIds.map { id -> + TokenStatsConfigOption(id = id, name = id, provider = provider, models = listOf(model)) + }, + legacyProviderModel = legacyPrices.keys.firstOrNull { key -> + val separator = key.indexOf(':') + separator > 0 && key.substring(0, separator).equals(provider, true) && + key.substring(separator + 1).equals(model, true) + }, + legacyPricing = legacyPrices.entries.firstOrNull { (key, _) -> + val separator = key.indexOf(':') + separator > 0 && key.substring(0, separator).equals(provider, true) && + key.substring(separator + 1).equals(model, true) + }?.value, + ) + }.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() })) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index d9ebb4a7d..fc55ceb64 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Analytics import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit @@ -22,7 +21,6 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -34,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -42,6 +41,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -49,10 +51,10 @@ import com.ai.assistance.operit.R import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo import com.ai.assistance.operit.data.stats.TokenStatsRangeData import com.ai.assistance.operit.ui.components.CustomScaffold import java.time.ZoneId +import kotlinx.coroutines.delay /** 性能卡指标切换。 */ internal enum class PerfMetric { TTFT, GENERATION } @@ -64,7 +66,11 @@ internal enum class PerfMetric { TTFT, GENERATION } * 四张图表卡 + 模型明细 + 汇率/币种/价格覆盖/分组管理设置。 */ @Composable -fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { +fun TokenUsageStatisticsScreen( + onBackPressed: () -> Unit, + onOpenGroupManagement: () -> Unit, + onOpenPricingManagement: () -> Unit, +) { val context = LocalContext.current // P1-3:VM 由路由级 ViewModelStore 管理(AppContent 为该 route 提供 // LocalViewModelStoreOwner,键 = screenKey)——配置变化保留实例, @@ -85,8 +91,6 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { // 模型删除两步确认:目标模型 + baseline 选择 var deleteModel by remember { mutableStateOf(null) } var showDeleteModelBaseline by rememberSaveable { mutableStateOf(false) } - var pricingTarget by remember { mutableStateOf(null) } - var groupTarget by remember { mutableStateOf(null) } var perfMetric by rememberSaveable { mutableStateOf(PerfMetric.TTFT) } LaunchedEffect(actionMessage) { @@ -95,6 +99,7 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { viewModel.consumeActionMessage() } } + LaunchedEffect(Unit) { viewModel.load() } TokenStatsColorsProvider { CustomScaffold( @@ -132,9 +137,8 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { onCustomRange = { showCustomRange = true }, onDeleteRange = { showDeleteRangeDialog = true }, onDeleteModel = { deleteModel = it }, - onEditPricing = { pricingTarget = PricingTarget.Edit(it) }, - onAddPricing = { pricingTarget = PricingTarget.New }, - onGroupManage = { groupTarget = it }, + onOpenGroupManagement = onOpenGroupManagement, + onOpenPricingManagement = onOpenPricingManagement, ) } } @@ -158,61 +162,6 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { ) } - pricingTarget?.let { target -> - PriceOverrideDialog( - existing = (target as? PricingTarget.Edit)?.override, - onSave = { draft -> - // P1-7:编辑走键校验入口(业务键只读),新增走 upsert - val existingOverride = (target as? PricingTarget.Edit)?.override - if (existingOverride == null) { - viewModel.upsertPriceOverride(draft) - } else { - viewModel.updatePriceOverride(existingOverride, draft) - } - }, - onDelete = (target as? PricingTarget.Edit)?.let { edit -> - { - val override = edit.override - PriceOverrideScope.fromNameOrNull(override.scope)?.let { scope -> - viewModel.deletePriceOverride( - scope = scope, - provider = override.provider, - model = override.model, - configId = override.configId, - ) - } - } - }, - onDismiss = { pricingTarget = null }, - ) - } - - groupTarget?.let { model -> - // P1 修复:成员与目标来自独立于统计筛选的完整分组元数据(state.groupModels, - // 全量身份/展示模型表)——范围明细只含当前筛选下有事件的身份,直接用它 - // 做合并会把无事件成员漏掉;无事件的目标组也不可选出。 - val group = - state.groupModels.firstOrNull { it.displayModelId == model.displayModelId } - ?: TokenStatsGroupModelInfo( - displayModelId = model.displayModelId, - displayName = model.displayName, - memberIdentityIds = model.identities.map { it.identityId }, - ) - GroupManageDialog( - groupInfo = group, - otherGroups = state.groupModels.filter { it.displayModelId != group.displayModelId }, - onRename = { name -> viewModel.renameDisplayGroup(group.displayModelId, name) }, - onCreateAndMerge = { name -> - viewModel.createGroupAndMerge(name, group.memberIdentityIds) - }, - onMergeInto = { targetId -> - viewModel.mergeIntoGroup(group.memberIdentityIds, targetId) - }, - onRestoreDefault = { viewModel.restoreDefaultGroup(group.displayModelId) }, - onDismiss = { groupTarget = null }, - ) - } - // ==== 阶段 5 删除对话框 ==== // 危险操作明确确认:范围删除单步确认(绝不触碰 baseline); // 模型/全部删除两步确认(第一步危险确认 → 第二步选择是否同时删除 baseline)。 @@ -223,17 +172,12 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { title = { Text(stringResource(R.string.token_stats_delete_range_title)) }, text = { Text(stringResource(R.string.token_stats_delete_range_message)) }, confirmButton = { - TextButton( + CountdownDeleteButton( onClick = { viewModel.deleteRangeEvents() showDeleteRangeDialog = false }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.token_stats_delete_confirm)) - } + ) }, dismissButton = { TextButton(onClick = { showDeleteRangeDialog = false }) { @@ -258,7 +202,7 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.token_stats_delete_confirm)) + Text(stringResource(R.string.token_stats_delete_continue)) } }, dismissButton = { @@ -332,7 +276,7 @@ fun TokenUsageStatisticsScreen(onBackPressed: () -> Unit) { contentColor = MaterialTheme.colorScheme.error, ), ) { - Text(stringResource(R.string.token_stats_delete_confirm)) + Text(stringResource(R.string.token_stats_delete_continue)) } }, dismissButton = { @@ -358,25 +302,31 @@ private fun DeleteBaselineDialog( onEventsAndBaseline: () -> Unit, onDismiss: () -> Unit, ) { + var remainingSeconds by rememberSaveable { mutableIntStateOf(DELETE_COUNTDOWN_SECONDS) } + LaunchedEffect(Unit) { + while (remainingSeconds > 0) { + delay(1_000) + remainingSeconds-- + } + } AlertDialog( onDismissRequest = onDismiss, title = { Text(title) }, text = { Text(message) }, confirmButton = { - TextButton( + CountdownDeleteButton( onClick = onEventsAndBaseline, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.token_stats_delete_events_and_baseline)) - } + remainingSeconds = remainingSeconds, + readyLabel = stringResource(R.string.token_stats_delete_events_and_baseline), + ) }, dismissButton = { Row { - TextButton(onClick = onEventsOnly) { - Text(stringResource(R.string.token_stats_delete_events_only)) - } + CountdownDeleteButton( + onClick = onEventsOnly, + remainingSeconds = remainingSeconds, + readyLabel = stringResource(R.string.token_stats_delete_events_only), + ) Spacer(Modifier.weight(1f)) TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) @@ -386,9 +336,42 @@ private fun DeleteBaselineDialog( ) } -private sealed interface PricingTarget { - data object New : PricingTarget - data class Edit(val override: TokenStatPriceOverrideEntity) : PricingTarget +private const val DELETE_COUNTDOWN_SECONDS = 5 + +/** 最终危险操作在对话框出现后等待五秒;取消始终保持可用。 */ +@Composable +private fun CountdownDeleteButton( + onClick: () -> Unit, + remainingSeconds: Int? = null, + readyLabel: String = stringResource(R.string.token_stats_delete_confirm), +) { + var localRemaining by rememberSaveable { mutableIntStateOf(DELETE_COUNTDOWN_SECONDS) } + val remaining = remainingSeconds ?: localRemaining + if (remainingSeconds == null) { + LaunchedEffect(Unit) { + while (localRemaining > 0) { + delay(1_000) + localRemaining-- + } + } + } + TextButton( + enabled = remaining == 0, + onClick = onClick, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + disabledContentColor = MaterialTheme.colorScheme.error.copy(alpha = 0.5f), + ), + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Text( + if (remaining > 0) { + stringResource(R.string.token_stats_delete_countdown, readyLabel, remaining) + } else { + readyLabel + } + ) + } } @Composable @@ -425,9 +408,8 @@ private fun TokenStatsPageContent( onCustomRange: () -> Unit, onDeleteRange: () -> Unit, onDeleteModel: (TokenStatsDisplayModelBreakdown) -> Unit, - onEditPricing: (TokenStatPriceOverrideEntity) -> Unit, - onAddPricing: () -> Unit, - onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, + onOpenGroupManagement: () -> Unit, + onOpenPricingManagement: () -> Unit, ) { val lifetime = state.lifetime ?: return val hasAnyData = lifetime.eventTotals.requests > 0L || lifetime.baselineTotals.identityCount > 0L @@ -445,6 +427,8 @@ private fun TokenStatsPageContent( currency = state.targetCurrency, manualRate = state.manualRate, rateIsEstimated = state.rateIsEstimated, + includeLegacy = state.includeLegacy, + onIncludeLegacyChange = viewModel::setIncludeLegacy, ) } @@ -506,19 +490,23 @@ private fun TokenStatsPageContent( text = stringResource(R.string.settings_model_details), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), ) Text( text = stringResource(R.string.token_stats_model_count, range.displayModels.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + TextButton(onClick = onOpenGroupManagement) { + Text(stringResource(R.string.token_stats_group_manage)) + } } TokenStatsModelCardsSection( models = range.displayModels, currency = state.targetCurrency, costMode = state.costMode, zone = zone, - onGroupManage = onGroupManage, + onGroupManage = { onOpenGroupManagement() }, onDelete = onDeleteModel, ) } @@ -531,7 +519,6 @@ private fun TokenStatsPageContent( TokenStatsRateCard( manualRate = state.manualRate, rateIsEstimated = state.rateIsEstimated, - currency = state.targetCurrency, onSaveRate = { rate -> val ok = viewModel.setManualRate(rate) if (!ok) { @@ -539,7 +526,6 @@ private fun TokenStatsPageContent( } ok }, - onSetCurrency = viewModel::setTargetCurrency, ) } @@ -547,19 +533,7 @@ private fun TokenStatsPageContent( TokenStatsPricingSection( range = range, overrides = state.overrides, - onAdd = onAddPricing, - onEdit = onEditPricing, - onDelete = { override -> - val scope = - PriceOverrideScope.fromNameOrNull(override.scope) - ?: return@TokenStatsPricingSection - viewModel.deletePriceOverride( - scope = scope, - provider = override.provider, - model = override.model, - configId = override.configId, - ) - }, + onManage = onOpenPricingManagement, ) } @@ -571,7 +545,9 @@ private fun TokenStatsPageContent( @Composable private fun EmptyStateCard() { - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Column( modifier = Modifier .fillMaxWidth() @@ -582,19 +558,19 @@ private fun EmptyStateCard() { imageVector = Icons.Default.Analytics, contentDescription = null, modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = TokenStatsCardMuted, ) Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.token_stats_empty), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.token_stats_empty_hint), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + color = TokenStatsCardMuted.copy(alpha = 0.8f), ) } } @@ -602,11 +578,13 @@ private fun EmptyStateCard() { @Composable private fun NoDataCard(text: String) { - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, modifier = Modifier.padding(24.dp), ) } @@ -750,10 +728,8 @@ private fun TokenChartCard( val colors = LocalTokenStatsColors.current // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 val outputLabel = stringResource(R.string.token_stats_token_output) - val cacheWriteLabel = stringResource(R.string.token_stats_token_cache_write) val cachedLabel = stringResource(R.string.token_stats_token_cached) val uncachedLabel = stringResource(R.string.token_stats_token_uncached) - val reasoningLabel = stringResource(R.string.token_stats_token_reasoning) val unknownPartsTemplate = stringResource(R.string.token_stats_unknown_parts) val chartTitle = stringResource(R.string.token_stats_chart_tokens) @@ -765,11 +741,11 @@ private fun TokenChartCard( TokenStatsChartCard( title = chartTitle, summary = formatCompactCount( - range.summary.uncachedInput.knownSum + - range.summary.cachedInput.knownSum + - range.summary.cacheWrite.knownSum + - range.summary.output.knownSum + - range.summary.reasoning.knownSum + saturatedTokenSum( + range.summary.uncachedInput.knownSum, + range.summary.cachedInput.knownSum, + range.summary.output.knownSum, + ) ), ) { if (totalUnknown > 0L) { @@ -785,14 +761,12 @@ private fun TokenChartCard( stackSelector = { bucket -> listOf( bucket.totals.output.knownSum.toDouble() to colors.output, - bucket.totals.cacheWrite.knownSum.toDouble() to colors.cacheWrite, - bucket.totals.cachedInput.knownSum.toDouble() to colors.cachedInput, bucket.totals.uncachedInput.knownSum.toDouble() to colors.uncachedInput, - bucket.totals.reasoning.knownSum.toDouble() to colors.reasoning, + bucket.totals.cachedInput.knownSum.toDouble() to colors.cachedInput, ) }, stackLabels = { - listOf(outputLabel, cacheWriteLabel, cachedLabel, uncachedLabel, reasoningLabel) + listOf(outputLabel, uncachedLabel, cachedLabel) }, unknownNote = { bucket -> val unknown = @@ -804,9 +778,7 @@ private fun TokenChartCard( legendItems = listOf( uncachedLabel to colors.uncachedInput, cachedLabel to colors.cachedInput, - cacheWriteLabel to colors.cacheWrite, outputLabel to colors.output, - reasoningLabel to colors.reasoning, ), ) } @@ -888,13 +860,13 @@ private fun RangeUnknownHint(text: String) { private fun TokenStatsPricingSection( range: TokenStatsRangeData?, overrides: List, - onAdd: () -> Unit, - onEdit: (TokenStatPriceOverrideEntity) -> Unit, - onDelete: (TokenStatPriceOverrideEntity) -> Unit, + onManage: () -> Unit, ) { var showBuiltin by remember { mutableStateOf(false) } - Card(modifier = Modifier.fillMaxWidth()) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -909,13 +881,13 @@ private fun TokenStatsPricingSection( Text( text = stringResource(R.string.token_stats_pricing_subtitle), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } - IconButton(onClick = onAdd) { + IconButton(onClick = onManage) { Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(R.string.token_stats_pricing_add), + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.token_stats_management_open), ) } } @@ -964,14 +936,14 @@ private fun TokenStatsPricingSection( } }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } if (providerModels.size > 12) { Text( text = stringResource(R.string.token_stats_more_count, providerModels.size - 12), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } } @@ -981,15 +953,11 @@ private fun TokenStatsPricingSection( Text( text = stringResource(R.string.token_stats_pricing_none), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } else { overrides.forEach { override -> - PriceOverrideRow( - override = override, - onEdit = { onEdit(override) }, - onDelete = { onDelete(override) }, - ) + PriceOverrideRow(override = override) } } } @@ -999,8 +967,6 @@ private fun TokenStatsPricingSection( @Composable private fun PriceOverrideRow( override: TokenStatPriceOverrideEntity, - onEdit: () -> Unit, - onDelete: () -> Unit, ) { val scopeText = if (override.scope == PriceOverrideScope.CONFIG.name) { @@ -1038,7 +1004,7 @@ private fun PriceOverrideRow( Text( text = stringResource(R.string.token_stats_config_id, override.configId), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = TokenStatsCardMuted, ) } Text( @@ -1048,24 +1014,7 @@ private fun PriceOverrideRow( prices.joinToString(" · ") }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // 触摸目标保持 IconButton 默认 48dp(P1-8:不低于 48dp) - IconButton(onClick = onEdit) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.token_stats_pricing_edit), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - } - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.token_stats_pricing_delete), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp), + color = TokenStatsCardMuted, ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index 63dbfb105..a22ba3acc 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -71,6 +71,8 @@ data class TokenStatsUiState( val userChoseTime: Boolean = false, val targetCurrency: PricingCurrency = PricingCurrency.CNY, val costMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL, + /** true = 生命周期累计包含迁移的旧版 baseline;关闭只影响展示,不删除数据。 */ + val includeLegacy: Boolean = true, val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, /** true = 汇率是默认估算值(用户未设置),界面必须明显标注。 */ val rateIsEstimated: Boolean = true, @@ -180,6 +182,7 @@ class TokenUsageStatisticsViewModel( val rateInfo = settings.loadRateWithEstimate() val currency = settings.loadTargetCurrency() val mode = settings.loadCostMode() + val includeLegacy = settings.loadIncludeLegacy() val savedSelection = settings.loadTimeSelection() val selectionWasManual = settings.loadSelectionWasManual() @@ -273,6 +276,7 @@ class TokenUsageStatisticsViewModel( manualRate = rateInfo.first, rateIsEstimated = rateInfo.second, costMode = mode, + includeLegacy = includeLegacy, selectedPreset = preset, customRange = customRange, currentRange = range, @@ -401,6 +405,13 @@ class TokenUsageStatisticsViewModel( // ==== 口径/币种/汇率 ==== + fun setIncludeLegacy(include: Boolean) { + viewModelScope.launch(dispatcher) { + settings.saveIncludeLegacy(include) + _state.update { it.copy(includeLegacy = include) } + } + } + fun setCostMode(mode: TokenStatsCostMode) { viewModelScope.launch(dispatcher) { settings.saveCostMode(mode) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index fd87ae87f..b306519cc 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -66,6 +66,7 @@ import com.ai.assistance.operit.ui.features.settings.screens.ToolPermissionSetti import com.ai.assistance.operit.ui.features.settings.screens.MnnModelDownloadScreen import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesSettingsScreen import com.ai.assistance.operit.ui.features.tokenstats.TokenUsageStatisticsScreen +import com.ai.assistance.operit.ui.features.tokenstats.TokenStatsManagementScreen import com.ai.assistance.operit.ui.features.token.TokenConfigWebViewScreen import com.ai.assistance.operit.ui.features.toolbox.screens.AppPermissionsToolScreen import com.ai.assistance.operit.ui.features.toolbox.screens.FileManagerToolScreen @@ -1130,7 +1131,51 @@ sealed class Screen( onError: (String) -> Unit, onGestureConsumed: (Boolean) -> Unit ) { - TokenUsageStatisticsScreen(onBackPressed = onGoBack) + TokenUsageStatisticsScreen( + onBackPressed = onGoBack, + onOpenGroupManagement = { navigateTo(TokenStatsManagement) }, + onOpenPricingManagement = { navigateTo(TokenStatsPricingManagement) } + ) + } + } + + data object TokenStatsManagement : + Screen( + navItem = NavItem.Settings, + titleRes = R.string.token_stats_management_title, + usesRouteViewModelStore = true + ) { + @Composable + override fun Content( + navController: NavController, + navigateTo: ScreenNavigationHandler, + onGoBack: () -> Unit, + hasBackgroundImage: Boolean, + onLoading: (Boolean) -> Unit, + onError: (String) -> Unit, + onGestureConsumed: (Boolean) -> Unit + ) { + TokenStatsManagementScreen() + } + } + + data object TokenStatsPricingManagement : + Screen( + navItem = NavItem.Settings, + titleRes = R.string.token_stats_management_title, + usesRouteViewModelStore = true + ) { + @Composable + override fun Content( + navController: NavController, + navigateTo: ScreenNavigationHandler, + onGoBack: () -> Unit, + hasBackgroundImage: Boolean, + onLoading: (Boolean) -> Unit, + onError: (String) -> Unit, + onGestureConsumed: (Boolean) -> Unit + ) { + TokenStatsManagementScreen(initialPricingTab = true) } } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 6faf356d9..f76a54cbf 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3629,6 +3629,7 @@ Rate must be a positive finite number Legacy data estimate Estimated + Include legacy data in totals %1$d legacy rows (no time distribution; excluded from trends) %1$d request(s) with unknown cost %1$d request(s) with partially unknown data @@ -3659,8 +3660,13 @@ %1$d categories All statuses %1$d statuses - Historical bill - Revalued + Price at request time + Recalculate now + Cost calculation + Price at request time: uses the price saved when each request occurred. Later pricing changes do not alter this cost. + Recalculate now: recalculates the selected range with current built-in and custom prices. This is useful for estimates and is not a provider bill. + Changing the exchange rate only changes converted display totals. It does not alter the original-currency cost saved with an event. + Got it Total currency CNY USD @@ -3701,6 +3707,30 @@ Config override Legacy price Price Overrides + Model and pricing management + Manage models and pricing + Model groups + Model pricing + Could not load model and pricing information. Try again. + The operation failed. Existing settings were not changed. + Search groups, models, providers, or configs + %1$d identities selected + New group + Move + Move to group + Custom group · %1$d identities + Default group · %1$d identities + No identities in this group + Restore identities in “%1$s” to default groups based on their model names. + Restore + Deleted configuration + Search models, providers, or API configs + All providers + Uses model price + Restore built-in price + Restore model price + The effective price will fall back to the next level. + Restore Built-in default → Provider/Model → Config, layered Built-in defaults (read-only) No custom price overrides yet @@ -3754,6 +3784,8 @@ Events only Events and baseline Delete + Continue + %1$s (%2$d) Previous time bucket Next time bucket Bucket %1$d of %2$d diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc1708be1..5a1a3c0c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3544,6 +3544,7 @@ 汇率必须为正的有限数值 旧数据估算 估算 + 累计中包含旧版数据 %1$d 行旧版累计数据(无时间分布,不进趋势图) 其中 %1$d 个请求费用未知 %1$d 个请求部分数据未知 @@ -3574,8 +3575,13 @@ %1$d 个分类 全部状态 %1$d 个状态 - 历史账单 - 当前重估 + 发生时价格 + 按当前价格重算 + 费用计算方式 + 发生时价格:使用请求发生时保存的价格。之后修改模型定价,不会改变这部分费用。 + 按当前价格重算:使用现在的内置价格和自定义价格重新计算所选时间范围,适合预算模拟,不代表供应商实际账单。 + 修改汇率只会改变统一币种后的展示结果,不会改变事件保存的原币费用。 + 知道了 总计币种 CNY USD @@ -3616,6 +3622,30 @@ API 配置覆盖 旧系统价格 价格覆盖 + 模型与定价管理 + 管理模型与定价 + 模型分组 + 模型定价 + 无法加载模型与定价信息,请重试。 + 操作失败,现有设置未改变。 + 搜索分组、模型、Provider 或配置 + 已选择 %1$d 个身份 + 新建分组 + 移动 + 移动到分组 + 自定义分组 · %1$d 个身份 + 默认分组 · %1$d 个身份 + 该分组暂无身份 + 将“%1$s”内的身份按各自模型名称恢复到默认分组。 + 恢复 + 已删除的配置 + 搜索模型、Provider 或 API 配置 + 全部 Provider + 使用模型价格 + 恢复内置价格 + 恢复模型价格 + 恢复后将自动使用上一级有效价格。 + 确认恢复 内置默认 → Provider/模型 → API 配置,逐层覆盖 内置默认(只读) 暂无自定义价格覆盖 @@ -3669,6 +3699,8 @@ 仅删除事件 删除事件与 baseline 确认删除 + 继续 + %1$s(%2$d) 上一个时间桶 下一个时间桶 第 %1$d / %2$d 桶 diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt index 7c3438fbe..9d41acd36 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt @@ -464,6 +464,13 @@ class TokenStatsSettingsManagerTest { val merged = groups.first { it.displayModelId == groupId } assertEquals("组合", merged.displayName) assertEquals(setOf("id-1", "id-2"), merged.memberIdentityIds.toSet()) + assertEquals( + setOf( + TokenStatsGroupMemberInfo("id-1", "cfg-1", "OPENAI", "gpt-4o"), + TokenStatsGroupMemberInfo("id-2", "cfg-2", "OPENAI", "claude-3-5-sonnet"), + ), + merged.members.toSet(), + ) // 默认分组(成员被移出后变空)仍在列表中:空组是合法合并目标 val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt index 39615f961..85024d221 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt @@ -1,6 +1,7 @@ package com.ai.assistance.operit.ui.features.tokenstats import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -67,4 +68,21 @@ class TokenStatsChartsTest { // 全空 → 无线段 assertEquals(emptyList>(), lineSegments(listOf(null, null))) } + + @Test + fun `lifetime token sum includes migrated baseline without overflow`() { + val legacyTotal = saturatedTokenSum(15_530_991L, 13_717_376L, 320_485L) + + assertEquals(29_568_852L, legacyTotal) + assertEquals(Long.MAX_VALUE, saturatedTokenSum(Long.MAX_VALUE, 1L)) + assertEquals(12L, includeLegacyValue(5L, 7L, true)) + assertEquals(5L, includeLegacyValue(5L, 7L, false)) + } + + @Test + fun `token trend uses Rainytoken palette`() { + assertEquals(Color(0xFFFFD1DC), RainyTokenCacheRead) + assertEquals(Color(0xFFFF85A2), RainyTokenUncachedInput) + assertEquals(Color(0xFFE91E63), RainyTokenOutput) + } } diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt new file mode 100644 index 000000000..b11fb7f9f --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt @@ -0,0 +1,84 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import com.ai.assistance.operit.data.model.PriceOverrideScope +import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.stats.TokenStatsGroupMemberInfo +import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.LegacyPriceSettings +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenStatsManagementViewModelTest { + @Test + fun `pricing models combine identities configs and overrides without case duplicates`() { + val groups = listOf( + TokenStatsGroupModelInfo( + displayModelId = "gpt", + displayName = "GPT", + memberIdentityIds = listOf("id-1"), + members = listOf(TokenStatsGroupMemberInfo("id-1", "cfg-1", "OPENAI", "gpt-4o")), + ) + ) + val configs = listOf( + TokenStatsConfigOption("cfg-1", "Primary", "openai", listOf("GPT-4O")), + TokenStatsConfigOption("cfg-2", "Backup", "ANTHROPIC", listOf("sonnet")), + ) + val overrides = listOf(override("openai", "gpt-4o", "")) + + val result = buildPricingModels(groups, configs, overrides) + + assertEquals(2, result.size) + val gpt = result.first { it.model.equals("gpt-4o", true) } + assertEquals("OPENAI", gpt.provider) + assertEquals(listOf("Primary"), gpt.configs.map { it.name }) + } + + @Test + fun `pricing models retain config overrides whose config was deleted`() { + val orphan = override("openai", "gpt-4o", "deleted-config", PriceOverrideScope.CONFIG) + + val result = buildPricingModels(emptyList(), emptyList(), listOf(orphan)).single() + + assertEquals("deleted-config", result.configs.single().id) + assertEquals("deleted-config", result.configs.single().name) + } + + @Test + fun `pricing models retain legacy pricing key and avoid duplicate observed model`() { + val groups = listOf( + TokenStatsGroupModelInfo( + displayModelId = "gpt", + displayName = "GPT", + memberIdentityIds = listOf("id-1"), + members = listOf(TokenStatsGroupMemberInfo("id-1", "", "OPENAI", "gpt-4o")), + ) + ) + val legacy = LegacyPriceSettings(inputPricePerMillion = 3.0) + + val result = buildPricingModels( + groups, + emptyList(), + emptyList(), + mapOf("OPENAI:gpt-4o" to legacy), + ).single() + + assertEquals("OPENAI:gpt-4o", result.legacyProviderModel) + assertEquals(legacy, result.legacyPricing) + } + + private fun override( + provider: String, + model: String, + configId: String, + scope: PriceOverrideScope = PriceOverrideScope.PROVIDER_MODEL, + ) = TokenStatPriceOverrideEntity.normalized( + scope = scope.name, + provider = provider, + model = model, + configId = configId, + billingMode = "TOKEN", + pricingCurrency = "USD", + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + ) +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt index 2ef07dc59..ea44fb9d2 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -677,6 +677,10 @@ class TokenUsageStatisticsViewModelTest { assertEquals(TokenStatsCostMode.REVALUED, viewModel.state.value.costMode) assertEquals(TokenStatsCostMode.REVALUED, settings.savedMode) + viewModel.setIncludeLegacy(false) + assertFalse(viewModel.state.value.includeLegacy) + assertFalse(settings.savedIncludeLegacy) + // 手动汇率:合法保存后不再标记估算 val v3 = viewModel.state.value.refreshVersion assertTrue(viewModel.setManualRate(7.35)) @@ -1132,6 +1136,7 @@ private class FakeSettingsStore : TokenStatsSettingsStore { var rateEstimated: Boolean = true var savedCurrency: PricingCurrency = PricingCurrency.CNY var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL + var savedIncludeLegacy: Boolean = true var savedSelection: TokenStatsTimeSelection? = null var savedManual: Boolean = false /** saveTimeSelection 调用次数(P1-2:第二个 VM 不得再次保存/探测)。 */ @@ -1157,6 +1162,12 @@ private class FakeSettingsStore : TokenStatsSettingsStore { savedMode = mode } + override suspend fun loadIncludeLegacy(): Boolean = savedIncludeLegacy + + override suspend fun saveIncludeLegacy(include: Boolean) { + savedIncludeLegacy = include + } + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection override suspend fun loadSelectionWasManual(): Boolean = savedManual @@ -1177,6 +1188,7 @@ private class GatedSettingsStore : TokenStatsSettingsStore { var rateEstimated: Boolean = true var savedCurrency: PricingCurrency = PricingCurrency.CNY var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL + var savedIncludeLegacy: Boolean = true var savedSelection: TokenStatsTimeSelection? = null var savedManual: Boolean = false val firstLoadStarted = CompletableDeferred() @@ -1209,6 +1221,12 @@ private class GatedSettingsStore : TokenStatsSettingsStore { savedMode = mode } + override suspend fun loadIncludeLegacy(): Boolean = savedIncludeLegacy + + override suspend fun saveIncludeLegacy(include: Boolean) { + savedIncludeLegacy = include + } + override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection override suspend fun loadSelectionWasManual(): Boolean = savedManual From d973cf49331b25d917461b383110eadbccd26141 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:03:52 +0800 Subject: [PATCH 07/28] feat(stats): refine pricing and adaptive ranges (cherry picked from commit 7c731b42ce954495787956da7b4d277231fc43da) --- .../data/stats/TokenStatsQueryService.kt | 9 +- .../data/stats/TokenStatsSettingsManager.kt | 55 ++++- .../data/stats/TokenStatsSettingsStore.kt | 6 +- .../features/tokenstats/TokenStatsDialogs.kt | 99 ++++++-- .../tokenstats/TokenStatsManagementScreen.kt | 217 +++++++++++++----- .../TokenStatsManagementViewModel.kt | 30 ++- .../tokenstats/TokenUsageStatisticsScreen.kt | 26 ++- .../TokenUsageStatisticsViewModel.kt | 41 +++- app/src/main/res/values-en/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + .../stats/TokenStatsSettingsManagerTest.kt | 59 +++++ .../TokenStatsManagementViewModelTest.kt | 43 ++++ .../TokenUsageStatisticsViewModelTest.kt | 41 +++- 13 files changed, 522 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 0a8e22c4a..67e8caea0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -108,11 +108,16 @@ object TokenStatsQueryService { suspend fun rangeHasEvents(dao: TokenStatsDao, range: TokenStatsTimeRange): Boolean = dao.rangeHasEvents(range.startMs, range.endMs) + suspend fun rangeHasEvents(context: Context, range: TokenStatsTimeRange): Boolean = + withContext(queryDispatcher) { + rangeHasEvents(daoOf(context), range) + } + /** - * 首次进入的初始回退建议:按 `5h -> 12h -> 24h -> 7d -> 30d` 顺序返回 + * 进入统计页时的自动回退建议:按 `5h -> 12h -> 24h -> 7d -> 30d` 顺序返回 * 最近有实际事件的范围;全部为空时返回 5h。 * “用户手选后不再自动跳转”由调用方(阶段 4 UI/ViewModel)持久化, - * 本函数只计算首次建议,不改变任何状态。 + * 本函数只计算建议,不改变任何状态。 */ suspend fun initialPresetWithData( dao: TokenStatsDao, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt index b1954421b..7f0726a1f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -74,13 +74,26 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { configId = configId, billingMode = billingMode.name, pricingCurrency = pricingCurrency.name, - inputPricePerMillion = validatePriceValue("inputPrice", inputPricePerMillion), + inputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("inputPrice", inputPricePerMillion) + } else null, cachedInputPricePerMillion = - validatePriceValue("cachedInputPrice", cachedInputPricePerMillion), + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("cachedInputPrice", cachedInputPricePerMillion) + } else null, cacheWritePricePerMillion = - validatePriceValue("cacheWritePrice", cacheWritePricePerMillion), - outputPricePerMillion = validatePriceValue("outputPrice", outputPricePerMillion), - pricePerRequest = validatePriceValue("pricePerRequest", pricePerRequest), + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("cacheWritePrice", cacheWritePricePerMillion) + } else null, + outputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("outputPrice", outputPricePerMillion) + } else null, + pricePerRequest = + if (billingMode == BillingMode.COUNT) { + validatePriceValue("pricePerRequest", pricePerRequest) + } else null, ) } @@ -129,7 +142,37 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { /** 全部价格覆盖(管理区展示用;小表,一次读取)。 */ suspend fun allPriceOverrides(): List = - dao.getAllPriceOverrides() + dao.getAllPriceOverrides().map { row -> + val normalized = row.activeBillingFieldsOnly() + if (normalized != row) { + dao.upsertPriceOverride( + scope = normalized.scope, + provider = normalized.provider, + model = normalized.model, + configId = normalized.configId, + billingMode = normalized.billingMode, + pricingCurrency = normalized.pricingCurrency, + inputPricePerMillion = normalized.inputPricePerMillion, + cachedInputPricePerMillion = normalized.cachedInputPricePerMillion, + cacheWritePricePerMillion = normalized.cacheWritePricePerMillion, + outputPricePerMillion = normalized.outputPricePerMillion, + pricePerRequest = normalized.pricePerRequest, + ) + } + normalized + } + + private fun TokenStatPriceOverrideEntity.activeBillingFieldsOnly(): TokenStatPriceOverrideEntity = + if (BillingMode.fromString(billingMode) == BillingMode.COUNT) { + copy( + inputPricePerMillion = null, + cachedInputPricePerMillion = null, + cacheWritePricePerMillion = null, + outputPricePerMillion = null, + ) + } else { + copy(pricePerRequest = null) + } /** 删除价格覆盖(按规范化业务组合;不存在的组合静默成功)。 */ suspend fun deletePriceOverride( diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt index 14f89186d..59f14b2df 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt @@ -12,8 +12,8 @@ import com.ai.assistance.operit.data.preferences.ApiPreferences * - 汇率**只由用户手动设置**:未设置时 [loadRateWithEstimate] 返回默认估算 * 7.0([TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE])并标记 estimated, * 界面必须明显标注“默认估算”;不联网获取汇率。 - * - 时间选择持久化的是“用户是否手动选过”:null = 从未选择(允许每次进入 - * 时按 5h→…→30d 自动回退);非 null = 用户选择,不再自动跳转。 + * - 时间选择持久化用户是否手动选过;进入页面时,自动选择会重新探测,手动滚动 + * 范围仅在自身无数据时扩展,自定义/日历范围保持用户选择。 */ interface TokenStatsSettingsStore { @@ -42,7 +42,7 @@ interface TokenStatsSettingsStore { */ suspend fun loadTimeSelection(): TokenStatsTimeSelection? - /** 当前时间选择是否由用户手动做出;false = 首次自动回退(或旧数据迁移)。 */ + /** 当前时间选择是否由用户手动做出;false = 自动回退(或旧数据迁移)。 */ suspend fun loadSelectionWasManual(): Boolean /** diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt index f6d6ecbfe..b90f07e6b 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AttachMoney @@ -151,6 +153,10 @@ internal fun PriceOverrideDialog( onDelete: (() -> Unit)?, onDismiss: () -> Unit, ) { + val storedBillingMode = + existing?.let { BillingMode.fromString(it.billingMode) } + ?: initialDraft?.billingMode + ?: BillingMode.TOKEN var scope by remember(existing, initialDraft) { mutableStateOf( existing?.let { PriceOverrideScope.fromNameOrNull(it.scope) } @@ -169,9 +175,7 @@ internal fun PriceOverrideDialog( } var billingMode by remember(existing, initialDraft) { mutableStateOf( - existing?.let { BillingMode.fromString(it.billingMode) } - ?: initialDraft?.billingMode - ?: BillingMode.TOKEN + storedBillingMode ) } var currency by remember(existing, initialDraft) { @@ -182,19 +186,49 @@ internal fun PriceOverrideDialog( ) } var inputPrice by remember(existing, initialDraft) { - mutableStateOf(formatEditablePrice(existing?.inputPricePerMillion ?: initialDraft?.inputPricePerMillion)) + mutableStateOf( + formatEditablePrice( + if (storedBillingMode == BillingMode.TOKEN) { + existing?.inputPricePerMillion ?: initialDraft?.inputPricePerMillion + } else null + ) + ) } var cachedInputPrice by remember(existing, initialDraft) { - mutableStateOf(formatEditablePrice(existing?.cachedInputPricePerMillion ?: initialDraft?.cachedInputPricePerMillion)) + mutableStateOf( + formatEditablePrice( + if (storedBillingMode == BillingMode.TOKEN) { + existing?.cachedInputPricePerMillion ?: initialDraft?.cachedInputPricePerMillion + } else null + ) + ) } var cacheWritePrice by remember(existing, initialDraft) { - mutableStateOf(formatEditablePrice(existing?.cacheWritePricePerMillion ?: initialDraft?.cacheWritePricePerMillion)) + mutableStateOf( + formatEditablePrice( + if (storedBillingMode == BillingMode.TOKEN) { + existing?.cacheWritePricePerMillion ?: initialDraft?.cacheWritePricePerMillion + } else null + ) + ) } var outputPrice by remember(existing, initialDraft) { - mutableStateOf(formatEditablePrice(existing?.outputPricePerMillion ?: initialDraft?.outputPricePerMillion)) + mutableStateOf( + formatEditablePrice( + if (storedBillingMode == BillingMode.TOKEN) { + existing?.outputPricePerMillion ?: initialDraft?.outputPricePerMillion + } else null + ) + ) } var pricePerRequest by remember(existing, initialDraft) { - mutableStateOf(formatEditablePrice(existing?.pricePerRequest ?: initialDraft?.pricePerRequest)) + mutableStateOf( + formatEditablePrice( + if (storedBillingMode == BillingMode.COUNT) { + existing?.pricePerRequest ?: initialDraft?.pricePerRequest + } else null + ) + ) } var inlineError by remember { mutableStateOf(null) } val pricingInvalidText = stringResource(R.string.token_stats_pricing_invalid) @@ -233,7 +267,10 @@ internal fun PriceOverrideDialog( ) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = scope == PriceOverrideScope.PROVIDER_MODEL, @@ -281,13 +318,26 @@ internal fun PriceOverrideDialog( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = billingMode == BillingMode.TOKEN, - onClick = { billingMode = BillingMode.TOKEN }, + onClick = { + if (billingMode != BillingMode.TOKEN) { + pricePerRequest = "" + billingMode = BillingMode.TOKEN + } + }, label = { Text(stringResource(R.string.settings_billing_mode_token)) }, modifier = Modifier.weight(1f), ) FilterChip( selected = billingMode == BillingMode.COUNT, - onClick = { billingMode = BillingMode.COUNT }, + onClick = { + if (billingMode != BillingMode.COUNT) { + inputPrice = "" + cachedInputPrice = "" + cacheWritePrice = "" + outputPrice = "" + billingMode = BillingMode.COUNT + } + }, label = { Text(stringResource(R.string.settings_billing_mode_count)) }, modifier = Modifier.weight(1f), ) @@ -351,13 +401,11 @@ internal fun PriceOverrideDialog( builtinReference?.let { defaults -> val referenceText = - buildString { - append("${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M") - append(" · ") - append("${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M") - if (defaults.billingMode == BillingMode.COUNT) { - append(" · ${stringResource(R.string.settings_billing_mode_count)}") - } + if (billingMode == BillingMode.COUNT) { + "${defaults.currency.symbol}${defaults.pricePerRequest}/${stringResource(R.string.settings_billing_mode_count)}" + } else { + "${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M · " + + "${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M" } Text( text = stringResource(R.string.token_stats_pricing_reference, referenceText), @@ -388,11 +436,16 @@ internal fun PriceOverrideDialog( configId = configId.ifBlank { null }, billingMode = billingMode, currency = currency, - inputPricePerMillion = parse(inputPrice), - cachedInputPricePerMillion = parse(cachedInputPrice), - cacheWritePricePerMillion = parse(cacheWritePrice), - outputPricePerMillion = parse(outputPrice), - pricePerRequest = parse(pricePerRequest), + inputPricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(inputPrice) else null, + cachedInputPricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(cachedInputPrice) else null, + cacheWritePricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(cacheWritePrice) else null, + outputPricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(outputPrice) else null, + pricePerRequest = + if (billingMode == BillingMode.COUNT) parse(pricePerRequest) else null, ) runCatching { onSave(draft) } .onSuccess { onDismiss() } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt index d652c7c63..b42389dd4 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AttachMoney import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -38,6 +39,7 @@ import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -112,10 +114,13 @@ fun TokenStatsManagementScreen(initialPricingTab: Boolean = false) { selectedTab == ManagementTab.GROUPS -> GroupManagementTab( groups = state.groups, configs = state.configs, + pricingModels = state.pricingModels, + overrides = state.overrides, onRename = viewModel::renameGroup, onCreate = viewModel::createGroup, onMove = viewModel::moveToGroup, onRestore = viewModel::restoreDefaultGroup, + onSavePrice = viewModel::savePriceOverride, ) else -> PricingManagementTab( models = state.pricingModels, @@ -133,10 +138,13 @@ fun TokenStatsManagementScreen(initialPricingTab: Boolean = false) { private fun GroupManagementTab( groups: List, configs: List, + pricingModels: List, + overrides: List, onRename: (String, String) -> Unit, onCreate: (String, List) -> Unit, onMove: (List, String) -> Unit, onRestore: (String) -> Unit, + onSavePrice: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, ) { var query by rememberSaveable { mutableStateOf("") } var selectedIds by remember { mutableStateOf(emptySet()) } @@ -144,6 +152,7 @@ private fun GroupManagementTab( var showCreate by remember { mutableStateOf(false) } var showMove by remember { mutableStateOf(false) } var restoreTarget by remember { mutableStateOf(null) } + var pricingEditor by remember { mutableStateOf(null) } val configNames = remember(configs) { configs.associate { it.id to it.name } } val normalizedQuery = query.trim().lowercase() val visibleGroups = remember(groups, normalizedQuery, configNames) { @@ -180,6 +189,9 @@ private fun GroupManagementTab( }, onRename = { renameTarget = group }, onRestore = { restoreTarget = group }, + onEditPricing = { member -> + pricingEditor = pricingEditorForMember(member, pricingModels, overrides) + }, ) } } @@ -259,6 +271,15 @@ private fun GroupManagementTab( }, ) } + pricingEditor?.let { target -> + PriceOverrideDialog( + existing = target.existing, + initialDraft = if (target.existing == null) target.draft else null, + onSave = { onSavePrice(target.existing, it) }, + onDelete = null, + onDismiss = { pricingEditor = null }, + ) + } } @Composable @@ -269,6 +290,7 @@ private fun GroupCard( onToggleMember: (String) -> Unit, onRename: () -> Unit, onRestore: () -> Unit, + onEditPricing: (TokenStatsGroupMemberInfo) -> Unit, ) { var expanded by rememberSaveable(group.displayModelId) { mutableStateOf(false) } val custom = group.displayModelId.startsWith(TokenStatsSettingsManager.CUSTOM_GROUP_ID_PREFIX) @@ -312,6 +334,7 @@ private fun GroupCard( configName = configNames[member.configId], checked = member.identityId in selectedIds, onToggle = { onToggleMember(member.identityId) }, + onEditPricing = { onEditPricing(member) }, ) } } @@ -325,6 +348,7 @@ private fun GroupMemberRow( configName: String?, checked: Boolean, onToggle: () -> Unit, + onEditPricing: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), @@ -347,6 +371,12 @@ private fun GroupMemberRow( overflow = TextOverflow.Ellipsis, ) } + IconButton(onClick = onEditPricing) { + Icon( + Icons.Default.AttachMoney, + stringResource(R.string.token_stats_group_edit_pricing, member.model), + ) + } } } @@ -371,7 +401,9 @@ private fun PricingManagementTab( (selectedProvider == null || option.provider == selectedProvider) && (query.isBlank() || option.model.contains(query, true) || option.provider.contains(query, true) || - option.configs.any { it.name.contains(query, true) }) + option.configs.any { + it.name.contains(query, true) || it.endpoint.contains(query, true) + }) } } @@ -467,33 +499,43 @@ private fun PricingModelCard( val providerDraft = providerOverride?.toDraft() ?: option.legacyPricing?.let { legacyDraft(option.provider, option.model, it) } ?: builtinDraft(option.provider, option.model) + val providerSourceRes = when { + providerOverride != null -> R.string.token_stats_pricing_source_override + option.legacyPricing != null -> R.string.token_stats_pricing_source_legacy + else -> R.string.token_stats_pricing_source_builtin + } TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(option.model, fontWeight = FontWeight.Bold) Text(option.provider, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted) } - Text( - stringResource( - when { - providerOverride != null -> R.string.token_stats_pricing_source_override - option.legacyPricing != null -> R.string.token_stats_pricing_source_legacy - else -> R.string.token_stats_pricing_source_builtin - } - ), - style = MaterialTheme.typography.labelSmall, - color = LocalTokenStatsColors.current.chartAccent, - ) + } + + HorizontalDivider() + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.token_stats_pricing_model_default), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + stringResource(providerSourceRes), + style = MaterialTheme.typography.labelSmall, + color = LocalTokenStatsColors.current.chartAccent, + ) + Text( + priceSummary(providerDraft), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + } IconButton(onClick = { onEdit(providerOverride, providerDraft) }) { Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit)) } } - Text( - priceSummary(providerDraft), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) if (providerOverride != null || option.legacyPricing != null) { TextButton(onClick = { onRestoreBuiltIn(providerOverride, option.legacyProviderModel) @@ -501,44 +543,80 @@ private fun PricingModelCard( Text(stringResource(R.string.token_stats_pricing_restore_builtin)) } } - option.configs.forEach { config -> - val configOverride = overrides.firstOrNull { - it.scope == PriceOverrideScope.CONFIG.name && - it.provider.equals(option.provider, true) && - it.model.equals(option.model, true) && it.configId == config.id - } - val draft = configOverride?.toDraft() ?: providerDraft.copy( - scope = PriceOverrideScope.CONFIG, - configId = config.id, - ) + + if (option.configs.isNotEmpty()) { HorizontalDivider() - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(config.name, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium) - Text( - stringResource( - if (configOverride == null) R.string.token_stats_pricing_inherits_model - else R.string.token_stats_pricing_source_config - ), - style = MaterialTheme.typography.labelSmall, - color = TokenStatsCardMuted, - ) - Text( - priceSummary(draft), - style = MaterialTheme.typography.labelSmall, - color = TokenStatsCardMuted, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) + Text( + stringResource(R.string.token_stats_pricing_config_prices), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + stringResource(R.string.token_stats_pricing_config_prices_hint), + style = MaterialTheme.typography.labelSmall, + color = TokenStatsCardMuted, + ) + option.configs.forEach { config -> + val configOverride = overrides.firstOrNull { + it.scope == PriceOverrideScope.CONFIG.name && + it.provider.equals(option.provider, true) && + it.model.equals(option.model, true) && it.configId == config.id } - if (configOverride != null) { - IconButton(onClick = { onResetConfig(configOverride) }) { - Icon(Icons.Default.Restore, stringResource(R.string.token_stats_pricing_restore_model)) + val draft = configOverride?.toDraft() ?: providerDraft.copy( + scope = PriceOverrideScope.CONFIG, + configId = config.id, + ) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + if (config.available) config.name + else stringResource(R.string.token_stats_config_deleted_with_id, config.id), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + if (config.endpoint.isNotBlank()) { + Text( + config.endpoint, + style = MaterialTheme.typography.labelSmall, + color = TokenStatsCardMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + stringResource( + if (configOverride == null) R.string.token_stats_pricing_inherits_model + else R.string.token_stats_pricing_source_config + ), + style = MaterialTheme.typography.labelSmall, + color = LocalTokenStatsColors.current.chartAccent, + ) + Text( + priceSummary(draft), + style = MaterialTheme.typography.labelSmall, + color = TokenStatsCardMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (configOverride != null) { + IconButton(onClick = { onResetConfig(configOverride) }) { + Icon(Icons.Default.Restore, stringResource(R.string.token_stats_pricing_restore_model)) + } + } + IconButton(onClick = { onEdit(configOverride, draft) }) { + Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit_config, config.name)) + } } } - IconButton(onClick = { onEdit(configOverride, draft) }) { - Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit)) - } } } } @@ -669,6 +747,41 @@ private fun builtinDraft(provider: String, model: String): TokenStatsPriceOverri ) } +private fun pricingEditorForMember( + member: TokenStatsGroupMemberInfo, + pricingModels: List, + overrides: List, +): PricingEditor { + val option = pricingModels.firstOrNull { + it.provider.equals(member.provider, true) && it.model.equals(member.model, true) + } + val providerOverride = overrides.firstOrNull { + it.scope == PriceOverrideScope.PROVIDER_MODEL.name && + it.provider.equals(member.provider, true) && it.model.equals(member.model, true) + } + val inherited = providerOverride?.toDraft() + ?: option?.legacyPricing?.let { legacyDraft(member.provider, member.model, it) } + ?: builtinDraft(member.provider, member.model) + val scope = if (member.configId.isBlank()) { + PriceOverrideScope.PROVIDER_MODEL + } else { + PriceOverrideScope.CONFIG + } + val existing = overrides.firstOrNull { + it.scope == scope.name && + it.provider.equals(member.provider, true) && + it.model.equals(member.model, true) && + (scope == PriceOverrideScope.PROVIDER_MODEL || it.configId == member.configId) + } + return PricingEditor( + existing = existing, + draft = existing?.toDraft() ?: inherited.copy( + scope = scope, + configId = member.configId.ifBlank { null }, + ), + ) +} + private fun legacyDraft( provider: String, model: String, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt index b6811b394..38faaaf44 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt @@ -29,6 +29,8 @@ data class TokenStatsConfigOption( val name: String, val provider: String, val models: List, + val endpoint: String = "", + val available: Boolean = true, ) data class TokenStatsPricingModelOption( @@ -170,6 +172,7 @@ private fun ModelConfigSummary.toTokenStatsOption() = TokenStatsConfigOption( name = name, provider = apiProviderTypeId, models = getModelList(modelName), + endpoint = apiEndpoint, ) internal fun buildPricingModels( @@ -178,13 +181,23 @@ internal fun buildPricingModels( overrides: List, legacyPrices: Map = emptyMap(), ): List { + val observedMembers = groups.flatMap { it.members } + fun observedProvider(configId: String, model: String): String? = + observedMembers.firstOrNull { member -> + member.configId == configId && member.model.equals(model, ignoreCase = true) + }?.provider + val keys = linkedMapOf>() fun add(provider: String, model: String) { val key = "${provider.trim().lowercase()}\u0000${model.trim().lowercase()}" keys.putIfAbsent(key, provider to model) } - groups.flatMap { it.members }.forEach { add(it.provider, it.model) } - configs.forEach { config -> config.models.forEach { add(config.provider, it) } } + observedMembers.forEach { add(it.provider, it.model) } + configs.forEach { config -> + config.models.forEach { model -> + add(observedProvider(config.id, model) ?: config.provider, model) + } + } overrides.forEach { add(it.provider, it.model) } legacyPrices.keys.forEach { providerModel -> val separator = providerModel.indexOf(':') @@ -192,8 +205,9 @@ internal fun buildPricingModels( } return keys.values.map { (provider, model) -> val matchingConfigs = configs.filter { config -> - config.provider.equals(provider, ignoreCase = true) && - config.models.any { it.equals(model, ignoreCase = true) } + config.models.any { it.equals(model, ignoreCase = true) } && + (observedProvider(config.id, model) ?: config.provider) + .equals(provider, ignoreCase = true) } val missingConfigIds = overrides.asSequence().filter { it.scope == PriceOverrideScope.CONFIG.name && @@ -203,7 +217,13 @@ internal fun buildPricingModels( provider = provider, model = model, configs = matchingConfigs + missingConfigIds.map { id -> - TokenStatsConfigOption(id = id, name = id, provider = provider, models = listOf(model)) + TokenStatsConfigOption( + id = id, + name = id, + provider = provider, + models = listOf(model), + available = false, + ) }, legacyProviderModel = legacyPrices.keys.firstOrNull { key -> val separator = key.indexOf(':') diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index fc55ceb64..4d5d9d5cc 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -99,7 +99,7 @@ fun TokenUsageStatisticsScreen( viewModel.consumeActionMessage() } } - LaunchedEffect(Unit) { viewModel.load() } + LaunchedEffect(Unit) { viewModel.loadForEntry() } TokenStatsColorsProvider { CustomScaffold( @@ -980,13 +980,23 @@ private fun PriceOverrideRow( } else { com.ai.assistance.operit.data.collects.PricingCurrency.USD } - val prices = buildList { - override.inputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, currency)}") } - override.cachedInputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, currency)}") } - override.cacheWritePricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cache_write)} ${formatPricePerMillion(it, currency)}") } - override.outputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, currency)}") } - override.pricePerRequest?.let { add("${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, currency)}") } - } + val prices = + if (com.ai.assistance.operit.data.model.BillingMode.fromString(override.billingMode) == + com.ai.assistance.operit.data.model.BillingMode.COUNT + ) { + listOfNotNull( + override.pricePerRequest?.let { + "${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, currency)}" + } + ) + } else { + buildList { + override.inputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, currency)}") } + override.cachedInputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, currency)}") } + override.cacheWritePricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cache_write)} ${formatPricePerMillion(it, currency)}") } + override.outputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, currency)}") } + } + } Column( modifier = Modifier diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index a22ba3acc..b70ea0843 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -160,7 +160,7 @@ class TokenUsageStatisticsViewModel( private val knownModelNames = mutableMapOf() init { - load() + loadForEntry() } fun consumeActionMessage() { @@ -170,6 +170,15 @@ class TokenUsageStatisticsViewModel( // ==== 查询 ==== fun load() { + loadInternal(reconsiderAutomaticTime = false) + } + + /** 进入/返回统计页时重新探测自动时间范围;用户手选范围始终保持不变。 */ + fun loadForEntry() { + loadInternal(reconsiderAutomaticTime = true) + } + + private fun loadInternal(reconsiderAutomaticTime: Boolean) { loadJob?.cancel() val generation = ++loadGeneration // 筛选状态同步快照:偏好读取挂起期间用户可能已改筛选并触发新 load, @@ -189,17 +198,37 @@ class TokenUsageStatisticsViewModel( val preset: TokenStatsPreset val customRange: TokenStatsTimeRange? val userChoseTime: Boolean - if (savedSelection != null) { - // 已有选择(用户手选或上次自动回退):直接复用,不再探测(P1-2) + val savedRange = savedSelection?.let { selection -> + selection.customRangeOrNull() + ?: selection.preset.takeIf { it != TokenStatsPreset.CUSTOM }?.let { + TokenStatsTimeRanges.rangeFor(it, nowMs(), zone) + } + } + val emptyManualRollingSelection = + reconsiderAutomaticTime && + selectionWasManual && + savedSelection?.preset in TokenStatsPreset.INITIAL_FALLBACK_ORDER && + savedRange != null && + !TokenStatsQueryService.rangeHasEvents(appContext, savedRange) + val shouldProbeAutomaticRange = + reconsiderAutomaticTime && + (savedSelection == null || !selectionWasManual || emptyManualRollingSelection) + + if (savedSelection != null && !shouldProbeAutomaticRange) { + // 普通刷新直接复用;手选范围有数据时也保持用户选择。 preset = savedSelection.preset customRange = savedSelection.customRangeOrNull() userChoseTime = selectionWasManual } else { - // 首次进入(无任何持久化状态):自动回退并持久化(manual=false), - // 下次进入的 VM 直接复用,不再探测(P1-2)。 + // 自动模式每次进入重探测;手选滚动范围为空时才扩展到更大窗口。 val suggested = TokenStatsQueryService.initialPresetWithData(appContext, zone, nowMs()) - settings.saveTimeSelection(TokenStatsTimeSelection(suggested), manual = false) + if (savedSelection?.preset != suggested || + savedSelection.customRangeOrNull() != null || + selectionWasManual + ) { + settings.saveTimeSelection(TokenStatsTimeSelection(suggested), manual = false) + } preset = suggested customRange = null userChoseTime = false diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index f76a54cbf..b798abcb9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3717,6 +3717,7 @@ %1$d identities selected New group Move + Edit pricing for %1$s Move to group Custom group · %1$d identities Default group · %1$d identities @@ -3727,6 +3728,11 @@ Search models, providers, or API configs All providers Uses model price + Model default price + Configuration-specific prices + Different API configurations can have separate prices even when they use the same provider and model. + Edit the specific price for “%1$s” + Deleted configuration (%1$s) Restore built-in price Restore model price The effective price will fall back to the next level. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a1a3c0c0..54c3e4949 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3632,6 +3632,7 @@ 已选择 %1$d 个身份 新建分组 移动 + 修改 %1$s 的定价 移动到分组 自定义分组 · %1$d 个身份 默认分组 · %1$d 个身份 @@ -3642,6 +3643,11 @@ 搜索模型、Provider 或 API 配置 全部 Provider 使用模型价格 + 模型默认价格 + 模型配置专属价格 + 相同 Provider 和模型的不同 API 配置可分别设置价格。 + 编辑“%1$s”的专属价格 + 已删除的配置(%1$s) 恢复内置价格 恢复模型价格 恢复后将自动使用上一级有效价格。 diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt index 9d41acd36..0ee23b6da 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt @@ -210,6 +210,65 @@ class TokenStatsSettingsManagerTest { assertTrue(dao.getAllPriceOverrides().isEmpty()) } + @Test + fun `price override drops fields from the inactive billing mode`() = runBlocking { + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + cacheWritePricePerMillion = 0.8, + outputPricePerMillion = 2.0, + pricePerRequest = 99.0, + ) + val tokenRow = dao.getAllPriceOverrides().single() + assertNull(tokenRow.pricePerRequest) + + manager.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.COUNT, + pricingCurrency = PricingCurrency.USD, + inputPricePerMillion = 99.0, + cachedInputPricePerMillion = 99.0, + cacheWritePricePerMillion = 99.0, + outputPricePerMillion = 99.0, + pricePerRequest = 0.01, + ) + val countRow = dao.getAllPriceOverrides().single() + assertNull(countRow.inputPricePerMillion) + assertNull(countRow.cachedInputPricePerMillion) + assertNull(countRow.cacheWritePricePerMillion) + assertNull(countRow.outputPricePerMillion) + assertEquals(0.01, countRow.pricePerRequest!!, 0.0) + } + + @Test + fun `reading overrides repairs mixed fields saved by older versions`() = runBlocking { + dao.upsertPriceOverride( + scope = PriceOverrideScope.PROVIDER_MODEL.name, + provider = "OPENAI", + model = "gpt-4o", + configId = null, + billingMode = BillingMode.TOKEN.name, + pricingCurrency = PricingCurrency.USD.name, + inputPricePerMillion = 1.0, + outputPricePerMillion = 2.0, + pricePerRequest = 99.0, + ) + + val repaired = manager.allPriceOverrides().single() + + assertNull(repaired.pricePerRequest) + assertNull(dao.getAllPriceOverrides().single().pricePerRequest) + } + @Test fun `config scope override keeps configId and is independent from provider scope`() = runBlocking { manager.upsertPriceOverride( diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt index b11fb7f9f..7459481d3 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt @@ -66,6 +66,49 @@ class TokenStatsManagementViewModelTest { assertEquals(legacy, result.legacyPricing) } + @Test + fun `same provider and model expose each api configuration independently`() { + val groups = listOf( + TokenStatsGroupModelInfo( + displayModelId = "deepseek", + displayName = "DeepSeek", + memberIdentityIds = listOf("official-id", "relay-id"), + members = listOf( + TokenStatsGroupMemberInfo("official-id", "official", "DEEPSEEK", "deepseek-chat"), + TokenStatsGroupMemberInfo("relay-id", "relay", "DEEPSEEK", "deepseek-chat"), + ), + ) + ) + val configs = listOf( + TokenStatsConfigOption( + id = "official", + name = "官方配置", + provider = "DEEPSEEK", + models = listOf("deepseek-chat"), + endpoint = "https://api.deepseek.com", + ), + TokenStatsConfigOption( + id = "relay", + name = "中转站", + provider = "DEEPSEEK", + models = listOf("deepseek-chat"), + endpoint = "https://relay.example.com", + ), + ) + val overrides = listOf( + override("DEEPSEEK", "deepseek-chat", "official", PriceOverrideScope.CONFIG), + override("DEEPSEEK", "deepseek-chat", "relay", PriceOverrideScope.CONFIG), + ) + + val result = buildPricingModels(groups, configs, overrides).single() + + assertEquals(listOf("official", "relay"), result.configs.map { it.id }) + assertEquals( + listOf("https://api.deepseek.com", "https://relay.example.com"), + result.configs.map { it.endpoint }, + ) + } + private fun override( provider: String, model: String, diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt index ea44fb9d2..b988e45a3 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -297,7 +297,7 @@ class TokenUsageStatisticsViewModelTest { awaitRefresh(viewModel, 0) assertEquals(TokenStatsPreset.LAST_7D, viewModel.state.value.selectedPreset) assertFalse(viewModel.state.value.userChoseTime) - // 自动回退结果已持久化(manual=false):下次进入直接复用,不再探测(P1-2) + // 自动回退结果持久化为 manual=false,下次进入仍可按最新数据重新探测。 assertNotNull(settings.savedSelection) assertEquals(TokenStatsPreset.LAST_7D, settings.savedSelection!!.preset) assertFalse(settings.savedManual) @@ -305,7 +305,7 @@ class TokenUsageStatisticsViewModelTest { } @Test - fun `second viewmodel reuses persisted auto fallback without probing`() { + fun `second viewmodel rechecks persisted automatic fallback on entry`() { kotlinx.coroutines.runBlocking { seedIdentity("id-1", configId = "cfg-a") // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 @@ -316,36 +316,55 @@ class TokenUsageStatisticsViewModelTest { assertEquals(TokenStatsPreset.LAST_7D, vm1.state.value.selectedPreset) assertEquals(1, settings.timeSelectionSaveCount) - // 清空事件:若第二个 VM 重新探测会回退到 5h(无任何数据);必须复用 - // 已保存的自动选择且不再次保存(P1-2 跨 VM 验证) + // 清空事件后再次进入:全部范围为空,自动回退应回到最短 5h。 runBlocking { dao.deleteAllEvents() } val vm2 = newViewModel() awaitRefresh(vm2, 0) - assertEquals(TokenStatsPreset.LAST_7D, vm2.state.value.selectedPreset) + assertEquals(TokenStatsPreset.LAST_5H, vm2.state.value.selectedPreset) assertFalse(vm2.state.value.userChoseTime) - assertEquals(1, settings.timeSelectionSaveCount) + assertEquals(2, settings.timeSelectionSaveCount) assertEquals(0L, vm2.state.value.range?.eventCount) } @Test - fun `user selection locks time and disables auto fallback`() { + fun `manual rolling selection with data stays locked`() { kotlinx.coroutines.runBlocking { seedIdentity("id-1", configId = "cfg-a") - // 数据在 30 小时前:5h/12h/24h 全空(自动回退会选 7d),但用户已选择 24h - dao.insertEvent(event("e1", "id-1", nowMs - 30 * 3600_000L)) + // 24h 内有数据,保留用户手动选择,不缩短到 5h。 + dao.insertEvent(event("e1", "id-1", nowMs - 20 * 3600_000L)) settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) settings.savedManual = true } val viewModel = newViewModel() awaitRefresh(viewModel, 0) - // 用户选择过 → 不自动回退 + // 手动范围本身有数据 → 不自动跳转 assertEquals(TokenStatsPreset.LAST_24H, viewModel.state.value.selectedPreset) assertTrue(viewModel.state.value.userChoseTime) - assertTrue(viewModel.state.value.range?.eventCount == 0L) + assertEquals(1L, viewModel.state.value.range?.eventCount) // 已有选择 → 本次 load 不再保存 assertEquals(0, settings.timeSelectionSaveCount) } + @Test + fun `empty manual rolling selection expands on entry`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + // 手选 24h 无数据,但 7d 有数据,进入页面时自动扩展。 + dao.insertEvent(event("e1", "id-1", nowMs - 30 * 3600_000L)) + settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) + settings.savedManual = true + } + + val viewModel = newViewModel() + awaitRefresh(viewModel, 0) + + assertEquals(TokenStatsPreset.LAST_7D, viewModel.state.value.selectedPreset) + assertFalse(viewModel.state.value.userChoseTime) + assertEquals(1L, viewModel.state.value.range?.eventCount) + assertFalse(settings.savedManual) + assertEquals(1, settings.timeSelectionSaveCount) + } + // ==== 自定义范围 ==== @Test From 8b6980408fdf8a603f3d8699d4e6ddd2cb402677 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:41:38 +0800 Subject: [PATCH 08/28] feat(stats): add interactive token activity (cherry picked from commit 412c1e7dccf0eb73f005abedcb8d7eeb21887b29) --- .../operit/data/dao/TokenStatsDao.kt | 9 + .../operit/data/stats/TokenActivityModels.kt | 236 +++++ .../data/stats/TokenStatsQueryService.kt | 6 + .../tokenstats/TokenActivitySection.kt | 913 ++++++++++++++++++ .../tokenstats/TokenStatsComponents.kt | 7 + .../tokenstats/TokenUsageStatisticsScreen.kt | 122 ++- .../TokenUsageStatisticsViewModel.kt | 106 +- app/src/main/res/values-en/strings.xml | 104 ++ app/src/main/res/values/strings.xml | 104 ++ .../data/stats/TokenActivityAggregatorTest.kt | 109 +++ 10 files changed, 1709 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index b4327fc6c..c6e4ae3c5 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -15,6 +15,7 @@ import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.model.TokenStatRangeCutoffEntity import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver +import com.ai.assistance.operit.data.stats.TokenActivityEventRow import com.ai.assistance.operit.data.stats.TokenStatsGroupMetadataSnapshot import com.ai.assistance.operit.data.stats.TokenStatsLifetimeRead import com.ai.assistance.operit.data.stats.TokenStatsQuerySnapshot @@ -51,6 +52,14 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_events") abstract suspend fun getAllEvents(): List + /** 活动热力图只读取所需列,避免把价格、诊断等完整事件字段整表实体化。 */ + @Query( + "SELECT startedAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + + "totalInputTokens, outputTokens, reasoningTokens, reasoningIncludedInOutput " + + "FROM token_stat_events" + ) + abstract suspend fun getTokenActivityRows(): List + @Query("SELECT COUNT(*) FROM token_stat_events") abstract suspend fun countEvents(): Int diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt new file mode 100644 index 000000000..5c05019ee --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -0,0 +1,236 @@ +package com.ai.assistance.operit.data.stats + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlin.math.ceil + +enum class TokenActivityViewMode { DAILY, WEEKLY, CUMULATIVE } + +data class TokenActivityRecord( + val startedAtMs: Long, + val tokens: Long, +) + +data class TokenActivityEventRow( + val startedAtMs: Long, + val uncachedInputTokens: Long?, + val cachedInputTokens: Long?, + val cacheWriteTokens: Long?, + val totalInputTokens: Long?, + val outputTokens: Long?, + val reasoningTokens: Long?, + val reasoningIncludedInOutput: Boolean?, +) + +data class TokenActivityDay( + val date: LocalDate, + val tokens: Long, + val level: Int, +) + +data class TokenActivityWeek( + val startDate: LocalDate, + val tokens: Long, + val level: Int, + val barHeight: Int, +) + +data class TokenActivityStats( + val totalTokens: Long = 0L, + val peakTokens: Long = 0L, + val currentStreak: Int = 0, + val longestStreak: Int = 0, +) + +data class TokenActivityInsights( + val totalRequests: Long = 0L, + val topHours: List = emptyList(), +) + +data class TokenActivityYearData( + val daily: List, + val weekly: List, + val cumulative: List, + val stats: TokenActivityStats, +) + +internal fun TokenActivityEventRow.toActivityRecord(): TokenActivityRecord { + val input = totalInputTokens ?: listOf(uncachedInputTokens, cachedInputTokens, cacheWriteTokens) + .fold(0L) { total, value -> + if (value != null && value > 0L) saturatedAdd(total, value) else total + } + var total = input.coerceAtLeast(0L) + outputTokens?.takeIf { it > 0L }?.let { total = saturatedAdd(total, it) } + if (reasoningIncludedInOutput == false) { + reasoningTokens?.takeIf { it > 0L }?.let { total = saturatedAdd(total, it) } + } + return TokenActivityRecord(startedAtMs = startedAtMs, tokens = total) +} + +object TokenActivityAggregator { + fun availableYears( + records: List, + zone: ZoneId, + nowMs: Long = System.currentTimeMillis(), + ): List { + val currentYear = Instant.ofEpochMilli(nowMs).atZone(zone).year + val firstYear = records.minOfOrNull { Instant.ofEpochMilli(it.startedAtMs).atZone(zone).year } + ?.coerceAtMost(currentYear) ?: currentYear + return (firstYear..currentYear).toList().reversed() + } + + fun insights(records: List, zone: ZoneId): TokenActivityInsights { + val hourCounts = LongArray(24) + records.forEach { record -> + val hour = Instant.ofEpochMilli(record.startedAtMs).atZone(zone).hour + hourCounts[hour]++ + } + return TokenActivityInsights( + totalRequests = records.size.toLong(), + topHours = hourCounts.indices + .filter { hourCounts[it] > 0L } + .sortedWith(compareByDescending { hourCounts[it] }.thenBy { it }) + .take(3), + ) + } + + fun yearData( + records: List, + zone: ZoneId, + year: Int, + nowMs: Long = System.currentTimeMillis(), + ): TokenActivityYearData { + val nowDate = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + val start = LocalDate.of(year, 1, 1) + val end = if (year == nowDate.year) nowDate else LocalDate.of(year, 12, 31) + return rangeData(records, zone, start, end) + } + + /** 默认活动窗口:包含今天在内的最近 365 个自然日。 */ + fun recentData( + records: List, + zone: ZoneId, + nowMs: Long = System.currentTimeMillis(), + ): TokenActivityYearData { + val end = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + return rangeData(records, zone, end.minusDays(364), end) + } + + private fun rangeData( + records: List, + zone: ZoneId, + start: LocalDate, + end: LocalDate, + ): TokenActivityYearData { + val dayTotals = HashMap() + records.forEach { record -> + val date = Instant.ofEpochMilli(record.startedAtMs).atZone(zone).toLocalDate() + if (!date.isBefore(start) && !date.isAfter(end)) { + dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, record.tokens) + } + } + + val days = ChronoUnit.DAYS.between(start, end).toInt() + 1 + val raw = List(days) { index -> + val date = start.plusDays(index.toLong()) + TokenActivityDay(date, dayTotals[date] ?: 0L, 0) + } + val dailyLevels = QuantileLevels.from(raw.map { it.tokens }) + val daily = raw.map { it.copy(level = dailyLevels.level(it.tokens)) } + + var cumulativeTotal = 0L + val cumulativeRaw = raw.map { + cumulativeTotal = saturatedAdd(cumulativeTotal, it.tokens) + it.copy(tokens = cumulativeTotal) + } + val cumulativeLevels = QuantileLevels.from(cumulativeRaw.map { it.tokens }) + val cumulative = cumulativeRaw.map { it.copy(level = cumulativeLevels.level(it.tokens)) } + + val firstWeek = start.minusDays((start.dayOfWeek.value % 7).toLong()) + val lastWeek = end.minusDays((end.dayOfWeek.value % 7).toLong()) + val weekCount = ChronoUnit.WEEKS.between(firstWeek, lastWeek).toInt() + 1 + val weekTotals = LongArray(weekCount) + raw.forEach { day -> + val index = ChronoUnit.WEEKS.between(firstWeek, day.date.minusDays((day.date.dayOfWeek.value % 7).toLong())).toInt() + weekTotals[index] = saturatedAdd(weekTotals[index], day.tokens) + } + val weekLevels = QuantileLevels.from(weekTotals.toList()) + val heights = barHeights(weekTotals.toList()) + val weekly = List(weekCount) { index -> + TokenActivityWeek( + startDate = firstWeek.plusWeeks(index.toLong()), + tokens = weekTotals[index], + level = weekLevels.level(weekTotals[index]), + barHeight = heights[index], + ) + } + + return TokenActivityYearData( + daily = daily, + weekly = weekly, + cumulative = cumulative, + stats = stats(raw), + ) + } + + private fun stats(days: List): TokenActivityStats { + var total = 0L + var peak = 0L + var run = 0 + var longest = 0 + days.forEach { day -> + total = saturatedAdd(total, day.tokens) + peak = maxOf(peak, day.tokens) + run = if (day.tokens > 0L) run + 1 else 0 + longest = maxOf(longest, run) + } + var index = days.lastIndex + while (index >= 0 && days[index].tokens == 0L) index-- + var current = 0 + while (index >= 0 && days[index].tokens > 0L) { + current++ + index-- + } + return TokenActivityStats(total, peak, current, longest) + } + + private fun barHeights(values: List): IntArray { + val distinct = values.filter { it > 0L }.distinct().sorted() + return IntArray(values.size) { index -> + when { + values[index] <= 0L -> 1 + distinct.size == 1 -> 7 + else -> 2 + distinct.indexOf(values[index]) * 5 / (distinct.size - 1) + } + } + } +} + +private class QuantileLevels(private val thresholds: LongArray) { + fun level(value: Long): Int { + if (value <= 0L) return 0 + for (level in 1..5) if (value <= thresholds[level]) return level + return 5 + } + + companion object { + fun from(values: List): QuantileLevels { + val nonZero = values.filter { it > 0L }.sorted() + if (nonZero.size < 2 || nonZero.firstOrNull() == nonZero.lastOrNull()) { + return QuantileLevels(LongArray(6).also { it[3] = Long.MAX_VALUE }) + } + fun nearest(percentile: Double): Long { + val index = (ceil(nonZero.size * percentile).toInt() - 1).coerceIn(0, nonZero.lastIndex) + return nonZero[index] + } + return QuantileLevels( + longArrayOf(0L, nearest(0.25), nearest(0.50), nearest(0.75), nearest(0.95), Long.MAX_VALUE) + ) + } + } +} + +private fun saturatedAdd(left: Long, right: Long): Long = + if (right > 0L && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 67e8caea0..1d1006e8a 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -113,6 +113,12 @@ object TokenStatsQueryService { rangeHasEvents(daoOf(context), range) } + /** 全局 Token 活动使用的轻量事件投影;不应用统计页筛选,也不包含旧 baseline。 */ + suspend fun activityRecords(context: Context): List = + withContext(queryDispatcher) { + daoOf(context).getTokenActivityRows().map { it.toActivityRecord() } + } + /** * 进入统计页时的自动回退建议:按 `5h -> 12h -> 24h -> 7d -> 30d` 顺序返回 * 最近有实际事件的范围;全部为空时返回 5h。 diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt new file mode 100644 index 000000000..eba1154fd --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -0,0 +1,913 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Paint +import android.net.Uri +import android.widget.Toast +import android.os.SystemClock +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImagePainter +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager +import com.ai.assistance.operit.data.stats.TokenActivityDay +import com.ai.assistance.operit.data.stats.TokenActivityViewMode +import com.ai.assistance.operit.data.stats.TokenActivityWeek +import com.canhub.cropper.CropImageContract +import com.canhub.cropper.CropImageContractOptions +import com.canhub.cropper.CropImageOptions +import com.canhub.cropper.CropImageView +import java.io.File +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.abs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext + +@Composable +internal fun TokenActivitySection( + state: TokenActivityUiState, + zone: ZoneId, + onSelectRecent: () -> Unit, + onSelectYear: (Int) -> Unit, + onSelectMode: (TokenActivityViewMode) -> Unit, +) { + val context = LocalContext.current + val locale = LocalConfiguration.current.locales[0] + var yearMenuExpanded by remember { mutableStateOf(false) } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.token_activity_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Box { + Text( + text = if (state.recentSelected) { + "${stringResource(R.string.token_activity_recent)} ▾" + } else { + "${state.selectedYear} ▾" + }, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .clickable(enabled = state.availableYears.isNotEmpty()) { yearMenuExpanded = true } + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + DropdownMenu( + expanded = yearMenuExpanded, + onDismissRequest = { yearMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_activity_recent), + fontWeight = if (state.recentSelected) FontWeight.Bold else FontWeight.Normal, + ) + }, + onClick = { + yearMenuExpanded = false + onSelectRecent() + }, + ) + state.availableYears.forEach { year -> + DropdownMenuItem( + text = { + Text( + year.toString(), + fontWeight = if (!state.recentSelected && year == state.selectedYear) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + ) + }, + onClick = { + yearMenuExpanded = false + onSelectYear(year) + }, + ) + } + } + } + } + + TokenActivityProfileCard() + + TokenStatsWhiteCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp)) { + Text( + stringResource(R.string.token_activity_insights), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.token_activity_total_requests), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + modifier = Modifier.weight(1f), + ) + Text( + if (state.loading) "–" else formatCount(state.insights.totalRequests), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.token_activity_peak_hours), + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + ) + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + repeat(3) { index -> + val hour = state.insights.topHours.getOrNull(index) + Text( + text = if (state.loading || hour == null) "–" else stringResource( + R.string.token_activity_hour_range, + hour, + (hour + 1) % 24, + ), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFF7F2F4)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.padding(horizontal = 4.dp)) { + TokenActivityViewMode.entries.forEach { mode -> + val selected = state.viewMode == mode + Text( + text = stringResource( + when (mode) { + TokenActivityViewMode.DAILY -> R.string.token_activity_daily + TokenActivityViewMode.WEEKLY -> R.string.token_activity_weekly + TokenActivityViewMode.CUMULATIVE -> R.string.token_activity_cumulative + } + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) Color(0xFFE91E63) else TokenStatsCardMuted, + modifier = Modifier.clickable { onSelectMode(mode) }, + ) + } + } + + val stats = state.yearData?.stats + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TokenActivityStat( + stringResource(R.string.token_activity_total_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.totalTokens), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_peak_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.peakTokens), + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TokenActivityStat( + stringResource(R.string.token_activity_current_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.currentStreak), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_longest_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.longestStreak), + Modifier.weight(1f), + ) + } + } + + TokenStatsWhiteCard(Modifier.fillMaxWidth()) { + Crossfade( + targetState = state.viewMode, + animationSpec = tween(150), + label = "token_activity_heatmap", + ) { mode -> + TokenActivityHeatmap( + state = state.copy(viewMode = mode), + zone = zone, + locale = locale, + modifier = Modifier.padding(12.dp), + ) + } + } + } +} + +@Composable +private fun TokenActivityStat(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text(label, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted) + } +} + +@Composable +private fun TokenActivityProfileCard() { + val context = LocalContext.current + val prefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) } + var nickname by remember { mutableStateOf(prefs.getString(KEY_NICKNAME, "").orEmpty()) } + var email by remember { mutableStateOf(prefs.getString(KEY_EMAIL, "").orEmpty()) } + val initialAvatarPath = remember { prefs.getString(KEY_AVATAR_PATH, null) } + var avatarPath by remember { mutableStateOf(initialAvatarPath) } + var avatarRevision by remember { + mutableStateOf( + prefs.getLong( + KEY_AVATAR_REVISION, + initialAvatarPath?.let { File(it).lastModified() } ?: 0L, + ) + ) + } + var showEdit by remember { mutableStateOf(false) } + var showAvatarActions by remember { mutableStateOf(false) } + var showImportConfirm by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + suspend fun persistAvatar(uri: Uri?): String? = withContext(Dispatchers.IO) { + val target = File(context.filesDir, AVATAR_FILE) + if (uri == null) { + target.delete() + return@withContext null + } + val temp = File(context.filesDir, "$AVATAR_FILE.tmp") + context.contentResolver.openInputStream(uri)?.use { input -> + temp.outputStream().use { output -> input.copyTo(output) } + } ?: error("Unable to open avatar") + if (target.exists()) target.delete() + if (!temp.renameTo(target)) { + temp.delete() + error("Unable to persist avatar") + } + target.absolutePath + } + + val cropLauncher = rememberLauncherForActivityResult(CropImageContract()) { result -> + if (result.isSuccessful) { + result.uriContent?.let { croppedUri -> + scope.launch { + runCatching { persistAvatar(croppedUri) }.onSuccess { path -> + val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) + avatarPath = path + avatarRevision = revision + prefs.edit() + .putString(KEY_AVATAR_PATH, path) + .putLong(KEY_AVATAR_REVISION, revision) + .apply() + } + } + } + } else if (result.error != null) { + Toast.makeText( + context, + context.getString(R.string.avatar_crop_failed, result.error?.message.orEmpty()), + Toast.LENGTH_LONG, + ).show() + } + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) { + cropLauncher.launch( + CropImageContractOptions( + uri, + CropImageOptions().apply { + guidelines = CropImageView.Guidelines.ON + outputCompressFormat = Bitmap.CompressFormat.PNG + outputCompressQuality = 90 + fixAspectRatio = true + aspectRatioX = 1 + aspectRatioY = 1 + cropMenuCropButtonTitle = context.getString(R.string.theme_crop_done) + activityTitle = context.getString(R.string.crop_avatar) + toolbarColor = Color.Gray.toArgb() + toolbarTitleColor = Color.White.toArgb() + }, + ) + ) + } + } + + TokenStatsWhiteCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.token_activity_profile), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { showImportConfirm = true }, + ) { + Icon(Icons.Default.Refresh, stringResource(R.string.token_activity_profile_import)) + } + } + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + val painter = rememberAsyncImagePainter( + ImageRequest.Builder(context) + .data(avatarPath?.let(::File)) + .memoryCacheKey("$avatarPath:$avatarRevision") + .diskCacheKey("$avatarPath:$avatarRevision") + .build() + ) + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFF7F2F4)) + .clickable { showAvatarActions = true }, + contentAlignment = Alignment.Center, + ) { + if (avatarPath != null && painter.state !is AsyncImagePainter.State.Error) { + Image(painter, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } else { + val trimmed = nickname.trim() + if (trimmed.isNotEmpty()) { + Text( + String(Character.toChars(trimmed.codePointAt(0))), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = TokenStatsCardMuted, + ) + } else { + Icon(Icons.Default.Person, null, tint = TokenStatsCardMuted) + } + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + val empty = stringResource(R.string.token_activity_profile_empty) + Text( + nickname.ifBlank { empty }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + email.ifBlank { empty }, + style = MaterialTheme.typography.bodySmall, + color = TokenStatsCardMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = { showEdit = true }) { + Icon(Icons.Default.Edit, stringResource(R.string.token_activity_profile_edit)) + } + } + } + } + + if (showImportConfirm) { + var remaining by remember { mutableIntStateOf(PROFILE_IMPORT_COUNTDOWN_SECONDS) } + LaunchedEffect(Unit) { + while (remaining > 0) { + delay(1_000) + remaining-- + } + } + AlertDialog( + onDismissRequest = { showImportConfirm = false }, + title = { Text(stringResource(R.string.token_activity_profile_import_title)) }, + text = { Text(stringResource(R.string.token_activity_profile_import_message)) }, + confirmButton = { + TextButton( + enabled = remaining == 0, + onClick = { + showImportConfirm = false + scope.launch { + val global = DisplayPreferencesManager.getInstance(context) + val importedName = global.globalUserName.first().orEmpty() + val importedAvatar = global.globalUserAvatarUri.first()?.let(Uri::parse) + val importedPath = runCatching { persistAvatar(importedAvatar) }.getOrNull() + val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) + nickname = importedName + avatarPath = importedPath + avatarRevision = revision + prefs.edit() + .putString(KEY_NICKNAME, importedName) + .putLong(KEY_AVATAR_REVISION, revision) + .apply { + if (importedPath == null) remove(KEY_AVATAR_PATH) + else putString(KEY_AVATAR_PATH, importedPath) + } + .apply() + } + }, + ) { + Text( + if (remaining > 0) { + stringResource(R.string.token_activity_profile_import_countdown, remaining) + } else { + stringResource(R.string.token_activity_profile_import_confirm) + } + ) + } + }, + dismissButton = { + TextButton(onClick = { showImportConfirm = false }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } + + if (showEdit) { + var editingName by remember(nickname) { mutableStateOf(nickname) } + var editingEmail by remember(email) { mutableStateOf(email) } + AlertDialog( + onDismissRequest = { showEdit = false }, + title = { Text(stringResource(R.string.token_activity_profile_edit)) }, + text = { + Column { + OutlinedTextField( + value = editingName, + onValueChange = { editingName = it }, + label = { Text(stringResource(R.string.token_activity_nickname)) }, + singleLine = true, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = editingEmail, + onValueChange = { editingEmail = it }, + label = { Text(stringResource(R.string.token_activity_email)) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { + nickname = editingName + email = editingEmail + prefs.edit().putString(KEY_NICKNAME, nickname).putString(KEY_EMAIL, email).apply() + showEdit = false + }) { Text(stringResource(R.string.token_activity_save)) } + }, + dismissButton = { + TextButton(onClick = { showEdit = false }) { Text(stringResource(R.string.settings_cancel)) } + }, + ) + } + + if (showAvatarActions) { + AlertDialog( + onDismissRequest = { showAvatarActions = false }, + title = { Text(stringResource(R.string.token_activity_avatar)) }, + text = { + Column { + TextButton(onClick = { + showAvatarActions = false + picker.launch("image/*") + }) { Text(stringResource(R.string.token_activity_avatar_pick)) } + if (avatarPath != null) { + TextButton(onClick = { + File(avatarPath.orEmpty()).delete() + val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) + avatarPath = null + avatarRevision = revision + prefs.edit() + .remove(KEY_AVATAR_PATH) + .putLong(KEY_AVATAR_REVISION, revision) + .apply() + showAvatarActions = false + }) { Text(stringResource(R.string.token_activity_avatar_remove)) } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showAvatarActions = false }) { Text(stringResource(R.string.settings_cancel)) } + }, + ) + } +} + +@Composable +private fun TokenActivityHeatmap( + state: TokenActivityUiState, + zone: ZoneId, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = state.yearData + if (state.loading || data == null) { + Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + val days = if (state.viewMode == TokenActivityViewMode.CUMULATIVE) data.cumulative else data.daily + val firstDate = days.firstOrNull()?.date + val padding = firstDate?.let { it.dayOfWeek.value % 7 } ?: 0 + val columns = ((padding + days.size + 6) / 7).coerceAtLeast(1) + val grid = remember(days, padding) { + List(columns) { column -> + List(7) { row -> + days.getOrNull(column * 7 + row - padding) + } + } + } + val density = LocalDensity.current + val block = 11.dp + val gap = 3.dp + val stepPx = with(density) { (block + gap).toPx() } + val blockPx = with(density) { block.toPx() } + val radiusPx = with(density) { 3.dp.toPx() } + val width = (block + gap) * columns - gap + val gridHeight = (block + gap) * 7 - gap + val monthLabelHeight = 20.dp + val canvasHeight = gridHeight + monthLabelHeight + val gridHeightPx = with(density) { gridHeight.toPx() } + val monthLabelGapPx = with(density) { 4.dp.toPx() } + val scroll = rememberScrollState() + var selectedDay by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableStateOf(null) + } + var selectedWeek by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableStateOf(null) + } + var indicatorDay by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableStateOf(null) + } + var indicatorWeek by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableStateOf(null) + } + var indicatorColumn by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableIntStateOf(-1) + } + var indicatorRow by remember(state.recentSelected, state.selectedYear, state.viewMode) { + mutableIntStateOf(-1) + } + val colors = listOf( + Color(0xFFEFE0E5), Color(0xFFFFD6E0), Color(0xFFFFB3C6), + Color(0xFFFF85A2), Color(0xFFFF6B8E), Color(0xFFE84973), + ) + val selectionColor = MaterialTheme.colorScheme.primary + val selectionStroke = with(density) { 1.5.dp.toPx() } + val monthLabels = remember(grid, locale) { + val formatter = DateTimeFormatter.ofPattern("MMM", locale) + val raw = buildList { + var previousMonth = -1 + grid.forEachIndexed { index, week -> + val date = week.firstOrNull { it != null }?.date ?: return@forEachIndexed + if (index == 0 || date.monthValue != previousMonth) { + add(TokenActivityMonthLabel(index, formatter.format(date))) + previousMonth = date.monthValue + } + } + } + raw.filterIndexed { index, label -> + when { + index == 0 -> raw.getOrNull(1)?.let { it.column - label.column >= 3 } ?: false + index == raw.lastIndex -> columns - label.column >= 3 + else -> true + } + } + } + val monthPaint = remember(density) { + Paint().apply { + textSize = with(density) { 12.sp.toPx() } + color = TokenStatsCardMuted.toArgb() + isAntiAlias = true + } + } + + LaunchedEffect(columns, state.recentSelected, state.selectedYear, state.viewMode) { + snapshotFlow { scroll.maxValue }.first { it > 0 } + scroll.scrollTo(scroll.maxValue) + } + + Column(modifier) { + Column(Modifier.horizontalScroll(scroll)) { + Canvas( + modifier = Modifier + .size(width, canvasHeight) + // 顺序与 RainyToken 一致:查看/滚动仲裁必须先于点击检测收到事件。 + .pointerInput(state.viewMode, grid, data.weekly, stepPx, blockPx) { + val viewSpeedThresholdPxPerMs = + with(density) { HEATMAP_VIEW_SPEED_DP_PER_S.dp.toPx() } / 1_000f + + fun updateIndicator(point: Offset) { + val column = (point.x / stepPx).toInt().coerceIn(0, columns - 1) + val row = (point.y / stepPx).toInt().coerceIn(0, 6) + if (state.viewMode == TokenActivityViewMode.WEEKLY) { + val week = data.weekly.getOrNull(column) + indicatorWeek = week + indicatorDay = null + indicatorColumn = if (week == null) -1 else column + indicatorRow = if (week == null) -1 else row + } else { + val day = grid.getOrNull(column)?.getOrNull(row) + indicatorDay = day + indicatorWeek = null + indicatorColumn = if (day == null) -1 else column + indicatorRow = if (day == null) -1 else row + } + } + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var mode: HeatmapDragMode? = null + var lastPosition = down.position + var lastTime = SystemClock.uptimeMillis() + var totalDx = 0f + var totalDy = 0f + val slop = viewConfiguration.touchSlop + val downTime = lastTime + val longPressMs = viewConfiguration.longPressTimeoutMillis + + while (mode == null) { + val remaining = longPressMs - (SystemClock.uptimeMillis() - downTime) + val event = if (remaining > 0L) { + withTimeoutOrNull(remaining) { awaitPointerEvent() } + } else { + null + } + if (event == null) { + mode = HeatmapDragMode.VIEW + break + } + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + val current = change.position + val now = SystemClock.uptimeMillis() + val dx = current.x - lastPosition.x + val dy = current.y - lastPosition.y + val elapsed = (now - lastTime).coerceAtLeast(1L) + val horizontalSpeed = abs(dx) / elapsed + lastPosition = current + lastTime = now + totalDx += dx + totalDy += dy + if (abs(totalDx) > slop || abs(totalDy) > slop) { + mode = if ( + abs(totalDx) > abs(totalDy) && + horizontalSpeed < viewSpeedThresholdPxPerMs + ) { + HeatmapDragMode.VIEW + } else { + HeatmapDragMode.SCROLL + } + if (mode == HeatmapDragMode.VIEW) change.consume() + } + } + + if (mode == HeatmapDragMode.VIEW) { + updateIndicator(lastPosition) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + updateIndicator(change.position) + change.consume() + if (!change.pressed) break + } + } else if (mode == HeatmapDragMode.SCROLL) { + indicatorDay = null + indicatorWeek = null + indicatorColumn = -1 + indicatorRow = -1 + } + } + } + .pointerInput(state.viewMode, grid, data.weekly) { + detectTapGestures { point -> + indicatorDay = null + indicatorWeek = null + indicatorColumn = -1 + indicatorRow = -1 + if (point.x % stepPx >= blockPx || point.y % stepPx >= blockPx) return@detectTapGestures + val column = (point.x / stepPx).toInt() + val row = (point.y / stepPx).toInt() + if (state.viewMode == TokenActivityViewMode.WEEKLY) { + val week = data.weekly.getOrNull(column) + selectedWeek = if (selectedWeek == week) null else week + selectedDay = null + } else { + val day = grid.getOrNull(column)?.getOrNull(row) + selectedDay = if (selectedDay == day) null else day + selectedWeek = null + } + } + }, + ) { + if (state.viewMode == TokenActivityViewMode.WEEKLY) { + data.weekly.forEachIndexed { column, week -> + repeat(7) { row -> + val inBar = row >= 7 - week.barHeight + drawRoundRect( + color = if (inBar) colors[week.level.coerceIn(0, 5)] else colors[0], + topLeft = Offset(column * stepPx, row * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + ) + } + } + } else { + grid.forEachIndexed { column, week -> + week.forEachIndexed { row, day -> + if (day != null) drawRoundRect( + color = colors[day.level.coerceIn(0, 5)], + topLeft = Offset(column * stepPx, row * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + ) + } + } + } + + drawIntoCanvas { canvas -> + val baseline = gridHeightPx + monthLabelGapPx - monthPaint.ascent() + monthLabels.forEach { label -> + canvas.nativeCanvas.drawText( + label.text, + label.column * stepPx, + baseline, + monthPaint, + ) + } + } + + val indicatorValid = when { + indicatorWeek != null -> data.weekly.getOrNull(indicatorColumn) != null + indicatorDay != null -> grid.getOrNull(indicatorColumn)?.getOrNull(indicatorRow) != null + else -> false + } + if (indicatorValid && indicatorColumn in 0 until columns && indicatorRow in 0..6) { + drawRoundRect( + color = selectionColor, + topLeft = Offset(indicatorColumn * stepPx, indicatorRow * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + style = Stroke(width = selectionStroke * 1.5f), + ) + } + } + } + + Box(Modifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.CenterStart) { + val text = when { + indicatorDay != null -> stringResource( + R.string.token_activity_day_detail, + indicatorDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(indicatorDay!!.tokens), + ) + indicatorWeek != null -> stringResource( + R.string.token_activity_week_detail, + indicatorWeek!!.startDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + indicatorWeek!!.startDate.plusDays(6).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(indicatorWeek!!.tokens), + ) + selectedDay != null -> stringResource( + R.string.token_activity_day_detail, + selectedDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(selectedDay!!.tokens), + ) + selectedWeek != null -> stringResource( + R.string.token_activity_week_detail, + selectedWeek!!.startDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + selectedWeek!!.startDate.plusDays(6).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(selectedWeek!!.tokens), + ) + else -> stringResource(R.string.token_activity_tap_hint) + } + Text(text, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted, maxLines = 1) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.token_activity_less), fontSize = 12.sp, color = TokenStatsCardMuted) + Spacer(Modifier.width(4.dp)) + colors.forEach { color -> + Box(Modifier.size(block).background(color, RoundedCornerShape(3.dp))) + Spacer(Modifier.width(gap)) + } + Text(stringResource(R.string.token_activity_more), fontSize = 12.sp, color = TokenStatsCardMuted) + } + } +} + +private enum class HeatmapDragMode { VIEW, SCROLL } + +private data class TokenActivityMonthLabel(val column: Int, val text: String) + +private const val HEATMAP_VIEW_SPEED_DP_PER_S = 150f +private const val PROFILE_IMPORT_COUNTDOWN_SECONDS = 3 + +private const val PROFILE_PREFS = "token_activity_profile" +private const val KEY_NICKNAME = "nickname" +private const val KEY_EMAIL = "email" +private const val KEY_AVATAR_PATH = "avatar_path" +private const val KEY_AVATAR_REVISION = "avatar_revision" +private const val AVATAR_FILE = "token_activity_avatar" diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index 977d0a15f..eded97ee6 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -1,5 +1,6 @@ package com.ai.assistance.operit.ui.features.tokenstats +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -873,6 +874,7 @@ internal fun TokenStatsChartCard( title: String, summary: String, modifier: Modifier = Modifier, + onSummaryClick: (() -> Unit)? = null, headerExtra: @Composable () -> Unit = {}, content: @Composable () -> Unit, ) { @@ -901,6 +903,11 @@ internal fun TokenStatsChartCard( style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = chartColors.chartAccent, + modifier = if (onSummaryClick != null) { + Modifier.clickable(onClick = onSummaryClick) + } else { + Modifier + }, ) } Spacer(Modifier.height(8.dp)) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index 4d5d9d5cc..f45aefafe 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -58,6 +58,7 @@ import kotlinx.coroutines.delay /** 性能卡指标切换。 */ internal enum class PerfMetric { TTFT, GENERATION } +private enum class ChartDetailMetric { COST, REQUESTS, TOKENS } /** * Token 统计完整页面(阶段 4)。 @@ -421,6 +422,16 @@ private fun TokenStatsPageContent( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + item { + TokenActivitySection( + state = state.activity, + zone = zone, + onSelectRecent = viewModel::setActivityRecent, + onSelectYear = viewModel::setActivityYear, + onSelectMode = viewModel::setActivityViewMode, + ) + } + item { TokenStatsLifetimeCard( overview = lifetime, @@ -600,6 +611,7 @@ private fun TokenStatsChartsSection( perfMetric: PerfMetric, onTogglePerfMetric: (PerfMetric) -> Unit, ) { + var detailMetric by rememberSaveable { mutableStateOf(null) } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val wide = maxWidth > 700.dp if (wide) { @@ -611,14 +623,20 @@ private fun TokenStatsChartsSection( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - CostChartCard(range = range, currency = currency, zone = zone) - TokenChartCard(range = range, currency = currency, zone = zone) + CostChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.COST + } + TokenChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.TOKENS + } } Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - RequestChartCard(range = range, currency = currency, zone = zone) + RequestChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.REQUESTS + } PerformanceChartCard( range = range, zone = zone, @@ -629,9 +647,15 @@ private fun TokenStatsChartsSection( } } else { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - CostChartCard(range = range, currency = currency, zone = zone) - RequestChartCard(range = range, currency = currency, zone = zone) - TokenChartCard(range = range, currency = currency, zone = zone) + CostChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.COST + } + RequestChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.REQUESTS + } + TokenChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.TOKENS + } PerformanceChartCard( range = range, zone = zone, @@ -641,6 +665,86 @@ private fun TokenStatsChartsSection( } } } + + detailMetric?.let { metric -> + TokenStatsChartDetailDialog( + metric = metric, + range = range, + currency = currency, + onDismiss = { detailMetric = null }, + ) + } +} + +@Composable +private fun TokenStatsChartDetailDialog( + metric: ChartDetailMetric, + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + onDismiss: () -> Unit, +) { + val title = stringResource( + when (metric) { + ChartDetailMetric.COST -> R.string.token_stats_detail_cost + ChartDetailMetric.REQUESTS -> R.string.token_stats_detail_requests + ChartDetailMetric.TOKENS -> R.string.token_stats_detail_tokens + } + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title, fontWeight = FontWeight.Bold) }, + text = { + Column { + when (metric) { + ChartDetailMetric.COST -> range.displayModels.forEach { model -> + if (model.totals.cost.knownAmount > 0.0) { + TokenStatsDetailRow(model.displayName, formatMoney(model.totals.cost.knownAmount, currency)) + } + } + ChartDetailMetric.REQUESTS -> range.displayModels.forEach { model -> + if (model.totals.requests > 0L) { + TokenStatsDetailRow(model.displayName, formatCount(model.totals.requests)) + } + } + ChartDetailMetric.TOKENS -> { + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_cached), + formatCount(range.summary.cachedInput.knownSum), + ) + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_uncached), + formatCount(range.summary.uncachedInput.knownSum), + ) + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_output), + formatCount(range.summary.output.knownSum), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.token_stats_detail_close)) + } + }, + ) +} + +@Composable +private fun TokenStatsDetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text( + value, + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.chartAccent, + fontWeight = FontWeight.Medium, + ) + } } @Composable @@ -648,6 +752,7 @@ private fun CostChartCard( range: TokenStatsRangeData, currency: com.ai.assistance.operit.data.collects.PricingCurrency, zone: ZoneId, + onSummaryClick: () -> Unit, ) { val colors = LocalTokenStatsColors.current val models = range.displayModels @@ -662,6 +767,7 @@ private fun CostChartCard( TokenStatsChartCard( title = chartTitle, summary = formatMoney(range.summary.cost.knownAmount, currency), + onSummaryClick = onSummaryClick, ) { if (range.summary.cost.unknownContributionCount > 0L) { RangeUnknownHint( @@ -701,11 +807,13 @@ private fun RequestChartCard( range: TokenStatsRangeData, currency: com.ai.assistance.operit.data.collects.PricingCurrency, zone: ZoneId, + onSummaryClick: () -> Unit, ) { val chartTitle = stringResource(R.string.token_stats_chart_requests) TokenStatsChartCard( title = chartTitle, summary = formatCount(range.summary.requests), + onSummaryClick = onSummaryClick, ) { TokenStatsLineChart( buckets = range.buckets, @@ -724,6 +832,7 @@ private fun TokenChartCard( range: TokenStatsRangeData, currency: com.ai.assistance.operit.data.collects.PricingCurrency, zone: ZoneId, + onSummaryClick: () -> Unit, ) { val colors = LocalTokenStatsColors.current // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 @@ -747,6 +856,7 @@ private fun TokenChartCard( range.summary.output.knownSum, ) ), + onSummaryClick = onSummaryClick, ) { if (totalUnknown > 0L) { RangeUnknownHint(stringResource(R.string.token_stats_unknown_parts, totalUnknown)) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index b70ea0843..0ca263f6d 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -12,6 +12,10 @@ import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.data.stats.ApiPreferencesTokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenActivityAggregator +import com.ai.assistance.operit.data.stats.TokenActivityInsights +import com.ai.assistance.operit.data.stats.TokenActivityViewMode +import com.ai.assistance.operit.data.stats.TokenActivityYearData import com.ai.assistance.operit.data.stats.TokenCostCurrency import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.data.stats.TokenStatStatus @@ -43,6 +47,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class TokenActivityUiState( + val loading: Boolean = true, + val viewMode: TokenActivityViewMode = TokenActivityViewMode.DAILY, + val recentSelected: Boolean = true, + val selectedYear: Int = 0, + val availableYears: List = emptyList(), + val yearData: TokenActivityYearData? = null, + val insights: TokenActivityInsights = TokenActivityInsights(), +) /** 页面 UI 状态(阶段 4)。 */ data class TokenStatsUiState( @@ -97,6 +112,8 @@ data class TokenStatsUiState( * 目标组列表)必须用完整归属,否则无事件成员被漏移、无事件目标组不可选。 */ val groupModels: List = emptyList(), + /** 全局历史活动;独立于下方时间、模型、分类和状态筛选。 */ + val activity: TokenActivityUiState = TokenActivityUiState(), ) /** 一次性操作结果消息(Toast):错误或成功提示,消费后清除。 */ @@ -156,6 +173,9 @@ class TokenUsageStatisticsViewModel( /** 当前加载任务:新一轮 [load] 先取消旧任务,旧任务不得写 state(P1-4)。 */ private var loadJob: Job? = null + private var activityLoadJob: Job? = null + private var activityLoadGeneration = 0 + /** 已知展示模型 id → 最近一次查询所见名称(P1-5,永不清除,只增补)。 */ private val knownModelNames = mutableMapOf() @@ -175,9 +195,80 @@ class TokenUsageStatisticsViewModel( /** 进入/返回统计页时重新探测自动时间范围;用户手选范围始终保持不变。 */ fun loadForEntry() { + loadActivity() loadInternal(reconsiderAutomaticTime = true) } + private fun loadActivity(requestedRecent: Boolean = true, requestedYear: Int? = null) { + activityLoadJob?.cancel() + val generation = ++activityLoadGeneration + _state.update { + it.copy( + activity = it.activity.copy( + loading = true, + recentSelected = requestedRecent, + selectedYear = requestedYear ?: it.activity.selectedYear, + ) + ) + } + activityLoadJob = viewModelScope.launch(dispatcher) { + try { + val records = TokenStatsQueryService.activityRecords(appContext) + val result = withContext(Dispatchers.Default) { + val years = TokenActivityAggregator.availableYears(records, zone, nowMs()) + val recent = requestedRecent || requestedYear !in years + val year = requestedYear?.takeIf { it in years } ?: years.first() + ActivityLoadResult( + years = years, + year = year, + recent = recent, + data = if (recent) { + TokenActivityAggregator.recentData(records, zone, nowMs()) + } else { + TokenActivityAggregator.yearData(records, zone, year, nowMs()) + }, + insights = TokenActivityAggregator.insights(records, zone), + ) + } + if (generation != activityLoadGeneration) return@launch + _state.update { + it.copy( + activity = it.activity.copy( + loading = false, + recentSelected = result.recent, + selectedYear = result.year, + availableYears = result.years, + yearData = result.data, + insights = result.insights, + ) + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == activityLoadGeneration) { + _state.update { it.copy(activity = it.activity.copy(loading = false)) } + } + runCatching { AppLogger.e(tag, "Token 活动加载失败", e) } + } + } + } + + fun setActivityViewMode(mode: TokenActivityViewMode) { + _state.update { it.copy(activity = it.activity.copy(viewMode = mode)) } + } + + fun setActivityYear(year: Int) { + val activity = _state.value.activity + if ((!activity.recentSelected && year == activity.selectedYear) || year !in activity.availableYears) return + loadActivity(requestedRecent = false, requestedYear = year) + } + + fun setActivityRecent() { + if (_state.value.activity.recentSelected) return + loadActivity(requestedRecent = true) + } + private fun loadInternal(reconsiderAutomaticTime: Boolean) { loadJob?.cancel() val generation = ++loadGeneration @@ -605,7 +696,10 @@ class TokenUsageStatisticsViewModel( viewModelScope.launch(dispatcher) { runCatching { TokenStatsResetCoordinator.deleteEventsInRange(appContext, range.startMs, range.endMs) - }.onSuccess { load() }.onFailure { e -> + }.onSuccess { + loadActivity() + load() + }.onFailure { e -> _actionMessage.value = TokenStatsActionMessage( text = stringResolver(R.string.token_stats_delete_range_failed), @@ -632,6 +726,7 @@ class TokenUsageStatisticsViewModel( viewModelScope.launch(dispatcher) { try { TokenStatsResetCoordinator.deleteDisplayModel(appContext, displayModelId, deleteBaselines) + loadActivity() load() } catch (e: CancellationException) { throw e @@ -663,6 +758,7 @@ class TokenUsageStatisticsViewModel( true } if (ok) { + loadActivity() load() } else { _actionMessage.value = @@ -713,6 +809,14 @@ private data class QueryLoadResult( val groups: List, ) +private data class ActivityLoadResult( + val years: List, + val year: Int, + val recent: Boolean, + val data: TokenActivityYearData, + val insights: TokenActivityInsights, +) + /** 保存范围时用:无效自定义边界返回 null(防御损坏状态)。 */ internal fun TokenStatsTimeSelection.customRangeOrNull(): TokenStatsTimeRange? { if (preset != TokenStatsPreset.CUSTOM) return null diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b798abcb9..9d8da265f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8239,4 +8239,108 @@ Rejected Changes Required Featured + + All + Recent + Favorites + Add to favorites + Remove from favorites + Automatic review policy + Add review rules here to reflect how you like to work; automatic review follows these rules. Policy version: %1$s + Edit managed rules + Collapse policy editor + These rules are sent to the review model as your custom requirements, and review judges risk and authorization according to your habits. Only add rules you truly intend to allow. + Example: Any action publishing data publicly must require manual confirmation. + Recent denials + %1$d denials across %2$d distinct actions + View statistics and audit details + Denial statistics + Total denials + Distinct actions + The 10 most recent distinct denied actions are shown below. Select one to open its review Subagent and inspect the full process. + This chat has no actions denied by automatic review + Open review Subagent details + The corresponding review Subagent is unavailable + Authorize re-review of exact action + Authorized; waiting for Agent retry + Re-review in progress + This authorization was used + Authorization expired; authorize again + Authorization is valid for 5 minutes. Return to the parent chat and ask the Agent to retry the exact action; this button does not execute it directly. + reviewing + approved + denied + timed out + aborted + review failed + Guardian · batch tool %1$d/%2$d · %3$s · %4$s + Later allowed by you or current settings + Later denied by you or current settings + Tap for status, decision, and review conversation + Guardian review details + Status: %1$s + Action: %1$s + Risk level: %1$s + User authorization: %1$s + Decision: %1$s + Review subagent: %1$s + Failure: %1$s + Low + Medium + High + Critical + Unknown + Invalid structured output + Review timed out + Reviewer model error + Open review conversation + Guardian stopped this turn + Automatic review denied several actions in a row. To protect your data, the remaining tools were not run and this turn was stopped safely. + Confirm in %1$d s + I understand + You can now confirm and close this notice + %1$d consecutive safety interruptions combined + What happened + Guardian denied several tool calls in this turn. The denied calls and the remaining tools were not run, so the turn was stopped. + What you should do + Review the audit record and check which operation was denied and why. If it is still needed, return to the main chat and clearly state the exact operation, target, and scope before asking the AI to try again. Do not authorize an operation you do not understand. + Token Activity + Recent + Profile + Not set + Edit profile + Reset and import global name and avatar + Reset profile + This replaces the independent Token Activity nickname and avatar with Operit’s global name and avatar. The independent email is preserved. + Confirm in %1$d s + Confirm reset + Nickname + Email + Save + Avatar + Choose from gallery + Remove avatar + Activity Insights + Total requests + Peak hours + %1$d-%2$d + Daily + Weekly + Cumulative + Total tokens + Peak tokens + Current streak + Longest streak + %1$d days + %1$s used %2$s tokens + %1$s - %2$s used %3$s tokens + Tap a cell to view details + Less + More + Spending breakdown + Request breakdown + Token breakdown + Close + Tap to view details + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54c3e4949..57ddae149 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8229,4 +8229,108 @@ 需要修改 入选精选 + + 所有 + 最近 + 收藏 + 收藏对话 + 取消收藏 + 自动审核策略 + 可在此按你的使用习惯追加审核规则,自动审核会遵守这些规则。当前策略版本:%1$s + 编辑追加规则 + 收起策略编辑 + 这些规则会作为你的自定义要求发送给审核模型,审核会按你的习惯判断风险与授权。请只添加你真正愿意放行的规则。 + 例如:所有发布到公网的操作都必须转为人工确认。 + 最近拒绝 + 共 %1$d 次拒绝,涉及 %2$d 个不同操作 + 查看统计和审核详情 + 拒绝统计 + 拒绝总数 + 不同操作 + 下方显示最近 10 个不同的被拒绝操作。点击记录可进入对应的审核 Subagent 查看完整过程。 + 这个对话还没有被自动审核拒绝的操作 + 查看审核 Subagent 详情 + 对应的审核 Subagent 已不可用 + 授权相同操作重新审核 + 已授权,等待 Agent 重试 + 正在重新审核 + 本次授权已使用 + 授权已过期,可重新授权 + 授权有效 5 分钟。请返回主对话,让 Agent 重试完全相同的操作;不会由此按钮直接执行。 + 审核中 + 已允许 + 已拒绝 + 已超时 + 已中止 + 审核失败 + Guardian · 本批工具 %1$d/%2$d · %3$s · %4$s + 随后由你或最新设置允许 + 随后由你或最新设置拒绝 + 点击查看状态、结论和审核对话 + Guardian 审核详情 + 状态:%1$s + 操作:%1$s + 风险等级:%1$s + 用户授权:%1$s + 审核结论:%1$s + 审核子任务:%1$s + 失败原因:%1$s + + + + 严重 + 未知 + 输出格式无效 + 审核超时 + 审核模型错误 + 进入审核对话 + Guardian 已停止本轮操作 + 自动审核连续拒绝了多个操作。为保护你的数据,本轮剩余工具均未执行,对话已安全停止。 + %1$d 秒后可确认 + 我知道了 + 现在可以确认并关闭此提示 + 已合并 %1$d 次连续安全中断 + 发生了什么 + Guardian 连续拒绝了本轮中的多个工具调用。被拒绝的调用和本轮剩余工具都没有执行,因此系统停止了本轮。 + 你应该怎么做 + 先查看审核记录,确认被拒绝的操作和原因。若仍需执行,请回到主对话,明确说明具体操作、目标和范围后再让 AI 重试;不要授权你不理解的操作。 + Token 活动 + 最近 + 个人资料 + 未设置 + 编辑个人资料 + 重置并导入全局用户名和头像 + 重置个人资料 + 将用 Operit 的全局用户名和头像覆盖 Token 活动中的独立昵称和头像。独立邮箱不会改变。 + %1$d 秒后确认 + 确认重置 + 昵称 + 邮箱 + 保存 + 头像 + 从相册选择 + 移除头像 + 活动洞察 + 总请求次数 + 请求高峰时段 + %1$d~%2$d 点 + 每日 + 每周 + 累计 + 累计 Token + 峰值 Token + 当前连续 + 最长连续 + %1$d 天 + %1$s使用了 %2$s Token + %1$s - %2$s 使用了 %3$s Token + 点击方格查看详细数据 + + + 费用明细 + 请求明细 + Token 明细 + 关闭 + 点击查看详细数据 + diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt new file mode 100644 index 000000000..a4d7cfa85 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt @@ -0,0 +1,109 @@ +package com.ai.assistance.operit.data.stats + +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenActivityAggregatorTest { + private val zone = ZoneId.of("Asia/Shanghai") + + @Test + fun `activity row honors total input and reasoning inclusion contract`() { + val included = TokenActivityEventRow( + startedAtMs = 1L, + uncachedInputTokens = null, + cachedInputTokens = null, + cacheWriteTokens = null, + totalInputTokens = 100L, + outputTokens = 40L, + reasoningTokens = 30L, + reasoningIncludedInOutput = true, + ) + val separate = TokenActivityEventRow( + startedAtMs = 2L, + uncachedInputTokens = 50L, + cachedInputTokens = 20L, + cacheWriteTokens = 10L, + totalInputTokens = null, + outputTokens = 40L, + reasoningTokens = 30L, + reasoningIncludedInOutput = false, + ) + + assertEquals(140L, included.toActivityRecord().tokens) + assertEquals(150L, separate.toActivityRecord().tokens) + } + + @Test + fun `recent data contains exactly the latest 365 calendar days`() { + val today = LocalDate.of(2026, 8, 8) + val records = listOf( + record(today.minusDays(364).toString(), 10), + record(today.toString(), 20), + record(today.minusDays(365).toString(), 40), + ) + + val result = TokenActivityAggregator.recentData( + records = records, + zone = zone, + nowMs = today.atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), + ) + + assertEquals(365, result.daily.size) + assertEquals(today.minusDays(364), result.daily.first().date) + assertEquals(today, result.daily.last().date) + assertEquals(30L, result.stats.totalTokens) + } + + @Test + fun `year data computes totals peaks and streaks`() { + val records = listOf( + record("2026-01-01", 10), + record("2026-01-02", 20), + record("2026-01-04", 30), + record("2026-01-05", 40), + ) + + val result = TokenActivityAggregator.yearData( + records = records, + zone = zone, + year = 2026, + nowMs = LocalDate.of(2026, 1, 8).atStartOfDay(zone).toInstant().toEpochMilli(), + ) + + assertEquals(100L, result.stats.totalTokens) + assertEquals(40L, result.stats.peakTokens) + assertEquals(2, result.stats.currentStreak) + assertEquals(2, result.stats.longestStreak) + assertEquals(100L, result.cumulative.last().tokens) + } + + @Test + fun `insights use all requests and rank peak hours`() { + val records = listOf( + record("2026-01-01", 1, 9), + record("2026-01-02", 1, 9), + record("2026-01-03", 1, 20), + record("2026-01-04", 1, 8), + ) + + val insights = TokenActivityAggregator.insights(records, zone) + + assertEquals(4L, insights.totalRequests) + assertEquals(listOf(9, 8, 20), insights.topHours) + } + + @Test + fun `available years span earliest event through current year`() { + val records = listOf(record("2024-06-01", 1), record("2026-01-01", 1)) + val now = LocalDate.of(2026, 8, 1).atStartOfDay(zone).toInstant().toEpochMilli() + + assertEquals(listOf(2026, 2025, 2024), TokenActivityAggregator.availableYears(records, zone, now)) + } + + private fun record(date: String, tokens: Long, hour: Int = 12): TokenActivityRecord { + val timestamp = LocalDate.parse(date).atTime(hour, 0).atZone(zone).toInstant().toEpochMilli() + return TokenActivityRecord(timestamp, tokens) + } +} From 33df47074bef7bab80fd3e37b6f1e97d04ae4bc1 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:19:59 +0800 Subject: [PATCH 09/28] fix(stats): preserve aliases and legacy pricing (cherry picked from commit e1fc9aa0b9cdee7e3e370f2a835314cd0602ab85) --- .../operit/data/dao/TokenStatsDao.kt | 2 +- .../operit/data/preferences/ApiPreferences.kt | 2 +- .../data/stats/LegacyTokenStatsSnapshot.kt | 14 +++++------ .../operit/data/stats/TokenStatsLedger.kt | 2 +- .../stats/LegacyTokenStatsSnapshotTest.kt | 21 +++++++++++++---- .../operit/data/stats/TokenStatsLedgerTest.kt | 23 +++++++++++++++++++ 6 files changed, 49 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index c6e4ae3c5..59faad419 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -328,7 +328,7 @@ abstract class TokenStatsDao { displayModel: TokenStatDisplayModelEntity, ): Long { insertIdentityIfAbsent(identity) - upsertDisplayModel(displayModel) + insertDisplayModelIfAbsent(displayModel) return currentResetGeneration() } diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 9b32ed69d..284787974 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -127,7 +127,7 @@ class ApiPreferences private constructor(private val context: Context) { "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" } } else { - encoded.replace("_", ":") + encoded.replaceFirst("_", ":") } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt index ebf3db9f1..9ece128d8 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt @@ -148,13 +148,11 @@ data class LegacyTokenStatsSnapshot( decodeProviderModelFromKeySuffix(keyName.removePrefix(BILLING_MODE_PREFIX)) if (providerModel.isNotBlank()) { val mode = BillingMode.fromString(value as? String) - if (mode != BillingMode.TOKEN) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - billingMode = mode - ) - } + builders.getOrPut(providerModel) { StatsBuilder(providerModel) } + .priceSettings = + builders.getValue(providerModel).priceSettings.copy( + billingMode = mode + ) } } } @@ -224,7 +222,7 @@ data class LegacyTokenStatsSnapshot( "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" } } else { - encoded.replace("_", ":") + encoded.replaceFirst("_", ":") } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt index 1048438d9..effcb5847 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt @@ -284,7 +284,7 @@ object TokenStatsLedger { ): TokenStatIdentityEntity { val identity = identityEntityFor(request.configId, request.provider, request.model) dao.insertIdentityIfAbsent(identity) - dao.upsertDisplayModel(displayModelEntityFor(request.model)) + dao.insertDisplayModelIfAbsent(displayModelEntityFor(request.model)) return identity } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt index 3eaf41dda..9b4b9d083 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt @@ -56,6 +56,20 @@ class LegacyTokenStatsSnapshotTest { assertEquals(2L, stats.requestCount) } + @Test + fun `explicit token billing mode is preserved`() { + val raw = + legacyPreferences( + "token_input_OPENAI_gpt-4o-mini-tts" to 100L, + "billing_mode_OPENAI_gpt-4o-mini-tts" to "TOKEN", + ) + + val snapshot = LegacyTokenStatsSnapshot.parse(raw) + + val stats = snapshot.providerModels.getValue("OPENAI:gpt-4o-mini-tts") + assertEquals(BillingMode.TOKEN, stats.priceSettings.billingMode) + } + @Test fun `missing counters are zero and all-zero models are dropped`() { val raw = @@ -104,16 +118,15 @@ class LegacyTokenStatsSnapshotTest { } @Test - fun `underscore fallback decoding handles unknown providers`() { + fun `unknown provider decoding preserves model underscores`() { val raw = legacyPreferences( - "token_input_MY_CUSTOM_PROVIDER_my-model" to 10L, + "token_input_Custom_gpt_4" to 10L, ) val snapshot = LegacyTokenStatsSnapshot.parse(raw) - // 未知 provider 前缀按旧约定整体替换 “_” 为 “:” - assertTrue(snapshot.providerModels.containsKey("MY:CUSTOM:PROVIDER:my-model")) + assertTrue(snapshot.providerModels.containsKey("Custom:gpt_4")) } @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt index 41622f3c1..23f865980 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt @@ -146,6 +146,29 @@ reasoningTokens = 50L, assertEquals(1, database.tokenStatsDao().countEvents()) } + @Test + fun `accepting and recording requests preserve renamed default display group`() = runBlocking { + val dao = database.tokenStatsDao() + val model = "gpt-4o-2024-11-20" + val displayModelId = TokenStatIdentityResolver.displayModelIdFor(model) + TokenStatsLedger.record(context, request(eventId = "evt-alias-1", model = model)) + dao.updateDisplayModelName(displayModelId, "Primary GPT") + + TokenStatsLedger.ensureIdentityAndCaptureGeneration( + context = context, + configId = "cfg-2", + provider = "OPENAI", + model = model, + ) + assertEquals("Primary GPT", dao.getDisplayModel(displayModelId)!!.displayName) + + TokenStatsLedger.record( + context, + request(eventId = "evt-alias-2", configId = "cfg-2", model = model), + ) + assertEquals("Primary GPT", dao.getDisplayModel(displayModelId)!!.displayName) + } + @Test fun `duplicate usage callbacks keep last snapshot only`() = runBlocking { val ctx = request(eventId = "evt-last") From 04a9fce233d9edab93bc3c651da32ac62b4ed862 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:47:23 +0800 Subject: [PATCH 10/28] fix(stats): resolve custom provider legacy keys (cherry picked from commit f226ae20059f6f85513323dee8a53dbd19500fc3) --- .../operit/data/preferences/ApiPreferences.kt | 43 ++++----- .../data/stats/LegacyTokenStatsSnapshot.kt | 91 ++++++++++++------- .../stats/LegacyTokenStatsSnapshotTest.kt | 19 ++++ 3 files changed, 98 insertions(+), 55 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 284787974..b4848676e 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -17,6 +17,8 @@ import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ParameterCategory import com.ai.assistance.operit.data.model.ParameterValueType +import com.ai.assistance.operit.data.stats.LegacyProviderModelKeyDecoder +import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first @@ -112,25 +114,6 @@ class ApiPreferences private constructor(private val context: Context) { listOf("model_input_price_", "model_cached_input_price_", "model_output_price_", "billing_mode_", "price_per_request_") - private val providerNameCandidates = - ApiProviderType.values().map { it.name }.sortedByDescending { it.length } - - private fun decodeProviderModelFromKeySuffix(encoded: String): String { - val matchedProvider = providerNameCandidates.firstOrNull { - encoded == it || encoded.startsWith("${it}_") - } - - return if (matchedProvider != null) { - if (encoded.length == matchedProvider.length) { - matchedProvider - } else { - "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" - } - } else { - encoded.replaceFirst("_", ":") - } - } - val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") private val STATS_TARGET_CURRENCY = stringPreferencesKey("stats_target_currency") @@ -621,13 +604,15 @@ class ApiPreferences private constructor(private val context: Context) { suspend fun getAllProviderModelTokens(): Map> { val preferences = context.apiDataStore.data.first() val result = mutableMapOf>() + val providerNames = registeredToolPkgProviderNames() // 遍历所有preferences,查找token相关的key preferences.asMap().forEach { (key, value) -> val keyName = key.name if (keyName.startsWith("token_input_")) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("token_input_")) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix("token_input_"), providerNames) val inputTokens = readTokenCountValue(value) val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) val cachedInputTokens = @@ -648,13 +633,15 @@ class ApiPreferences private constructor(private val context: Context) { val allProviderModelTokensFlow: Flow>> = context.apiDataStore.data.map { preferences -> val result = mutableMapOf>() + val providerNames = registeredToolPkgProviderNames() // 遍历所有preferences,查找token相关的key preferences.asMap().forEach { (key, value) -> val keyName = key.name if (keyName.startsWith("token_input_")) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("token_input_")) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix("token_input_"), providerNames) val inputTokens = readTokenCountValue(value) val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) val cachedInputTokens = @@ -732,7 +719,8 @@ class ApiPreferences private constructor(private val context: Context) { return LegacyStatsSnapshotRead( snapshot = com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( - preferences.asMap().mapKeys { it.key.name } + preferences.asMap().mapKeys { it.key.name }, + registeredToolPkgProviderNames(), ), cleanupMarkerIds = appliedMarkerIdsFrom(preferences), ) @@ -804,11 +792,13 @@ class ApiPreferences private constructor(private val context: Context) { suspend fun allLegacyPriceSettings(): Map { val preferences = context.apiDataStore.data.first() val candidates = linkedSetOf() + val providerNames = registeredToolPkgProviderNames() preferences.asMap().keys.forEach { key -> val name = key.name for (prefix in LEGACY_PRICE_KEY_PREFIXES) { if (name.startsWith(prefix) && name.length > prefix.length) { - candidates += decodeProviderModelFromKeySuffix(name.substring(prefix.length)) + candidates += LegacyProviderModelKeyDecoder.decode( + name.substring(prefix.length), providerNames) break } } @@ -938,13 +928,15 @@ class ApiPreferences private constructor(private val context: Context) { suspend fun getAllProviderModelRequestCounts(): Map { val preferences = context.apiDataStore.data.first() val result = mutableMapOf() + val providerNames = registeredToolPkgProviderNames() // 遍历所有preferences,查找请求次数相关的key preferences.asMap().forEach { (key, value) -> val keyName = key.name if (keyName.startsWith("request_count_")) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("request_count_")) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix("request_count_"), providerNames) val count = value as? Int ?: 0 if (count > 0) { result[providerModel] = count @@ -1072,6 +1064,9 @@ class ApiPreferences private constructor(private val context: Context) { } } + private fun registeredToolPkgProviderNames(): List = + ToolPkgAiProviderRegistry.list().map { it.displayName } + /** 旧版累计 baseline 是否加入生命周期累计;缺省开启以保持升级前后的总计连续。 */ suspend fun getStatsIncludeLegacy(): Boolean { val preferences = context.apiDataStore.data.first() diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt index 9ece128d8..1f02c4541 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt @@ -32,18 +32,20 @@ data class LegacyTokenStatsSnapshot( val providerModels: Map, ) { companion object { - - private val providerNameCandidates = - ApiProviderType.entries.map { it.name }.sortedByDescending { it.length } - - fun parse(rawPreferences: Map): LegacyTokenStatsSnapshot { + fun parse( + rawPreferences: Map, + additionalProviderNames: Collection = emptyList(), + ): LegacyTokenStatsSnapshot { val builders = linkedMapOf() rawPreferences.forEach { (key, value) -> val keyName = key if (keyName.startsWith(TOKEN_INPUT_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_INPUT_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(TOKEN_INPUT_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { builders.getOrPut(providerModel) { StatsBuilder(providerModel) } .inputTokens = readTokenCountValue(value) @@ -55,7 +57,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(TOKEN_CACHED_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_CACHED_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(TOKEN_CACHED_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { builders.getOrPut(providerModel) { StatsBuilder(providerModel) } .cachedInputTokens = readTokenCountValue(value) @@ -67,7 +72,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(TOKEN_OUTPUT_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(TOKEN_OUTPUT_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(TOKEN_OUTPUT_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { builders.getOrPut(providerModel) { StatsBuilder(providerModel) } .outputTokens = readTokenCountValue(value) @@ -79,7 +87,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(REQUEST_COUNT_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(REQUEST_COUNT_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(REQUEST_COUNT_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { builders.getOrPut(providerModel) { StatsBuilder(providerModel) } .requestCount = (value as? Int)?.toLong() ?: 0L @@ -91,7 +102,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(PRICE_INPUT_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_INPUT_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(PRICE_INPUT_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { val price = (value as? Float)?.toDouble() if (price != null) { @@ -109,7 +123,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(PRICE_CACHED_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_CACHED_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(PRICE_CACHED_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { val price = (value as? Float)?.toDouble() if (price != null) { @@ -127,7 +144,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(PRICE_OUTPUT_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_OUTPUT_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(PRICE_OUTPUT_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { val price = (value as? Float)?.toDouble() if (price != null) { @@ -145,7 +165,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(BILLING_MODE_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(BILLING_MODE_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(BILLING_MODE_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { val mode = BillingMode.fromString(value as? String) builders.getOrPut(providerModel) { StatsBuilder(providerModel) } @@ -161,7 +184,10 @@ data class LegacyTokenStatsSnapshot( val keyName = key if (keyName.startsWith(PRICE_PER_REQUEST_PREFIX)) { val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix(PRICE_PER_REQUEST_PREFIX)) + LegacyProviderModelKeyDecoder.decode( + keyName.removePrefix(PRICE_PER_REQUEST_PREFIX), + additionalProviderNames, + ) if (providerModel.isNotBlank()) { val price = (value as? Float)?.toDouble() if (price != null) { @@ -209,23 +235,6 @@ data class LegacyTokenStatsSnapshot( else -> 0L } - /** 与 ApiPreferences 一致的 “provider:model” 后缀解码。 */ - private fun decodeProviderModelFromKeySuffix(encoded: String): String { - val matchedProvider = - providerNameCandidates.firstOrNull { - encoded == it || encoded.startsWith("${it}_") - } - return if (matchedProvider != null) { - if (encoded.length == matchedProvider.length) { - matchedProvider - } else { - "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" - } - } else { - encoded.replaceFirst("_", ":") - } - } - private const val TOKEN_INPUT_PREFIX = "token_input_" private const val TOKEN_CACHED_PREFIX = "token_cached_input_" private const val TOKEN_OUTPUT_PREFIX = "token_output_" @@ -237,3 +246,23 @@ data class LegacyTokenStatsSnapshot( private const val PRICE_PER_REQUEST_PREFIX = "price_per_request_" } } + +/** 统一旧键解码,优先匹配内置或当前注册 provider 的完整名称。 */ +internal object LegacyProviderModelKeyDecoder { + private val builtInProviderNames = ApiProviderType.entries.map { it.name } + + fun decode(encoded: String, additionalProviderNames: Collection = emptyList()): String { + val matchedProvider = + (builtInProviderNames.asSequence() + additionalProviderNames.asSequence()) + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .sortedByDescending(String::length) + .firstOrNull { encoded == it || encoded.startsWith("${it}_") } + return when { + matchedProvider == null -> encoded.replaceFirst("_", ":") + encoded.length == matchedProvider.length -> matchedProvider + else -> "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt index 9b4b9d083..54b913e1c 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt @@ -129,6 +129,25 @@ class LegacyTokenStatsSnapshotTest { assertTrue(snapshot.providerModels.containsKey("Custom:gpt_4")) } + @Test + fun `registered provider decoding preserves provider and model underscores`() { + val raw = + legacyPreferences( + "token_input_My_Custom_gpt_4" to 10L, + "token_output_My_Custom_gpt_4" to 5L, + ) + + val snapshot = + LegacyTokenStatsSnapshot.parse( + rawPreferences = raw, + additionalProviderNames = listOf("My", "My_Custom"), + ) + + val stats = snapshot.providerModels.getValue("My_Custom:gpt_4") + assertEquals(10L, stats.inputTokens) + assertEquals(5L, stats.outputTokens) + } + @Test fun `zero prices are treated as unset`() { val raw = From f7e4b5eba6adf429f1e1decba74726e2b5bed544 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:11:49 +0800 Subject: [PATCH 11/28] fix(stats): stream usage and token totals (cherry picked from commit cc359bb8ff37bb8e0ad2a5b960c8d987036358a4) --- .../api/chat/llmprovider/DeepseekProvider.kt | 1 + .../operit/api/chat/llmprovider/KimiProvider.kt | 1 + .../api/chat/llmprovider/OpenAIProvider.kt | 12 ++++++++++++ .../operit/data/stats/TokenStatsAggregator.kt | 14 +++++++++++--- .../features/tokenstats/TokenStatsComponents.kt | 6 +++--- .../tokenstats/TokenStatsManagementViewModel.kt | 5 ++++- .../data/stats/TokenStatsAggregatorTest.kt | 17 +++++++++++++++++ 7 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index cab1037eb..a0df5931e 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -81,6 +81,7 @@ class DeepseekProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) + jsonObject.putStreamUsageOption(stream) // DeepSeek Thinking Mode 默认开启,关闭时也必须显式发送 thinking.type=disabled。 applyThinkingParamsIfNeeded(jsonObject) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt index 8cce902c2..613c2b742 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt @@ -72,6 +72,7 @@ open class KimiProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) + jsonObject.putStreamUsageOption(stream) applyThinkingParams(jsonObject) for (param in modelParameters) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 749e22e2a..86070c4be 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -633,6 +633,17 @@ open class OpenAIProvider( return jsonString.toByteArray(Charsets.UTF_8).toRequestBody(JSON) } + /** + * 流式 Chat Completions 请求体附加 usage 返回选项:OpenAI 只在显式请求时于 + * 末块返回 usage;Responses API 始终自带 usage(response.completed),不需要 + * 也不接受 stream_options。DeepSeek/Kimi 等自建请求体的子类必须复用本方法。 + */ + protected fun JSONObject.putStreamUsageOption(stream: Boolean) { + if (stream && !useResponsesApi) { + put("stream_options", JSONObject().put("include_usage", true)) + } + } + /** * 内部方法,用于构建请求体的JSON字符串,以便子类可以重用和扩展。 */ @@ -647,6 +658,7 @@ open class OpenAIProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) // 根据stream参数设置 + jsonObject.putStreamUsageOption(stream) // 添加已启用的模型参数 for (param in modelParameters) { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt index fc13ae2d8..bad069f73 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt @@ -104,7 +104,7 @@ object TokenStatsAggregator { cacheWrite.accept(event.cacheWriteTokens) totalInput.accept(event.totalInputTokens) output.accept(event.outputTokens) - reasoning.accept(event.reasoningTokens) + reasoning.accept(independentlyBilledReasoning(event)) val (amount, currency) = eventCost(event, identities[event.statIdentityId], pricing!!, params) if (amount == null) { @@ -252,7 +252,7 @@ object TokenStatsAggregator { cachedInput = sumKnownTokens(modelEvents) { it.cachedInputTokens }, cacheWrite = sumKnownTokens(modelEvents) { it.cacheWriteTokens }, output = sumKnownTokens(modelEvents) { it.outputTokens }, - reasoning = sumKnownTokens(modelEvents) { it.reasoningTokens }, + reasoning = sumKnownTokens(modelEvents, ::independentlyBilledReasoning), unknownTokenEventCount = modelEvents.count { it.uncachedInputTokens == null || it.cachedInputTokens == null || it.outputTokens == null @@ -358,7 +358,7 @@ object TokenStatsAggregator { cacheWrite = tokenAggregateOf(events) { it.cacheWriteTokens }, totalInput = tokenAggregateOf(events) { it.totalInputTokens }, output = tokenAggregateOf(events) { it.outputTokens }, - reasoning = tokenAggregateOf(events) { it.reasoningTokens }, + reasoning = tokenAggregateOf(events, ::independentlyBilledReasoning), cost = costSummaryOf(events, identitiesById, pricing, params), ) @@ -390,6 +390,14 @@ object TokenStatsAggregator { TokenCostCalculator.saturatedAdd(acc, value) } + /** + * 推理 token 只在该计费被输出计数排除([TokenStatEventEntity.reasoningIncludedInOutput] + * == false)时才独立累计;provider 输出已含推理(OpenAI/Gemini/Anthropic)时再相加 + * 会双重计数。与活动聚合([TokenActivityModels])口径一致。 + */ + private fun independentlyBilledReasoning(event: TokenStatEventEntity): Long? = + if (event.reasoningIncludedInOutput == false) event.reasoningTokens else null + // ==== 费用 ==== private fun costSummaryOf( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index eded97ee6..918c14050 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -429,7 +429,7 @@ private fun BaselineLine( } } -/** 已知 token 分量合计(uncached+cached+cacheWrite+output+reasoning,未知不算 0)。 */ +/** 已知 token 分量合计(uncached+cached+cacheWrite+output+独立计费 reasoning,未知不算 0)。 */ internal fun knownTokenSum( totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, ): Long = saturatedTokenSum( @@ -440,9 +440,9 @@ internal fun knownTokenSum( totals.reasoning.knownSum, ) -/** 旧累计值没有额外 token 分类,只能按输入、缓存输入和输出合计。 */ +/** 旧累计值没有额外 token 分类:inputTokens 已含缓存命中,只能按总输入和输出合计。 */ internal fun knownBaselineTokenSum(totals: TokenStatsBaselineTotals): Long = - saturatedTokenSum(totals.inputTokens, totals.cachedInputTokens, totals.outputTokens) + saturatedTokenSum(totals.inputTokens, totals.outputTokens) /** 生命周期总 Token 必须同时包含新事件与迁移的旧累计 baseline。 */ internal fun knownLifetimeTokenSum( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt index 38faaaf44..3c12a6ba6 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt @@ -17,6 +17,7 @@ import com.ai.assistance.operit.data.stats.LegacyPriceSettings import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager +import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -170,7 +171,9 @@ class TokenStatsManagementViewModel( private fun ModelConfigSummary.toTokenStatsOption() = TokenStatsConfigOption( id = id, name = name, - provider = apiProviderTypeId, + // ToolPkg 事件的 provider 记录为 displayName;未产生事件时也按同一名称解析, + // 否则价格覆盖保存后 TokenPriceResolver 按 displayName 查不到。 + provider = ToolPkgAiProviderRegistry.get(apiProviderTypeId)?.displayName ?: apiProviderTypeId, models = getModelList(modelName), endpoint = apiEndpoint, ) diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt index 46a98ca8e..c0e65ac0b 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt @@ -141,6 +141,23 @@ class TokenStatsAggregatorTest { params = params, ).summary + @Test + fun `reasoning aggregate counts only separately billed reasoning`() { + val id1 = identity("id-1") + val totals = + aggregated( + events = + listOf( + event("e1", "id-1", 1000L, reasoning = 30L, reasoningIncluded = true), + event("e2", "id-1", 2000L, reasoning = 20L, reasoningIncluded = false), + ), + identities = listOf(id1), + ) + // 输出已含推理的事件不再独立计入,仅独立计费的推理入账 + assertEquals(20L, totals.reasoning.knownSum) + assertEquals(1L, totals.reasoning.knownEventCount) + } + // ==== 生命周期 ==== @Test From 190b85615d08f0cb6dd5ee8a0676a22289dcb3f6 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:11:57 +0800 Subject: [PATCH 12/28] fix(stats): improve custom range picker feedback (cherry picked from commit b75a63a4fac2d2720c47f22da09e3233e80f8755) --- .../features/tokenstats/TokenStatsDialogs.kt | 108 +++++++++++++++--- .../tokenstats/TokenUsageStatisticsScreen.kt | 1 + app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values/strings.xml | 4 + 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt index b90f07e6b..62c75a279 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -50,6 +51,8 @@ import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft import java.time.Instant import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Locale // ==== 自定义时间范围(两步日期选择,设备时区自然日边界) ==== @@ -82,11 +85,13 @@ internal fun customRangeInclusiveEnd( @Composable internal fun CustomRangeDialog( zone: ZoneId, + maxRangeDays: Long, onConfirm: (startMs: Long, endMs: Long) -> Boolean, onDismiss: () -> Unit, ) { var step by remember { mutableIntStateOf(0) } var startDate by remember { mutableStateOf(null) } + var inlineError by remember { mutableStateOf(null) } // 步骤切换时重建 picker(rememberDatePickerState 只取首帧初始值); // DatePicker 的毫秒语义是“UTC 当日 0 点”,初始值同样按 UTC 日历生成。 @@ -98,6 +103,30 @@ internal fun CustomRangeDialog( ) } + val dateFormatter = + remember { + DateTimeFormatter + .ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault()) + } + + // 已选日期回显:开始日跨步骤保留;结束日实时显示当前日历选中项。 + // 每次选中变化即清除上次的错误提示(用户已尝试修正)。 + androidx.compose.runtime.LaunchedEffect(pickerState.selectedDateMillis) { + inlineError = null + } + + val notSelected = stringResource(R.string.token_stats_custom_range_not_selected) + val invalidRangeText = stringResource(R.string.token_stats_custom_range_invalid) + val rangeTooLongText = stringResource(R.string.token_stats_custom_range_too_long) + val startText = startDate?.format(dateFormatter) ?: notSelected + val endText = + if (step == 1) { + pickerState.selectedDateMillis?.let(::datePickerMillisToLocalDate)?.format(dateFormatter) + } else { + null + } ?: notSelected + val title = if (step == 0) { stringResource(R.string.token_stats_custom_range_pick_start) @@ -109,24 +138,31 @@ internal fun CustomRangeDialog( onDismissRequest = onDismiss, confirmButton = { TextButton( + enabled = pickerState.selectedDateMillis != null, onClick = { - val selected = pickerState.selectedDateMillis - if (selected != null) { - val date = datePickerMillisToLocalDate(selected) - if (step == 0) { - startDate = date - step = 1 - } else { - val start = startDate ?: return@TextButton - // 结束日包含当天:+1 天 0 点作为半开区间终点(P1-6), - // 同日合法;结束早于开始产生的非法边界由 onConfirm - // (VM 校验)拒绝并提示。 - val startMs = start.atStartOfDay(zone).toInstant().toEpochMilli() - val endMs = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - if (onConfirm(startMs, endMs)) { - onDismiss() - } + val selected = pickerState.selectedDateMillis ?: return@TextButton + val date = datePickerMillisToLocalDate(selected) + if (step == 0) { + startDate = date + inlineError = null + step = 1 + return@TextButton + } + val start = startDate ?: return@TextButton + // 结束日包含当天:+1 天 0 点作为半开区间终点(P1-6),同日合法; + // 非法边界在对话框内就地提示(不静默停留)。 + val startMs = start.atStartOfDay(zone).toInstant().toEpochMilli() + val endMs = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + inlineError = + when { + endMs <= startMs -> invalidRangeText + (endMs - startMs) > maxRangeDays * + com.ai.assistance.operit.data.stats.TokenStatsTimeRanges.DAY_MS -> + rangeTooLongText + else -> null } + if (inlineError == null && onConfirm(startMs, endMs)) { + onDismiss() } }, ) { @@ -135,11 +171,47 @@ internal fun CustomRangeDialog( }, dismissButton = { TextButton(onClick = { if (step == 0) onDismiss() else step = 0 }) { - Text(stringResource(R.string.settings_cancel)) + Text( + stringResource( + if (step == 0) R.string.settings_cancel + else R.string.token_stats_custom_range_back + ) + ) } }, ) { - DatePicker(state = pickerState) + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.token_stats_custom_range_summary_start, startText), + style = MaterialTheme.typography.bodySmall, + color = if (startDate == null) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + fontWeight = if (startDate != null) FontWeight.Medium else FontWeight.Normal, + ) + Text( + text = stringResource(R.string.token_stats_custom_range_summary_end, endText), + style = MaterialTheme.typography.bodySmall, + color = if (step == 1 && pickerState.selectedDateMillis != null) + MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (step == 1 && pickerState.selectedDateMillis != null) + FontWeight.Medium + else FontWeight.Normal, + ) + } + DatePicker(state = pickerState) + inlineError?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index f45aefafe..d28c6dd60 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -158,6 +158,7 @@ fun TokenUsageStatisticsScreen( if (showCustomRange) { CustomRangeDialog( zone = viewModel.zone, + maxRangeDays = TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS, onConfirm = { start, end -> viewModel.setCustomRange(start, end) }, onDismiss = { showCustomRange = false }, ) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9d8da265f..9b5ed7dc9 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3654,6 +3654,10 @@ OK End date must be after start date Custom range must not exceed 3 years + Start date: %1$s + End date: %1$s + Not selected + Back All models %1$d models All categories diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 57ddae149..c2de6b788 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3569,6 +3569,10 @@ 确定 结束日期需晚于开始日期 自定义范围不能超过 3 年 + 开始日期:%1$s + 结束日期:%1$s + 未选择 + 上一步 全部模型 %1$d 个模型 全部分类 From 4038b06d07fa751be977f6dbc9a1b2b1ca8cbb58 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:50:32 +0800 Subject: [PATCH 13/28] fix(stats): harden reliability and token totals (cherry picked from commit a9bd28753db57a6dbfb3bbf28aedd3131fd44344) --- .../chat/llmprovider/LocalGenerationEnd.kt | 18 +- .../llmprovider/ToolPkgJsAiProviderService.kt | 44 +++-- .../core/application/OperitApplication.kt | 13 +- .../operit/data/dao/TokenStatsDao.kt | 35 +++- .../operit/data/preferences/ApiPreferences.kt | 6 +- .../operit/data/stats/TokenActivityModels.kt | 95 ++++++++-- .../data/stats/TokenBaselineImportRunner.kt | 38 +++- .../operit/data/stats/TokenStatSpool.kt | 161 ++++++++++++++-- .../operit/data/stats/TokenStatsAggregator.kt | 7 + .../data/stats/TokenStatsQueryModels.kt | 11 ++ .../stats/TokenStatsStartupCoordinator.kt | 121 ++++++++++++ .../features/tokenstats/TokenStatsCharts.kt | 12 +- .../tokenstats/TokenStatsComponents.kt | 16 +- .../tokenstats/TokenUsageStatisticsScreen.kt | 27 ++- .../TokenUsageStatisticsViewModel.kt | 71 +++++++ .../llmprovider/LocalGenerationEndTest.kt | 29 ++- .../ToolPkgJsAiProviderServiceTest.kt | 78 ++++++++ .../data/stats/TokenActivityAggregatorTest.kt | 109 ++++++++++- .../stats/TokenBaselineImportRunnerTest.kt | 34 +++- .../data/stats/TokenCanonicalTotalsTest.kt | 116 ++++++++++++ .../data/stats/TokenStatReliabilityTest.kt | 94 ++++++++++ .../data/stats/TokenStatsAggregatorTest.kt | 72 ++++++++ .../data/stats/TokenStatsDeletionTest.kt | 36 ++++ .../stats/TokenStatsStartupCoordinatorTest.kt | 163 ++++++++++++++++ .../TokenStatsCanonicalTotalsTest.kt | 84 +++++++++ .../TokenUsageStatisticsViewModelTest.kt | 174 ++++++++++++++++++ 26 files changed, 1551 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt index e704fff3c..a123463a7 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt @@ -10,9 +10,11 @@ import com.ai.assistance.operit.util.exceptions.UserCancellationException * 顺序即契约,供两个 provider 共用并单独测试: * 1. **取消优先**:native 生成返回后,先判定 [cancelled]——取消时先上报已实测的 * usage,再抛 [UserCancellationException],**绝不**转换/emit 不完整的工具 XML; - * 2. 未取消才由 [emitToolResult] 处理工具缓冲(解析 + emit); - * 3. 成功路径上报 usage;失败路径([success] = false)由 [failWith] 处理 - * (保留用户可见错误文本并以失败异常终止),失败同样先上报 usage。 + * 2. **失败次之**:未取消但 [success] = false 时,先上报已实测 usage,再由 + * [failWith] 终止(保留用户可见错误文本并以失败异常结束),失败路径同样 + * **绝不**转换/emit 工具缓冲; + * 3. **成功最后**:仅成功路径([success] = true)由 [emitToolResult] 处理工具 + * 缓冲(解析 + emit),随后上报 usage。 * * 背景:旧实现先转换/emit 工具缓冲再检查 isCancelled,取消时会向调用方发出 * 半截工具 XML,下游可能按完整工具调用执行导致错误落账。 @@ -27,7 +29,7 @@ internal object LocalGenerationEnd { * @param source 来源标签(SOURCE_LLAMA / SOURCE_MNN)。 * @param cancelMessage 取消异常的用户可见消息。 * @param onUsageReported usage 上报回调(统计账本通道)。 - * @param emitToolResult 未取消时的工具缓冲处理(解析/转换/emit)。 + * @param emitToolResult 仅成功路径的工具缓冲处理(解析/转换/emit)。 * @param failWith 失败时的终止动作(错误文本 + 抛 IOException 等)。 */ suspend fun end( @@ -46,11 +48,15 @@ internal object LocalGenerationEnd { reportUsage(inputTokens, outputTokens, source, onUsageReported) throw UserCancellationException(cancelMessage) } - emitToolResult() - reportUsage(inputTokens, outputTokens, source, onUsageReported) if (!success) { + // 失败次之:不转换/emit 不完整的工具缓冲,先上报已实测 usage 再终止 + reportUsage(inputTokens, outputTokens, source, onUsageReported) failWith() + return } + // 成功最后:仅成功路径处理工具缓冲(解析 + emit),随后上报 usage + emitToolResult() + reportUsage(inputTokens, outputTokens, source, onUsageReported) } private suspend fun reportUsage( diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 3654f93e3..9500aa413 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -159,8 +159,11 @@ internal class ToolPkgJsAiProviderService( } ) - ensureNoFatalError(decoded) + // 最终结果先 apply/forward usage,再检查致命错误:失败结果里的 usage + // 不能丢(与 intermediate 同一通道、同一 attempt 合并语义);fatal + // 抛出后不会执行下方最终 chunk 发射,因此失败结果不产生最终文本。 applyAndForwardUsage(decoded, onTokensUpdated, onUsageReported) + ensureNoFatalError(decoded) extractNonFatalError(decoded)?.let { error -> onNonFatalError(error) } @@ -185,13 +188,18 @@ internal class ToolPkgJsAiProviderService( event = TOOLPKG_EVENT_AI_PROVIDER_TEST_CONNECTION, eventPayload = buildBasePayload(context), onIntermediateResult = { intermediateDecoded -> - forwardUsage(intermediateDecoded, onUsageReported) + extractUsage(intermediateDecoded)?.let { usage -> + forwardUsage(usage, onUsageReported) + } } ) } catch (e: kotlinx.coroutines.CancellationException) { throw e } - forwardUsage(decoded, onUsageReported) + // 失败结果里的 usage 同样先转发(不丢),再走致命检查 + extractUsage(decoded)?.let { usage -> + forwardUsage(usage, onUsageReported) + } return runCatching { ensureNoFatalError(decoded) parseConnectionMessage(decoded) @@ -606,28 +614,26 @@ internal class ToolPkgJsAiProviderService( currentCachedInputTokenCount, currentOutputTokenCount ) - forwardUsage(decoded, onUsageReported) + forwardUsage(usage, onUsageReported) } } - /** 只转发规范化 usage(testConnection 等无 UI 计数通道的场景共用)。 */ + /** 只转发规范化 usage(testConnection 等无 UI 计数通道的场景共用);接收已解析的 usage,避免重复解析。 */ private suspend fun forwardUsage( - decoded: ProviderHookValue, + usage: TokenUsage, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, ) { - extractUsage(decoded)?.let { usage -> - onUsageReported?.invoke( - com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.toolPkg( - input = usage.input, - cachedInput = usage.cachedInput, - output = usage.output, - // 协议语义:attempt 在场 = 同 attempt 部分更新;缺省 = 整个 - // 逻辑请求的累计完整快照 - completeSnapshot = !usage.attemptPresent, - ), - usage.attempt - ) - } + onUsageReported?.invoke( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.toolPkg( + input = usage.input, + cachedInput = usage.cachedInput, + output = usage.output, + // 协议语义:attempt 在场 = 同 attempt 部分更新;缺省 = 整个 + // 逻辑请求的累计完整快照 + completeSnapshot = !usage.attemptPresent, + ), + usage.attempt + ) } private fun applyUsage(usage: TokenUsage) { diff --git a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt index 1bc1c7e0e..a4a13b16b 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt @@ -47,6 +47,7 @@ import com.ai.assistance.operit.data.preferences.preferencesManager import com.ai.assistance.operit.data.repository.CustomEmojiRepository import com.ai.assistance.operit.data.stats.TokenBaselineImportRunner import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator import com.ai.assistance.operit.ui.features.chat.webview.LocalWebServer import com.ai.assistance.operit.ui.features.chat.webview.workspace.editor.language.LanguageFactory import com.ai.assistance.operit.util.GlobalExceptionHandler @@ -294,16 +295,16 @@ class OperitApplication : Application(), ImageLoaderFactory, WorkConfiguration.P AppLogger.d(TAG, "【启动计时】数据库预加载完成(异步) - ${System.currentTimeMillis() - dbStartTime}ms") } - // 旧 DataStore 累计统计 → baseline 幂等导入(一次性;已存在 baseline 冻结)。 - // 最后主动重放统计 spool(P1-2):生产冷启动不依赖新请求,失败段由退避 - // 定时重试持续恢复。 + // 启动统计 single-flight 初始化(P1 关键链路):旧 DataStore 累计统计 → baseline + // 幂等导入 → 等待 spool 初始 drain 完成,保证统计页首次查询看到重放完成后的 + // 数据。失败不缓存:统计页首次查询的 readiness 门控会自动重试(spool drain + // 另有退避重试)。 applicationScope.launch { val statsStartTime = System.currentTimeMillis() - TokenBaselineImportRunner.ensureMigrated(applicationContext) - TokenStatSpool.replay(applicationContext) + val ready = TokenStatsStartupCoordinator.awaitInitialized(applicationContext) AppLogger.d( TAG, - "【启动计时】旧累计统计 baseline 导入完成(异步) - " + + "【启动计时】旧累计统计 baseline 导入完成(异步,ready=$ready) - " + "${System.currentTimeMillis() - statsStartTime}ms" ) } diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index 59faad419..192eb1d7f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -55,7 +55,8 @@ abstract class TokenStatsDao { /** 活动热力图只读取所需列,避免把价格、诊断等完整事件字段整表实体化。 */ @Query( "SELECT startedAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + - "totalInputTokens, outputTokens, reasoningTokens, reasoningIncludedInOutput " + + "totalInputTokens, outputTokens, reasoningTokens, reasoningIncludedInOutput, " + + "cacheWriteSeparateBilling " + "FROM token_stat_events" ) abstract suspend fun getTokenActivityRows(): List @@ -683,6 +684,24 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_range_cutoffs") abstract suspend fun rangeCutoffs(): List + /** + * RANGE tombstone 覆盖检查(排空插入用):是否存在 generation **大于**事件 + * 接受 generation 且半开区间 [startMs, endMs) 包含事件 startedAtMs 的 + * tombstone。单条 EXISTS 短路查询,避免把 range cutoffs 全表 materialize + * 后在 JVM 侧遍历(行数 = 用户范围删除次数)。 + */ + @Query( + "SELECT EXISTS(" + + "SELECT 1 FROM token_stat_range_cutoffs " + + "WHERE generation > :acceptedGeneration " + + "AND startMs <= :startedAtMs AND endMs > :startedAtMs" + + ")" + ) + protected abstract suspend fun rangeCutoffCoversEvent( + acceptedGeneration: Long, + startedAtMs: Long, + ): Boolean + @Query("DELETE FROM token_stat_range_cutoffs") protected abstract suspend fun deleteAllRangeCutoffs() @@ -854,7 +873,8 @@ abstract class TokenStatsDao { * - FULL:全量删除/重置后不接受任何更早接受的事件; * - IDENTITY:按展示分组删除后不接受该身份更早接受的事件(精确到身份); * - MODEL:按 provider:model 重置后不接受该模型更早接受的事件; - * - RANGE:范围删除后不接受 startedAtMs 落在已删范围且更早接受的事件。 + * - RANGE:范围删除后不接受 startedAtMs 落在已删范围且更早接受的事件 + * (单条 EXISTS 短路,不整表 materialize range cutoffs)。 * 统一 generation 计数(两表 UNION)保证“接受于删除前”判断不依赖墙钟。 */ @Transaction @@ -877,14 +897,9 @@ abstract class TokenStatsDao { return false } } - for (cutoff in rangeCutoffs()) { - if (event.acceptedGeneration < cutoff.generation && - event.startedAtMs >= cutoff.startMs && - event.startedAtMs < cutoff.endMs - ) { - return false - } - } + // RANGE tombstone 用单条 EXISTS 判断(半开区间 [startMs, endMs), + // generation > acceptedGeneration 等价于 acceptedGeneration < generation)。 + if (rangeCutoffCoversEvent(event.acceptedGeneration, event.startedAtMs)) return false insertEvent(event) return true } diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index b4848676e..22894ff5d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -40,6 +40,9 @@ class ApiPreferences private constructor(private val context: Context) { @Volatile private var INSTANCE: ApiPreferences? = null + /** JVM tests can avoid initializing the application-scoped ToolPkg runtime. */ + internal var toolPkgProviderNamesProvider: (() -> List)? = null + fun getInstance(context: Context): ApiPreferences { return INSTANCE ?: synchronized(this) { val instance = ApiPreferences(context.applicationContext) @@ -1065,7 +1068,8 @@ class ApiPreferences private constructor(private val context: Context) { } private fun registeredToolPkgProviderNames(): List = - ToolPkgAiProviderRegistry.list().map { it.displayName } + toolPkgProviderNamesProvider?.invoke() + ?: ToolPkgAiProviderRegistry.list().map { it.displayName } /** 旧版累计 baseline 是否加入生命周期累计;缺省开启以保持升级前后的总计连续。 */ suspend fun getStatsIncludeLegacy(): Boolean { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt index 5c05019ee..f67bd04f7 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -1,5 +1,6 @@ package com.ai.assistance.operit.data.stats +import com.ai.assistance.operit.data.model.TokenStatEventEntity import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -22,6 +23,8 @@ data class TokenActivityEventRow( val outputTokens: Long?, val reasoningTokens: Long?, val reasoningIncludedInOutput: Boolean?, + /** null = 旧行未声明,按保守默认 true(独立计费)处理。 */ + val cacheWriteSeparateBilling: Boolean? = null, ) data class TokenActivityDay( @@ -56,17 +59,84 @@ data class TokenActivityYearData( val stats: TokenActivityStats, ) -internal fun TokenActivityEventRow.toActivityRecord(): TokenActivityRecord { - val input = totalInputTokens ?: listOf(uncachedInputTokens, cachedInputTokens, cacheWriteTokens) - .fold(0L) { total, value -> - if (value != null && value > 0L) saturatedAdd(total, value) else total +/** + * 逐事件 canonical token 总量推导(聚合器与活动热力图共用同一纯 helper)。 + * + * - 输入:权威 [totalInputTokens](provider 明确上报的总输入,含缓存命中/写入) + * 已知则直接使用;未知时按 [cacheWriteSeparateBilling] 决定 fallback: + * true(Anthropic:缓存写入独立计费,输入总量 = uncached + cached + cacheWrite, + * 漏加即漏算);false(OpenAI/Gemini/本地/ToolPkg:写入成本已包含在输入单价内, + * 输入总量 = uncached + cached,再加 cacheWrite 即重复)。null(旧行未声明) + * 按 true 保守默认,与费用重估([TokenCostCalculator])同一边界。 + * - 输出:outputTokens +([reasoningIncludedInOutput] == false 时的 reasoningTokens); + * 推理已包含在输出(true/null)时不再加,避免双重计数。 + * - 任一所必需分量未知(null)→ 整体 unknown(返回 null),绝不把 null 当作 0; + * 使用饱和加法([TokenCostCalculator.saturatedAdd]),Long 溢出钳制不回绕。 + * - 旧 baseline 无上述细分字段,只能按 input + output 合计(见 + * [com.ai.assistance.operit.ui.features.tokenstats.knownBaselineTokenSum])。 + */ +internal fun canonicalTotalTokens( + totalInputTokens: Long?, + uncachedInputTokens: Long?, + cachedInputTokens: Long?, + cacheWriteTokens: Long?, + cacheWriteSeparateBilling: Boolean?, + outputTokens: Long?, + reasoningTokens: Long?, + reasoningIncludedInOutput: Boolean?, +): Long? { + val input = + totalInputTokens ?: run { + val uncached = uncachedInputTokens ?: return null + val cached = cachedInputTokens ?: return null + val sum = TokenCostCalculator.saturatedAdd(uncached, cached) + if (cacheWriteSeparateBilling ?: true) { + val cacheWrite = cacheWriteTokens ?: return null + TokenCostCalculator.saturatedAdd(sum, cacheWrite) + } else { + sum + } } - var total = input.coerceAtLeast(0L) - outputTokens?.takeIf { it > 0L }?.let { total = saturatedAdd(total, it) } - if (reasoningIncludedInOutput == false) { - reasoningTokens?.takeIf { it > 0L }?.let { total = saturatedAdd(total, it) } - } - return TokenActivityRecord(startedAtMs = startedAtMs, tokens = total) + val output = outputTokens ?: return null + val billedOutput = + if (reasoningIncludedInOutput == false) { + val reasoning = reasoningTokens ?: return null + TokenCostCalculator.saturatedAdd(output, reasoning) + } else { + output + } + return TokenCostCalculator.saturatedAdd(input, billedOutput) +} + +/** [canonicalTotalTokens] 的事件实体重载(聚合器按事件列表逐条推导)。 */ +internal fun canonicalTotalTokens(event: TokenStatEventEntity): Long? = + canonicalTotalTokens( + totalInputTokens = event.totalInputTokens, + uncachedInputTokens = event.uncachedInputTokens, + cachedInputTokens = event.cachedInputTokens, + cacheWriteTokens = event.cacheWriteTokens, + cacheWriteSeparateBilling = event.cacheWriteSeparateBilling, + outputTokens = event.outputTokens, + reasoningTokens = event.reasoningTokens, + reasoningIncludedInOutput = event.reasoningIncludedInOutput, + ) + +internal fun TokenActivityEventRow.toActivityRecord(): TokenActivityRecord { + // 复用与聚合器相同的 canonical 推导;活动热力图只展示“已知 token 活动”, + // canonical 未知(必需分量缺失)的事件按 0 计,不假装精确——请求计数 + // 仍然准确(记录不因 0 被丢弃),未知明细由统计页的 unknown 计数表达。 + val tokens = + canonicalTotalTokens( + totalInputTokens = totalInputTokens, + uncachedInputTokens = uncachedInputTokens, + cachedInputTokens = cachedInputTokens, + cacheWriteTokens = cacheWriteTokens, + cacheWriteSeparateBilling = cacheWriteSeparateBilling, + outputTokens = outputTokens, + reasoningTokens = reasoningTokens, + reasoningIncludedInOutput = reasoningIncludedInOutput, + ) ?: 0L + return TokenActivityRecord(startedAtMs = startedAtMs, tokens = tokens) } object TokenActivityAggregator { @@ -186,9 +256,10 @@ object TokenActivityAggregator { run = if (day.tokens > 0L) run + 1 else 0 longest = maxOf(longest, run) } - var index = days.lastIndex - while (index >= 0 && days[index].tokens == 0L) index-- + // currentStreak 只看 days **尾部**:从最后一天起连续正值;尾日 0 则 0 + // (绝不跳过尾部零日——今天无活动就是断更,不能用更早的活跃日续算)。 var current = 0 + var index = days.lastIndex while (index >= 0 && days[index].tokens > 0L) { current++ index-- diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt index 86c2cdbca..b3af3a9cc 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt @@ -17,9 +17,17 @@ import kotlinx.coroutines.CancellationException * 普通导入([ensureMigrated]):计数指纹变化时(旧系统累计 setter 增长,或用户 * reset 后降低)用 baseline 行内**冻结价格**重估计数/成本,整体替换为快照绝对值; * 计数不变时普通价格 setter 不重估。普通导入**只更新快照中明确存在的模型**, - * 快照缺失的模型保持原样(不删除——偏好文件可能暂时缺失;显式删除 - * 走 [TokenStatsResetCoordinator] 的用户重置路径)。普通启动的空快照安全 no-op - * (见 [runImport] 的空快照守卫)。 + * 快照缺失的模型保持原样(不删除——偏好文件可能暂时缺失/部分恢复;显式删除 + * 走 [TokenStatsResetCoordinator] 的用户重置路径)。受控补导(恢复驱动):真实 + * 恢复后的偏好快照,在**同一 Room 事务**内以 forceReplace 语义整体重导(替换 + * 冻结价格),并把恢复 generation 写入幂等锚点表;相同 generation 已应用则跳过 + * (崩溃后重放安全)。只有该完整受控补导才按恢复快照处理缺失模型(删除其旧系统 + * baseline,见 [TokenBaselineMigrator]);恢复快照**为空同样合法**——旧系统 + * 从未有统计即无 legacy baseline 可留,受控补导仍执行并删除全部 legacy baseline + * (保留非空 configId 的 baseline)。普通启动的空快照则安全 no-op(见 + * [runImport] 的空快照守卫)。 + * 整库(databases/)与偏好(datastore/)恢复顺序由 RawSnapshot 流程保证 + * (datastore 先于 databases 覆盖,登记在全部替换成功后、recovery state 完成前)。 * * P1 闭环(legacy cleanup outbox fence):两个导入入口在读取快照**之前**先排空 * pending cleanup operation([TokenStatsResetCoordinator.drainPendingCleanupWith], @@ -40,22 +48,33 @@ object TokenBaselineImportRunner { internal var databaseProvider: ((Context) -> AppDatabase)? = null suspend fun ensureMigrated(context: Context) { + ensureMigratedStrict(context) + } + + /** Startup readiness entry point: false means this attempt must not be treated as ready. */ + internal suspend fun ensureMigratedStrict(context: Context): Boolean { try { - runImport(context.applicationContext, forceReplace = false) + return runImport(context.applicationContext, forceReplace = false) } catch (e: CancellationException) { // 取消必须向上传播,不能当作迁移失败吞掉 throw e } catch (e: Exception) { // 迁移失败不影响主流程;下次启动会重试(指纹与事务保证幂等)。 AppLogger.e(TAG, "旧累计统计导入失败(将在下次启动重试)", e) + return false } } // ==== 恢复生命周期:pending 标记 ==== + /** + * 冷启动消费 pending 标记:有标记才补导。无标记(含 Room-only 恢复)不动作。 + * 补导与 generation 记录在同一事务中;相同 generation 已应用则跳过。 + * 新 generation(不静默删除信号)。 + */ // ==== 导入 ==== - internal suspend fun runImport(appContext: Context, forceReplace: Boolean) { + internal suspend fun runImport(appContext: Context, forceReplace: Boolean): Boolean { val injected = databaseProvider val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) val dao = database.tokenStatsDao() @@ -67,8 +86,8 @@ object TokenBaselineImportRunner { // 普通启动守卫:空快照直接返回,不触碰数据库(取消/空源都安全,绝不删除)。 // 注意:受控补导(consumePendingLocked)不走此入口,空快照也以 // forceReplace 语义执行删除计划。 - if (read.snapshot.providerModels.isEmpty()) return - if (injected != null) { + if (read.snapshot.providerModels.isEmpty()) return true + return if (injected != null) { runImport(appContext, dao, read.snapshot, read.cleanupMarkerIds, forceReplace) } else { database.withTransaction { @@ -83,7 +102,7 @@ object TokenBaselineImportRunner { snapshot: LegacyTokenStatsSnapshot, cleanupMarkerIds: Set, forceReplace: Boolean, - ) { + ): Boolean { // 导入 fence(P1 闭环):Room 侧无 PENDING cleanup 且**全部** cleanup // operation ID 都包含在本快照的 marker 集合中,才允许应用该快照—— // 否则快照早于某次 legacy cleanup(或清理尚未排空),应用会复活已删除 @@ -93,7 +112,7 @@ object TokenBaselineImportRunner { TAG, "legacy cleanup 未排空或快照 marker 过期,跳过本次 baseline 导入(下次启动重试)" ) - return + return false } val existingBaselines = dao.getAllBaselines().associateBy { it.identityId } val existingIdentities = dao.getAllIdentities().associateBy { it.identityId } @@ -154,6 +173,7 @@ object TokenBaselineImportRunner { "旧累计统计导入完成: 导入 ${preserved.baselines.size} 个 baseline, " + "跳过 ${preserved.skippedProviderModels.size} 个无模型键" ) + return true } private fun ensureIdentity(providerModel: String): TokenStatIdentityEntity { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index 27b083fc1..06892c055 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -224,7 +224,27 @@ internal object TokenStatSpool { private val lifecycleMutex = Mutex() private val stateLock = Any() + + /** + * 调度令牌:有 worker 任务已入队/正在运行(用于入队去重)。快照屏障在递增 + * [sessionGeneration] 时同步清空,使旧 generation 的排队 worker 失效。 + */ private var drainScheduled = false + + /** + * 未消费的 drain 请求(丢失唤醒修复):每次 [scheduleDrain] 都在 [stateLock] 下 + * 置位;worker 每轮开始前消费,轮末在同一锁内决定 retire/立即 rerun/失败 backoff。 + * 请求在轮内到达时由同一 worker 接管,绝不依赖下一次外部触发;RejectedExecution + * 时请求保留、仅释放调度令牌(见 [scheduleDrain])。 + */ + private var drainRequested = false + + /** + * [awaitInitialDrain] 的等待者:worker 轮末决策点持 [stateLock] 统一完成并清空; + * 完成/失败都不保留——下次调用重新登记并触发新轮(失败不缓存,可重试)。 + */ + private val initialDrainWaiters = ArrayList>() + private var sessionGeneration = 0L private var retryDelayMs = RETRY_BACKOFF_BASE_MS private var writerExecutor = newWriterExecutor() @@ -293,6 +313,12 @@ internal object TokenStatSpool { */ internal var beforeSealPublishForTest: ((File) -> Boolean?)? = null + /** 测试注入缝:返回 true 时 [scheduleDrain] 的入队被模拟拒绝(RejectedExecution 状态恢复)。 */ + internal var rejectDrainScheduleForTest: Boolean = false + + /** 测试注入缝:每轮 drain(runBlocking 结束、轮末决策前)在 worker 线程调用。 */ + internal var afterDrainRoundForTest: (() -> Unit)? = null + /** 测试注入缝:返回 false 强制模拟硬链接不受支持(走 copy 回退发布);其余走真实 createLink。 */ internal var sealHardLinkForTest: ((File, File) -> Boolean?)? = null @@ -1617,49 +1643,139 @@ internal object TokenStatSpool { } } + /** + * 请求合并式 drain 调度(丢失唤醒修复):每次调用都在 [stateLock] 下置位 + * [drainRequested]——请求绝不丢失;仅当没有 worker 在跑/在队列([drainScheduled] + * 为 false)时才入队新任务。worker 每轮开始前消费请求,轮末在同一锁内决定 + * retire/立即 rerun/失败 backoff,请求在轮内到达时由同一 worker 接管。 + * + * RejectedExecution 恢复正确状态:请求保留(drainRequested=true,绝不丢),仅释放 + * 调度令牌(drainScheduled=false);下一次 schedule(append/replay/awaitInitialDrain) + * 会重建 executor(isShutdown 检查)并重新入队。 + */ private fun scheduleDrain(context: Context, delayMs: Long = 0L) { val generation: Long synchronized(stateLock) { - if (drainScheduled) return if (writerExecutor.isShutdown) writerExecutor = newWriterExecutor() + drainRequested = true + if (drainScheduled) return drainScheduled = true generation = sessionGeneration } try { + if (rejectDrainScheduleForTest) { + throw RejectedExecutionException("drain schedule rejected (injected)") + } val task = Runnable { runDrain(context, generation) } if (delayMs == 0L) writerExecutor.execute(task) else writerExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS) } catch (e: RejectedExecutionException) { synchronized(stateLock) { drainScheduled = false } - logE("statistics drain scheduling failed", e) + logE("statistics drain scheduling failed; request retained", e) } } + /** + * 每轮开始前消费 drain 请求(持 [stateLock])。返回 false 表示本轮无需运行: + * - 无请求:释放调度令牌并 retire; + * - 已被快照 generation 取代:不触碰任何标志——快照屏障已清 [drainScheduled], + * 新 generation 的请求由新 schedule 自行记账,旧 worker 绝不消费新请求。 + */ + private fun consumeDrainRequest(generation: Long): Boolean = synchronized(stateLock) { + when { + sessionGeneration != generation -> false + !drainRequested -> { + drainScheduled = false + false + } + else -> { + drainRequested = false + true + } + } + } + + /** + * 轮末决策(持 [stateLock],同一锁内原子完成等待者与状态转移): + * - generation 已变:快照屏障已接管(其 drain/替换处理了被等待的数据),retire + * 且不触碰标志;等待者按成功完成。 + * - 本轮失败:完成等待者(false),释放调度令牌并计算退避延迟,稍后重试。 + * - 成功且有新请求(轮内到达):完成等待者(true)后立即 rerun,绝不丢请求。 + * - 成功且无请求:完成等待者(true),释放调度令牌并 retire。 + */ private fun runDrain(context: Context, generation: Long) { - var success = false - try { - success = runBlocking { - lifecycleMutex.withLock { - if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true - drainCore(context, generation) + while (true) { + if (!consumeDrainRequest(generation)) return + var success = false + try { + success = runBlocking { + lifecycleMutex.withLock { + if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true + drainCore(context, generation) + } } + } catch (e: Throwable) { + logE("statistics spool drain failed", e) } - } catch (e: Throwable) { - logE("statistics spool drain failed", e) - } finally { - var retry = 0L + afterDrainRoundForTest?.invoke() + val retry: Long + val rerun: Boolean synchronized(stateLock) { - drainScheduled = false - if (sessionGeneration == generation) { - if (success) retryDelayMs = RETRY_BACKOFF_BASE_MS - else { - retry = retryDelayMs - retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) - } + if (sessionGeneration != generation) { + completeInitialDrainWaitersLocked(true) + return + } + completeInitialDrainWaitersLocked(success) + if (!success) { + drainScheduled = false + retry = retryDelayMs + retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) + rerun = false + } else if (drainRequested) { + retryDelayMs = RETRY_BACKOFF_BASE_MS + retry = 0L + rerun = true + } else { + retryDelayMs = RETRY_BACKOFF_BASE_MS + drainScheduled = false + retry = 0L + rerun = false } } + if (rerun) continue if (retry > 0L) scheduleDrain(context, retry) + return + } + } + + /** + * 可等待的初始 drain(P1 关键链路,启动 readiness 使用):请求一轮 drain(已有 + * worker 在跑则请求被保留并由同一 worker 接管),并挂起直到该轮结束。返回 true = + * 本轮成功(排空到轮内最后检查点,pre-replay 数据已入 Room);false = 本轮失败或 + * 超时——**不缓存**:后续调用重新登记并触发新轮(drain 自身另有退避重试)。 + * 并发调用 join 同一轮;不持 [stateLock] 挂起(等待者由 worker 轮末决策点完成)。 + */ + suspend fun awaitInitialDrain(context: Context, timeoutMs: Long): Boolean { + val appContext = context.applicationContext + val waiter = synchronized(stateLock) { + CompletableDeferred().also { initialDrainWaiters += it } + } + scheduleDrain(appContext) + return try { + withTimeoutOrNull(timeoutMs) { waiter.await() } ?: false + } finally { + synchronized(stateLock) { + initialDrainWaiters.removeAll { it === waiter } + } + } + } + + private fun completeInitialDrainWaitersLocked(success: Boolean) { + if (initialDrainWaiters.isEmpty()) return + initialDrainWaiters.forEach { waiter -> + if (waiter.isActive) waiter.complete(success) } + initialDrainWaiters.clear() } /** Called with lifecycleMutex held. */ @@ -3484,6 +3600,10 @@ internal object TokenStatSpool { internal fun clearPendingStateForTest() = synchronized(stateLock) { sessionGeneration += 1L drainScheduled = false + drainRequested = false + // 未完成的初始 drain 等待者按失败完成(进程重启语义;测试内不应依赖旧轮) + initialDrainWaiters.forEach { if (it.isActive) it.complete(false) } + initialDrainWaiters.clear() retryDelayMs = RETRY_BACKOFF_BASE_MS // P1 终审:逐测试复位 restore fencing 状态(进程内标记绝不跨测试泄漏) restoreEpoch = 0L @@ -3496,6 +3616,9 @@ internal object TokenStatSpool { internal fun emergencyQueueSizeForTest(): Int = 0 internal fun pendingLatchCountForTest(): Int = synchronized(stateLock) { insertionWaiters.size } internal fun activeInsertCountForTest(): Int = synchronized(stateLock) { activeInserts.size } + internal fun drainRequestPendingForTest(): Boolean = synchronized(stateLock) { drainRequested } + internal fun drainScheduledForTest(): Boolean = synchronized(stateLock) { drainScheduled } + internal fun initialDrainWaiterCountForTest(): Int = synchronized(stateLock) { initialDrainWaiters.size } private fun logE(message: String, error: Throwable? = null) { try { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt index bad069f73..d5d9692ef 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt @@ -87,6 +87,7 @@ object TokenStatsAggregator { private val totalInput = TokenComponentAccumulator() private val output = TokenComponentAccumulator() private val reasoning = TokenComponentAccumulator() + private val totalTokens = TokenComponentAccumulator() private val originalCosts = EnumMap(PricingCurrency::class.java) private var costUnknownCount = 0L @@ -105,6 +106,7 @@ object TokenStatsAggregator { totalInput.accept(event.totalInputTokens) output.accept(event.outputTokens) reasoning.accept(independentlyBilledReasoning(event)) + totalTokens.accept(canonicalTotalTokens(event)) val (amount, currency) = eventCost(event, identities[event.statIdentityId], pricing!!, params) if (amount == null) { @@ -124,6 +126,7 @@ object TokenStatsAggregator { totalInput = totalInput.aggregate(requests), output = output.aggregate(requests), reasoning = reasoning.aggregate(requests), + totalTokens = totalTokens.aggregate(requests), cost = buildCostSummary( original = originalCosts, @@ -253,6 +256,9 @@ object TokenStatsAggregator { cacheWrite = sumKnownTokens(modelEvents) { it.cacheWriteTokens }, output = sumKnownTokens(modelEvents) { it.outputTokens }, reasoning = sumKnownTokens(modelEvents, ::independentlyBilledReasoning), + totalTokens = sumKnownTokens(modelEvents, ::canonicalTotalTokens), + totalTokensUnknownEventCount = + modelEvents.count { canonicalTotalTokens(it) == null }.toLong(), unknownTokenEventCount = modelEvents.count { it.uncachedInputTokens == null || it.cachedInputTokens == null || it.outputTokens == null @@ -359,6 +365,7 @@ object TokenStatsAggregator { totalInput = tokenAggregateOf(events) { it.totalInputTokens }, output = tokenAggregateOf(events) { it.outputTokens }, reasoning = tokenAggregateOf(events, ::independentlyBilledReasoning), + totalTokens = tokenAggregateOf(events, ::canonicalTotalTokens), cost = costSummaryOf(events, identitiesById, pricing, params), ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt index d9f58b177..605782c68 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -133,6 +133,13 @@ data class TokenStatsTotals( val totalInput: TokenStatsTokenAggregate, val output: TokenStatsTokenAggregate, val reasoning: TokenStatsTokenAggregate, + /** + * canonical 总 token(逐事件 [canonicalTotalTokens] 推导:权威 totalInputTokens + * 优先,fallback 按 cacheWriteSeparateBilling 决定输入口径,输出按 + * reasoningIncludedInOutput 决定是否补推理;未知分量保持 unknown)。 + * 是 UI 展示总 Token 的唯一事实来源,Compose 不得再从原始聚合字段重组。 + */ + val totalTokens: TokenStatsTokenAggregate, val cost: TokenStatsCostSummary, ) @@ -175,6 +182,10 @@ data class TokenStatsModelBucket( val cacheWrite: Long, val output: Long, val reasoning: Long, + /** canonical 总 token 已知和(逐事件推导,口径见 [TokenStatsTotals.totalTokens])。 */ + val totalTokens: Long, + /** canonical 总 token 未知的事件数(不把 null 当 0)。 */ + val totalTokensUnknownEventCount: Long, /** 任一核心 token 分量(uncached/cached/output)未知的事件数。 */ val unknownTokenEventCount: Long, val cost: TokenStatsCostSummary, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt new file mode 100644 index 000000000..ef33e9280 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt @@ -0,0 +1,121 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** + * 启动统计 readiness 门控(P1 关键链路)。测试可注入 no-op 或门控实现(见 + * [TokenUsageStatisticsViewModel] 的构造参数)。 + */ +fun interface TokenStatsReadiness { + /** + * 等待初始统计就绪(旧 baseline 导入 + spool 初始重放完成)。返回 true = 就绪; + * false = 超时或失败(调用方可稍后重试,就绪状态不缓存)。 + */ + suspend fun awaitReady(timeoutMs: Long): Boolean +} + +/** + * 启动统计 single-flight 初始化(P1 关键链路):依次 + * [TokenBaselineImportRunner.ensureMigrated] → [TokenBaselineImportRunner.consumePendingRestore] + * → [TokenStatSpool.awaitInitialDrain],保证统计页首次查询看到的是重放完成后的数据, + * 绝不无限展示 pre-replay 快照。 + * + * - **single-flight**:并发调用 join 同一轮初始化([inFlight] 引用只在锁内读写, + * 初始化执行本身在锁外——绝不在持有任何锁时执行 DAO/DataStore/spool 工作)。 + * - **失败不永久缓存**:一轮失败/超时后 [inFlight] 不保留,下一次调用重新执行; + * spool drain 自身另有退避重试。 + * - **无反向依赖**:本协调器只从外部调用各步骤,spool 内部/DAO 事务绝不反向 await + * 本协调器(无死锁环)。 + */ +object TokenStatsStartupCoordinator { + + private const val TAG = "TokenStatsStartupCoordinator" + + /** baseline、pending restore 与初始 drain 共用的端到端初始化预算。 */ + internal const val INITIALIZATION_TIMEOUT_MS = 60_000L + + /** [awaitInitialized] 默认等待上限。 */ + private const val DEFAULT_AWAIT_TIMEOUT_MS = 10_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 单飞 bookkeeping:只保护 [inFlight] 引用,绝不持锁执行初始化(避免反向死锁)。 */ + private val singleFlight = Any() + + /** 进行中的初始化轮;完成后不保留(失败/成功都不缓存,下次调用重新执行)。 */ + private var inFlight: kotlinx.coroutines.Deferred? = null + + // 测试注入缝:生产代码始终为 null,走真实实现。 + internal var ensureMigratedStep: (suspend (Context) -> Boolean)? = null + internal var consumePendingRestoreStep: (suspend (Context) -> Boolean)? = null + internal var initialDrainStep: (suspend (Context, Long) -> Boolean)? = null + internal var initializationTimeoutMsForTest: Long? = null + + /** + * 等待启动统计就绪(single-flight):首次调用触发初始化,并发调用 join 同一轮。 + * 返回 true = 本轮初始化成功(含幂等重放);false = 超时或失败——**不缓存**, + * 后续调用重新执行(失败的 spool drain 有退避重试,成功后即可重试成功)。 + */ + suspend fun awaitInitialized( + context: Context, + timeoutMs: Long = DEFAULT_AWAIT_TIMEOUT_MS, + ): Boolean { + val appContext = context.applicationContext + val job = synchronized(singleFlight) { + inFlight?.takeIf { it.isActive } + ?: scope.async { + val budgetMs = initializationTimeoutMsForTest ?: INITIALIZATION_TIMEOUT_MS + val deadlineNanos = + System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs) + withTimeoutOrNull(budgetMs) { + runInitialization(appContext, deadlineNanos) + } ?: false + }.also { inFlight = it } + } + // 超时只停止本次等待(初始化继续,幂等;调用方取消则向上传播)。 + return withTimeoutOrNull(timeoutMs) { job.await() } ?: false + } + + /** 生产 readiness(绑定 applicationContext;测试可注入 no-op 或门控实现)。 */ + fun readiness(context: Context): TokenStatsReadiness = + TokenStatsReadiness { timeoutMs -> awaitInitialized(context, timeoutMs) } + + private suspend fun runInitialization(context: Context, deadlineNanos: Long): Boolean { + return try { + val migrated = ensureMigratedStep + val migrationReady = + if (migrated != null) migrated(context) + else TokenBaselineImportRunner.ensureMigratedStrict(context) + if (!migrationReady) return false + val restore = consumePendingRestoreStep + val restoreReady = + if (restore != null) restore(context) + else TokenBaselineImportRunner.consumePendingRestoreStrict(context) + if (!restoreReady) return false + val remainingMs = + TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()) + if (remainingMs <= 0L) return false + val drain = initialDrainStep + if (drain != null) { + drain(context, remainingMs) + } else { + TokenStatSpool.awaitInitialDrain(context, remainingMs) + } + } catch (e: CancellationException) { + // 取消必须向上传播(scope.async 的任务被外部取消时正常清理) + throw e + } catch (e: Exception) { + // 失败不缓存:记日志并返回 false,下一次调用重新执行 + runCatching { AppLogger.e(TAG, "启动统计初始化失败(不缓存,可重试)", e) } + false + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt index b56dc3201..1f78d050f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt @@ -85,6 +85,14 @@ internal fun TokenStatsStackedBarChart( chartLabel: String = "", stackSelector: (TokenStatsTrendBucket) -> List>, stackLabels: (TokenStatsTrendBucket) -> List, + /** + * tooltip/无障碍里的“合计”数值。默认 = 堆叠分量之和(诊断口径);调用方可 + * 传入 canonical 合计(如 [com.ai.assistance.operit.data.stats.TokenStatsTotals.totalTokens]) + * 使展示总 Token 与聚合器口径一致,堆叠分量仍作为诊断明细展示。 + */ + stackTotalSelector: (TokenStatsTrendBucket) -> Double = { bucket -> + stackSelector(bucket).sumOf { it.first } + }, unknownNote: (TokenStatsTrendBucket) -> String? = { null }, legendItems: List> = emptyList(), ) { @@ -130,7 +138,7 @@ internal fun TokenStatsStackedBarChart( chartLabel, bucketTimeLabel(selected.bucketStartMs, granularity, zone), positionText, - formatValue(stacks.sumOf { it.first }), + formatValue(stackTotalSelector(selected)), ) val rows = stacks.mapIndexedNotNull { index, (value, color) -> val label = stackLabels(selected).getOrNull(index) ?: "" @@ -226,7 +234,7 @@ internal fun TokenStatsStackedBarChart( null } }, - total = formatValue(stacks.sumOf { it.first }), + total = formatValue(stackTotalSelector(selected)), unknownNote = unknownNote(selected), ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index 918c14050..f5b484a14 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -429,16 +429,16 @@ private fun BaselineLine( } } -/** 已知 token 分量合计(uncached+cached+cacheWrite+output+独立计费 reasoning,未知不算 0)。 */ +/** + * 事件 canonical 总 Token(聚合器逐事件推导,口径见 + * [com.ai.assistance.operit.data.stats.TokenStatsTotals.totalTokens]: + * 权威 totalInputTokens 优先,fallback 按 cacheWriteSeparateBilling 决定输入口径, + * 输出按 reasoningIncludedInOutput 决定是否补推理;未知分量保持 unknown 不当作 0)。 + * UI 展示总 Token 的唯一事实来源,不再从原始聚合字段自行重组。 + */ internal fun knownTokenSum( totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, -): Long = saturatedTokenSum( - totals.uncachedInput.knownSum, - totals.cachedInput.knownSum, - totals.cacheWrite.knownSum, - totals.output.knownSum, - totals.reasoning.knownSum, - ) +): Long = totals.totalTokens.knownSum /** 旧累计值没有额外 token 分类:inputTokens 已含缓存命中,只能按总输入和输出合计。 */ internal fun knownBaselineTokenSum(totals: TokenStatsBaselineTotals): Long = diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index d28c6dd60..cfd6e4226 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -708,6 +708,11 @@ private fun TokenStatsChartDetailDialog( } } ChartDetailMetric.TOKENS -> { + // canonical 总 Token 为权威合计;缓存/非缓存/输出仍是诊断分量 + TokenStatsDetailRow( + stringResource(R.string.token_stats_tokens_total), + formatCount(range.summary.totalTokens.knownSum), + ) TokenStatsDetailRow( stringResource(R.string.token_stats_token_cached), formatCount(range.summary.cachedInput.knownSum), @@ -843,20 +848,12 @@ private fun TokenChartCard( val unknownPartsTemplate = stringResource(R.string.token_stats_unknown_parts) val chartTitle = stringResource(R.string.token_stats_chart_tokens) - val totalUnknown = - range.summary.uncachedInput.unknownEventCount + - range.summary.cachedInput.unknownEventCount + - range.summary.output.unknownEventCount + val totalUnknown = range.summary.totalTokens.unknownEventCount TokenStatsChartCard( title = chartTitle, - summary = formatCompactCount( - saturatedTokenSum( - range.summary.uncachedInput.knownSum, - range.summary.cachedInput.knownSum, - range.summary.output.knownSum, - ) - ), + // canonical 总 Token(聚合器逐事件推导,口径与 headline/detail 一致) + summary = formatCompactCount(range.summary.totalTokens.knownSum), onSummaryClick = onSummaryClick, ) { if (totalUnknown > 0L) { @@ -879,11 +876,11 @@ private fun TokenChartCard( stackLabels = { listOf(outputLabel, uncachedLabel, cachedLabel) }, + // 堆叠分量是诊断明细(可能因 provider 口径不完全等于总量), + // tooltip/无障碍合计必须用 canonical 总 Token + stackTotalSelector = { bucket -> bucket.totals.totalTokens.knownSum.toDouble() }, unknownNote = { bucket -> - val unknown = - bucket.totals.uncachedInput.unknownEventCount + - bucket.totals.cachedInput.unknownEventCount + - bucket.totals.output.unknownEventCount + val unknown = bucket.totals.totalTokens.unknownEventCount if (unknown > 0L) String.format(unknownPartsTemplate, unknown) else null }, legendItems = listOf( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index 0ca263f6d..c071bc912 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -33,7 +33,9 @@ import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.data.stats.TokenStatsReadiness import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator +import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator import com.ai.assistance.operit.util.AppLogger import java.time.ZoneId import kotlinx.coroutines.CancellationException @@ -150,6 +152,14 @@ class TokenUsageStatisticsViewModel( * TestMainDispatcher 冲突);生产默认 = Main.immediate(与 viewModelScope 一致)。 */ private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, + /** + * 启动统计 readiness 门控(P1 关键链路):首次数据查询等待初始 spool 重放完成, + * 避免无限展示 pre-replay 快照。测试注入 no-op 或门控实现;生产默认绑定 + * [TokenStatsStartupCoordinator](single-flight,失败不缓存可重试)。 + */ + private val readiness: TokenStatsReadiness = TokenStatsStartupCoordinator.readiness(context), + private val readinessInitialWaitMs: Long = READINESS_WAIT_MS, + private val readinessRefreshWaitMs: Long = READINESS_REFRESH_WAIT_MS, ) : ViewModel() { // 只保存 applicationContext(进程级单例,无泄漏风险;与 CustomEmojiViewModel 同模式) @@ -176,6 +186,18 @@ class TokenUsageStatisticsViewModel( private var activityLoadJob: Job? = null private var activityLoadGeneration = 0 + /** + * 首次查询 readiness 门控(P1 关键链路):单 VM 生命周期只等待一次。置位先于等待, + * 即使等待被 loadJob 取消也不会重复等待(取消时另行安排后台刷新兜底)。 + */ + private var readinessGateDone = false + + /** Once true, all later loads can skip readiness retry scheduling. */ + private var readinessReady = false + + /** 首次 readiness 未就绪时安排的后台“就绪后自动刷新”任务(独立于 loadJob)。 */ + private var readinessRefreshJob: Job? = null + /** 已知展示模型 id → 最近一次查询所见名称(P1-5,永不清除,只增补)。 */ private val knownModelNames = mutableMapOf() @@ -277,6 +299,24 @@ class TokenUsageStatisticsViewModel( val filterSnapshot = _state.value loadJob = viewModelScope.launch(dispatcher) { try { + // 首次数据查询 readiness 门控(P1 关键链路):等待初始 spool 重放完成, + // 首次渲染即包含 pre-replay 事件。等待有界——超时/失败先按现状查询, + // 并由 [scheduleRefreshAfterReadiness] 在就绪后自动刷新,绝不无限展示 + // pre-replay 快照。 + if (!readinessGateDone) { + readinessGateDone = true + val ready = try { + readiness.awaitReady(readinessInitialWaitMs) + } catch (e: CancellationException) { + // loadJob 被新操作取消:数据可能仍是 pre-replay 快照,后台兜底刷新 + scheduleRefreshAfterReadiness() + throw e + } + readinessReady = ready + if (!ready) scheduleRefreshAfterReadiness() + } else if (!readinessReady) { + scheduleRefreshAfterReadiness() + } // 偏好全部读取为不可变本地快照:任何 _state.update 之前先核对 // generation,旧 load 即使恢复也不污染共享 state(P1-4)。 val rateInfo = settings.loadRateWithEstimate() @@ -427,6 +467,29 @@ class TokenUsageStatisticsViewModel( models.forEach { knownModelNames[it.displayModelId] = it.displayName } } + /** + * 首次 readiness 未就绪(超时/失败)时的兜底(P1 关键链路):后台等待就绪(含 + * 协调器后续重试成功),完成后触发一次 [load] 自动刷新,绝不无限展示 pre-replay + * 快照。独立于 loadJob(不被用户操作取消);同一时刻只调度一次。等待 coordinator + * 的完整有界生命周期;失败后由下一次 load/进入页面重新尝试。 + */ + private fun scheduleRefreshAfterReadiness() { + if (readinessRefreshJob?.isActive == true) return + readinessRefreshJob = viewModelScope.launch(dispatcher) { + try { + val ready = readiness.awaitReady(readinessRefreshWaitMs) + if (ready) { + readinessReady = true + load() + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + runCatching { AppLogger.e(tag, "统计就绪后自动刷新失败", e) } + } + } + } + // ==== 时间选择 ==== /** 用户手动选择预设:持久化(manual=true)后锁定(不再自动回退)。 */ @@ -797,6 +860,14 @@ class TokenUsageStatisticsViewModel( companion object { /** 自定义范围时长上限(天):与聚合器 10k 桶上限留出余量。 */ const val MAX_CUSTOM_RANGE_DAYS = 3 * 366L + + /** 首次查询等待 readiness 的时长上限;超时先按现状查询并安排就绪后自动刷新。 */ + private const val READINESS_WAIT_MS = 5_000L + + /** Covers a newly started end-to-end coordinator flight without racing its boundary. */ + private const val READINESS_REFRESH_WAIT_MS = + TokenStatsStartupCoordinator.INITIALIZATION_TIMEOUT_MS + READINESS_WAIT_MS + } } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt index bb21f9438..30432ae70 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt @@ -14,7 +14,8 @@ import org.junit.Test /** * 本地 provider 生成结束顺序契约测试(评审 P2-3): * 取消必须在工具缓冲转换/emit 之前判定——取消时无工具结果 emit、 - * 已实测 usage 保留、CANCELLED(UserCancellationException)传播。 + * 已实测 usage 保留、CANCELLED(UserCancellationException)传播; + * 失败路径同样绝不 emit 工具缓冲(先上报 usage 再 failWith)。 */ class LocalGenerationEndTest { @@ -71,7 +72,7 @@ class LocalGenerationEndTest { } @Test - fun `failure emits tool result reports usage then fails`() = runBlocking { + fun `failure reports usage then fails without emitting tool result`() = runBlocking { val usageReports = mutableListOf() var toolEmitted = false try { @@ -90,10 +91,32 @@ class LocalGenerationEndTest { } catch (e: IOException) { assertEquals("inference failed", e.message) } - assertTrue(toolEmitted) + // 失败路径绝不转换/emit 不完整的工具 XML + assertFalse("tool buffer must not be emitted on failure", toolEmitted) // 失败前已实测 usage 必须落账 assertEquals(1, usageReports.size) assertEquals(200L, usageReports[0].uncachedInputTokens) assertEquals(5L, usageReports[0].outputTokens) } + + @Test + fun `failure never emits tool result even if failWith returns normally`() = runBlocking { + val usageReports = mutableListOf() + var toolEmitted = false + LocalGenerationEnd.end( + cancelled = false, + success = false, + inputTokens = 80, + outputTokens = 2, + source = ProviderUsageNormalizer.SOURCE_MNN, + cancelMessage = "cancelled", + onUsageReported = { usage, _ -> usageReports.add(usage) }, + emitToolResult = { toolEmitted = true }, + failWith = {}, + ) + // failWith 正常返回(未抛异常)时,失败路径也必须就此结束,绝不落入 emit + assertFalse("tool buffer must never be emitted on failure", toolEmitted) + assertEquals(1, usageReports.size) + assertEquals(80L, usageReports[0].uncachedInputTokens) + } } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt index 9ee8486f8..d2d759e0f 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.api.chat.llmprovider import android.content.Context import com.ai.assistance.operit.data.model.ModelConfigData import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistration +import com.ai.assistance.operit.util.stream.StreamLogger import kotlinx.coroutines.runBlocking import org.json.JSONObject import org.junit.Assert.assertEquals @@ -81,6 +82,33 @@ class ToolPkgJsAiProviderServiceTest { collected.toString() } + /** 期望失败的 sendMessage 运行:返回已收集文本与 collect 传播出的异常。 */ + private fun runSendMessageExpectFailure( + svc: ToolPkgJsAiProviderService, + runner: ToolPkgMainHookRunner, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, + ): Pair = runBlocking { + svc.mainHookRunnerOverride = runner + val collected = StringBuilder() + // JVM 测试环境没有可用的 android.util.Log:stream 构建器捕获异常后 + // StreamLogger.e → AppLogger.e → Log.e 会抛 "not mocked" 掩盖原始错误, + // 关闭日志使真实异常原样传播出来 + StreamLogger.setEnabled(false) + val failure = + try { + svc.sendMessage( + context = mock(Context::class.java), + onUsageReported = onUsageReported, + ).collect { collected.append(it) } + null + } catch (e: Throwable) { + e + } finally { + StreamLogger.setEnabled(true) + } + collected.toString() to failure + } + @Test fun `usage protocol carries attempt number from usage object`() { val svc = service() @@ -357,6 +385,56 @@ class ToolPkgJsAiProviderServiceTest { assertEquals(60L, aggregated.outputTokens) } + // ==== 聚焦修复:final 致命失败结果 ==== + + @Test + fun `final failure with usage reports usage once, propagates error, and emits no final text`() { + val svc = service() + val reports = mutableListOf() + val (text, failure) = + runSendMessageExpectFailure( + svc, + runnerWith( + intermediates = emptyList(), + final = + """{"usage": {"input": 80, "output": 9, "attempt": 1}, "success": false, "error": "denied"}""", + ), + ) { usage, attempt -> + reports.add(report(usage, attempt)) + } + // 致命错误必须传播(stream collect 抛出,不吞成空结果) + assertTrue( + "fatal result must propagate error, failure=$failure text=<$text>", + failure is IllegalStateException, + ) + assertEquals("denied", failure?.message) + // 最终失败结果里的 usage 先于致命检查被转发,且只解析/上报一次 + assertEquals(1, reports.size) + assertEquals(80L, reports[0].input) + assertEquals(9L, reports[0].output) + assertEquals(1, reports[0].attempt) + // fatal 结果不得发射最终文本 + assertEquals("", text) + } + + @Test + fun `final failure without usage emits no final text and propagates error`() { + val svc = service() + val (text, failure) = + runSendMessageExpectFailure( + svc, + runnerWith( + intermediates = emptyList(), + final = """{"success": false, "error": "boom"}""", + ), + ) { _, _ -> + error("usage callback must not fire without usage") + } + assertTrue("fatal result must propagate error, failure=$failure text=<$text>", failure is IllegalStateException) + assertEquals("boom", failure?.message) + assertEquals("", text) + } + // ==== 评审 P1-7:testConnection 的 usage 提取与 attempt 转发 ==== private fun runTestConnection( diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt index a4d7cfa85..5a549268e 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt @@ -30,9 +30,52 @@ class TokenActivityAggregatorTest { reasoningTokens = 30L, reasoningIncludedInOutput = false, ) + // OpenAI 非独立计费:cacheWrite 已含在输入内,不得重复计入 + val openai = TokenActivityEventRow( + startedAtMs = 3L, + uncachedInputTokens = 50L, + cachedInputTokens = 20L, + cacheWriteTokens = 10L, + totalInputTokens = null, + outputTokens = 40L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = false, + ) assertEquals(140L, included.toActivityRecord().tokens) assertEquals(150L, separate.toActivityRecord().tokens) + assertEquals(110L, openai.toActivityRecord().tokens) // (50+20)+40,不含 cacheWrite + } + + @Test + fun `activity row with unknown canonical total maps to zero tokens`() { + // 输入未知(无权威 totalInput 且拆分缺失)→ canonical 总 token 未知, + // 热力图按 0 展示(不把 null 当 0),请求计数仍由记录本身承载 + val unknownInput = TokenActivityEventRow( + startedAtMs = 1L, + uncachedInputTokens = null, + cachedInputTokens = null, + cacheWriteTokens = null, + totalInputTokens = null, + outputTokens = 40L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + ) + // 独立计费下 cacheWrite 未知 → 输入 unknown + val unknownCacheWrite = TokenActivityEventRow( + startedAtMs = 2L, + uncachedInputTokens = 50L, + cachedInputTokens = 20L, + cacheWriteTokens = null, + totalInputTokens = null, + outputTokens = 40L, + reasoningTokens = null, + reasoningIncludedInOutput = true, + cacheWriteSeparateBilling = true, + ) + assertEquals(0L, unknownInput.toActivityRecord().tokens) + assertEquals(0L, unknownCacheWrite.toActivityRecord().tokens) } @Test @@ -69,7 +112,8 @@ class TokenActivityAggregatorTest { records = records, zone = zone, year = 2026, - nowMs = LocalDate.of(2026, 1, 8).atStartOfDay(zone).toInstant().toEpochMilli(), + // 尾日 01-05 有活动:currentStreak 从尾部连续正值(01-05/01-04) + nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), ) assertEquals(100L, result.stats.totalTokens) @@ -79,6 +123,69 @@ class TokenActivityAggregatorTest { assertEquals(100L, result.cumulative.last().tokens) } + @Test + fun `current streak counts only consecutive positive days at the tail`() { + // 尾部连续正值只有 01-04/01-05 两天;01-03 的 0 断开更早的 01-01/01-02 + val records = listOf( + record("2026-01-01", 10), + record("2026-01-02", 20), + record("2026-01-03", 0), + record("2026-01-04", 30), + record("2026-01-05", 40), + ) + + val result = TokenActivityAggregator.yearData( + records = records, + zone = zone, + year = 2026, + nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), + ) + + assertEquals(2, result.stats.currentStreak) + assertEquals(2, result.stats.longestStreak) + } + + @Test + fun `current streak is zero when the tail day has no activity`() { + // 尾日 01-05 无活动(01-03/01-04 同样为空)→ currentStreak 必须为 0 + val records = listOf( + record("2026-01-01", 10), + record("2026-01-02", 20), + ) + + val result = TokenActivityAggregator.yearData( + records = records, + zone = zone, + year = 2026, + nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), + ) + + assertEquals(0, result.stats.currentStreak) + // longest 只看全区间最长连续段,不受尾部影响 + assertEquals(2, result.stats.longestStreak) + } + + @Test + fun `longest streak is independent of the tail and reflects the longest run anywhere`() { + // 中部连续 3 天(01-01..01-03),尾部仅 01-05 一天(01-04 为 0 断开) + val records = listOf( + record("2026-01-01", 10), + record("2026-01-02", 10), + record("2026-01-03", 10), + record("2026-01-05", 20), + ) + + val result = TokenActivityAggregator.yearData( + records = records, + zone = zone, + year = 2026, + nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), + ) + + assertEquals(1, result.stats.currentStreak) + assertEquals(3, result.stats.longestStreak) + } + @Test fun `insights use all requests and rank peak hours`() { val records = listOf( diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt index 5604a7d87..01d3a2e98 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -23,6 +23,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -59,6 +60,15 @@ class TokenBaselineImportRunnerTest { injectApiPreferences(null) } + @After + fun tearDown() { + injectApiPreferences(null) + clearApiDataStoreSingleton() + TokenBaselineImportRunner.databaseProvider = null + TokenStatsResetCoordinator.daoProvider = null + ApiPreferences.toolPkgProviderNamesProvider = null + } + /** 清空 `Context.apiDataStore` 委托缓存的数据存储单例(隔离生命周期)。 */ private fun clearApiDataStoreSingleton() { val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") @@ -192,6 +202,18 @@ class TokenBaselineImportRunnerTest { // ==== 测试 ==== + @Test + fun `strict migration reports database failure as not ready`() = runBlocking { + TokenBaselineImportRunner.databaseProvider = { throw java.io.IOException("database unavailable") } + val context = mock() + whenever(context.applicationContext).thenReturn(context) + + val ready = Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigratedStrict(context) + } + assertFalse(ready) + } + @Test fun `cancellation propagates through import runner instead of being swallowed`() = runBlocking { @@ -610,9 +632,16 @@ class TokenBaselineImportRunnerTest { injectApiPreferences(realPrefs) realPrefs.getInputTokensForProviderModel(providerA) check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) + val retryReady = Mockito.mockStatic(AppLogger::class.java).use { + TokenBaselineImportRunner.ensureMigratedStrict(ctx) } + val retryRead = realPrefs.legacyStatsSnapshotWithMarkers() + assertTrue( + "retry not ready: pending=${dao.countPendingCleanupOperations()}, " + + "markers=${retryRead.cleanupMarkerIds}, " + + "models=${retryRead.snapshot.providerModels.keys}", + retryReady, + ) assertNull(dao.getBaseline(identityIdA)) assertNotNull(dao.getBaseline(identityIdB)) assertEquals(0, dao.countPendingCleanupOperations()) @@ -676,4 +705,5 @@ class TokenBaselineImportRunnerTest { } } + } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt new file mode 100644 index 000000000..0bfb444e9 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt @@ -0,0 +1,116 @@ +package com.ai.assistance.operit.data.stats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * canonical 总 Token 推导(聚合器/活动/UI 共用同一纯 helper): + * - 权威 totalInputTokens 已知则用它(拆分未知也可表达输入量); + * - fallback 按 cacheWriteSeparateBilling 决定输入口径:OpenAI 非独立计费不重复 + * cacheWrite,Anthropic 独立计费不漏 cacheWrite; + * - reasoning 按 reasoningIncludedInOutput 决定是否补加(已包含/未声明不加); + * - 未知组件保持 unknown(返回 null),绝不把 null 当作 0; + * - 饱和加法,Long 溢出钳制不回绕。 + */ +class TokenCanonicalTotalsTest { + + private fun total( + totalInput: Long? = null, + uncached: Long? = null, + cached: Long? = null, + cacheWrite: Long? = null, + separate: Boolean? = null, + output: Long? = null, + reasoning: Long? = null, + reasoningIncluded: Boolean? = null, + ): Long? = + canonicalTotalTokens( + totalInputTokens = totalInput, + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + cacheWriteSeparateBilling = separate, + outputTokens = output, + reasoningTokens = reasoning, + reasoningIncludedInOutput = reasoningIncluded, + ) + + @Test + fun `authoritative total input wins even when split is unknown`() { + // OpenAI 兼容端点缺 prompt_tokens_details:拆分未知但总输入权威已知 + assertEquals(1500L, total(totalInput = 1000L, uncached = null, cached = null, output = 500L)) + // Gemini:cachedContentTokenCount 缺失同理 + assertEquals(1200L, total(totalInput = 700L, cached = null, output = 500L)) + } + + @Test + fun `non separate billing never double counts cache write`() { + // OpenAI:无 totalInput 时输入 = uncached + cached,cacheWrite 已含在输入内 + assertEquals( + 1000L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = false, output = 400L), + ) + // 有权威 totalInput 时同样不再追加 cacheWrite + assertEquals( + 1000L, + total(totalInput = 600L, uncached = 500L, cached = 100L, cacheWrite = 50L, separate = false, output = 400L), + ) + // cacheWrite 未知也不阻碍(非独立计费概念下该分量不影响总量) + assertEquals( + 1000L, + total(uncached = 500L, cached = 100L, cacheWrite = null, separate = false, output = 400L), + ) + } + + @Test + fun `separate billing counts cache write exactly once`() { + // Anthropic:无 totalInput 时输入 = uncached + cached + cacheWrite + assertEquals( + 1050L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = true, output = 400L), + ) + // 权威 totalInput(= 三分量之和)直接使用,不得再加 cacheWrite(只计一次) + assertEquals( + 1050L, + total(totalInput = 650L, uncached = 500L, cached = 100L, cacheWrite = 50L, separate = true, output = 400L), + ) + // 旧行未声明独立计费 → 保守默认 true,cacheWrite 计入(与费用重估同一边界) + assertEquals( + 1050L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = null, output = 400L), + ) + } + + @Test + fun `reasoning added only when excluded from output`() { + assertEquals(1000L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = true)) + assertEquals(1050L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = false)) + // null = 未声明 → 按“已包含”处理,避免重复收费 + assertEquals(1000L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = null)) + } + + @Test + fun `unknown required component keeps total unknown`() { + // fallback 输入拆分缺失 → 整体 unknown(不把 null 当 0) + assertNull(total(uncached = 100L, cached = null, separate = false, output = 50L)) + // 独立计费下 cacheWrite 缺失 → unknown + assertNull(total(uncached = 100L, cached = 20L, cacheWrite = null, separate = true, output = 50L)) + // 独立推理但 reasoning 未知 → 输出 unknown → 整体 unknown + assertNull(total(totalInput = 100L, output = 50L, reasoning = null, reasoningIncluded = false)) + // 输出未知 → 整体 unknown + assertNull(total(totalInput = 100L, output = null)) + } + + @Test + fun `saturated addition never wraps negative`() { + val saturated = + total( + totalInput = Long.MAX_VALUE, + uncached = Long.MAX_VALUE, + cached = Long.MAX_VALUE, + output = Long.MAX_VALUE, + ) + assertEquals(Long.MAX_VALUE, saturated) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt index 85b1923b7..6b97fd1d1 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt @@ -117,6 +117,8 @@ class TokenStatReliabilityTest { TokenStatSpool.fileSyncForTest = null TokenStatSpool.dirSyncForTest = null TokenStatSpool.sealCopyForTest = null + TokenStatSpool.afterDrainRoundForTest = null + TokenStatSpool.rejectDrainScheduleForTest = false TokenStatSpool.prepareTimeoutMs = 5_000L TokenStatSpool.insertTimeoutMs = 5_000L TokenStatSpool.exclusiveQuiesceTimeoutMs = 5_000L @@ -5542,6 +5544,98 @@ class TokenStatReliabilityTest { } } + // ==== P1 关键链路:drain 请求合并(丢失唤醒修复)==== + + @Test + fun `schedule during an in-flight drain round is not lost and the worker reruns`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("rerun-a")) + val lineB = line(request("rerun-b")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") + var rounds = 0 + var replayInjected = false + TokenStatSpool.afterDrainRoundForTest = { + rounds += 1 + // 第一轮结束、轮末决策之前注入一次 replay:请求必须被保留并由同一 worker + // 立即 rerun(旧实现:drainScheduled=true 直接丢弃该请求,轮数恒为 1)。 + if (!replayInjected) { + replayInjected = true + TokenStatSpool.replay(context) + } + } + try { + TokenStatSpool.replay(context) + // 两轮结束:第 1 轮排空数据,第 2 轮消费注入的请求(维护轮)后 retire + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (rounds < 2 && System.nanoTime() < deadline) delay(10) + assertEquals("injected replay must trigger a rerun round", 2, rounds) + awaitEvent("rerun-a") + awaitEvent("rerun-b") + awaitNoSealedSegments(spool) + assertFalse("request must be consumed by the rerun", TokenStatSpool.drainRequestPendingForTest()) + assertFalse("worker must retire after the rerun", TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.afterDrainRoundForTest = null + } + } + + @Test + fun `rejected drain schedule retains the request and recovers on the next schedule`() = runBlocking { + TokenStatSpool.rejectDrainScheduleForTest = true + try { + val lineA = line(request("rejected-schedule-a")) + assertTrue("append must succeed durably despite rejected scheduling", TokenStatSpool.append(context, lineA, "rejected-schedule-a")) + assertTrue("request must be retained after rejection", TokenStatSpool.drainRequestPendingForTest()) + assertFalse("schedule token must be released after rejection", TokenStatSpool.drainScheduledForTest()) + // 恢复调度能力后 replay:请求不丢,事件最终入 Room + TokenStatSpool.rejectDrainScheduleForTest = false + TokenStatSpool.replay(context) + awaitEvent("rejected-schedule-a") + assertFalse(TokenStatSpool.drainRequestPendingForTest()) + assertFalse(TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.rejectDrainScheduleForTest = false + } + } + + @Test + fun `await initial drain joins concurrent waiters and failed rounds are retryable`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("init-drain-a")) + val lineB = line(request("init-drain-b")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") + // 失败轮:bootstrap gate 目录 sync 失败 → drainCore false → 等待者按失败完成 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + try { + assertFalse("failed round must complete the waiter with false", TokenStatSpool.awaitInitialDrain(context, 5_000)) + // 失败不缓存:恢复后重试成功;并发调用 join 同一轮 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + val r1 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } + val r2 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } + assertTrue("retry must succeed", r1.await()) + assertTrue("concurrent join must see the same success", r2.await()) + awaitEvent("init-drain-a") + awaitEvent("init-drain-b") + awaitNoSealedSegments(spool) + assertFalse(TokenStatSpool.drainRequestPendingForTest()) + assertFalse(TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + + @Test + fun `timed out initial drain waiter is removed when scheduling stays rejected`() = runBlocking { + TokenStatSpool.rejectDrainScheduleForTest = true + try { + assertFalse(TokenStatSpool.awaitInitialDrain(context, 25)) + assertEquals(0, TokenStatSpool.initialDrainWaiterCountForTest()) + assertTrue("drain request remains retryable", TokenStatSpool.drainRequestPendingForTest()) + } finally { + TokenStatSpool.rejectDrainScheduleForTest = false + } + } + private fun padLineTo(line: String, targetBytes: Int): String { val overhead = ",\"pad\":\"\"".toByteArray(Charsets.UTF_8).size val current = (line + "\n").toByteArray(Charsets.UTF_8).size diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt index c0e65ac0b..4a5894a11 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt @@ -235,6 +235,8 @@ class TokenStatsAggregatorTest { assertEquals(a.totalInput, b.totalInput) assertEquals(a.output, b.output) assertEquals(a.reasoning, b.reasoning) + // canonical 总 token 同样必须逐字段一致(P2-1 分页等价) + assertEquals(a.totalTokens, b.totalTokens) assertEquals(a.cost.knownAmount, b.cost.knownAmount, 1e-9) assertEquals(a.cost.unknownContributionCount, b.cost.unknownContributionCount) assertEquals(a.cost.totalContributionCount, b.cost.totalContributionCount) @@ -632,6 +634,76 @@ class TokenStatsAggregatorTest { assertEquals(2, data.summary.requests) } + @Test + fun `canonical totals aggregate per event across summary buckets and model buckets`() { + val id1 = identity("id-1", provider = "OPENAI", model = "gpt-4o") + val id2 = identity("id-2", configId = "cfg-2", provider = "ANTHROPIC", model = "claude-3-5-sonnet") + val openaiModelId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") + val anthropicModelId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") + val events = + listOf( + // OpenAI:权威 totalInput(600)即使拆分未知也能用 → 600+400 + event( + "e1", "id-1", alignedDayStart, + uncached = null, cached = null, totalInput = 600L, output = 400L, + cacheWriteSeparateBilling = false, + ), + // OpenAI 无 totalInput:输入 = uncached+cached,cacheWrite 不重复 → 500+100+400 + event( + "e2", "id-1", alignedDayStart + TokenStatsTimeRanges.HOUR_MS, + uncached = 500L, cached = 100L, cacheWrite = 50L, output = 400L, + cacheWriteSeparateBilling = false, + ), + // Anthropic:权威 totalInput = 三分量之和,cacheWrite 只计一次 → 650+400 + event( + "e3", "id-2", alignedDayStart + 2L * TokenStatsTimeRanges.HOUR_MS, + uncached = 500L, cached = 100L, cacheWrite = 50L, totalInput = 650L, output = 400L, + cacheWriteSeparateBilling = true, + ), + // 独立推理:output + reasoning → 0+100+20 + event( + "e4", "id-2", alignedDayStart + 3L * TokenStatsTimeRanges.HOUR_MS, + uncached = 0L, cached = 0L, cacheWrite = 0L, totalInput = 0L, output = 100L, + reasoning = 20L, reasoningIncluded = false, cacheWriteSeparateBilling = true, + ), + // 输入未知 → canonical unknown(输出已知也不拼 0) + event( + "e5", "id-1", alignedDayStart + 4L * TokenStatsTimeRanges.HOUR_MS, + uncached = null, cached = null, totalInput = null, output = 50L, + cacheWriteSeparateBilling = false, + ), + ) + val data = hourlyRangeData(events, identities = listOf(id1, id2)) + val summary = data.summary + assertEquals(1000L + 1000L + 1050L + 120L, summary.totalTokens.knownSum) + assertEquals(4L, summary.totalTokens.knownEventCount) + assertEquals(1L, summary.totalTokens.unknownEventCount) + assertEquals(5L, summary.totalTokens.totalEventCount) + // 桶合计 == 范围总计(canonical 与分量同样守恒) + assertEquals( + summary.totalTokens.knownSum, + data.buckets.sumOf { it.totals.totalTokens.knownSum }, + ) + assertEquals( + summary.totalTokens.unknownEventCount, + data.buckets.sumOf { it.totals.totalTokens.unknownEventCount }, + ) + // 模型桶同样聚合 canonical(按桶×模型分组):跨桶合计与范围总计一致 + // OpenAI e1+e2=2000、e5 unknown;Anthropic e3+e4=1170 + val openaiTotal = data.buckets.sumOf { it.byModel[openaiModelId]?.totalTokens ?: 0L } + val openaiUnknown = + data.buckets.sumOf { it.byModel[openaiModelId]?.totalTokensUnknownEventCount ?: 0L } + assertEquals(2000L, openaiTotal) + assertEquals(1L, openaiUnknown) + val anthropicTotal = data.buckets.sumOf { it.byModel[anthropicModelId]?.totalTokens ?: 0L } + val anthropicUnknown = + data.buckets.sumOf { it.byModel[anthropicModelId]?.totalTokensUnknownEventCount ?: 0L } + assertEquals(1170L, anthropicTotal) + assertEquals(0L, anthropicUnknown) + // 桶 0 只有 e1:该桶 OpenAI 模型桶的 canonical = e1 单事件 + assertEquals(1000L, data.buckets[0].byModel.getValue(openaiModelId).totalTokens) + } + // ==== 模型分组与明细 ==== @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt index b700dc599..dff9a4344 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt @@ -224,6 +224,42 @@ class TokenStatsDeletionTest { assertNotNull(dao.getEvent("new-in")) } + @Test + fun `range tombstone covers half open boundaries and generation equality`() = + runBlocking { + seedIdentity("id-a", configId = "cfg-a") + dao.deleteRangeEventsTx(10_000L, 20_000L) + assertEquals(1L, dao.currentResetGeneration()) + + // 半开区间 [10_000, 20_000):startMs == 左边界被覆盖;startMs == 右边界不被覆盖 + assertFalse( + "startMs at the left boundary must be covered", + dao.insertEventIfNotResetCovered(event("at-start", "id-a", startedAtMs = 10_000L, generation = 0L)) + ) + assertTrue( + "startMs at the right boundary must not be covered", + dao.insertEventIfNotResetCovered(event("at-end", "id-a", startedAtMs = 20_000L, generation = 0L)) + ) + assertNull(dao.getEvent("at-start")) + assertNotNull(dao.getEvent("at-end")) + + // generation == tombstone(删除后接受)→ 不覆盖,即使 startedAtMs 落在范围内 + assertTrue( + "generation equal to the cutoff must not be covered", + dao.insertEventIfNotResetCovered(event("gen-equal", "id-a", startedAtMs = 15_000L, generation = 1L)) + ) + assertNotNull(dao.getEvent("gen-equal")) + + // generation 低于 cutoff 但 startedAtMs 在范围外 → 不覆盖 + assertTrue( + "out of range startedAtMs must not be covered", + dao.insertEventIfNotResetCovered(event("out-of-range", "id-a", startedAtMs = 30_000L, generation = 0L)) + ) + assertNotNull(dao.getEvent("out-of-range")) + + assertEquals(3, dao.countEvents()) + } + // ==== 按展示分组删除 ==== @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt new file mode 100644 index 000000000..62103eca5 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt @@ -0,0 +1,163 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever + +/** + * 启动统计 single-flight readiness 测试(P1 关键链路): + * - 并发调用 join 同一轮初始化(各步骤只执行一次); + * - 失败不缓存:下一轮重新执行并可成功。 + * 三个步骤全部注入(不触碰真实 Room/DataStore/spool)。 + */ +class TokenStatsStartupCoordinatorTest { + + private lateinit var context: Context + + @Before + fun setUp() { + val root = kotlin.io.path.createTempDirectory("token-startup-coordinator").toFile() + context = Mockito.mock(Context::class.java).also { ctx -> + whenever(ctx.applicationContext).thenReturn(ctx) + whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") + whenever(ctx.filesDir).thenReturn(root) + whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } + } + } + + @After + fun tearDown() { + TokenStatsStartupCoordinator.ensureMigratedStep = null + TokenStatsStartupCoordinator.consumePendingRestoreStep = null + TokenStatsStartupCoordinator.initialDrainStep = null + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = null + } + + @Test + fun `concurrent awaitInitialized joins a single initialization attempt`() = runBlocking { + val enter = CountDownLatch(1) + val release = CountDownLatch(1) + val laterSteps = AtomicInteger(0) + TokenStatsStartupCoordinator.ensureMigratedStep = { + enter.countDown() + // 初始化执行在协调器锁外:阻塞第一个步骤,验证并发调用 join 而非重入 + assertTrue(release.await(10, TimeUnit.SECONDS)) + true + } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { + laterSteps.incrementAndGet() + true + } + TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> + laterSteps.incrementAndGet() + true + } + try { + // 显式分发到 IO:runBlocking 主线程随后会阻塞在 latch 上,默认分发(事件循环) + // 的 async 在阻塞期间无法被调度。 + val a = async(Dispatchers.IO) { TokenStatsStartupCoordinator.awaitInitialized(context, 10_000) } + val b = async(Dispatchers.IO) { TokenStatsStartupCoordinator.awaitInitialized(context, 10_000) } + assertTrue("initialization must start", enter.await(5, TimeUnit.SECONDS)) + delay(100) + assertEquals( + "concurrent join must not start a second attempt", + 0, + laterSteps.get(), + ) + release.countDown() + assertTrue(a.await()) + assertTrue(b.await()) + assertEquals("steps must run exactly once for the joined round", 2, laterSteps.get()) + } finally { + release.countDown() + } + } + + @Test + fun `failed initialization is not cached and a later call retries`() = runBlocking { + val drainAttempts = AtomicInteger(0) + TokenStatsStartupCoordinator.ensureMigratedStep = { true } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } + TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> + drainAttempts.incrementAndGet() + drainAttempts.get() == 2 // 第一次失败,第二次成功 + } + assertFalse("first attempt failure must surface as not ready", TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) + assertTrue("failure must not be cached; retry succeeds", TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) + assertEquals(2, drainAttempts.get()) + } + + @Test + fun `false prerequisite is not ready and is retried without running later steps`() = runBlocking { + val migrationAttempts = AtomicInteger(0) + val laterSteps = AtomicInteger(0) + TokenStatsStartupCoordinator.ensureMigratedStep = { + migrationAttempts.incrementAndGet() == 2 + } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { + laterSteps.incrementAndGet() + true + } + TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> + laterSteps.incrementAndGet() + true + } + + assertFalse(TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) + assertEquals("failed prerequisite must stop this round", 0, laterSteps.get()) + assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) + assertEquals(2, migrationAttempts.get()) + assertEquals(2, laterSteps.get()) + } + + @Test + fun `end to end timeout completes false and a later call retries`() = runBlocking { + val attempts = AtomicInteger(0) + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 40L + TokenStatsStartupCoordinator.ensureMigratedStep = { + if (attempts.incrementAndGet() == 1) delay(100) + true + } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } + TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> true } + + assertFalse(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L + assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) + assertEquals(2, attempts.get()) + } + + @Test + fun `drain receives only the budget remaining after prerequisites`() = runBlocking { + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L + TokenStatsStartupCoordinator.ensureMigratedStep = { + delay(100) + true + } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } + var drainBudgetMs = 0L + TokenStatsStartupCoordinator.initialDrainStep = { _, remainingMs -> + drainBudgetMs = remainingMs + true + } + + assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) + assertTrue("drain budget must be positive", drainBudgetMs > 0L) + assertTrue("prerequisite time must be deducted", drainBudgetMs < 500L) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt new file mode 100644 index 000000000..cb85ed990 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt @@ -0,0 +1,84 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.TokenStatsBaselineTotals +import com.ai.assistance.operit.data.stats.TokenStatsCostMode +import com.ai.assistance.operit.data.stats.TokenStatsCostSummary +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview +import com.ai.assistance.operit.data.stats.TokenStatsTokenAggregate +import com.ai.assistance.operit.data.stats.TokenStatsTotals +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * UI 总 Token 展示口径测试(阶段 3/4 修复): + * - headline 采用聚合器 canonical 总 token(不再从原始分量重组); + * - legacy baseline 只能按 input + output 合计(旧数据无细分字段); + * - includeLegacy 开关:关 = 只算事件 canonical;开 = 追加 baseline input+output。 + */ +class TokenStatsCanonicalTotalsTest { + + private fun aggregate(sum: Long, known: Long, unknown: Long, total: Long) = + TokenStatsTokenAggregate( + knownSum = sum, + knownEventCount = known, + unknownEventCount = unknown, + totalEventCount = total, + ) + + private fun cost() = + TokenStatsCostSummary( + currency = PricingCurrency.CNY, + knownAmount = 0.0, + unknownContributionCount = 0L, + totalContributionCount = 0L, + rateUsed = 7.0, + rateIsEstimated = true, + mode = TokenStatsCostMode.HISTORICAL, + originalCurrencyAmounts = emptyMap(), + ) + + private fun totals(totalTokens: TokenStatsTokenAggregate): TokenStatsTotals = + TokenStatsTotals( + requests = totalTokens.totalEventCount, + uncachedInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + cachedInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + cacheWrite = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + totalInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + output = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + reasoning = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), + totalTokens = totalTokens, + cost = cost(), + ) + + private fun baselineTotals(input: Long, output: Long): TokenStatsBaselineTotals = + TokenStatsBaselineTotals( + identityCount = 1L, + requests = 1L, + inputTokens = input, + cachedInputTokens = 0L, + outputTokens = output, + cost = cost(), + anyEstimated = true, + ) + + @Test + fun `lifetime headline uses canonical event total and baseline input plus output`() { + val overview = + TokenStatsLifetimeOverview( + eventTotals = totals(aggregate(sum = 3_170L, known = 4L, unknown = 1L, total = 5L)), + baselineTotals = baselineTotals(input = 1_000L, output = 500L), + combinedRequests = 6L, + ) + // includeLegacy 开:事件 canonical(3_170)+ baseline(1_000+500) + assertEquals(3_170L + 1_500L, knownLifetimeTokenSum(overview, includeLegacy = true)) + // includeLegacy 关:只算事件 canonical + assertEquals(3_170L, knownLifetimeTokenSum(overview, includeLegacy = false)) + } + + @Test + fun `baseline legacy totals are input plus output`() { + assertEquals(1_500L, knownBaselineTokenSum(baselineTotals(input = 1_000L, output = 500L))) + assertEquals(0L, knownBaselineTokenSum(baselineTotals(input = 0L, output = 0L))) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt index b988e45a3..8233a9b22 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -23,6 +23,8 @@ import com.ai.assistance.operit.data.stats.TokenStatsPreset import com.ai.assistance.operit.data.stats.TokenStatsQueryService import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenStatsReadiness +import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft import java.io.File @@ -176,6 +178,8 @@ class TokenUsageStatisticsViewModelTest { // 避免 TestMainDispatcher/不可 mock 的 Context.getString stringResolver = { "msg-$it" }, dispatcher = Dispatchers.Unconfined, + // P1 关键链路:readiness 门控注入 no-op(不触碰真实 spool/协调器) + readiness = TokenStatsReadiness { true }, ) /** @@ -594,6 +598,174 @@ class TokenUsageStatisticsViewModelTest { assertNull(viewModel.state.value.selectedStatuses) } + // ==== P1 关键链路:readiness 门控 ==== + + @Test + fun `first query waits for the readiness gate before loading data`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val gate = CompletableDeferred() + val readiness = TokenStatsReadiness { _ -> + gate.await() + true + } + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + readiness = readiness, + ) + // 门控挂起期间:首次查询不得完成(loading 保持、无结果、无版本推进) + assertTrue(vm.state.value.loading) + assertEquals(0L, vm.state.value.refreshVersion) + assertNull(vm.state.value.range) + // 释放门控:查询执行并携带数据完成 + gate.complete(Unit) + awaitRefresh(vm, 0) + assertEquals(1L, vm.state.value.range!!.eventCount) + assertFalse(vm.state.value.loading) + } + + @Test + fun `not ready within timeout falls back to query then auto-refreshes after readiness`() { + kotlinx.coroutines.runBlocking { + seedIdentity("id-1", configId = "cfg-a") + dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) + } + val calls = java.util.concurrent.atomic.AtomicInteger(0) + val timeouts = mutableListOf() + val gate = CompletableDeferred() + val readiness = TokenStatsReadiness { timeoutMs -> + timeouts += timeoutMs + if (calls.getAndIncrement() == 0) { + // 首次:模拟超时未就绪——VM 必须先按现状查询,不能无限等待 + false + } else { + gate.await() + true + } + } + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + readiness = readiness, + ) + // 首次未就绪:按现状完成查询(pre-replay 快照可暂时展示,但不无限停留) + awaitRefresh(vm, 0) + assertEquals(1L, vm.state.value.range!!.eventCount) + // 后台等待就绪:完成后自动刷新(第二次查询) + gate.complete(Unit) + val versionAfterFallback = vm.state.value.refreshVersion + awaitRefresh(vm, versionAfterFallback) + assertEquals(1L, vm.state.value.range!!.eventCount) + assertFalse(vm.state.value.loading) + assertEquals("readiness must be awaited exactly twice (gate + refresh)", 2, calls.get()) + assertEquals(listOf(5_000L, 65_000L), timeouts) + } + + @Test + fun `initial timeout joins delayed coordinator flight and auto loads once`() { + val migrationCalls = java.util.concurrent.atomic.AtomicInteger(0) + val drainCalls = java.util.concurrent.atomic.AtomicInteger(0) + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L + TokenStatsStartupCoordinator.ensureMigratedStep = { + migrationCalls.incrementAndGet() + kotlinx.coroutines.delay(200) + true + } + TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } + TokenStatsStartupCoordinator.initialDrainStep = { _, remainingMs -> + assertTrue(remainingMs in 1L until 500L) + drainCalls.incrementAndGet() + true + } + try { + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + readiness = TokenStatsStartupCoordinator.readiness(context), + readinessInitialWaitMs = 10L, + readinessRefreshWaitMs = 600L, + ) + + awaitRefresh(vm, 0) + val fallbackVersion = vm.state.value.refreshVersion + awaitRefresh(vm, fallbackVersion) + Thread.sleep(100) + assertEquals(fallbackVersion + 1, vm.state.value.refreshVersion) + assertEquals("both waits must share one initialization", 1, migrationCalls.get()) + assertEquals(1, drainCalls.get()) + } finally { + TokenStatsStartupCoordinator.ensureMigratedStep = null + TokenStatsStartupCoordinator.consumePendingRestoreStep = null + TokenStatsStartupCoordinator.initialDrainStep = null + TokenStatsStartupCoordinator.initializationTimeoutMsForTest = null + } + } + + @Test + fun `failed background readiness can retry without duplicate refresh tasks`() { + val calls = java.util.concurrent.atomic.AtomicInteger(0) + val secondAttempt = CompletableDeferred() + val releaseSecondAttempt = CompletableDeferred() + val readiness = TokenStatsReadiness { _ -> + when (calls.incrementAndGet()) { + 1 -> false // initial gate + 2 -> { + secondAttempt.complete(Unit) + releaseSecondAttempt.await() + false // first full-lifecycle subscription fails + } + else -> true + } + } + val vm = + TokenUsageStatisticsViewModel( + context = context, + settings = settings, + zone = shanghai, + nowMs = { nowMs }, + dao = dao, + stringResolver = { "msg-$it" }, + dispatcher = Dispatchers.Unconfined, + readiness = readiness, + ) + runBlocking { withTimeout(5_000) { secondAttempt.await() } } + + val initialVersion = vm.state.value.refreshVersion + vm.load() + vm.load() + assertEquals("active readiness subscription must remain single-flight in the VM", 2, calls.get()) + releaseSecondAttempt.complete(Unit) + awaitRefresh(vm, initialVersion) + + val beforeRetry = vm.state.value.refreshVersion + vm.load() + awaitRefresh(vm, beforeRetry) + Thread.sleep(100) + assertEquals("successful readiness must trigger exactly one load", beforeRetry + 1, vm.state.value.refreshVersion) + assertEquals("later load must retry readiness once", 3, calls.get()) + } + // ==== P1-4:旧 load 不得污染共享 state ==== @Test @@ -612,6 +784,7 @@ class TokenUsageStatisticsViewModelTest { dao = dao, stringResolver = { "msg-$it" }, dispatcher = Dispatchers.Unconfined, + readiness = TokenStatsReadiness { true }, ) // 第一次 load 卡在偏好读取(构造期间已挂起,尚未写任何 state) runBlocking { withTimeout(5_000) { gated.firstLoadStarted.await() } } @@ -658,6 +831,7 @@ class TokenUsageStatisticsViewModelTest { dao = dao, stringResolver = { "msg-$it" }, dispatcher = dispatcher, + readiness = TokenStatsReadiness { true }, ) // load 已入队但未执行;ViewModelStore.clear() 触发 onCleared → // viewModelScope 取消 → 任务不运行、不写 state、不执行首次回退持久化 From 053123c2a667d318c2f0ed1f0abf4b2372567c9e Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:26:31 +0800 Subject: [PATCH 14/28] fix(stats): close review edge cases (cherry picked from commit dbb03ee8dc4300b55e342e470dea4348ad3c0231) --- .../api/chat/llmprovider/LlamaProvider.kt | 140 +++++++++--------- .../chat/llmprovider/LocalGenerationEnd.kt | 67 ++++++--- .../api/chat/llmprovider/MNNProvider.kt | 129 ++++++++-------- .../api/chat/llmprovider/OpenAIProvider.kt | 21 ++- .../operit/data/preferences/ApiPreferences.kt | 26 +++- .../operit/data/stats/TokenStatSpool.kt | 78 ++++++++-- .../features/tokenstats/CustomRangePolicy.kt | 27 ++++ .../tokenstats/TokenActivitySection.kt | 88 ++++++++--- .../features/tokenstats/TokenStatsDialogs.kt | 13 +- .../TokenUsageStatisticsViewModel.kt | 30 ++-- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../llmprovider/LocalGenerationEndTest.kt | 90 +++++++++-- .../OpenAIStreamUsageOptionTest.kt | 45 ++++++ .../preferences/UsdToCnyRateValidationTest.kt | 36 +++++ .../data/stats/TokenStatReliabilityTest.kt | 26 ++++ .../tokenstats/AvatarImportDecisionTest.kt | 36 +++++ .../tokenstats/TokenStatsDatePickerTest.kt | 17 +++ .../TokenUsageStatisticsViewModelTest.kt | 39 ++++- 19 files changed, 673 insertions(+), 237 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt index f24151ae3..e98d162db 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt @@ -43,9 +43,9 @@ class LlamaProvider( } } - private var _inputTokenCount: Long = 0L - private var _outputTokenCount: Long = 0L - private var _cachedInputTokenCount: Long = 0L + private var _inputTokenCount: Int = 0 + private var _outputTokenCount: Int = 0 + private var _cachedInputTokenCount: Int = 0 @Volatile private var isCancelled = false @@ -53,22 +53,22 @@ class LlamaProvider( private val sessionLock = Any() private var session: LlamaSession? = null - override val inputTokenCount: Long + override val inputTokenCount: Int get() = _inputTokenCount - override val cachedInputTokenCount: Long + override val cachedInputTokenCount: Int get() = _cachedInputTokenCount - override val outputTokenCount: Long + override val outputTokenCount: Int get() = _outputTokenCount override val providerModel: String get() = "${providerType.name}:$modelName" override fun resetTokenCounts() { - _inputTokenCount = 0L - _outputTokenCount = 0L - _cachedInputTokenCount = 0L + _inputTokenCount = 0 + _outputTokenCount = 0 + _cachedInputTokenCount = 0 } private fun logLargeString(prefix: String, message: String) { @@ -139,7 +139,7 @@ class LlamaProvider( override suspend fun calculateInputTokens( chatHistory: List, availableTools: List? - ): Long { + ): Int { return withContext(Dispatchers.IO) { kotlin.runCatching { val s = ensureSessionLocked() @@ -160,8 +160,8 @@ class LlamaProvider( s.applyChatTemplate(roles, contents, true) } ?: return@runCatching null - s.countTokens(prompt).toLong() - }.getOrNull() ?: 0L + s.countTokens(prompt) + }.getOrNull() ?: 0 } } @@ -173,7 +173,7 @@ class LlamaProvider( stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, @@ -278,9 +278,9 @@ class LlamaProvider( } } - _inputTokenCount = kotlin.runCatching { s.countTokens(prompt).toLong() }.getOrElse { 0L } - _outputTokenCount = 0L - onTokensUpdated(_inputTokenCount, 0L, 0L) + _inputTokenCount = kotlin.runCatching { s.countTokens(prompt) }.getOrElse { 0 } + _outputTokenCount = 0 + onTokensUpdated(_inputTokenCount, 0, 0) val requestedMaxNewTokens = modelParameters .find { it.name == "max_tokens" } @@ -292,71 +292,73 @@ class LlamaProvider( "开始llama.cpp推理,history=${chatHistory.size}, threads=${sessionConfig.nThreads}, n_ctx=${sessionConfig.nCtx}, n_batch=${sessionConfig.nBatch}, n_ubatch=${sessionConfig.nUBatch}, gpu_layers=${sessionConfig.nGpuLayers}, mmap=${sessionConfig.useMmap}" ) - var outputTokenCount = 0L + var outputTokenCount = 0 val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() - val success = withContext(Dispatchers.IO) { - s.generateStream(prompt, requestedMaxNewTokens) { token -> - if (isCancelled) { - false - } else { - outputTokenCount += 1L - _outputTokenCount = outputTokenCount - - if (effectiveEnableToolCall) { - toolCallOutputBuffer.append(token) + val usageReporter = LocalUsageReporter( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_LLAMA, + onUsageReported, + ) + usageReporter.runReportingFinally({ _inputTokenCount }, { _outputTokenCount }) { + val success = withContext(Dispatchers.IO) { + s.generateStream(prompt, requestedMaxNewTokens) { token -> + if (isCancelled) { + false } else { - finalOutputBuffer.append(token) - runBlocking { emit(token) } - } + outputTokenCount += 1 + _outputTokenCount = outputTokenCount + + if (effectiveEnableToolCall) { + toolCallOutputBuffer.append(token) + } else { + finalOutputBuffer.append(token) + runBlocking { emit(token) } + } - kotlin.runCatching { - kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + kotlin.runCatching { + kotlinx.coroutines.runBlocking { + onTokensUpdated(_inputTokenCount, 0, _outputTokenCount) + } } - } - true + true + } } } - } - // 结束顺序即契约(评审 P2-3):取消优先判定——先上报已实测 usage 再抛 - // 取消,绝不转换/emit 不完整的工具 XML;未取消才处理工具缓冲 - LocalGenerationEnd.end( - cancelled = isCancelled, - success = success, - inputTokens = _inputTokenCount, - outputTokens = _outputTokenCount, - source = com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_LLAMA, - cancelMessage = context.getString(R.string.llama_error_request_cancelled), - onUsageReported = onUsageReported, - emitToolResult = { - if (effectiveEnableToolCall) { - val normalizedPayload = withContext(Dispatchers.IO) { - kotlin.runCatching { - s.parseToolCallResponse(toolCallOutputBuffer.toString()) - }.getOrNull() + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + usageReporter = usageReporter, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + cancelMessage = context.getString(R.string.llama_error_request_cancelled), + emitToolResult = { + if (effectiveEnableToolCall) { + val normalizedPayload = withContext(Dispatchers.IO) { + kotlin.runCatching { + s.parseToolCallResponse(toolCallOutputBuffer.toString()) + }.getOrNull() + } + val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( + normalizedPayload ?: toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } } - val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( - normalizedPayload ?: toolCallOutputBuffer.toString() - ) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) + }, + failWith = { + kotlin.runCatching { + onNonFatalError(context.getString(R.string.llama_error_inference_failed)) } - } - }, - failWith = { - // 推理失败:保留用户可见错误文本后以失败终止(统计边界记为 FAILED) - kotlin.runCatching { - onNonFatalError(context.getString(R.string.llama_error_inference_failed)) - } - emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") - throw IOException(context.getString(R.string.llama_error_inference_failed)) - }, - ) + emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") + throw IOException(context.getString(R.string.llama_error_inference_failed)) + }, + ) + } AppLogger.i(TAG, "llama.cpp推理完成,输出token数: $_outputTokenCount") logFinalOutput(finalOutputBuffer, "Final llama.cpp output summary: ") diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt index a123463a7..f412f4de8 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt @@ -3,6 +3,45 @@ package com.ai.assistance.operit.api.chat.llmprovider import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot import com.ai.assistance.operit.util.exceptions.UserCancellationException +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +internal class LocalUsageReporter( + private val source: String, + private val onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, +) { + private val reported = AtomicBoolean(false) + + suspend fun report(inputTokens: Int, outputTokens: Int) { + val callback = onUsageReported ?: return + if (!reported.compareAndSet(false, true)) return + withContext(NonCancellable) { + try { + callback( + ProviderUsageNormalizer.local( + uncachedInputTokens = inputTokens, + outputTokens = outputTokens, + source = source, + ), + 1, + ) + } catch (_: Exception) { + // Usage accounting must not replace the generation result or cancellation cause. + } + } + } + + suspend fun runReportingFinally( + inputTokens: () -> Int, + outputTokens: () -> Int, + block: suspend () -> T, + ): T = try { + block() + } finally { + report(inputTokens(), outputTokens()) + } +} /** * 本地 provider(Llama/MNN)生成结束的统一顺序契约(评审 P2-3 修复)。 @@ -24,54 +63,36 @@ internal object LocalGenerationEnd { /** * @param cancelled 用户是否已取消(cancelStreaming 触发 native 停止)。 * @param success native 生成是否正常结束(false = 失败或取消)。 + * @param usageReporter 本次生成共享的一次性 usage 上报器。 * @param inputTokens 已实测输入 token 数(tokenizer 计数)。 * @param outputTokens 已生成输出 token 数(逐 token 实测)。 - * @param source 来源标签(SOURCE_LLAMA / SOURCE_MNN)。 * @param cancelMessage 取消异常的用户可见消息。 - * @param onUsageReported usage 上报回调(统计账本通道)。 * @param emitToolResult 仅成功路径的工具缓冲处理(解析/转换/emit)。 * @param failWith 失败时的终止动作(错误文本 + 抛 IOException 等)。 */ suspend fun end( cancelled: Boolean, success: Boolean, + usageReporter: LocalUsageReporter, inputTokens: Int, outputTokens: Int, - source: String, cancelMessage: String, - onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, emitToolResult: suspend () -> Unit, failWith: suspend () -> Unit, ) { if (cancelled) { // 取消优先:先保留已实测 usage,再以取消异常结束——不 emit 工具缓冲 - reportUsage(inputTokens, outputTokens, source, onUsageReported) + usageReporter.report(inputTokens, outputTokens) throw UserCancellationException(cancelMessage) } if (!success) { // 失败次之:不转换/emit 不完整的工具缓冲,先上报已实测 usage 再终止 - reportUsage(inputTokens, outputTokens, source, onUsageReported) + usageReporter.report(inputTokens, outputTokens) failWith() return } // 成功最后:仅成功路径处理工具缓冲(解析 + emit),随后上报 usage emitToolResult() - reportUsage(inputTokens, outputTokens, source, onUsageReported) - } - - private suspend fun reportUsage( - inputTokens: Int, - outputTokens: Int, - source: String, - onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, - ) { - onUsageReported?.invoke( - ProviderUsageNormalizer.local( - uncachedInputTokens = inputTokens, - outputTokens = outputTokens, - source = source, - ), - 1, - ) + usageReporter.report(inputTokens, outputTokens) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt index 167dadecb..766fa2565 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt @@ -67,29 +67,29 @@ class MNNProvider( private var cachedModelIsAudio: Boolean? = null // Token计数 - private var _inputTokenCount = 0L - private var _outputTokenCount = 0L - private var _cachedInputTokenCount = 0L + private var _inputTokenCount = 0 + private var _outputTokenCount = 0 + private var _cachedInputTokenCount = 0 @Volatile private var isCancelled = false - override val inputTokenCount: Long + override val inputTokenCount: Int get() = _inputTokenCount - override val outputTokenCount: Long + override val outputTokenCount: Int get() = _outputTokenCount - override val cachedInputTokenCount: Long + override val cachedInputTokenCount: Int get() = _cachedInputTokenCount override val providerModel: String get() = "${providerType.name}:$modelName" override fun resetTokenCounts() { - _inputTokenCount = 0L - _outputTokenCount = 0L - _cachedInputTokenCount = 0L + _inputTokenCount = 0 + _outputTokenCount = 0 + _cachedInputTokenCount = 0 } override fun cancelStreaming() { @@ -602,7 +602,7 @@ class MNNProvider( stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, @@ -660,76 +660,77 @@ class MNNProvider( val safeHistory = trimHistoryToTokenBudget(session, conversationHistory, maxPromptTokens) _inputTokenCount = - kotlin.runCatching { session.countTokensWithHistory(safeHistory).toLong() } + kotlin.runCatching { session.countTokensWithHistory(safeHistory) } .getOrElse { error -> if (useInternalToolCall) { throw error } - countTokens(buildPrompt(conversationHistory)).toLong() + countTokens(buildPrompt(conversationHistory)) } - onTokensUpdated(_inputTokenCount, 0L, 0L) + onTokensUpdated(_inputTokenCount, 0, 0) AppLogger.d( TAG, "开始MNN LLM推理,历史消息数: ${conversationHistory.size}, thinking模式: $enableThinking, toolCall=$useInternalToolCall" ) - var outputTokenCount = 0L + var outputTokenCount = 0 val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() val emitDirectly = !useInternalToolCall - val success = session.generateStream(safeHistory, requestedMaxNewTokens) { token -> - if (isCancelled) { - false - } else { - outputTokenCount += 1L - _outputTokenCount = outputTokenCount - - if (emitDirectly) { - finalOutputBuffer.append(token) - runBlocking { emit(token) } + val usageReporter = LocalUsageReporter( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_MNN, + onUsageReported, + ) + usageReporter.runReportingFinally({ _inputTokenCount }, { _outputTokenCount }) { + val success = session.generateStream(safeHistory, requestedMaxNewTokens) { token -> + if (isCancelled) { + false } else { - toolCallOutputBuffer.append(token) - } + outputTokenCount += 1 + _outputTokenCount = outputTokenCount + + if (emitDirectly) { + finalOutputBuffer.append(token) + runBlocking { emit(token) } + } else { + toolCallOutputBuffer.append(token) + } - kotlin.runCatching { - kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + kotlin.runCatching { + kotlinx.coroutines.runBlocking { + onTokensUpdated(_inputTokenCount, 0, _outputTokenCount) + } } - } - true + true + } } - } - // 结束顺序即契约(评审 P2-3):取消优先判定——先上报已实测 usage 再抛 - // 取消,绝不转换/emit 不完整的工具 XML;未取消才处理工具缓冲 - LocalGenerationEnd.end( - cancelled = isCancelled, - success = success, - inputTokens = _inputTokenCount, - outputTokens = _outputTokenCount, - source = com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_MNN, - cancelMessage = context.getString(R.string.mnn_error_request_cancelled), - onUsageReported = onUsageReported, - emitToolResult = { - if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { - val converted = - StructuredToolCallBridge.convertToolCallPayloadToXml( - toolCallOutputBuffer.toString() - ) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + usageReporter = usageReporter, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + cancelMessage = context.getString(R.string.mnn_error_request_cancelled), + emitToolResult = { + if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { + val converted = + StructuredToolCallBridge.convertToolCallPayloadToXml( + toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } } - } - }, - failWith = { - // 推理失败:先上报已实测的 usage,再以失败终止(用户可见错误 - // 文本由下方 catch 统一 emit) - throw IOException(context.getString(R.string.mnn_reasoning_error)) - }, - ) + }, + failWith = { + throw IOException(context.getString(R.string.mnn_reasoning_error)) + }, + ) + } AppLogger.i(TAG, "MNN LLM推理完成,输出token数: $_outputTokenCount") logFinalOutput(finalOutputBuffer, "Final MNN output summary: ") @@ -980,17 +981,17 @@ class MNNProvider( override suspend fun calculateInputTokens( chatHistory: List, availableTools: List? - ): Long { + ): Int { val flattenedHistory = flattenTypedHistory(chatHistory, preserveThinkInHistory = false) val initResult = initModel() if (initResult.isFailure) { val prompt = buildPrompt(flattenedHistory) - return countTokens(prompt).toLong() + return countTokens(prompt) } val session = llmSession ?: run { val prompt = buildPrompt(flattenedHistory) - return countTokens(prompt).toLong() + return countTokens(prompt) } val modelDir = getModelDir(context, modelName) @@ -998,10 +999,10 @@ class MNNProvider( val maxPromptTokens = (maxAllTokens - 512).coerceAtLeast(128) val safeHistory = trimHistoryToTokenBudget(session, flattenedHistory, maxPromptTokens) - return kotlin.runCatching { session.countTokensWithHistory(safeHistory).toLong() } + return kotlin.runCatching { session.countTokensWithHistory(safeHistory) } .getOrElse { val prompt = buildPrompt(flattenedHistory) - countTokens(prompt).toLong() + countTokens(prompt) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 86070c4be..85ddbe3e3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -49,6 +49,20 @@ import org.json.JSONArray import org.json.JSONObject import com.ai.assistance.operit.api.chat.llmprovider.MediaLinkParser +internal fun JSONObject.applyChatCompletionsStreamUsageOption( + stream: Boolean, + providerType: ApiProviderType, + useResponsesApi: Boolean, +) { + val supportsIncludeUsage = + providerType == ApiProviderType.OPENAI || + providerType == ApiProviderType.DEEPSEEK || + providerType == ApiProviderType.MOONSHOT + if (stream && !useResponsesApi && supportsIncludeUsage) { + put("stream_options", JSONObject().put("include_usage", true)) + } +} + /** * OpenAI API格式的实现,支持标准OpenAI接口和兼容此格式的其他提供商 * @@ -636,12 +650,11 @@ open class OpenAIProvider( /** * 流式 Chat Completions 请求体附加 usage 返回选项:OpenAI 只在显式请求时于 * 末块返回 usage;Responses API 始终自带 usage(response.completed),不需要 - * 也不接受 stream_options。DeepSeek/Kimi 等自建请求体的子类必须复用本方法。 + * 也不接受 stream_options。仅对明确支持 include_usage 的服务发送,避免通用或 + * 本地兼容端点因未知字段拒绝请求。DeepSeek/Kimi 自建请求体复用本方法。 */ protected fun JSONObject.putStreamUsageOption(stream: Boolean) { - if (stream && !useResponsesApi) { - put("stream_options", JSONObject().put("include_usage", true)) - } + applyChatCompletionsStreamUsageOption(stream, providerType, useResponsesApi) } /** diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 22894ff5d..374bb323b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -33,6 +33,18 @@ import kotlinx.serialization.json.Json private val Context.apiDataStore: DataStore by preferencesDataStore(name = "api_settings") +internal fun usdToCnyStorageValue(rate: Double): Float? = + rate.toFloat().takeIf { it.isFinite() && it > 0f } + +internal fun validStoredUsdToCnyRate(stored: Float?): Double? = + stored?.takeIf { it.isFinite() && it > 0f }?.toDouble() + +internal fun resolveUsdToCnyExchangeRate(stored: Float?): Double = + validStoredUsdToCnyRate(stored) ?: 7.2 + +internal fun resolveUsdToCnyRateWithEstimate(stored: Float?): Pair = + validStoredUsdToCnyRate(stored)?.let { it to false } ?: (7.0 to true) + class ApiPreferences private constructor(private val context: Context) { // Define our preferences keys @@ -1009,7 +1021,7 @@ class ApiPreferences private constructor(private val context: Context) { suspend fun getUsdToCnyExchangeRate(): Double { val preferences = context.apiDataStore.data.first() - return preferences[USD_TO_CNY_EXCHANGE_RATE]?.toDouble() ?: 7.2 + return resolveUsdToCnyExchangeRate(preferences[USD_TO_CNY_EXCHANGE_RATE]) } /** @@ -1019,17 +1031,15 @@ class ApiPreferences private constructor(private val context: Context) { */ suspend fun usdToCnyRateWithEstimate(): Pair { val preferences = context.apiDataStore.data.first() - val stored = preferences[USD_TO_CNY_EXCHANGE_RATE] - return if (stored != null) { - stored.toDouble() to false - } else { - 7.0 to true - } + return resolveUsdToCnyRateWithEstimate(preferences[USD_TO_CNY_EXCHANGE_RATE]) } suspend fun setUsdToCnyExchangeRate(rate: Double) { + val stored = requireNotNull(usdToCnyStorageValue(rate)) { + "USD to CNY exchange rate must remain finite and positive as Float" + } context.apiDataStore.edit { preferences -> - preferences[USD_TO_CNY_EXCHANGE_RATE] = rate.toFloat() + preferences[USD_TO_CNY_EXCHANGE_RATE] = stored } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index 06892c055..0a4359f2f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -74,10 +74,9 @@ import org.json.JSONObject * - Every request captures [restoreEpoch] synchronously at its start via [captureRestoreEpoch] * (pure in-memory, no Room) and carries it in its [TokenStatRequestContext.sessionEpoch]. * - [append] validates the captured epoch against the current [restoreEpoch] AND - * [acceptingEventsThisProcess] inside [lifecycleMutex] before any spool write. A restore - * barrier (clearAfter=true) atomically increments [restoreEpoch] at its start, so every old - * request fails the epoch check and is rejected with a clear persistence failure — it never - * writes the (replaced) spool/Room, and the epoch is never round-tripped through JSON/Room. + * [acceptingEventsThisProcess] inside [lifecycleMutex] before any spool write. Raw restore uses + * [withExclusiveRestoreAccess] to increment [restoreEpoch] only after its durable REPLACING + * commit; every old request is then rejected before stores close or directories are replaced. * - Once a restore replacement actually starts (right before [block] runs), the process stops * accepting ALL statistics events ([acceptingEventsThisProcess] = false) until it restarts: * the UI allows restarting later, so same-process new requests must be explicitly rejected @@ -258,7 +257,8 @@ internal object TokenStatSpool { private val activeInserts = HashMap() /** - * Request/session fencing epoch(P1 终审):只在恢复屏障(clearAfter=true)开始时原子递增, + * Request/session fencing epoch(P1 终审):通用恢复屏障开始时递增;Raw restore 则在 + * 外部 REPLACING 状态成功持久化后、关闭 stores 前原子递增, * 使所有在屏障开始前开始(已捕获旧 epoch)的 in-flight provider/stream 请求在收尾 * [append] 时被明确拒绝——绝不写入可能已被恢复替换的 spool/Room。导出/快照屏障 * (clearAfter=false)不递增:进行中的请求在导出期间正常收尾。进程内单调递增, @@ -652,6 +652,41 @@ internal object TokenStatSpool { drainBefore: Boolean, clearAfter: Boolean = false, block: suspend () -> T, + ): T = withExclusiveSnapshotAccessInternal( + context = context, + drainBefore = drainBefore, + clearAfter = clearAfter, + deferredRestoreCommit = null, + block = block, + ) + + /** + * Raw restore two-phase barrier. [prepareBeforeCommit] may do fallible, non-replacement work; + * [commitReplacement] must persist the external REPLACING state. Request fencing changes only + * after that commit succeeds, and before [block] closes stores or replaces any directory. + */ + internal suspend fun withExclusiveRestoreAccess( + context: Context, + prepareBeforeCommit: suspend () -> Unit, + commitReplacement: suspend () -> Unit, + block: suspend () -> T, + ): T = withExclusiveSnapshotAccessInternal( + context = context, + drainBefore = false, + clearAfter = true, + deferredRestoreCommit = { + prepareBeforeCommit() + commitReplacement() + }, + block = block, + ) + + private suspend fun withExclusiveSnapshotAccessInternal( + context: Context, + drainBefore: Boolean, + clearAfter: Boolean, + deferredRestoreCommit: (suspend () -> Unit)?, + block: suspend () -> T, ): T = lifecycleMutex.withLock { val appContext = context.applicationContext // P1-1 终审:快照/恢复前必须先确认 spool 目录项持久(上一进程可见未确认的目录项 @@ -664,7 +699,7 @@ internal object TokenStatSpool { val generation = synchronized(stateLock) { sessionGeneration += 1L drainScheduled = false - if (clearAfter) { + if (clearAfter && deferredRestoreCommit == null) { // P1 终审:恢复屏障开始即原子递增 restore epoch——所有在屏障前开始的请求 // 收尾 append 时 epoch 不匹配而被明确拒绝;导出/快照(clearAfter=false) // 不递增,进行中的请求在导出期间正常收尾。 @@ -690,11 +725,32 @@ internal object TokenStatSpool { ) } if (clearAfter) { - // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 - // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 - // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 - // 可继续(替换前失败可恢复)。 - synchronized(stateLock) { acceptingEventsThisProcess = false } + if (deferredRestoreCommit != null) { + var restoreFenceCommitted = false + try { + withContext(kotlinx.coroutines.NonCancellable) { + deferredRestoreCommit() + synchronized(stateLock) { + restoreEpoch += 1L + acceptingEventsThisProcess = false + } + restoreFenceCommitted = true + } + } catch (e: Exception) { + if (!restoreFenceCommitted) { + // The request fence is unchanged. Resume normal draining after releasing + // lifecycleMutex so durable old/new events can still reach the old DB. + scheduleDrain(appContext) + } + throw e + } + } else { + // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 + // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 + // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 + // 可继续(替换前失败可恢复)。 + synchronized(stateLock) { acceptingEventsThisProcess = false } + } } val result = block() if (clearAfter) clearForRestoreLocked(appContext) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt new file mode 100644 index 000000000..78ea45886 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt @@ -0,0 +1,27 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +internal enum class CustomRangeValidation { + VALID, + INVALID_BOUNDS, + TOO_LONG, +} + +internal fun validateCustomRange( + startMs: Long, + endMs: Long, + zone: ZoneId, + maxRangeDays: Long, +): CustomRangeValidation { + if (endMs <= startMs) return CustomRangeValidation.INVALID_BOUNDS + val startDate = Instant.ofEpochMilli(startMs).atZone(zone).toLocalDate() + val exclusiveEndDate = Instant.ofEpochMilli(endMs).atZone(zone).toLocalDate() + return if (ChronoUnit.DAYS.between(startDate, exclusiveEndDate) > maxRangeDays) { + CustomRangeValidation.TOO_LONG + } else { + CustomRangeValidation.VALID + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt index eba1154fd..7e30c1c48 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Paint import android.net.Uri +import android.util.AtomicFile import android.widget.Toast import android.os.SystemClock import androidx.activity.compose.rememberLauncherForActivityResult @@ -93,6 +94,7 @@ import com.canhub.cropper.CropImageContractOptions import com.canhub.cropper.CropImageOptions import com.canhub.cropper.CropImageView import java.io.File +import java.io.FileOutputStream import java.time.DayOfWeek import java.time.LocalDate import java.time.ZoneId @@ -106,6 +108,28 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +internal data class AvatarImportDecision( + val applyAvatar: Boolean, + val avatarPath: String?, + val avatarRevision: Long, +) + +internal fun decideAvatarImport( + globalAvatarUri: String?, + persistedPath: String?, + currentPath: String?, + currentRevision: Long, + nowMs: Long, +): AvatarImportDecision { + if (globalAvatarUri.isNullOrBlank()) { + return AvatarImportDecision(true, null, maxOf(nowMs, currentRevision + 1L)) + } + if (persistedPath == null) { + return AvatarImportDecision(false, currentPath, currentRevision) + } + return AvatarImportDecision(true, persistedPath, maxOf(nowMs, currentRevision + 1L)) +} + @Composable internal fun TokenActivitySection( state: TokenActivityUiState, @@ -332,18 +356,24 @@ private fun TokenActivityProfileCard() { suspend fun persistAvatar(uri: Uri?): String? = withContext(Dispatchers.IO) { val target = File(context.filesDir, AVATAR_FILE) + val atomicFile = AtomicFile(target) if (uri == null) { - target.delete() + atomicFile.delete() return@withContext null } - val temp = File(context.filesDir, "$AVATAR_FILE.tmp") - context.contentResolver.openInputStream(uri)?.use { input -> - temp.outputStream().use { output -> input.copyTo(output) } - } ?: error("Unable to open avatar") - if (target.exists()) target.delete() - if (!temp.renameTo(target)) { - temp.delete() - error("Unable to persist avatar") + val input = context.contentResolver.openInputStream(uri) ?: error("Unable to open avatar") + var output: FileOutputStream? = null + try { + input.use { + val stream = atomicFile.startWrite() + output = stream + it.copyTo(stream) + atomicFile.finishWrite(stream) + output = null + } + } catch (e: Exception) { + output?.let(atomicFile::failWrite) + throw e } target.absolutePath } @@ -486,20 +516,36 @@ private fun TokenActivityProfileCard() { scope.launch { val global = DisplayPreferencesManager.getInstance(context) val importedName = global.globalUserName.first().orEmpty() - val importedAvatar = global.globalUserAvatarUri.first()?.let(Uri::parse) - val importedPath = runCatching { persistAvatar(importedAvatar) }.getOrNull() - val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) + val globalAvatarUri = global.globalUserAvatarUri.first()?.takeUnless { it.isBlank() } + val importedPath = if (globalAvatarUri == null) { + persistAvatar(null) + } else { + runCatching { persistAvatar(Uri.parse(globalAvatarUri)) }.getOrNull() + } + val decision = decideAvatarImport( + globalAvatarUri, + importedPath, + avatarPath, + avatarRevision, + System.currentTimeMillis(), + ) nickname = importedName - avatarPath = importedPath - avatarRevision = revision - prefs.edit() - .putString(KEY_NICKNAME, importedName) - .putLong(KEY_AVATAR_REVISION, revision) - .apply { - if (importedPath == null) remove(KEY_AVATAR_PATH) - else putString(KEY_AVATAR_PATH, importedPath) + val editor = prefs.edit().putString(KEY_NICKNAME, importedName) + if (decision.applyAvatar) { + avatarPath = decision.avatarPath + avatarRevision = decision.avatarRevision + editor.putLong(KEY_AVATAR_REVISION, decision.avatarRevision).apply { + if (decision.avatarPath == null) remove(KEY_AVATAR_PATH) + else putString(KEY_AVATAR_PATH, decision.avatarPath) } - .apply() + } else { + Toast.makeText( + context, + context.getString(R.string.token_activity_profile_avatar_import_failed), + Toast.LENGTH_LONG, + ).show() + } + editor.apply() } }, ) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt index 62c75a279..bb97d1cf9 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -153,14 +153,11 @@ internal fun CustomRangeDialog( // 非法边界在对话框内就地提示(不静默停留)。 val startMs = start.atStartOfDay(zone).toInstant().toEpochMilli() val endMs = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - inlineError = - when { - endMs <= startMs -> invalidRangeText - (endMs - startMs) > maxRangeDays * - com.ai.assistance.operit.data.stats.TokenStatsTimeRanges.DAY_MS -> - rangeTooLongText - else -> null - } + inlineError = when (validateCustomRange(startMs, endMs, zone, maxRangeDays)) { + CustomRangeValidation.INVALID_BOUNDS -> invalidRangeText + CustomRangeValidation.TOO_LONG -> rangeTooLongText + CustomRangeValidation.VALID -> null + } if (inlineError == null && onConfirm(startMs, endMs)) { onDismiss() } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index c071bc912..4be5fb3aa 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -11,6 +11,7 @@ import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.data.preferences.usdToCnyStorageValue import com.ai.assistance.operit.data.stats.ApiPreferencesTokenStatsSettingsStore import com.ai.assistance.operit.data.stats.TokenActivityAggregator import com.ai.assistance.operit.data.stats.TokenActivityInsights @@ -201,10 +202,6 @@ class TokenUsageStatisticsViewModel( /** 已知展示模型 id → 最近一次查询所见名称(P1-5,永不清除,只增补)。 */ private val knownModelNames = mutableMapOf() - init { - loadForEntry() - } - fun consumeActionMessage() { _actionMessage.value = null } @@ -503,27 +500,28 @@ class TokenUsageStatisticsViewModel( /** * 设置自定义范围(半开区间 [startMs, endMs))。 - * 校验:end > start 且时长不超过 [MAX_CUSTOM_RANGE_DAYS] 天; + * 校验:end > start 且设备时区自然日数不超过 [MAX_CUSTOM_RANGE_DAYS] 天; * 非法时不持久化、不触发查询,返回 false 并由 [actionMessage] 说明原因。 */ fun setCustomRange(startMs: Long, endMs: Long): Boolean { - if (endMs <= startMs) { - _actionMessage.value = - TokenStatsActionMessage( + when (validateCustomRange(startMs, endMs, zone, MAX_CUSTOM_RANGE_DAYS)) { + CustomRangeValidation.INVALID_BOUNDS -> { + _actionMessage.value = TokenStatsActionMessage( text = stringResolver(R.string.token_stats_custom_range_invalid), isError = true, ) - return false - } - val range = TokenStatsTimeRanges.customRange(startMs, endMs) - if (range.durationMs > MAX_CUSTOM_RANGE_DAYS * TokenStatsTimeRanges.DAY_MS) { - _actionMessage.value = - TokenStatsActionMessage( + return false + } + CustomRangeValidation.TOO_LONG -> { + _actionMessage.value = TokenStatsActionMessage( text = stringResolver(R.string.token_stats_custom_range_too_long), isError = true, ) - return false + return false + } + CustomRangeValidation.VALID -> Unit } + val range = TokenStatsTimeRanges.customRange(startMs, endMs) viewModelScope.launch(dispatcher) { settings.saveTimeSelection( TokenStatsTimeSelection( @@ -611,7 +609,7 @@ class TokenUsageStatisticsViewModel( /** 手动汇率:非正或非有限值拒绝(不改持久化、不重查),返回 false。 */ fun setManualRate(rate: Double): Boolean { - if (!rate.isFinite() || rate <= 0.0) return false + if (usdToCnyStorageValue(rate) == null) return false viewModelScope.launch(dispatcher) { settings.saveRate(rate) load() diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 9b5ed7dc9..01dd384c1 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8318,6 +8318,7 @@ This replaces the independent Token Activity nickname and avatar with Operit’s global name and avatar. The independent email is preserved. Confirm in %1$d s Confirm reset + Could not import the global avatar. The current avatar was kept. Nickname Email Save diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2de6b788..2a8c3d331 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8308,6 +8308,7 @@ 将用 Operit 的全局用户名和头像覆盖 Token 活动中的独立昵称和头像。独立邮箱不会改变。 %1$d 秒后确认 确认重置 + 全局头像导入失败,已保留当前头像 昵称 邮箱 保存 diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt index 30432ae70..7706e3fef 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt @@ -4,6 +4,10 @@ import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot import com.ai.assistance.operit.util.exceptions.UserCancellationException import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -22,6 +26,10 @@ class LocalGenerationEndTest { @Test fun `cancel reports usage then throws without emitting tool result`() = runBlocking { val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, attempt -> + assertEquals(1, attempt) + usageReports.add(usage) + } var toolEmitted = false try { LocalGenerationEnd.end( @@ -29,12 +37,8 @@ class LocalGenerationEndTest { success = false, inputTokens = 300, outputTokens = 12, - source = ProviderUsageNormalizer.SOURCE_LLAMA, + usageReporter = reporter, cancelMessage = "cancelled by user", - onUsageReported = { usage, attempt -> - assertEquals(1, attempt) - usageReports.add(usage) - }, emitToolResult = { toolEmitted = true }, failWith = { fail("cancel path must not reach failWith") }, ) @@ -53,15 +57,15 @@ class LocalGenerationEndTest { @Test fun `success emits tool result and reports usage without throwing`() = runBlocking { val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> usageReports.add(usage) } var toolEmitted = false LocalGenerationEnd.end( cancelled = false, success = true, inputTokens = 100, outputTokens = 30, - source = ProviderUsageNormalizer.SOURCE_MNN, + usageReporter = reporter, cancelMessage = "cancelled", - onUsageReported = { usage, _ -> usageReports.add(usage) }, emitToolResult = { toolEmitted = true }, failWith = { fail("success path must not fail") }, ) @@ -74,6 +78,7 @@ class LocalGenerationEndTest { @Test fun `failure reports usage then fails without emitting tool result`() = runBlocking { val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, _ -> usageReports.add(usage) } var toolEmitted = false try { LocalGenerationEnd.end( @@ -81,9 +86,8 @@ class LocalGenerationEndTest { success = false, inputTokens = 200, outputTokens = 5, - source = ProviderUsageNormalizer.SOURCE_LLAMA, + usageReporter = reporter, cancelMessage = "cancelled", - onUsageReported = { usage, _ -> usageReports.add(usage) }, emitToolResult = { toolEmitted = true }, failWith = { throw IOException("inference failed") }, ) @@ -102,15 +106,15 @@ class LocalGenerationEndTest { @Test fun `failure never emits tool result even if failWith returns normally`() = runBlocking { val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> usageReports.add(usage) } var toolEmitted = false LocalGenerationEnd.end( cancelled = false, success = false, inputTokens = 80, outputTokens = 2, - source = ProviderUsageNormalizer.SOURCE_MNN, + usageReporter = reporter, cancelMessage = "cancelled", - onUsageReported = { usage, _ -> usageReports.add(usage) }, emitToolResult = { toolEmitted = true }, failWith = {}, ) @@ -119,4 +123,68 @@ class LocalGenerationEndTest { assertEquals(1, usageReports.size) assertEquals(80L, usageReports[0].uncachedInputTokens) } + + @Test + fun `coroutine cancellation reports latest usage once and still propagates`() = runBlocking { + val reports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, _ -> reports += usage } + val entered = CompletableDeferred() + val job = launch { + reporter.runReportingFinally({ 42 }, { 7 }) { + entered.complete(Unit) + awaitCancellation() + } + } + entered.await() + job.cancelAndJoin() + + assertTrue(job.isCancelled) + assertEquals(1, reports.size) + assertEquals(42L, reports.single().uncachedInputTokens) + assertEquals(7L, reports.single().outputTokens) + reporter.report(99, 99) + assertEquals("reporter must be once-only", 1, reports.size) + } + + @Test + fun `native exception reports usage once without running success payload`() = runBlocking { + val reports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> reports += usage } + var toolEmitted = false + try { + reporter.runReportingFinally({ 15 }, { 4 }) { + throw IOException("native failure") + } + toolEmitted = true + } catch (e: IOException) { + assertEquals("native failure", e.message) + } + + assertFalse(toolEmitted) + assertEquals(1, reports.size) + assertEquals(15L, reports.single().uncachedInputTokens) + assertEquals(4L, reports.single().outputTokens) + } + + @Test + fun `usage callback failure cannot mask cancellation`() = runBlocking { + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { _, _ -> + throw IOException("ledger unavailable") + } + try { + LocalGenerationEnd.end( + cancelled = true, + success = false, + usageReporter = reporter, + inputTokens = 10, + outputTokens = 2, + cancelMessage = "cancelled", + emitToolResult = { fail("cancel must not emit") }, + failWith = { fail("cancel must not use failure payload") }, + ) + fail("cancellation must propagate") + } catch (e: UserCancellationException) { + assertEquals("cancelled", e.message) + } + } } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt new file mode 100644 index 000000000..0fa644ab3 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt @@ -0,0 +1,45 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.data.model.ApiProviderType +import org.json.JSONObject +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenAIStreamUsageOptionTest { + + @Test + fun `known chat completions providers request streamed usage`() { + listOf(ApiProviderType.OPENAI, ApiProviderType.DEEPSEEK, ApiProviderType.MOONSHOT).forEach { provider -> + val body = JSONObject() + body.applyChatCompletionsStreamUsageOption(true, provider, useResponsesApi = false) + assertTrue(body.getJSONObject("stream_options").getBoolean("include_usage")) + } + } + + @Test + fun `generic and local compatible providers omit stream options`() { + listOf( + ApiProviderType.OPENAI_GENERIC, + ApiProviderType.OPENAI_LOCAL, + ApiProviderType.LMSTUDIO, + ApiProviderType.OLLAMA, + ApiProviderType.OTHER, + ).forEach { provider -> + val body = JSONObject() + body.applyChatCompletionsStreamUsageOption(true, provider, useResponsesApi = false) + assertFalse("$provider must not receive stream_options", body.has("stream_options")) + } + } + + @Test + fun `responses and non streaming requests omit stream options`() { + val responsesBody = JSONObject() + responsesBody.applyChatCompletionsStreamUsageOption(true, ApiProviderType.OPENAI, useResponsesApi = true) + assertFalse(responsesBody.has("stream_options")) + + val nonStreamingBody = JSONObject() + nonStreamingBody.applyChatCompletionsStreamUsageOption(false, ApiProviderType.OPENAI, useResponsesApi = false) + assertFalse(nonStreamingBody.has("stream_options")) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt new file mode 100644 index 000000000..24089f1c4 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt @@ -0,0 +1,36 @@ +package com.ai.assistance.operit.data.preferences + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class UsdToCnyRateValidationTest { + + @Test + fun `double must remain finite and positive after Float conversion`() { + assertNull(usdToCnyStorageValue(1e-50)) + assertNull(usdToCnyStorageValue(1e50)) + assertNull(usdToCnyStorageValue(Double.NaN)) + assertNull(usdToCnyStorageValue(Double.POSITIVE_INFINITY)) + assertNull(usdToCnyStorageValue(Double.NEGATIVE_INFINITY)) + assertEquals(7.35f, usdToCnyStorageValue(7.35)) + } + + @Test + fun `historical invalid Float values are treated as missing`() { + assertNull(validStoredUsdToCnyRate(0f)) + assertNull(validStoredUsdToCnyRate(-1f)) + assertNull(validStoredUsdToCnyRate(Float.NaN)) + assertNull(validStoredUsdToCnyRate(Float.POSITIVE_INFINITY)) + assertEquals(7.25, validStoredUsdToCnyRate(7.25f)!!, 0.0) + assertTrue(validStoredUsdToCnyRate(Float.MIN_VALUE)!! > 0.0) + + listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, 0f, -1f).forEach { stored -> + assertEquals(7.2, resolveUsdToCnyExchangeRate(stored), 0.0) + assertEquals(7.0 to true, resolveUsdToCnyRateWithEstimate(stored)) + } + assertEquals(7.25, resolveUsdToCnyExchangeRate(7.25f), 0.0) + assertEquals(7.25 to false, resolveUsdToCnyRateWithEstimate(7.25f)) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt index 6b97fd1d1..56bf58357 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt @@ -288,6 +288,32 @@ class TokenStatReliabilityTest { assertNull(database.tokenStatsDao().getEvent("old-before-restore")) } + @Test + fun `deferred restore commit failure preserves old and new request accounting`() = runBlocking { + val oldRequest = request("old-request-after-commit-failure") + val oldEpoch = oldRequest.sessionEpoch + try { + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = {}, + commitReplacement = { throw IOException("REPLACING write failed") }, + ) { + fail("replacement must not run when commit fails") + } + fail("commit failure must propagate") + } catch (e: IOException) { + assertEquals("REPLACING write failed", e.message) + } + + assertEquals(oldEpoch, TokenStatSpool.captureRestoreEpoch()) + assertTrue(TokenStatSpool.isAcceptingEvents()) + TokenTrackingAIService.recordSafely(context, oldRequest) + TokenTrackingAIService.recordSafely(context, request("new-request-after-commit-failure")) + awaitEvent("old-request-after-commit-failure") + awaitEvent("new-request-after-commit-failure") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + @Test fun `interrupt ignoring insert never locks spool and restore barrier stays clean`() = runBlocking { Mockito.mockStatic(AppLogger::class.java).use { diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt new file mode 100644 index 000000000..042e2d31d --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt @@ -0,0 +1,36 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AvatarImportDecisionTest { + + @Test + fun `missing global avatar clears current avatar`() { + listOf(null, "", " \t\n").forEach { globalAvatar -> + val decision = decideAvatarImport(globalAvatar, null, "/old/avatar", 10L, 20L) + assertTrue(decision.applyAvatar) + assertNull(decision.avatarPath) + assertEquals(20L, decision.avatarRevision) + } + } + + @Test + fun `failed nonnull global avatar import preserves current avatar and revision`() { + val decision = decideAvatarImport("content://global", null, "/old/avatar", 10L, 20L) + assertFalse(decision.applyAvatar) + assertEquals("/old/avatar", decision.avatarPath) + assertEquals(10L, decision.avatarRevision) + } + + @Test + fun `successful global avatar import applies new path and revision`() { + val decision = decideAvatarImport("content://global", "/new/avatar", "/old/avatar", 10L, 20L) + assertTrue(decision.applyAvatar) + assertEquals("/new/avatar", decision.avatarPath) + assertEquals(20L, decision.avatarRevision) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt index e6c74845c..027286c0a 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt @@ -72,4 +72,21 @@ class TokenStatsDatePickerTest { } assertTrue("end before start must be rejected", failure.isFailure) } + + @Test + fun `fall back range at maximum natural days is accepted despite extra elapsed hour`() { + val maxDays = 10L + val start = LocalDate.of(2026, 10, 25).atStartOfDay(newYork).toInstant().toEpochMilli() + val end = LocalDate.of(2026, 11, 4).atStartOfDay(newYork).toInstant().toEpochMilli() + assertEquals(10L * TokenStatsTimeRanges.DAY_MS + TokenStatsTimeRanges.HOUR_MS, end - start) + assertEquals(CustomRangeValidation.VALID, validateCustomRange(start, end, newYork, maxDays)) + } + + @Test + fun `range one natural day over maximum is rejected across fall back`() { + val maxDays = 10L + val start = LocalDate.of(2026, 10, 25).atStartOfDay(newYork).toInstant().toEpochMilli() + val end = LocalDate.of(2026, 11, 5).atStartOfDay(newYork).toInstant().toEpochMilli() + assertEquals(CustomRangeValidation.TOO_LONG, validateCustomRange(start, end, newYork, maxDays)) + } } diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt index 8233a9b22..22a349019 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt @@ -167,7 +167,7 @@ class TokenUsageStatisticsViewModelTest { } } - private fun newViewModel(): TokenUsageStatisticsViewModel = + private fun constructViewModel(): TokenUsageStatisticsViewModel = TokenUsageStatisticsViewModel( context = context, settings = settings, @@ -182,6 +182,9 @@ class TokenUsageStatisticsViewModelTest { readiness = TokenStatsReadiness { true }, ) + private fun newViewModel(): TokenUsageStatisticsViewModel = + constructViewModel().also { it.loadForEntry() } + /** * 等待异步查询落定:Room 在 arch 后台线程恢复协程,不能靠虚拟时间推进; * 用 refreshVersion 单调递增判断“本次操作触发的查询已完成”。 @@ -290,6 +293,21 @@ class TokenUsageStatisticsViewModelTest { // ==== 首次自动回退 ==== + @Test + fun `construction does not load and each route entry loads once`() { + val viewModel = constructViewModel() + Thread.sleep(50) + assertEquals(0L, viewModel.state.value.refreshVersion) + + viewModel.loadForEntry() + awaitRefresh(viewModel, 0) + assertEquals(1L, viewModel.state.value.refreshVersion) + + viewModel.loadForEntry() + awaitRefresh(viewModel, 1) + assertEquals(2L, viewModel.state.value.refreshVersion) + } + @Test fun `initial fallback picks first preset with data and persists it as auto`() { kotlinx.coroutines.runBlocking { @@ -388,9 +406,16 @@ class TokenUsageStatisticsViewModelTest { assertEquals(TokenStatsPreset.LAST_5H, viewModel.state.value.selectedPreset) // 超过 3 年 → 拒绝 + val tooEarly = java.time.Instant.ofEpochMilli(start) + .atZone(shanghai) + .toLocalDate() + .minusDays(TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS + 1L) + .atStartOfDay(shanghai) + .toInstant() + .toEpochMilli() val tooLong = viewModel.setCustomRange( - start - TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS * 24 * 3600_000L - 1, + tooEarly, start, ) assertFalse(tooLong) @@ -622,6 +647,7 @@ class TokenUsageStatisticsViewModelTest { dispatcher = Dispatchers.Unconfined, readiness = readiness, ) + vm.loadForEntry() // 门控挂起期间:首次查询不得完成(loading 保持、无结果、无版本推进) assertTrue(vm.state.value.loading) assertEquals(0L, vm.state.value.refreshVersion) @@ -663,6 +689,7 @@ class TokenUsageStatisticsViewModelTest { dispatcher = Dispatchers.Unconfined, readiness = readiness, ) + vm.loadForEntry() // 首次未就绪:按现状完成查询(pre-replay 快照可暂时展示,但不无限停留) awaitRefresh(vm, 0) assertEquals(1L, vm.state.value.range!!.eventCount) @@ -706,6 +733,7 @@ class TokenUsageStatisticsViewModelTest { readinessInitialWaitMs = 10L, readinessRefreshWaitMs = 600L, ) + vm.loadForEntry() awaitRefresh(vm, 0) val fallbackVersion = vm.state.value.refreshVersion @@ -749,6 +777,7 @@ class TokenUsageStatisticsViewModelTest { dispatcher = Dispatchers.Unconfined, readiness = readiness, ) + vm.loadForEntry() runBlocking { withTimeout(5_000) { secondAttempt.await() } } val initialVersion = vm.state.value.refreshVersion @@ -786,6 +815,7 @@ class TokenUsageStatisticsViewModelTest { dispatcher = Dispatchers.Unconfined, readiness = TokenStatsReadiness { true }, ) + vm.loadForEntry() // 第一次 load 卡在偏好读取(构造期间已挂起,尚未写任何 state) runBlocking { withTimeout(5_000) { gated.firstLoadStarted.await() } } @@ -833,6 +863,7 @@ class TokenUsageStatisticsViewModelTest { dispatcher = dispatcher, readiness = TokenStatsReadiness { true }, ) + vm.loadForEntry() // load 已入队但未执行;ViewModelStore.clear() 触发 onCleared → // viewModelScope 取消 → 任务不运行、不写 state、不执行首次回退持久化 val store = androidx.lifecycle.ViewModelStore() @@ -884,6 +915,10 @@ class TokenUsageStatisticsViewModelTest { // 非法汇率:拒绝且不持久化 assertFalse(viewModel.setManualRate(-1.0)) + assertFalse(viewModel.setManualRate(1e-50)) + assertFalse(viewModel.setManualRate(1e50)) + assertFalse(viewModel.setManualRate(Double.NaN)) + assertFalse(viewModel.setManualRate(Double.POSITIVE_INFINITY)) assertEquals(7.35, settings.savedRate, 0.0) } From 5ead52c10cdbca59db0e341c57d6e19658639dea Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:47:25 +0800 Subject: [PATCH 15/28] fix(stats): handle retry and cost edge cases (cherry picked from commit 481acb49d73f15092e7d56fd625dd83d3b9749ed) --- .../api/chat/llmprovider/ClaudeProvider.kt | 21 +++++ .../data/stats/ProviderUsageSnapshot.kt | 16 ++-- .../operit/data/stats/TokenCostCalculator.kt | 51 +++++++++--- .../data/stats/TokenStatRequestContext.kt | 22 +++-- .../operit/data/stats/TokenStatsAggregator.kt | 6 +- .../data/stats/TokenStatsQueryModels.kt | 4 +- .../ClaudeProviderCancellationTest.kt | 17 ++++ .../llmprovider/TokenTrackingAIServiceTest.kt | 4 + .../data/stats/ProviderUsageNormalizerTest.kt | 53 ++++++++++++ .../data/stats/TokenCostCalculatorTest.kt | 73 +++++++++++++++-- .../data/stats/TokenStatRequestContextTest.kt | 80 +++++++++++++++++++ .../data/stats/TokenStatsAggregatorTest.kt | 26 ++++++ 12 files changed, 336 insertions(+), 37 deletions(-) create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index b91aa6333..8f5f1448b 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -39,6 +39,9 @@ import org.json.JSONArray import org.json.JSONObject /** Anthropic Claude API的实现,处理Claude特有的API格式 */ +internal fun shouldPropagateClaudeCancellation(isManuallyCancelled: Boolean): Boolean = + isManuallyCancelled + class ClaudeProvider( private val apiEndpoint: String, private val apiKeyProvider: ApiKeyProvider, @@ -1610,6 +1613,11 @@ class ClaudeProvider( tokenCacheManager.outputTokenCount ) } + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } return@withContext } @@ -1892,6 +1900,12 @@ class ClaudeProvider( } } + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } + if (!emittedAny && nonSseJsonLinesBuffer.isNotBlank()) { val buffered = nonSseJsonLinesBuffer.toString().trim() AppLogger.w( @@ -1960,6 +1974,13 @@ class ClaudeProvider( } } + // Cancellation can race with fallback parsing after the stream loop. Recheck at + // the final success boundary so a manually cancelled request is never completed. + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } AppLogger.d("AIService", "【Claude】请求成功完成") logFinalOutput(receivedContent, "Claude final output summary: ") return@stream diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt index 054787778..e1a04788a 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt @@ -324,18 +324,18 @@ object ProviderUsageNormalizer { output: Long?, completeSnapshot: Boolean, ): ProviderUsageSnapshot { + val validInput = input?.takeIf { it >= 0 } + val validCachedInput = cachedInput?.takeIf { it >= 0 } + val splitIsValid = + validInput != null && validCachedInput != null && validCachedInput <= validInput val uncached = - when { - input == null || cachedInput == null -> null - input >= cachedInput -> (input - cachedInput).coerceAtLeast(0) - else -> input - } + if (splitIsValid) validInput!! - validCachedInput!! else null return ProviderUsageSnapshot( uncachedInputTokens = uncached, - cachedInputTokens = cachedInput?.coerceAtLeast(0), + cachedInputTokens = validCachedInput.takeIf { splitIsValid }, cacheWriteTokens = null, - totalInputTokens = input?.coerceAtLeast(0), - outputTokens = output?.coerceAtLeast(0), + totalInputTokens = validInput, + outputTokens = output?.takeIf { it >= 0 }, reasoningTokens = null, reasoningIncludedInOutput = null, cacheWriteSeparateBilling = false, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt index a02a995ee..ab86eeb8e 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -80,7 +80,7 @@ object TokenCostCalculator { return TokenCostResult(amount = null, currency = pricing.currency) } if (pricing.billingMode == BillingMode.COUNT) { - val price = pricing.pricePerRequest + val price = pricing.pricePerRequest?.takeIf { it.isFinite() } return TokenCostResult( amount = price, currency = pricing.currency, @@ -94,7 +94,9 @@ object TokenCostCalculator { val inputPrice = pricing.inputPricePerMillion val cachedPrice = pricing.cachedInputPricePerMillion val outputPrice = pricing.outputPricePerMillion - if (inputPrice == null || cachedPrice == null || outputPrice == null) { + if (inputPrice == null || cachedPrice == null || outputPrice == null || + !inputPrice.isFinite() || !cachedPrice.isFinite() || !outputPrice.isFinite() + ) { return TokenCostResult(amount = null, currency = pricing.currency) } @@ -110,17 +112,23 @@ object TokenCostCalculator { if (cachedTokens != null && uncachedTokens != null) { billedInput = saturatedAdd(uncachedTokens, cachedTokens) inputAmount = - uncachedTokens / 1_000_000.0 * inputPrice + - cachedTokens / 1_000_000.0 * cachedPrice + safeAdd( + scaledTokenCost(uncachedTokens, inputPrice), + scaledTokenCost(cachedTokens, cachedPrice), + ) ?: return TokenCostResult(amount = null, currency = pricing.currency) } else { val total = usage.totalInputTokens if (total == null || inputPrice != cachedPrice) { return TokenCostResult(amount = null, currency = pricing.currency) } billedInput = total - inputAmount = total / 1_000_000.0 * inputPrice + inputAmount = + scaledTokenCost(total, inputPrice) + ?: return TokenCostResult(amount = null, currency = pricing.currency) } - var amount = inputAmount + billedOutput / 1_000_000.0 * outputPrice + var amount = + safeAdd(inputAmount, scaledTokenCost(billedOutput, outputPrice)) + ?: return TokenCostResult(amount = null, currency = pricing.currency) // 缓存写入: // - 独立计费概念下未知 → 成本未知(不静默当作 0); @@ -139,7 +147,7 @@ object TokenCostCalculator { } if (cacheWriteTokens != null && cacheWriteTokens > 0 && usage.cacheWriteSeparateBilling) { val cacheWritePrice = pricing.cacheWritePricePerMillion - if (cacheWritePrice == null) { + if (cacheWritePrice == null || !cacheWritePrice.isFinite()) { return TokenCostResult( amount = null, currency = pricing.currency, @@ -148,7 +156,15 @@ object TokenCostCalculator { billedOutputTokens = billedOutput, ) } - amount += cacheWriteTokens / 1_000_000.0 * cacheWritePrice + amount = + safeAdd(amount, scaledTokenCost(cacheWriteTokens, cacheWritePrice)) + ?: return TokenCostResult( + amount = null, + currency = pricing.currency, + billedInputTokens = billedInput, + billedCacheWriteTokens = cacheWriteTokens, + billedOutputTokens = billedOutput, + ) } return TokenCostResult( @@ -166,6 +182,14 @@ object TokenCostCalculator { */ internal fun saturatedAdd(left: Long, right: Long): Long = if (right > 0 && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right + + private fun scaledTokenCost(tokens: Long, pricePerMillion: Double): Double? = + (tokens / 1_000_000.0 * pricePerMillion).takeIf { it.isFinite() } + + private fun safeAdd(left: Double?, right: Double?): Double? { + if (left == null || right == null) return null + return (left + right).takeIf { it.isFinite() } + } } /** @@ -201,13 +225,16 @@ object TokenCostCurrency { from: PricingCurrency, to: PricingCurrency, manualRate: Double, - ): Double { - require(manualRate > 0.0) { "manual rate must be positive" } - if (from == to) return amount - return if (from == PricingCurrency.USD) { + ): Double? { + require(manualRate.isFinite() && manualRate > 0.0) { "manual rate must be finite and positive" } + if (!amount.isFinite()) return null + val converted = if (from == to) { + amount + } else if (from == PricingCurrency.USD) { amount * manualRate } else { amount / manualRate } + return converted.takeIf { it.isFinite() } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt index 76459a91a..978ce1403 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt @@ -80,6 +80,9 @@ class TokenStatRequestContext( /** attempt -> 该 attempt 最后一次上报的快照(同一 attempt 重复上报取最后)。 */ private val attemptUsages = LinkedHashMap() + /** spool 中的 usage 已在写入前聚合,重放时不得按原 attemptCount 再检查缺口。 */ + private var replayAggregatedUsage: ProviderUsageSnapshot? = null + /** 仅在首个真实内容 chunk 到达时记录一次。 */ fun onFirstToken(nowMs: Long = System.currentTimeMillis()) { if (firstTokenAtMs == null) { @@ -158,15 +161,18 @@ class TokenStatRequestContext( * 来源/包含推理声明取最后一次。 */ fun aggregatedUsage(): ProviderUsageSnapshot? { + replayAggregatedUsage?.let { return it } val snapshots = attemptUsages.values.toList() if (snapshots.isEmpty()) return null + val allAttemptsReported = + attemptCount > 0 && (1..attemptCount).all { attemptUsages.containsKey(it) } return ProviderUsageSnapshot( - uncachedInputTokens = sumComponent(snapshots) { it.uncachedInputTokens }, - cachedInputTokens = sumComponent(snapshots) { it.cachedInputTokens }, - cacheWriteTokens = sumComponent(snapshots) { it.cacheWriteTokens }, - totalInputTokens = sumComponent(snapshots) { it.totalInputTokens }, - outputTokens = sumComponent(snapshots) { it.outputTokens }, - reasoningTokens = sumComponent(snapshots) { it.reasoningTokens }, + uncachedInputTokens = sumComponent(snapshots, allAttemptsReported) { it.uncachedInputTokens }, + cachedInputTokens = sumComponent(snapshots, allAttemptsReported) { it.cachedInputTokens }, + cacheWriteTokens = sumComponent(snapshots, allAttemptsReported) { it.cacheWriteTokens }, + totalInputTokens = sumComponent(snapshots, allAttemptsReported) { it.totalInputTokens }, + outputTokens = sumComponent(snapshots, allAttemptsReported) { it.outputTokens }, + reasoningTokens = sumComponent(snapshots, allAttemptsReported) { it.reasoningTokens }, reasoningIncludedInOutput = snapshots.lastOrNull()?.reasoningIncludedInOutput, cacheWriteSeparateBilling = snapshots.lastOrNull()?.cacheWriteSeparateBilling ?: true, completeSnapshot = true, @@ -176,8 +182,10 @@ class TokenStatRequestContext( private fun sumComponent( snapshots: List, + allAttemptsReported: Boolean, pick: (ProviderUsageSnapshot) -> Long?, ): Long? { + if (!allAttemptsReported) return null val values = snapshots.mapNotNull(pick) if (values.size != snapshots.size) return null return values.fold(0L) { acc, value -> TokenCostCalculator.saturatedAdd(acc, value) } @@ -255,7 +263,7 @@ class TokenStatRequestContext( json.optJSONObject("usage")?.let { usageJson -> val usage = usageFromJson(usageJson) context.lastUsage = usage - context.attemptUsages[1] = usage + context.replayAggregatedUsage = usage } val pricingJson = json.optJSONObject("pricing") diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt index d5d9692ef..d3139b8b2 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt @@ -109,7 +109,7 @@ object TokenStatsAggregator { totalTokens.accept(canonicalTotalTokens(event)) val (amount, currency) = eventCost(event, identities[event.statIdentityId], pricing!!, params) - if (amount == null) { + if (amount == null || !amount.isFinite()) { costUnknownCount += 1 } else { originalCosts.merge(currency, BigDecimal(amount)) { left, right -> left.add(right) } @@ -417,7 +417,7 @@ object TokenStatsAggregator { var unknownCount = 0L for (event in events) { val (amount, currency) = eventCost(event, identitiesById[event.statIdentityId], pricing, params) - if (amount == null) { + if (amount == null || !amount.isFinite()) { unknownCount += 1 continue } @@ -471,7 +471,7 @@ object TokenStatsAggregator { outputTokens = TokenCostCalculator.saturatedAdd(outputTokens, baseline.outputTokens) anyEstimated = anyEstimated || baseline.isEstimated val amount = baseline.costInPricingCurrency - if (amount == null) { + if (amount == null || !amount.isFinite()) { unknownCount += 1 } else { original.merge( diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt index 605782c68..99d028149 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -49,7 +49,9 @@ data class TokenStatsQueryParams( val statuses: Set? = null, ) { init { - require(manualRate > 0.0) { "manual rate must be positive" } + require(manualRate.isFinite() && manualRate > 0.0) { + "manual rate must be finite and positive" + } } } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt new file mode 100644 index 000000000..4724abc61 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt @@ -0,0 +1,17 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClaudeProviderCancellationTest { + @Test + fun `normal stream termination is not treated as manual cancellation`() { + assertFalse(shouldPropagateClaudeCancellation(false)) + } + + @Test + fun `cancel streaming marks normal loop exit for cancellation propagation`() { + assertTrue(shouldPropagateClaudeCancellation(true)) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index e973f556e..5a4d3864c 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -673,6 +673,10 @@ class TokenTrackingAIServiceTest { TokenTrackingAIService.classify(CancellationException("c")), ) assertEquals(TokenStatStatus.FAILED, TokenTrackingAIService.classify(IOException("f"))) + assertEquals( + TokenStatStatus.CANCELLED, + TokenTrackingAIService.classify(UserCancellationException("manual stream cancellation")), + ) // 明确非超时取消优先于 cause 链:UserCancellationException(cause=InterruptedIOException) // 必须 CANCELLED,不能被 cause 里的 InterruptedIOException 误判为超时 assertEquals( diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt index 8547ca59c..ebf6f3b45 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt @@ -291,6 +291,59 @@ class ProviderUsageNormalizerTest { assertFalse(snapshot.cacheWriteSeparateBilling) } + @Test + fun `toolpkg cached greater than input keeps total but rejects split`() { + val snapshot = ProviderUsageNormalizer.toolPkg(100L, 250L, 20L, true) + assertEquals(100L, snapshot.totalInputTokens) + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.cachedInputTokens) + val pricing = + ResolvedPricing( + billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN, + currency = com.ai.assistance.operit.data.collects.PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 0.5, + outputPricePerMillion = 1.0, + source = PricingSource.DEFAULT, + known = true, + ) + assertNull( + TokenCostCalculator.computeCost( + snapshot.toTokenUsageInput(), + pricing, + ).amount + ) + assertEquals( + 120.0 / 1_000_000.0, + TokenCostCalculator.computeCost( + snapshot.toTokenUsageInput(), + pricing.copy(cachedInputPricePerMillion = 1.0), + ).amount!!, + 1e-12, + ) + } + + @Test + fun `toolpkg negative input components become unknown independently`() { + val negativeInput = ProviderUsageNormalizer.toolPkg(-1L, 0L, 20L, true) + assertNull(negativeInput.totalInputTokens) + assertNull(negativeInput.uncachedInputTokens) + assertNull(negativeInput.cachedInputTokens) + + val negativeCached = ProviderUsageNormalizer.toolPkg(100L, -1L, 20L, true) + assertEquals(100L, negativeCached.totalInputTokens) + assertNull(negativeCached.uncachedInputTokens) + assertNull(negativeCached.cachedInputTokens) + } + + @Test + fun `toolpkg equal cached and input is a valid zero uncached boundary`() { + val snapshot = ProviderUsageNormalizer.toolPkg(100L, 100L, 20L, true) + assertEquals(0L, snapshot.uncachedInputTokens) + assertEquals(100L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.totalInputTokens) + } + // ==== 快照语义 ==== @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt index e289aa267..3131c2b16 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -4,6 +4,7 @@ import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.model.BillingMode import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class TokenCostCalculatorTest { @@ -366,10 +367,50 @@ outputTokens = 500L, // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 assertEquals(0.0019, cost.amount!!, 1e-12) } + + @Test + fun `non finite token price and overflowing token cost are unknown`() { + val usage = + TokenUsageInput( + uncachedInputTokens = Long.MAX_VALUE, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + outputTokens = Long.MAX_VALUE, + ) + assertNull( + TokenCostCalculator.computeCost( + usage, + tokenPricing.copy(inputPricePerMillion = 1e308), + ).amount + ) + assertNull( + TokenCostCalculator.computeCost( + usage.copy(uncachedInputTokens = 1L, outputTokens = 1L), + tokenPricing.copy(outputPricePerMillion = Double.NaN), + ).amount + ) + } + + @Test + fun `non finite count price is unknown`() { + assertNull( + TokenCostCalculator.computeCost( + TokenUsageInput(), + countPricing.copy(pricePerRequest = Double.POSITIVE_INFINITY), + ).amount + ) + } } class TokenCostCurrencyTest { + @Test + fun `query params reject non finite manual rate`() { + assertTrue(runCatching { TokenStatsQueryParams(manualRate = Double.POSITIVE_INFINITY) }.isFailure) + assertTrue(runCatching { TokenStatsQueryParams(manualRate = Double.NaN) }.isFailure) + } + + @Test fun `default manual rate is 7`() { assertEquals(7.0, TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, 1e-12) @@ -384,7 +425,7 @@ class TokenCostCurrencyTest { to = PricingCurrency.CNY, manualRate = 7.0, ) - assertEquals(70.0, converted, 1e-12) + assertEquals(70.0, converted!!, 1e-12) } @Test @@ -396,7 +437,7 @@ class TokenCostCurrencyTest { to = PricingCurrency.USD, manualRate = 7.0, ) - assertEquals(10.0, converted, 1e-12) + assertEquals(10.0, converted!!, 1e-12) } @Test @@ -408,7 +449,7 @@ class TokenCostCurrencyTest { to = PricingCurrency.CNY, manualRate = 7.0, ) - assertEquals(5.0, converted, 1e-12) + assertEquals(5.0, converted!!, 1e-12) } @Test @@ -418,8 +459,8 @@ class TokenCostCurrencyTest { val atRate7 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 7.0) val atRate8 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 8.0) - assertEquals(70.0, atRate7, 1e-12) - assertEquals(80.0, atRate8, 1e-12) + assertEquals(70.0, atRate7!!, 1e-12) + assertEquals(80.0, atRate8!!, 1e-12) assertEquals(10.0, nativeCost, 1e-12) // 原币成本不受汇率影响 } @@ -488,6 +529,26 @@ outputTokens = 500L, 7.0, ) assertEquals(0.014, historicalCny!!, 1e-12) - assertEquals(0.028, revaluedCny, 1e-12) + assertEquals(0.028, revaluedCny!!, 1e-12) + } + + @Test + fun `currency conversion overflow returns unknown`() { + assertNull( + TokenCostCurrency.convertTo( + Double.MAX_VALUE, + PricingCurrency.USD, + PricingCurrency.CNY, + 2.0, + ) + ) + assertNull( + TokenCostCurrency.historicalCostConverted( + Double.MAX_VALUE, + PricingCurrency.USD, + PricingCurrency.CNY, + 2.0, + ) + ) } } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt new file mode 100644 index 000000000..436b2596a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt @@ -0,0 +1,80 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TokenStatRequestContextTest { + private fun context(eventId: String = "evt-context") = + TokenStatRequestContext( + eventId = eventId, + category = TokenStatCategory.CHAT, + configId = "cfg", + provider = "TEST", + model = "model", + startedAtMs = 1L, + ) + + private fun usage(input: Long, output: Long) = + ProviderUsageSnapshot( + uncachedInputTokens = input, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = input, + outputTokens = output, + source = "test", + ) + + @Test + fun `missing earlier attempt makes every usage component unknown`() { + val request = context() + request.onUsage(usage(input = 200L, output = 20L), attempt = 2) + + val aggregated = request.aggregatedUsage()!! + assertEquals(2, request.attemptCount) + assertNull(aggregated.uncachedInputTokens) + assertNull(aggregated.cachedInputTokens) + assertNull(aggregated.cacheWriteTokens) + assertNull(aggregated.totalInputTokens) + assertNull(aggregated.outputTokens) + assertNull(aggregated.reasoningTokens) + } + + @Test + fun `contiguous attempts sum and duplicate snapshot semantics stay unchanged`() { + val request = context() + request.onUsage(usage(input = 100L, output = 10L), attempt = 1) + request.onUsage(usage(input = 200L, output = 20L), attempt = 2) + request.onUsage(usage(input = 250L, output = 25L), attempt = 2) + + val aggregated = request.aggregatedUsage()!! + assertEquals(350L, aggregated.uncachedInputTokens) + assertEquals(35L, aggregated.outputTokens) + } + + @Test + fun `spool replay preserves already aggregated usage when attempt count exceeds one`() { + val request = context("evt-replay") + request.onUsage(usage(input = 100L, output = 10L), attempt = 1) + request.onUsage(usage(input = 200L, output = 20L), attempt = 2) + request.finish(TokenStatStatus.COMPLETED, 2L) + val pricing = + ResolvedPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = 1.0, + cachedInputPricePerMillion = 1.0, + outputPricePerMillion = 1.0, + source = PricingSource.DEFAULT, + known = true, + ) + + val replay = TokenStatRequestContext.fromSpoolLine(request.toSpoolLine(pricing, 0.00033)) + val aggregated = replay.aggregatedUsage()!! + assertEquals(2, replay.attemptCount) + assertEquals(300L, aggregated.uncachedInputTokens) + assertEquals(30L, aggregated.outputTokens) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt index 4a5894a11..8e85db364 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt @@ -260,6 +260,32 @@ class TokenStatsAggregatorTest { assertFalse(cost.isFullyKnown) } + @Test + fun `non finite historical costs are unknown and never reach BigDecimal`() { + val id = identity("id-1") + val events = + listOf( + event("finite", id.identityId, 1000L, cost = 1.0), + event("infinite", id.identityId, 2000L, cost = Double.POSITIVE_INFINITY), + event("nan", id.identityId, 3000L, cost = Double.NaN), + ) + val rangeCost = aggregated(events, listOf(id)).cost + assertEquals(7.0, rangeCost.knownAmount, 1e-9) + assertEquals(2L, rangeCost.unknownContributionCount) + + val lifetime = + TokenStatsAggregator.lifetime( + events = events, + baselines = listOf(baseline(id.identityId, cost = Double.POSITIVE_INFINITY)), + identitiesById = mapOf(id.identityId to id), + overrides = emptyList(), + legacyPrices = emptyMap(), + params = params, + ) + assertEquals(2L, lifetime.eventTotals.cost.unknownContributionCount) + assertEquals(1L, lifetime.baselineTotals.cost.unknownContributionCount) + } + @Test fun `zero cost is a known contribution`() { val cost = aggregated(listOf(event("e1", "id-1", 1000L, cost = 0.0)), listOf(identity("id-1"))).cost From 90df9f72cf2afba785b1ad5d2dfde7c0512f3f7b Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:45:47 +0800 Subject: [PATCH 16/28] fix(stats): preserve evidence and page activity (cherry picked from commit 212205853ffe579c8647ce4d96d7b3de3ccbe319) --- .../operit/data/dao/TokenStatsDao.kt | 38 +++++- .../operit/data/stats/TokenActivityModels.kt | 112 ++++++++++++++---- .../operit/data/stats/TokenStatSpool.kt | 44 ++++++- .../data/stats/TokenStatsQueryService.kt | 18 ++- .../screens/ChatBackupSettingsScreen.kt | 20 +++- .../TokenUsageStatisticsViewModel.kt | 10 +- app/src/main/res/values-en/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../data/stats/TokenStatReliabilityTest.kt | 55 +++++++++ .../stats/TokenStatsQueryServiceRoomTest.kt | 30 +++++ 10 files changed, 288 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index 192eb1d7f..6f5f0cfe4 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -52,14 +52,21 @@ abstract class TokenStatsDao { @Query("SELECT * FROM token_stat_events") abstract suspend fun getAllEvents(): List - /** 活动热力图只读取所需列,避免把价格、诊断等完整事件字段整表实体化。 */ + /** 活动热力图按 `(startedAtMs, eventId)` 键集分页读取轻量投影。 */ @Query( - "SELECT startedAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + + "SELECT eventId, startedAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + "totalInputTokens, outputTokens, reasoningTokens, reasoningIncludedInOutput, " + "cacheWriteSeparateBilling " + - "FROM token_stat_events" + "FROM token_stat_events " + + "WHERE (startedAtMs > :afterStartMs OR " + + "(startedAtMs = :afterStartMs AND eventId > :afterEventId)) " + + "ORDER BY startedAtMs ASC, eventId ASC LIMIT :limit" ) - abstract suspend fun getTokenActivityRows(): List + abstract suspend fun getTokenActivityRowsPage( + afterStartMs: Long, + afterEventId: String, + limit: Int, + ): List @Query("SELECT COUNT(*) FROM token_stat_events") abstract suspend fun countEvents(): Int @@ -202,6 +209,29 @@ abstract class TokenStatsDao { ) } + /** + * 活动视图的同事务分页快照。每页只保留计算 token、自然日与小时所需的列,调用方 + * 增量压缩后立即释放页面,内存不再随账本事件总数线性增长。 + */ + @Transaction + open suspend fun loadActivitySnapshot( + pageSize: Int, + onRowsPage: (List) -> Unit, + ) { + require(pageSize > 0) { "activity page size must be positive" } + var afterStartMs = Long.MIN_VALUE + var afterEventId = "" + while (true) { + val page = getTokenActivityRowsPage(afterStartMs, afterEventId, pageSize) + if (page.isEmpty()) break + onRowsPage(page) + if (page.size < pageSize) break + val last = page.last() + afterStartMs = last.startedAtMs + afterEventId = last.eventId + } + } + /** * 分组元数据快照(阶段 4 P1 修复):全量身份 + 展示模型行在**同一个事务**内 * 固定读取,与统计筛选(时间/模型/分类/状态)无关——分组管理与合并的 diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt index f67bd04f7..01e70db59 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -15,6 +15,7 @@ data class TokenActivityRecord( ) data class TokenActivityEventRow( + val eventId: String = "", val startedAtMs: Long, val uncachedInputTokens: Long?, val cachedInputTokens: Long?, @@ -27,6 +28,42 @@ data class TokenActivityEventRow( val cacheWriteSeparateBilling: Boolean? = null, ) +internal class TokenActivitySnapshot( + val zone: ZoneId, + val dayTotals: Map, + val earliestYear: Int?, + val hourCounts: LongArray, + val totalRequests: Long, +) + +internal class TokenActivityAccumulator(private val zone: ZoneId) { + private val dayTotals = HashMap() + private val hourCounts = LongArray(24) + private var earliestYear: Int? = null + private var totalRequests = 0L + + fun addPage(rows: List) { + rows.forEach { row -> + val dateTime = Instant.ofEpochMilli(row.startedAtMs).atZone(zone) + val date = dateTime.toLocalDate() + val tokens = row.toActivityRecord().tokens + dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, tokens) + hourCounts[dateTime.hour] = saturatedAdd(hourCounts[dateTime.hour], 1L) + earliestYear = minOf(earliestYear ?: date.year, date.year) + totalRequests = saturatedAdd(totalRequests, 1L) + } + } + + fun snapshot(): TokenActivitySnapshot = + TokenActivitySnapshot( + zone = zone, + dayTotals = dayTotals.toMap(), + earliestYear = earliestYear, + hourCounts = hourCounts.copyOf(), + totalRequests = totalRequests, + ) +} + data class TokenActivityDay( val date: LocalDate, val tokens: Long, @@ -144,24 +181,27 @@ object TokenActivityAggregator { records: List, zone: ZoneId, nowMs: Long = System.currentTimeMillis(), + ): List = availableYears(snapshotOf(records, zone), nowMs) + + internal fun availableYears( + snapshot: TokenActivitySnapshot, + nowMs: Long = System.currentTimeMillis(), ): List { + val zone = snapshot.zone val currentYear = Instant.ofEpochMilli(nowMs).atZone(zone).year - val firstYear = records.minOfOrNull { Instant.ofEpochMilli(it.startedAtMs).atZone(zone).year } - ?.coerceAtMost(currentYear) ?: currentYear + val firstYear = snapshot.earliestYear?.coerceAtMost(currentYear) ?: currentYear return (firstYear..currentYear).toList().reversed() } - fun insights(records: List, zone: ZoneId): TokenActivityInsights { - val hourCounts = LongArray(24) - records.forEach { record -> - val hour = Instant.ofEpochMilli(record.startedAtMs).atZone(zone).hour - hourCounts[hour]++ - } + fun insights(records: List, zone: ZoneId): TokenActivityInsights = + insights(snapshotOf(records, zone)) + + internal fun insights(snapshot: TokenActivitySnapshot): TokenActivityInsights { return TokenActivityInsights( - totalRequests = records.size.toLong(), - topHours = hourCounts.indices - .filter { hourCounts[it] > 0L } - .sortedWith(compareByDescending { hourCounts[it] }.thenBy { it }) + totalRequests = snapshot.totalRequests, + topHours = snapshot.hourCounts.indices + .filter { snapshot.hourCounts[it] > 0L } + .sortedWith(compareByDescending { snapshot.hourCounts[it] }.thenBy { it }) .take(3), ) } @@ -171,11 +211,18 @@ object TokenActivityAggregator { zone: ZoneId, year: Int, nowMs: Long = System.currentTimeMillis(), + ): TokenActivityYearData = yearData(snapshotOf(records, zone), year, nowMs) + + internal fun yearData( + snapshot: TokenActivitySnapshot, + year: Int, + nowMs: Long = System.currentTimeMillis(), ): TokenActivityYearData { + val zone = snapshot.zone val nowDate = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() val start = LocalDate.of(year, 1, 1) val end = if (year == nowDate.year) nowDate else LocalDate.of(year, 12, 31) - return rangeData(records, zone, start, end) + return rangeData(snapshot.dayTotals, start, end) } /** 默认活动窗口:包含今天在内的最近 365 个自然日。 */ @@ -183,25 +230,22 @@ object TokenActivityAggregator { records: List, zone: ZoneId, nowMs: Long = System.currentTimeMillis(), + ): TokenActivityYearData = recentData(snapshotOf(records, zone), nowMs) + + internal fun recentData( + snapshot: TokenActivitySnapshot, + nowMs: Long = System.currentTimeMillis(), ): TokenActivityYearData { + val zone = snapshot.zone val end = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() - return rangeData(records, zone, end.minusDays(364), end) + return rangeData(snapshot.dayTotals, end.minusDays(364), end) } private fun rangeData( - records: List, - zone: ZoneId, + dayTotals: Map, start: LocalDate, end: LocalDate, ): TokenActivityYearData { - val dayTotals = HashMap() - records.forEach { record -> - val date = Instant.ofEpochMilli(record.startedAtMs).atZone(zone).toLocalDate() - if (!date.isBefore(start) && !date.isAfter(end)) { - dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, record.tokens) - } - } - val days = ChronoUnit.DAYS.between(start, end).toInt() + 1 val raw = List(days) { index -> val date = start.plusDays(index.toLong()) @@ -245,6 +289,26 @@ object TokenActivityAggregator { ) } + private fun snapshotOf(records: List, zone: ZoneId): TokenActivitySnapshot { + val dayTotals = HashMap() + val hourCounts = LongArray(24) + var earliestYear: Int? = null + records.forEach { record -> + val dateTime = Instant.ofEpochMilli(record.startedAtMs).atZone(zone) + val date = dateTime.toLocalDate() + dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, record.tokens) + hourCounts[dateTime.hour] = saturatedAdd(hourCounts[dateTime.hour], 1L) + earliestYear = minOf(earliestYear ?: date.year, date.year) + } + return TokenActivitySnapshot( + zone = zone, + dayTotals = dayTotals, + earliestYear = earliestYear, + hourCounts = hourCounts, + totalRequests = records.size.toLong(), + ) + } + private fun stats(days: List): TokenActivityStats { var total = 0L var peak = 0L diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index 0a4359f2f..3556922ba 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -713,6 +713,11 @@ internal object TokenStatSpool { if (drainBefore && hasPendingSegments(appContext)) { throw IOException("statistics spool still contains pending events after drain") } + if (drainBefore && hasQuarantineEvidenceForSnapshotLocked(appContext)) { + throw IOException( + "statistics quarantine evidence must be exported and acknowledged before snapshot", + ) + } // 排他状态必须在 drain 阶段之后设置:drainBefore 自己的 insert 需要登记。 // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 synchronized(stateLock) { exclusiveBarrierActive = true } @@ -1127,8 +1132,8 @@ internal object TokenStatSpool { } /** - * File I/O always runs on [ioDispatcher] (P2-2); the summary file is intentionally kept as - * the bounded rolling record of already-discarded over-cap evidence. 受管失败段(P1-3): + * File I/O always runs on [ioDispatcher] (P2-2); the bounded rolling summary is kept unless + * the caller explicitly acknowledges it with [deleteSummary]. 受管失败段(P1-3): * 按 identity 删除原损坏文件并移除对应 manifest 记录;文件已消失只移除记录;身份不匹配 * 视为陈旧记录绝不删新身份文件。删除失败保留记录并抛错(UI 反馈失败,不声称全部成功)。 * @@ -1150,7 +1155,11 @@ internal object TokenStatSpool { * 目录(NOFOLLOW、拒绝符号链接)。全部名字都是 trash 目录时跳过 manifest 读取(其完整性 * 与 trash 删除无关)。trash 删除放在文件事务完成后,文件侧失败时 trash 保持原样。 */ - suspend fun acknowledgeAndDeleteQuarantine(context: Context, names: Set) = + suspend fun acknowledgeAndDeleteQuarantine( + context: Context, + names: Set, + deleteSummary: Boolean = false, + ) = lifecycleMutex.withLock { withContext(ioDispatcher) { val dir = spoolDir(context.applicationContext) @@ -1179,6 +1188,7 @@ internal object TokenStatSpool { ) } } + if (deleteSummary) deleteQuarantineSummaryLocked(context) return@withContext } val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) @@ -1375,9 +1385,22 @@ internal object TokenStatSpool { ) } } + if (deleteSummary) deleteQuarantineSummaryLocked(context) } } + /** Explicit post-export acknowledgment for the bounded rolling summary and every sidecar. */ + private suspend fun deleteQuarantineSummaryLocked(context: Context) { + val dir = spoolDir(context.applicationContext) + val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) + summaryStore(summaryFile).delete() + val remaining = listDir(dir) + ?: throw IOException("cannot verify quarantine summary deletion: ${dir.absolutePath}") + if (remaining.any { it.name == QUARANTINE_SUMMARY_NAME || it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") }) { + throw IOException("statistics quarantine summary deletion failed: ${summaryFile.absolutePath}") + } + } + /** * ack 的主 manifest 重写(P1-1,调用方持 lifecycleMutex):发布前投影实际总量 + 最坏 * sidecar 增量,超限有界失败不写文件;测试注入缝照常生效。失败抛 [IOException]。 @@ -1898,6 +1921,21 @@ internal object TokenStatSpool { } } + /** + * Raw snapshots intentionally exclude the active spool queue. Quarantine files and their + * summary/tombstone/trash metadata are not queue data and must never be silently omitted from a + * successful snapshot. Until raw restore has a selective evidence-preservation protocol, fail + * before ZIP creation and leave every evidence byte in place for explicit export/acknowledgment. + */ + private suspend fun hasQuarantineEvidenceForSnapshotLocked(context: Context): Boolean { + val dir = spoolDir(context.applicationContext) + if (quarantineAreaFiles(dir).isNotEmpty()) return true + if (stuckAckTrashEvidenceLocked(context).isNotEmpty()) return true + if (readTombstoneLines(context).isNotEmpty()) return true + val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) + return readMetadata(summaryStore(summaryFile), summaryFile)?.isNotBlank() == true + } + /** * P1-2:对 sealed 队列应用受管失败集合。身份匹配的段跳过(受管);身份不匹配或文件已 * 消失的条目是陈旧 tombstone(旧文件已删但 manifest 未更新,随后同名不同 hash 的健康 diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 1d1006e8a..6fadb95ee 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -49,6 +49,9 @@ object TokenStatsQueryService { /** 生命周期事件分页大小(P2-1):固定批次读取 + 增量聚合,避免整表实体化峰值。 */ internal var lifetimeEventPageSize: Int = 1_000 + /** 活动视图事件分页大小:每页读取轻量投影并立即压缩为按日/小时汇总。 */ + internal var activityEventPageSize: Int = 1_000 + // ==== 核心查询(DAO 直连,生产与测试共用) ==== /** 生命周期累计总览(事件 + baseline,独立于筛选;事件分页增量聚合,不整表实体化)。 */ @@ -113,10 +116,19 @@ object TokenStatsQueryService { rangeHasEvents(daoOf(context), range) } - /** 全局 Token 活动使用的轻量事件投影;不应用统计页筛选,也不包含旧 baseline。 */ - suspend fun activityRecords(context: Context): List = + /** + * 全局 Token 活动快照;不应用统计页筛选,也不包含旧 baseline。Room 在同一事务内 + * keyset 分页,页面逐批压缩为按日 token 与按小时请求数,不保留完整历史事件列表。 + */ + internal suspend fun activitySnapshot(dao: TokenStatsDao, zone: ZoneId): TokenActivitySnapshot { + val accumulator = TokenActivityAccumulator(zone) + dao.loadActivitySnapshot(activityEventPageSize) { page -> accumulator.addPage(page) } + return accumulator.snapshot() + } + + internal suspend fun activitySnapshot(context: Context, zone: ZoneId): TokenActivitySnapshot = withContext(queryDispatcher) { - daoOf(context).getTokenActivityRows().map { it.toActivityRecord() } + activitySnapshot(daoOf(context), zone) } /** diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index a603cc43b..2938e9de8 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -1397,7 +1397,9 @@ fun ChatBackupSettingsScreen() { onClick = { showQuarantineDeleteConfirmDialog = true }, modifier = Modifier.weight(1f, fill = false), isDestructive = true, - enabled = !quarantineBusy && quarantineEvidenceCount > 0 + enabled = + !quarantineBusy && + (quarantineEvidenceCount > 0 || quarantineSummaryCount > 0) ) } } @@ -1414,7 +1416,9 @@ fun ChatBackupSettingsScreen() { stringResource( R.string.stats_quarantine_delete_confirm_message, quarantineEvidenceCount, - formatBytes(quarantineEvidenceBytes) + formatBytes(quarantineEvidenceBytes), + quarantineSummaryCount, + formatBytes(quarantineSummaryBytes), ) ) }, @@ -1431,9 +1435,17 @@ fun ChatBackupSettingsScreen() { withContext(Dispatchers.IO) { TokenStatSpool.quarantineEvidence(context).map { it.name }.toSet() } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, names) + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context = context, + names = names, + deleteSummary = quarantineSummaryCount > 0, + ) quarantineOperationMessage = - context.getString(R.string.stats_quarantine_delete_success, names.size) + context.getString( + R.string.stats_quarantine_delete_success, + names.size, + quarantineSummaryCount, + ) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index 4be5fb3aa..c7a89db69 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -232,9 +232,9 @@ class TokenUsageStatisticsViewModel( } activityLoadJob = viewModelScope.launch(dispatcher) { try { - val records = TokenStatsQueryService.activityRecords(appContext) + val snapshot = TokenStatsQueryService.activitySnapshot(appContext, zone) val result = withContext(Dispatchers.Default) { - val years = TokenActivityAggregator.availableYears(records, zone, nowMs()) + val years = TokenActivityAggregator.availableYears(snapshot, nowMs()) val recent = requestedRecent || requestedYear !in years val year = requestedYear?.takeIf { it in years } ?: years.first() ActivityLoadResult( @@ -242,11 +242,11 @@ class TokenUsageStatisticsViewModel( year = year, recent = recent, data = if (recent) { - TokenActivityAggregator.recentData(records, zone, nowMs()) + TokenActivityAggregator.recentData(snapshot, nowMs()) } else { - TokenActivityAggregator.yearData(records, zone, year, nowMs()) + TokenActivityAggregator.yearData(snapshot, year, nowMs()) }, - insights = TokenActivityAggregator.insights(records, zone), + insights = TokenActivityAggregator.insights(snapshot), ) } if (generation != activityLoadGeneration) return@launch diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 01dd384c1..a26981c36 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -5412,10 +5412,10 @@ Evidence export failed: %1$s (export directory cleanup also failed, please remove it manually: %2$s) Delete evidence Delete statistics quarantine evidence? - This permanently deletes %1$d quarantine evidence file(s) (%2$s total). This cannot be undone; exporting the evidence first is recommended. + This permanently deletes %1$d quarantine evidence file(s) (%2$s total) and %3$d quarantine summary record(s) (%4$s total). This cannot be undone; exporting the evidence first is recommended. Delete Cancel - Deleted %1$d quarantine evidence file(s) + Deleted %1$d quarantine evidence file(s) and %2$d quarantine summary record(s) Deletion failed: %1$s Quarantine evidence info unavailable: %1$s (keeping the last shown values) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a8c3d331..4c2bddefc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5859,10 +5859,10 @@ 证据导出失败:%1$s(导出目录清理也失败,请手动删除:%2$s) 确认删除 确认删除统计隔离证据 - 将永久删除 %1$d 个隔离证据文件(共 %2$s)。此操作不可撤销,建议先导出证据。 + 将永久删除 %1$d 个隔离证据文件(共 %2$s)及 %3$d 条隔离摘要(共 %4$s)。此操作不可撤销,建议先导出证据。 确认删除 取消 - 已删除 %1$d 个隔离证据文件 + 已删除 %1$d 个隔离证据文件及 %2$d 条隔离摘要 删除失败:%1$s 隔离证据信息读取失败:%1$s(保留上次显示,请稍后重试) diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt index 56bf58357..c3070177c 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt @@ -661,6 +661,61 @@ class TokenStatReliabilityTest { assertEquals(1, database.tokenStatsDao().countEvents()) } + @Test + fun `snapshot fails before block while quarantine evidence would be excluded`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{corrupt snapshot evidence\n") + var blockRan = false + + Mockito.mockStatic(AppLogger::class.java).use { + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must not silently omit quarantine evidence") + } catch (e: IOException) { + assertTrue(e.message!!.contains("quarantine evidence")) + } + } + + assertFalse("snapshot block must not run", blockRan) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue(evidence.single().readText().contains("corrupt snapshot evidence")) + } + + @Test + fun `summary only evidence can be explicitly acknowledged before snapshot`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summary = File(spool, "quarantine_summary.jsonl") + summary.writeText("{\"count\":1}\n") + var blockRan = false + + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must not silently omit the quarantine summary") + } catch (e: IOException) { + assertTrue(e.message!!.contains("quarantine evidence")) + } + assertFalse(blockRan) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context = context, + names = emptySet(), + deleteSummary = true, + ) + + assertFalse(summary.exists()) + assertEquals(null, TokenStatSpool.quarantineSummaryInfo(context)) + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + assertTrue(blockRan) + } + @Test fun `two corrupt segments quarantine uniquely and healthy segment drains`() = runBlocking { val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt index 923b760f3..017cddf0c 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt @@ -75,6 +75,7 @@ class TokenStatsQueryServiceRoomTest { TokenStatsQueryService.legacyPricesProvider = null TokenStatsQueryService.queryDispatcher = Dispatchers.IO TokenStatsQueryService.lifetimeEventPageSize = 1_000 + TokenStatsQueryService.activityEventPageSize = 1_000 database.close() } @@ -527,6 +528,35 @@ class TokenStatsQueryServiceRoomTest { assertTrue(pageQueries.all { it.binds[4] == "7" }) } + @Test + fun `activity snapshot pages the ledger and incrementally preserves totals`() = runBlocking { + TokenStatsQueryService.activityEventPageSize = 7 + val dao = database.tokenStatsDao() + seedIdentity(dao, "id-1") + dao.insertEvents( + (0 until 20).map { index -> + event( + id = "activity-$index", + identityId = "id-1", + startedAtMs = localMs("2026-08-${(1 + index / 5).toString().padStart(2, '0')}T${(index % 5 + 8).toString().padStart(2, '0')}:00:00", shanghai), + ) + } + ) + recordingDriver.clear() + + val snapshot = TokenStatsQueryService.activitySnapshot(dao, shanghai) + + assertEquals(20L, snapshot.totalRequests) + assertEquals(4, snapshot.dayTotals.size) + assertEquals(3_000L, snapshot.dayTotals.values.sum()) + assertEquals(20L, snapshot.hourCounts.sum()) + val pageQueries = recordingDriver.executed.filter { + it.sql.contains("SELECT eventId, startedAtMs") && it.sql.contains("ORDER BY startedAtMs ASC") + } + assertEquals(listOf(7, 7, 6), pageQueries.map { it.rows }) + assertTrue(pageQueries.all { it.binds[4] == "7" }) + } + // ==== 快照一致性(P1-2:并发提交完全前或完全后) ==== private fun assertInternallyConsistent(data: TokenStatsRangeData, expectedRequests: Long) { From 05425c3b9d4aad913cdb66e400bf38eb139b26fd Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:53:05 +0800 Subject: [PATCH 17/28] fix(stats): resolve dev quality failures (cherry picked from commit 157d353eb0130681f5cc6d9647026d4ccaef0b46) --- .../ui/features/tokenstats/TokenActivitySection.kt | 10 ++++++---- .../chat/llmprovider/TokenTrackingAIServiceTest.kt | 12 ++++++++++-- .../preferences/ApiPreferencesResetFailureTest.kt | 7 +++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt index 7e30c1c48..d74ae4fe3 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -72,6 +72,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -336,6 +337,7 @@ private fun TokenActivityStat(label: String, value: String, modifier: Modifier = @Composable private fun TokenActivityProfileCard() { val context = LocalContext.current + val resources = LocalResources.current val prefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) } var nickname by remember { mutableStateOf(prefs.getString(KEY_NICKNAME, "").orEmpty()) } var email by remember { mutableStateOf(prefs.getString(KEY_EMAIL, "").orEmpty()) } @@ -396,7 +398,7 @@ private fun TokenActivityProfileCard() { } else if (result.error != null) { Toast.makeText( context, - context.getString(R.string.avatar_crop_failed, result.error?.message.orEmpty()), + resources.getString(R.string.avatar_crop_failed, result.error?.message.orEmpty()), Toast.LENGTH_LONG, ).show() } @@ -413,8 +415,8 @@ private fun TokenActivityProfileCard() { fixAspectRatio = true aspectRatioX = 1 aspectRatioY = 1 - cropMenuCropButtonTitle = context.getString(R.string.theme_crop_done) - activityTitle = context.getString(R.string.crop_avatar) + cropMenuCropButtonTitle = resources.getString(R.string.theme_crop_done) + activityTitle = resources.getString(R.string.crop_avatar) toolbarColor = Color.Gray.toArgb() toolbarTitleColor = Color.White.toArgb() }, @@ -541,7 +543,7 @@ private fun TokenActivityProfileCard() { } else { Toast.makeText( context, - context.getString(R.string.token_activity_profile_avatar_import_failed), + resources.getString(R.string.token_activity_profile_avatar_import_failed), Toast.LENGTH_LONG, ).show() } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index 5a4d3864c..e9253227f 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -285,9 +285,13 @@ class TokenTrackingAIServiceTest { } } val firstDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < firstDeadline) { + while (TokenStatSpool.activeInsertCountForTest() == 0 && System.nanoTime() < firstDeadline) { delay(10) } + assertTrue( + "the first Room insert must be active before restore starts", + TokenStatSpool.activeInsertCountForTest() > 0, + ) try { TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } fail("restore must fail bounded while the caller's insert is still live") @@ -312,9 +316,13 @@ class TokenTrackingAIServiceTest { } } val secondDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < secondDeadline) { + while (TokenStatSpool.activeInsertCountForTest() == 0 && System.nanoTime() < secondDeadline) { delay(10) } + assertTrue( + "the second Room insert must be active before restore starts", + TokenStatSpool.activeInsertCountForTest() > 0, + ) try { TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } fail("restore must fail bounded while the caller's insert is still live") diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt index c16902ed2..a566c7d6d 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt @@ -11,6 +11,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Assert.fail +import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.Mockito @@ -32,6 +33,12 @@ class ApiPreferencesResetFailureTest { @Before fun isolateDataStoreSingleton() { clearApiDataStoreSingleton() + ApiPreferences.toolPkgProviderNamesProvider = { emptyList() } + } + + @After + fun clearTestProviders() { + ApiPreferences.toolPkgProviderNamesProvider = null } private fun contextWithFiles(tempDir: File): Context { From 6d314754fa2eada1e19bae286f97057c82d29c59 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:09:37 +0800 Subject: [PATCH 18/28] refactor(stats): split spool reliability components (cherry picked from commit 3ec8b23f3cf12e3d0e1b386d97b6c70fcba78508) --- .../30.json | 1274 ---- .../31.json | 1413 ---- .../operit/data/stats/CleanupOutbox.kt | 849 +++ .../operit/data/stats/Quarantine.kt | 434 ++ .../operit/data/stats/SpoolDrain.kt | 159 + .../operit/data/stats/SpoolFileSystem.kt | 120 + .../assistance/operit/data/stats/SpoolLock.kt | 211 + .../operit/data/stats/SpoolReader.kt | 402 ++ .../operit/data/stats/SpoolRecovery.kt | 525 ++ .../operit/data/stats/SpoolWriter.kt | 44 + .../operit/data/stats/TokenStatSpool.kt | 2573 +------- .../data/stats/CleanupReliabilityTest.kt | 2539 ++++++++ .../data/stats/QuarantineReliabilityTest.kt | 758 +++ .../data/stats/RestoreReliabilityTest.kt | 778 +++ .../operit/data/stats/SpoolConcurrencyTest.kt | 378 ++ .../operit/data/stats/SpoolRecoveryTest.kt | 855 +++ .../data/stats/SpoolWriterReliabilityTest.kt | 486 ++ .../data/stats/TokenStatReliabilityTest.kt | 5784 ----------------- .../stats/TokenStatReliabilityTestBase.kt | 293 + 19 files changed, 8936 insertions(+), 10939 deletions(-) delete mode 100644 app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json delete mode 100644 app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt diff --git a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json deleted file mode 100644 index 81c77fb30..000000000 --- a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/30.json +++ /dev/null @@ -1,1274 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 30, - "identityHash": "bff25c874a4958bb715deb3b47854c70", - "entities": [ - { - "tableName": "chats", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `currentWindowSize` INTEGER NOT NULL, `group` TEXT, `folderId` TEXT, `displayOrder` INTEGER NOT NULL, `workspace` TEXT, `workspaceEnv` TEXT, `parentChatId` TEXT, `chatKind` TEXT NOT NULL DEFAULT 'NORMAL', `characterCardName` TEXT, `characterGroupId` TEXT, `locked` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastMessageAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`folderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "currentWindowSize", - "columnName": "currentWindowSize", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT" - }, - { - "fieldPath": "folderId", - "columnName": "folderId", - "affinity": "TEXT" - }, - { - "fieldPath": "displayOrder", - "columnName": "displayOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "workspace", - "columnName": "workspace", - "affinity": "TEXT" - }, - { - "fieldPath": "workspaceEnv", - "columnName": "workspaceEnv", - "affinity": "TEXT" - }, - { - "fieldPath": "parentChatId", - "columnName": "parentChatId", - "affinity": "TEXT" - }, - { - "fieldPath": "chatKind", - "columnName": "chatKind", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'NORMAL'" - }, - { - "fieldPath": "characterCardName", - "columnName": "characterCardName", - "affinity": "TEXT" - }, - { - "fieldPath": "characterGroupId", - "columnName": "characterGroupId", - "affinity": "TEXT" - }, - { - "fieldPath": "locked", - "columnName": "locked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pinned", - "columnName": "pinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastMessageAt", - "columnName": "lastMessageAt", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_chats_folderId", - "unique": false, - "columnNames": [ - "folderId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_folderId` ON `${TABLE_NAME}` (`folderId`)" - }, - { - "name": "index_chats_chatKind", - "unique": false, - "columnNames": [ - "chatKind" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_chatKind` ON `${TABLE_NAME}` (`chatKind`)" - }, - { - "name": "index_chats_parentChatId_chatKind", - "unique": false, - "columnNames": [ - "parentChatId", - "chatKind" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_parentChatId_chatKind` ON `${TABLE_NAME}` (`parentChatId`, `chatKind`)" - } - ], - "foreignKeys": [ - { - "table": "chat_folders", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "folderId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "chat_folders", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `parentFolderId` TEXT, `displayOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentFolderId", - "columnName": "parentFolderId", - "affinity": "TEXT" - }, - { - "fieldPath": "displayOrder", - "columnName": "displayOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_chat_folders_parentFolderId_displayOrder", - "unique": false, - "columnNames": [ - "parentFolderId", - "displayOrder" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_folders_parentFolderId_displayOrder` ON `${TABLE_NAME}` (`parentFolderId`, `displayOrder`)" - } - ], - "foreignKeys": [ - { - "table": "chat_folders", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "parentFolderId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "messages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `sender` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `roleName` TEXT NOT NULL, `selectedVariantIndex` INTEGER NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "messageId", - "columnName": "messageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "chatId", - "columnName": "chatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sender", - "columnName": "sender", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "orderIndex", - "columnName": "orderIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "roleName", - "columnName": "roleName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "selectedVariantIndex", - "columnName": "selectedVariantIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sentAt", - "columnName": "sentAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputDurationMs", - "columnName": "outputDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "waitDurationMs", - "columnName": "waitDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "displayMode", - "columnName": "displayMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "messageId" - ] - }, - "indices": [ - { - "name": "index_messages_chatId", - "unique": false, - "columnNames": [ - "chatId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `${TABLE_NAME}` (`chatId`)" - }, - { - "name": "index_messages_chatId_timestamp", - "unique": false, - "columnNames": [ - "chatId", - "timestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` ON `${TABLE_NAME}` (`chatId`, `timestamp`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "message_variants", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `messageTimestamp` INTEGER NOT NULL, `variantIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, `roleName` TEXT NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "variantId", - "columnName": "variantId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "chatId", - "columnName": "chatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "messageTimestamp", - "columnName": "messageTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "variantIndex", - "columnName": "variantIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "roleName", - "columnName": "roleName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sentAt", - "columnName": "sentAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputDurationMs", - "columnName": "outputDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "waitDurationMs", - "columnName": "waitDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "variantId" - ] - }, - "indices": [ - { - "name": "index_message_variants_chatId_messageTimestamp", - "unique": false, - "columnNames": [ - "chatId", - "messageTimestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`)" - }, - { - "name": "index_message_variants_chatId_messageTimestamp_variantIndex", - "unique": true, - "columnNames": [ - "chatId", - "messageTimestamp", - "variantIndex" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp_variantIndex` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`, `variantIndex`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "subagent_runs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentChatId` TEXT NOT NULL, `childChatId` TEXT NOT NULL, `parentToolCallId` TEXT, `agentProfileId` TEXT NOT NULL, `title` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `startedAt` INTEGER, `completedAt` INTEGER, `error` TEXT, `agentConfigSnapshot` TEXT, `modelConfigIdSnapshot` TEXT, `modelIndexSnapshot` INTEGER, `toolInvocationCount` INTEGER NOT NULL, `archivedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`parentChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`childChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentChatId", - "columnName": "parentChatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "childChatId", - "columnName": "childChatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentToolCallId", - "columnName": "parentToolCallId", - "affinity": "TEXT" - }, - { - "fieldPath": "agentProfileId", - "columnName": "agentProfileId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "startedAt", - "columnName": "startedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "error", - "columnName": "error", - "affinity": "TEXT" - }, - { - "fieldPath": "agentConfigSnapshot", - "columnName": "agentConfigSnapshot", - "affinity": "TEXT" - }, - { - "fieldPath": "modelConfigIdSnapshot", - "columnName": "modelConfigIdSnapshot", - "affinity": "TEXT" - }, - { - "fieldPath": "modelIndexSnapshot", - "columnName": "modelIndexSnapshot", - "affinity": "INTEGER" - }, - { - "fieldPath": "toolInvocationCount", - "columnName": "toolInvocationCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "archivedAt", - "columnName": "archivedAt", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_subagent_runs_parentChatId", - "unique": false, - "columnNames": [ - "parentChatId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId` ON `${TABLE_NAME}` (`parentChatId`)" - }, - { - "name": "index_subagent_runs_childChatId", - "unique": true, - "columnNames": [ - "childChatId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subagent_runs_childChatId` ON `${TABLE_NAME}` (`childChatId`)" - }, - { - "name": "index_subagent_runs_parentChatId_parentToolCallId", - "unique": false, - "columnNames": [ - "parentChatId", - "parentToolCallId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId_parentToolCallId` ON `${TABLE_NAME}` (`parentChatId`, `parentToolCallId`)" - }, - { - "name": "index_subagent_runs_status", - "unique": false, - "columnNames": [ - "status" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_status` ON `${TABLE_NAME}` (`status`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "NO ACTION", - "onUpdate": "NO ACTION", - "columns": [ - "parentChatId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "childChatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "token_stat_events", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `statIdentityId` TEXT NOT NULL, `category` TEXT NOT NULL, `status` TEXT NOT NULL, `acceptedGeneration` INTEGER NOT NULL, `startedAtMs` INTEGER NOT NULL, `endedAtMs` INTEGER NOT NULL, `firstTokenAtMs` INTEGER, `uncachedInputTokens` INTEGER, `cachedInputTokens` INTEGER, `cacheWriteTokens` INTEGER, `totalInputTokens` INTEGER, `outputTokens` INTEGER, `reasoningTokens` INTEGER, `reasoningIncludedInOutput` INTEGER, `cacheWriteSeparateBilling` INTEGER, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL, `pricingSource` TEXT NOT NULL, `costInPricingCurrency` REAL, `diagnosticsJson` TEXT, PRIMARY KEY(`eventId`), FOREIGN KEY(`statIdentityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "statIdentityId", - "columnName": "statIdentityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "acceptedGeneration", - "columnName": "acceptedGeneration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "startedAtMs", - "columnName": "startedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "endedAtMs", - "columnName": "endedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "firstTokenAtMs", - "columnName": "firstTokenAtMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "uncachedInputTokens", - "columnName": "uncachedInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "cacheWriteTokens", - "columnName": "cacheWriteTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "totalInputTokens", - "columnName": "totalInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "reasoningTokens", - "columnName": "reasoningTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "reasoningIncludedInOutput", - "columnName": "reasoningIncludedInOutput", - "affinity": "INTEGER" - }, - { - "fieldPath": "cacheWriteSeparateBilling", - "columnName": "cacheWriteSeparateBilling", - "affinity": "INTEGER" - }, - { - "fieldPath": "billingMode", - "columnName": "billingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputPricePerMillion", - "columnName": "inputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cachedInputPricePerMillion", - "columnName": "cachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cacheWritePricePerMillion", - "columnName": "cacheWritePricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "outputPricePerMillion", - "columnName": "outputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "pricePerRequest", - "columnName": "pricePerRequest", - "affinity": "REAL" - }, - { - "fieldPath": "pricingSource", - "columnName": "pricingSource", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "costInPricingCurrency", - "columnName": "costInPricingCurrency", - "affinity": "REAL" - }, - { - "fieldPath": "diagnosticsJson", - "columnName": "diagnosticsJson", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "eventId" - ] - }, - "indices": [ - { - "name": "index_token_stat_events_statIdentityId_startedAtMs", - "unique": false, - "columnNames": [ - "statIdentityId", - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_statIdentityId_startedAtMs` ON `${TABLE_NAME}` (`statIdentityId`, `startedAtMs`)" - }, - { - "name": "index_token_stat_events_startedAtMs", - "unique": false, - "columnNames": [ - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` ON `${TABLE_NAME}` (`startedAtMs`)" - }, - { - "name": "index_token_stat_events_category_startedAtMs", - "unique": false, - "columnNames": [ - "category", - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` ON `${TABLE_NAME}` (`category`, `startedAtMs`)" - } - ], - "foreignKeys": [ - { - "table": "token_stat_identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "statIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "token_stat_identities", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `configId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `displayModelId` TEXT NOT NULL, PRIMARY KEY(`identityId`))", - "fields": [ - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configId", - "columnName": "configId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayModelId", - "columnName": "displayModelId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identityId" - ] - }, - "indices": [ - { - "name": "index_token_stat_identities_configId_provider_model", - "unique": true, - "columnNames": [ - "configId", - "provider", - "model" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_identities_configId_provider_model` ON `${TABLE_NAME}` (`configId`, `provider`, `model`)" - }, - { - "name": "index_token_stat_identities_displayModelId", - "unique": false, - "columnNames": [ - "displayModelId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` ON `${TABLE_NAME}` (`displayModelId`)" - } - ] - }, - { - "tableName": "token_stat_display_models", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`displayModelId` TEXT NOT NULL, `normalizedModel` TEXT NOT NULL, `displayName` TEXT NOT NULL, PRIMARY KEY(`displayModelId`))", - "fields": [ - { - "fieldPath": "displayModelId", - "columnName": "displayModelId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "normalizedModel", - "columnName": "normalizedModel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "displayModelId" - ] - }, - "indices": [ - { - "name": "index_token_stat_display_models_normalizedModel", - "unique": true, - "columnNames": [ - "normalizedModel" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_display_models_normalizedModel` ON `${TABLE_NAME}` (`normalizedModel`)" - } - ] - }, - { - "tableName": "token_stat_price_overrides", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scope` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `configId` TEXT NOT NULL, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL)", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configId", - "columnName": "configId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "billingMode", - "columnName": "billingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputPricePerMillion", - "columnName": "inputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cachedInputPricePerMillion", - "columnName": "cachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cacheWritePricePerMillion", - "columnName": "cacheWritePricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "outputPricePerMillion", - "columnName": "outputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "pricePerRequest", - "columnName": "pricePerRequest", - "affinity": "REAL" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_token_stat_price_overrides_scope_provider_model_configId", - "unique": true, - "columnNames": [ - "scope", - "provider", - "model", - "configId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_price_overrides_scope_provider_model_configId` ON `${TABLE_NAME}` (`scope`, `provider`, `model`, `configId`)" - } - ] - }, - { - "tableName": "token_stat_baselines", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `requestCount` INTEGER NOT NULL, `pricingCurrency` TEXT NOT NULL, `costInPricingCurrency` REAL, `isEstimated` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `importedAtMs` INTEGER NOT NULL, `frozenBillingMode` TEXT NOT NULL, `frozenInputPricePerMillion` REAL, `frozenCachedInputPricePerMillion` REAL, `frozenOutputPricePerMillion` REAL, `frozenPricePerRequest` REAL, PRIMARY KEY(`identityId`), FOREIGN KEY(`identityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "requestCount", - "columnName": "requestCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "costInPricingCurrency", - "columnName": "costInPricingCurrency", - "affinity": "REAL" - }, - { - "fieldPath": "isEstimated", - "columnName": "isEstimated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fingerprint", - "columnName": "fingerprint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "importedAtMs", - "columnName": "importedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "frozenBillingMode", - "columnName": "frozenBillingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "frozenInputPricePerMillion", - "columnName": "frozenInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenCachedInputPricePerMillion", - "columnName": "frozenCachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenOutputPricePerMillion", - "columnName": "frozenOutputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenPricePerRequest", - "columnName": "frozenPricePerRequest", - "affinity": "REAL" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identityId" - ] - }, - "foreignKeys": [ - { - "table": "token_stat_identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "identityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "token_stat_restore_generations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` TEXT NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", - "fields": [ - { - "fieldPath": "generation", - "columnName": "generation", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "appliedAtMs", - "columnName": "appliedAtMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "generation" - ] - } - }, - { - "tableName": "token_stat_reset_cutoffs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `generation` INTEGER NOT NULL, PRIMARY KEY(`kind`, `provider`, `model`))", - "fields": [ - { - "fieldPath": "kind", - "columnName": "kind", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "generation", - "columnName": "generation", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "kind", - "provider", - "model" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bff25c874a4958bb715deb3b47854c70')" - ] - } -} \ No newline at end of file diff --git a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json b/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json deleted file mode 100644 index 399795e78..000000000 --- a/app/schemas/com.ai.assistance.operit.data.db.AppDatabase/31.json +++ /dev/null @@ -1,1413 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 31, - "identityHash": "f518d4399dae2579dd24e4ddc77d0517", - "entities": [ - { - "tableName": "chats", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `currentWindowSize` INTEGER NOT NULL, `group` TEXT, `folderId` TEXT, `displayOrder` INTEGER NOT NULL, `workspace` TEXT, `workspaceEnv` TEXT, `parentChatId` TEXT, `chatKind` TEXT NOT NULL DEFAULT 'NORMAL', `characterCardName` TEXT, `characterGroupId` TEXT, `locked` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `isFavorite` INTEGER NOT NULL, `lastMessageAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`folderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "currentWindowSize", - "columnName": "currentWindowSize", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "group", - "columnName": "group", - "affinity": "TEXT" - }, - { - "fieldPath": "folderId", - "columnName": "folderId", - "affinity": "TEXT" - }, - { - "fieldPath": "displayOrder", - "columnName": "displayOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "workspace", - "columnName": "workspace", - "affinity": "TEXT" - }, - { - "fieldPath": "workspaceEnv", - "columnName": "workspaceEnv", - "affinity": "TEXT" - }, - { - "fieldPath": "parentChatId", - "columnName": "parentChatId", - "affinity": "TEXT" - }, - { - "fieldPath": "chatKind", - "columnName": "chatKind", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'NORMAL'" - }, - { - "fieldPath": "characterCardName", - "columnName": "characterCardName", - "affinity": "TEXT" - }, - { - "fieldPath": "characterGroupId", - "columnName": "characterGroupId", - "affinity": "TEXT" - }, - { - "fieldPath": "locked", - "columnName": "locked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pinned", - "columnName": "pinned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastMessageAt", - "columnName": "lastMessageAt", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_chats_folderId", - "unique": false, - "columnNames": [ - "folderId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_folderId` ON `${TABLE_NAME}` (`folderId`)" - }, - { - "name": "index_chats_chatKind", - "unique": false, - "columnNames": [ - "chatKind" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_chatKind` ON `${TABLE_NAME}` (`chatKind`)" - }, - { - "name": "index_chats_parentChatId_chatKind", - "unique": false, - "columnNames": [ - "parentChatId", - "chatKind" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chats_parentChatId_chatKind` ON `${TABLE_NAME}` (`parentChatId`, `chatKind`)" - } - ], - "foreignKeys": [ - { - "table": "chat_folders", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "folderId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "chat_folders", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `parentFolderId` TEXT, `displayOrder` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`parentFolderId`) REFERENCES `chat_folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentFolderId", - "columnName": "parentFolderId", - "affinity": "TEXT" - }, - { - "fieldPath": "displayOrder", - "columnName": "displayOrder", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_chat_folders_parentFolderId_displayOrder", - "unique": false, - "columnNames": [ - "parentFolderId", - "displayOrder" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_folders_parentFolderId_displayOrder` ON `${TABLE_NAME}` (`parentFolderId`, `displayOrder`)" - } - ], - "foreignKeys": [ - { - "table": "chat_folders", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "parentFolderId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "messages", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `sender` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `orderIndex` INTEGER NOT NULL, `roleName` TEXT NOT NULL, `selectedVariantIndex` INTEGER NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, `displayMode` TEXT NOT NULL, `isFavorite` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "messageId", - "columnName": "messageId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "chatId", - "columnName": "chatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sender", - "columnName": "sender", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timestamp", - "columnName": "timestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "orderIndex", - "columnName": "orderIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "roleName", - "columnName": "roleName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "selectedVariantIndex", - "columnName": "selectedVariantIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sentAt", - "columnName": "sentAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputDurationMs", - "columnName": "outputDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "waitDurationMs", - "columnName": "waitDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "displayMode", - "columnName": "displayMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isFavorite", - "columnName": "isFavorite", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "messageId" - ] - }, - "indices": [ - { - "name": "index_messages_chatId", - "unique": false, - "columnNames": [ - "chatId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `${TABLE_NAME}` (`chatId`)" - }, - { - "name": "index_messages_chatId_timestamp", - "unique": false, - "columnNames": [ - "chatId", - "timestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` ON `${TABLE_NAME}` (`chatId`, `timestamp`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "message_variants", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatId` TEXT NOT NULL, `messageTimestamp` INTEGER NOT NULL, `variantIndex` INTEGER NOT NULL, `content` TEXT NOT NULL, `roleName` TEXT NOT NULL, `provider` TEXT NOT NULL, `modelName` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `sentAt` INTEGER NOT NULL, `outputDurationMs` INTEGER NOT NULL, `waitDurationMs` INTEGER NOT NULL, `completedAt` INTEGER NOT NULL, FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "variantId", - "columnName": "variantId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "chatId", - "columnName": "chatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "messageTimestamp", - "columnName": "messageTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "variantIndex", - "columnName": "variantIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "content", - "columnName": "content", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "roleName", - "columnName": "roleName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "modelName", - "columnName": "modelName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "sentAt", - "columnName": "sentAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputDurationMs", - "columnName": "outputDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "waitDurationMs", - "columnName": "waitDurationMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "variantId" - ] - }, - "indices": [ - { - "name": "index_message_variants_chatId_messageTimestamp", - "unique": false, - "columnNames": [ - "chatId", - "messageTimestamp" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`)" - }, - { - "name": "index_message_variants_chatId_messageTimestamp_variantIndex", - "unique": true, - "columnNames": [ - "chatId", - "messageTimestamp", - "variantIndex" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp_variantIndex` ON `${TABLE_NAME}` (`chatId`, `messageTimestamp`, `variantIndex`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "chatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "subagent_runs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `parentChatId` TEXT NOT NULL, `childChatId` TEXT NOT NULL, `parentToolCallId` TEXT, `agentProfileId` TEXT NOT NULL, `title` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `startedAt` INTEGER, `completedAt` INTEGER, `error` TEXT, `agentConfigSnapshot` TEXT, `modelConfigIdSnapshot` TEXT, `modelIndexSnapshot` INTEGER, `toolInvocationCount` INTEGER NOT NULL, `archivedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`parentChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`childChatId`) REFERENCES `chats`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentChatId", - "columnName": "parentChatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "childChatId", - "columnName": "childChatId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentToolCallId", - "columnName": "parentToolCallId", - "affinity": "TEXT" - }, - { - "fieldPath": "agentProfileId", - "columnName": "agentProfileId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "startedAt", - "columnName": "startedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "completedAt", - "columnName": "completedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "error", - "columnName": "error", - "affinity": "TEXT" - }, - { - "fieldPath": "agentConfigSnapshot", - "columnName": "agentConfigSnapshot", - "affinity": "TEXT" - }, - { - "fieldPath": "modelConfigIdSnapshot", - "columnName": "modelConfigIdSnapshot", - "affinity": "TEXT" - }, - { - "fieldPath": "modelIndexSnapshot", - "columnName": "modelIndexSnapshot", - "affinity": "INTEGER" - }, - { - "fieldPath": "toolInvocationCount", - "columnName": "toolInvocationCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "archivedAt", - "columnName": "archivedAt", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_subagent_runs_parentChatId", - "unique": false, - "columnNames": [ - "parentChatId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId` ON `${TABLE_NAME}` (`parentChatId`)" - }, - { - "name": "index_subagent_runs_childChatId", - "unique": true, - "columnNames": [ - "childChatId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subagent_runs_childChatId` ON `${TABLE_NAME}` (`childChatId`)" - }, - { - "name": "index_subagent_runs_parentChatId_parentToolCallId", - "unique": false, - "columnNames": [ - "parentChatId", - "parentToolCallId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_parentChatId_parentToolCallId` ON `${TABLE_NAME}` (`parentChatId`, `parentToolCallId`)" - }, - { - "name": "index_subagent_runs_status", - "unique": false, - "columnNames": [ - "status" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_subagent_runs_status` ON `${TABLE_NAME}` (`status`)" - } - ], - "foreignKeys": [ - { - "table": "chats", - "onDelete": "NO ACTION", - "onUpdate": "NO ACTION", - "columns": [ - "parentChatId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "chats", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "childChatId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "token_stat_events", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`eventId` TEXT NOT NULL, `statIdentityId` TEXT NOT NULL, `category` TEXT NOT NULL, `status` TEXT NOT NULL, `acceptedGeneration` INTEGER NOT NULL, `startedAtMs` INTEGER NOT NULL, `endedAtMs` INTEGER NOT NULL, `firstTokenAtMs` INTEGER, `uncachedInputTokens` INTEGER, `cachedInputTokens` INTEGER, `cacheWriteTokens` INTEGER, `totalInputTokens` INTEGER, `outputTokens` INTEGER, `reasoningTokens` INTEGER, `reasoningIncludedInOutput` INTEGER, `cacheWriteSeparateBilling` INTEGER, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL, `pricingSource` TEXT NOT NULL, `costInPricingCurrency` REAL, `diagnosticsJson` TEXT, PRIMARY KEY(`eventId`), FOREIGN KEY(`statIdentityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "eventId", - "columnName": "eventId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "statIdentityId", - "columnName": "statIdentityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "category", - "columnName": "category", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "acceptedGeneration", - "columnName": "acceptedGeneration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "startedAtMs", - "columnName": "startedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "endedAtMs", - "columnName": "endedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "firstTokenAtMs", - "columnName": "firstTokenAtMs", - "affinity": "INTEGER" - }, - { - "fieldPath": "uncachedInputTokens", - "columnName": "uncachedInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "cacheWriteTokens", - "columnName": "cacheWriteTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "totalInputTokens", - "columnName": "totalInputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "reasoningTokens", - "columnName": "reasoningTokens", - "affinity": "INTEGER" - }, - { - "fieldPath": "reasoningIncludedInOutput", - "columnName": "reasoningIncludedInOutput", - "affinity": "INTEGER" - }, - { - "fieldPath": "cacheWriteSeparateBilling", - "columnName": "cacheWriteSeparateBilling", - "affinity": "INTEGER" - }, - { - "fieldPath": "billingMode", - "columnName": "billingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputPricePerMillion", - "columnName": "inputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cachedInputPricePerMillion", - "columnName": "cachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cacheWritePricePerMillion", - "columnName": "cacheWritePricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "outputPricePerMillion", - "columnName": "outputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "pricePerRequest", - "columnName": "pricePerRequest", - "affinity": "REAL" - }, - { - "fieldPath": "pricingSource", - "columnName": "pricingSource", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "costInPricingCurrency", - "columnName": "costInPricingCurrency", - "affinity": "REAL" - }, - { - "fieldPath": "diagnosticsJson", - "columnName": "diagnosticsJson", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "eventId" - ] - }, - "indices": [ - { - "name": "index_token_stat_events_statIdentityId_startedAtMs", - "unique": false, - "columnNames": [ - "statIdentityId", - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_statIdentityId_startedAtMs` ON `${TABLE_NAME}` (`statIdentityId`, `startedAtMs`)" - }, - { - "name": "index_token_stat_events_startedAtMs", - "unique": false, - "columnNames": [ - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` ON `${TABLE_NAME}` (`startedAtMs`)" - }, - { - "name": "index_token_stat_events_category_startedAtMs", - "unique": false, - "columnNames": [ - "category", - "startedAtMs" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` ON `${TABLE_NAME}` (`category`, `startedAtMs`)" - } - ], - "foreignKeys": [ - { - "table": "token_stat_identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "statIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "token_stat_identities", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `configId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `displayModelId` TEXT NOT NULL, PRIMARY KEY(`identityId`))", - "fields": [ - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configId", - "columnName": "configId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayModelId", - "columnName": "displayModelId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identityId" - ] - }, - "indices": [ - { - "name": "index_token_stat_identities_configId_provider_model", - "unique": true, - "columnNames": [ - "configId", - "provider", - "model" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_identities_configId_provider_model` ON `${TABLE_NAME}` (`configId`, `provider`, `model`)" - }, - { - "name": "index_token_stat_identities_displayModelId", - "unique": false, - "columnNames": [ - "displayModelId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` ON `${TABLE_NAME}` (`displayModelId`)" - } - ] - }, - { - "tableName": "token_stat_display_models", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`displayModelId` TEXT NOT NULL, `normalizedModel` TEXT NOT NULL, `displayName` TEXT NOT NULL, PRIMARY KEY(`displayModelId`))", - "fields": [ - { - "fieldPath": "displayModelId", - "columnName": "displayModelId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "normalizedModel", - "columnName": "normalizedModel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "displayModelId" - ] - }, - "indices": [ - { - "name": "index_token_stat_display_models_normalizedModel", - "unique": true, - "columnNames": [ - "normalizedModel" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_display_models_normalizedModel` ON `${TABLE_NAME}` (`normalizedModel`)" - } - ] - }, - { - "tableName": "token_stat_price_overrides", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scope` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `configId` TEXT NOT NULL, `billingMode` TEXT NOT NULL, `pricingCurrency` TEXT NOT NULL, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, `pricePerRequest` REAL)", - "fields": [ - { - "fieldPath": "rowId", - "columnName": "rowId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "configId", - "columnName": "configId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "billingMode", - "columnName": "billingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputPricePerMillion", - "columnName": "inputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cachedInputPricePerMillion", - "columnName": "cachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "cacheWritePricePerMillion", - "columnName": "cacheWritePricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "outputPricePerMillion", - "columnName": "outputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "pricePerRequest", - "columnName": "pricePerRequest", - "affinity": "REAL" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "rowId" - ] - }, - "indices": [ - { - "name": "index_token_stat_price_overrides_scope_provider_model_configId", - "unique": true, - "columnNames": [ - "scope", - "provider", - "model", - "configId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_token_stat_price_overrides_scope_provider_model_configId` ON `${TABLE_NAME}` (`scope`, `provider`, `model`, `configId`)" - } - ] - }, - { - "tableName": "token_stat_baselines", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` TEXT NOT NULL, `inputTokens` INTEGER NOT NULL, `cachedInputTokens` INTEGER NOT NULL, `outputTokens` INTEGER NOT NULL, `requestCount` INTEGER NOT NULL, `pricingCurrency` TEXT NOT NULL, `costInPricingCurrency` REAL, `isEstimated` INTEGER NOT NULL, `fingerprint` TEXT NOT NULL, `importedAtMs` INTEGER NOT NULL, `frozenBillingMode` TEXT NOT NULL, `frozenInputPricePerMillion` REAL, `frozenCachedInputPricePerMillion` REAL, `frozenOutputPricePerMillion` REAL, `frozenPricePerRequest` REAL, PRIMARY KEY(`identityId`), FOREIGN KEY(`identityId`) REFERENCES `token_stat_identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "inputTokens", - "columnName": "inputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cachedInputTokens", - "columnName": "cachedInputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outputTokens", - "columnName": "outputTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "requestCount", - "columnName": "requestCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "pricingCurrency", - "columnName": "pricingCurrency", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "costInPricingCurrency", - "columnName": "costInPricingCurrency", - "affinity": "REAL" - }, - { - "fieldPath": "isEstimated", - "columnName": "isEstimated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fingerprint", - "columnName": "fingerprint", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "importedAtMs", - "columnName": "importedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "frozenBillingMode", - "columnName": "frozenBillingMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "frozenInputPricePerMillion", - "columnName": "frozenInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenCachedInputPricePerMillion", - "columnName": "frozenCachedInputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenOutputPricePerMillion", - "columnName": "frozenOutputPricePerMillion", - "affinity": "REAL" - }, - { - "fieldPath": "frozenPricePerRequest", - "columnName": "frozenPricePerRequest", - "affinity": "REAL" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identityId" - ] - }, - "foreignKeys": [ - { - "table": "token_stat_identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "identityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "token_stat_restore_generations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` TEXT NOT NULL, `appliedAtMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", - "fields": [ - { - "fieldPath": "generation", - "columnName": "generation", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "appliedAtMs", - "columnName": "appliedAtMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "generation" - ] - } - }, - { - "tableName": "token_stat_reset_cutoffs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, `generation` INTEGER NOT NULL, PRIMARY KEY(`kind`, `provider`, `model`))", - "fields": [ - { - "fieldPath": "kind", - "columnName": "kind", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "generation", - "columnName": "generation", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "kind", - "provider", - "model" - ] - } - }, - { - "tableName": "token_stat_range_cutoffs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`generation` INTEGER NOT NULL, `startMs` INTEGER NOT NULL, `endMs` INTEGER NOT NULL, PRIMARY KEY(`generation`))", - "fields": [ - { - "fieldPath": "generation", - "columnName": "generation", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "startMs", - "columnName": "startMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "endMs", - "columnName": "endMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "generation" - ] - } - }, - { - "tableName": "token_stat_cleanup_operations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`operationId` TEXT NOT NULL, `scope` TEXT NOT NULL, `targetRef` TEXT NOT NULL, `deleteBaselines` INTEGER NOT NULL, `status` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, PRIMARY KEY(`operationId`))", - "fields": [ - { - "fieldPath": "operationId", - "columnName": "operationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scope", - "columnName": "scope", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "targetRef", - "columnName": "targetRef", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "deleteBaselines", - "columnName": "deleteBaselines", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAtMs", - "columnName": "createdAtMs", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "operationId" - ] - } - }, - { - "tableName": "token_stat_cleanup_items", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`operationId` TEXT NOT NULL, `identityId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, PRIMARY KEY(`operationId`, `identityId`), FOREIGN KEY(`operationId`) REFERENCES `token_stat_cleanup_operations`(`operationId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "operationId", - "columnName": "operationId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "provider", - "columnName": "provider", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "model", - "columnName": "model", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "operationId", - "identityId" - ] - }, - "indices": [ - { - "name": "index_token_stat_cleanup_items_operationId", - "unique": false, - "columnNames": [ - "operationId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_stat_cleanup_items_operationId` ON `${TABLE_NAME}` (`operationId`)" - } - ], - "foreignKeys": [ - { - "table": "token_stat_cleanup_operations", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "operationId" - ], - "referencedColumns": [ - "operationId" - ] - } - ] - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f518d4399dae2579dd24e4ddc77d0517')" - ] - } -} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt new file mode 100644 index 000000000..d55a94f38 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt @@ -0,0 +1,849 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal CleanupOutbox responsibilities extracted from [TokenStatSpool]. */ +internal fun TokenStatSpool.stuckAckTrashEvidenceLocked(context: Context): List { + val dir = spoolDir(context.applicationContext) + if (!dir.isDirectory) return emptyList() + // P1-6:根目录枚举统一走 [listDir](测试可注入失败);失败(null)即明确抛错, + // 绝不当作“没有 trash”返回(否则 export 会成功遗漏全部 stuck 证据)。 + val rootFiles = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for ack trash: ${dir.absolutePath}") + // 先完整枚举结果,再对每个候选做 NOFOLLOW 目录验证(File.isDirectory 会跟随符号链接) + return rootFiles + .filter { f -> + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) && + Files.isDirectory(f.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) + } + .mapNotNull { trash -> + // P1-6:trash 枚举失败(null)是明确失败,绝不当作空目录跳过该证据 + val files = listDir(trash) + ?: throw IOException("cannot enumerate ack trash directory: ${trash.name}") + files.takeIf { it.isNotEmpty() }?.let { trash } + } + .sortedBy { it.name } +} +/** + * ack 的主 manifest 重写(P1-1,调用方持 lifecycleMutex):发布前投影实际总量 + 最坏 + * sidecar 增量,超限有界失败不写文件;测试注入缝照常生效。失败抛 [IOException]。 + */ +internal suspend fun TokenStatSpool.rewriteAckManifestLocked(context: Context, manifestFile: File, newContent: String) { + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + throw IOException("tombstone manifest rewrite refused: metadata budget over the total cap") + } + if (metadataWriteErrorForTest?.invoke(manifestFile) == true) { + throw IOException("tombstone manifest write failed (injected)") + } + summaryStore(manifestFile).write(newContent) +} +/** + * 写入 ack trash 状态文件(P1-2,调用方持 lifecycleMutex):UNCOMMITTED + mapping(只含 + * 已 stage 文件)。P1-1:发布前投影预算,超限返回 false。写失败返回 false(调用方保留 + * 无状态 trash,维护 fail-closed 保留,绝不误删)。原子崩溃安全写入(sidecar 可恢复)。 + * + * P2 终审:mapping 身份(bytes+sha256)必须从**实际当前所在文件**捕获——回滚 move 已 + * 可见但目录项 sync 失败时,文件可能已回到原路径(trash 内已无此文件),此时从已移走 + * 的 target 盲读会得到 0 字节/空哈希的伪身份,甚至使整个状态写入失败;因此 target + * 存在读 target,否则读 original(两者内容同一,身份一致)。两者都不存在(文件消失, + * 不可能的正常路径)→ 返回 false fail-closed,绝不写残缺 mapping。 + */ +internal suspend fun TokenStatSpool.writeUncommittedTrashState( + context: Context, + trashDir: File, + staged: List>, +): Boolean { + return try { + val mappingEntries = staged.map { (original, target) -> + // P2 终审:身份从实际所在位置捕获(trash 或 original),绝不盲读已移走的 target。 + val location = + when { + target.exists() -> target + original.exists() -> original + else -> null + } + if (location == null) { + logE( + "statistics ack trash state identity unavailable; refusing to write mapping: " + + "${original.name}", + ) + return false + } + AckMappingEntry( + original = original.name, + trashName = target.name, + bytes = location.length(), + sha256 = sha256Hex(location.readBytes()), + ) + } + val stateContent = buildAckStateContent(ACK_STATE_UNCOMMITTED, mappingEntries) + if (metadataWriteBudgetExceeded(context, stateContent.toByteArray(Charsets.UTF_8).size)) { + logE("statistics ack trash state publish refused: metadata budget over the total cap") + return false + } + summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)).write(stateContent) + true + } catch (e: Exception) { + logE("statistics ack trash state write failed", e) + false + } +} +internal data class RollbackStagedResult( + val success: Boolean, + val syncFailed: Boolean, +) +/** + * ack 的 rename 回滚(reviewer P1 + P2 终审):把已 stage 进 trash 的文件按逆序移回原位; + * 全部成功且目录项全部确认持久才删除本轮 trash 目录(含状态/mapping 文件)。P2:每个 + * 移动/删除都是目录项变更——move 后必须严格 sync([requireSpoolDirSync],跨 spool 根与 + * trash 两个目录),任一非 OK 置 [RollbackStagedResult.syncFailed]:上层保留 + * UNCOMMITTED 状态并失败,绝不带着未确认状态声称回滚完成(变更可见时下一轮按 + * mapping+identity 幂等完成)。某个回滚失败时保留 trash 及其证据(状态仍为 UNCOMMITTED + * + mapping,维护入口按状态机判定/回滚,绝不误删),同样报告失败。 + */ +internal fun TokenStatSpool.rollbackStagedRenames( + staged: List>, + trashDir: File, +): RollbackStagedResult { + val dir = trashDir.parentFile ?: return RollbackStagedResult(false, false) + var allRolledBack = true + var syncFailed = false + for ((original, target) in staged.asReversed()) { + if (!target.exists()) continue + if (original.exists() || !atomicMoveForAck(target, original)) { + allRolledBack = false + logE( + "statistics ack rollback failed for ${original.name}; evidence stays in ${trashDir.name}", + ) + continue + } + // P2 终审:回滚 move 跨 spool 根与 trash 两个目录——两者目录项都必须确认持久; + // 非 OK 置 syncFailed(调用方保留 UNCOMMITTED 状态并失败)。 + if (!requireSpoolDirSync(dir, trashDir)) { + logE("statistics ack rollback move not durable: ${original.name}") + syncFailed = true + } + } + if (allRolledBack && !syncFailed) { + if (!deleteAckTrashDirNoFollow(trashDir)) { + logE("statistics ack trash directory cleanup failed after rollback: ${trashDir.name}") + allRolledBack = false + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable after rollback: ${trashDir.name}") + syncFailed = true + } + } + return RollbackStagedResult(allRolledBack, syncFailed) +} +/** + * 目标路径归属预检(reviewer P1,防目录穿越):ack 只接受 spool 根目录下的单层相对 + * 文件名——非空、不含路径分隔符、不是 "."/"..",且解析后父目录仍是 spool 根目录。 + */ +internal fun TokenStatSpool.requireSafeEvidenceName(dir: File, name: String) { + if (!isSafeEvidenceName(dir, name)) { + throw IOException("unsafe acknowledged evidence name: $name") + } +} +/** 单层相对名检查(reviewer P1,防目录穿越):非空、不含分隔符、解析后父目录是 dir。 */ +internal fun TokenStatSpool.isSafeEvidenceName(dir: File, name: String): Boolean = + name.isNotBlank() && + name != "." && + name != ".." && + !name.contains('/') && + !name.contains('\\') && + File(dir, name).parentFile?.canonicalFile == dir.canonicalFile +/** trash 内文件名检查(P1-2):同 [isSafeEvidenceName],父目录必须是 trash 目录本身。 */ +internal fun TokenStatSpool.isSafeTrashName(trash: File, name: String): Boolean = + name.isNotBlank() && + name != "." && + name != ".." && + !name.contains('/') && + !name.contains('\\') && + File(trash, name).parentFile?.canonicalFile == trash.canonicalFile +internal data class AckMappingEntry( + val original: String, + val trashName: String, + val bytes: Long, + val sha256: String, +) +internal fun TokenStatSpool.ackMappingLine(entry: AckMappingEntry): String = + JSONObject() + .put("o", entry.original) + .put("t", entry.trashName) + .put("b", entry.bytes) + .put("s", entry.sha256) + .toString() +internal fun TokenStatSpool.parseAckMappingLine(line: String): AckMappingEntry? = try { + val obj = JSONObject(line) + val original = obj.optString("o").takeIf { it.isNotEmpty() } ?: return null + val trashName = obj.optString("t").takeIf { it.isNotEmpty() } ?: return null + AckMappingEntry( + original = original, + trashName = trashName, + bytes = obj.optLong("b", -1L), + sha256 = obj.optString("s", ""), + ) +} catch (_: Exception) { + null +} +/** + * P1-2 修复:状态 mapping 的**全有或全无**解析(调用方持 lifecycleMutex)。任一条件失败 + * 返回 null,调用方对整个 trash fail-closed 保留(不执行 delete/rollback/manifest 改动): + * - header 之后的每一行都必须解析成功(mapNotNull 静默丢弃损坏行会漏掉未回滚的证据, + * 导致 trash 被整体删除); + * - bytes/sha256 必须完整(缺失即身份不可校验); + * - 原名/trash 名必须单层安全(防穿越),且无重复(同名两份证据无法可靠处置); + * - trash 内所有普通文件(排除状态文件及其 `.new`/`.bak`/`.tmp*` sidecar)都必须被 + * mapping 覆盖(mapping 数量与证据文件集合完整对应);存在符号链接/特殊文件也 fail-closed。 + * + * mapping 条目引用的 trash 文件**缺失**是允许的(该文件可能已在先前一次回滚中移回原槽位, + * 由 [rollbackUncommittedTrash] 按原槽位身份判定),因此这里只校验“trash 里的每个文件都 + * 有 mapping”,不要求反向一一对应。 + */ +internal fun TokenStatSpool.parseAckMappingStrict( + dir: File, + trash: File, + lines: List, +): List? { + val entries = mutableListOf() + val originals = HashSet() + val trashNames = HashSet() + for (raw in lines.drop(1)) { + val entry = parseAckMappingLine(raw) ?: return null + if (entry.bytes < 0L || entry.sha256.isEmpty()) return null + if (!isSafeEvidenceName(dir, entry.original)) return null + if (!isSafeTrashName(trash, entry.trashName)) return null + if (!originals.add(entry.original)) return null + if (!trashNames.add(entry.trashName)) return null + entries += entry + } + val stateBase = ACK_TRASH_STATE_FILE_NAME + // P1-5 fail-closed:trash 目录枚举失败(null)时内部证据集合不可知——mapping 无法 + // 证明覆盖了全部证据文件,任何 rollback 后对 trash 的整体删除都会丢失未枚举的证据, + // 立即返回 null 使整个 trash 被保留。 + val trashFiles = listDir(trash) + if (trashFiles == null) { + logE("statistics ack trash directory enumeration failed; retaining trash: ${trash.name}") + return null + } + val unaccounted = trashFiles.any { file -> + val name = file.name + val isStateSidecar = + name == stateBase || + name == "$stateBase.new" || + name == "$stateBase.bak" || + name.startsWith("$stateBase.tmp") + if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + // 符号链接/特殊文件:无法按身份管理,fail-closed + true + } else if (isStateSidecar) { + false + } else { + name !in trashNames + } + } + return if (unaccounted) null else entries +} +/** 状态文件内容:首行状态 + 每行一条 mapping(状态与 mapping 一次原子写入,无半写窗口)。 */ +internal fun TokenStatSpool.buildAckStateContent(state: String, entries: List): String = + state + "\n" + entries.joinToString("\n") { ackMappingLine(it) } + "\n" +/** ack 只管理 spool 根目录中的普通文件,不跟随符号链接或其他特殊路径。 */ +internal fun TokenStatSpool.requireManageableEvidenceFile(dir: File, file: File) { + val dirPath = dir.canonicalFile.toPath() + val filePath = file.toPath() + if (file.parentFile?.canonicalFile?.toPath() != dirPath || + Files.isSymbolicLink(filePath) || + !Files.isRegularFile(filePath, java.nio.file.LinkOption.NOFOLLOW_LINKS) || + file.canonicalFile.parentFile?.toPath() != dirPath + ) { + throw IOException("unmanageable acknowledged evidence path: ${file.name}") + } +} +/** + * P1-3:stuck ack trash 目录的删除前校验(防目录穿越/符号链接):只接受 spool 根内匹配 + * [ACK_TRASH_PREFIX] 的真实普通目录(NOFOLLOW_LINKS),拒绝符号链接与特殊路径。 + */ +internal fun TokenStatSpool.requireAckTrashDirForDelete(dir: File, trash: File) { + if (!trash.name.startsWith(ACK_TRASH_PREFIX)) { + throw IOException("not an ack trash directory: ${trash.name}") + } + if (Files.isSymbolicLink(trash.toPath())) { + throw IOException("ack trash must not be a symbolic link: ${trash.name}") + } + if (!Files.isDirectory(trash.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { + throw IOException("ack trash must be a real directory: ${trash.name}") + } + if (trash.canonicalFile.parentFile?.toPath() != dir.canonicalFile.toPath()) { + throw IOException("ack trash escapes the spool root: ${trash.name}") + } +} +/** + * P1-3:递归删除 ack trash 目录(NOFOLLOW):不跟随符号链接(链接本身被删除,绝不触及 + * 其目标),只删除普通文件与空目录;任何遍历/删除失败返回 false(调用方保留并报错)。 + */ +internal fun TokenStatSpool.deleteAckTrashDirNoFollow(trash: File): Boolean { + return try { + Files.walkFileTree( + trash.toPath(), + EnumSet.noneOf(FileVisitOption::class.java), + Int.MAX_VALUE, + object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { + if (exc != null) throw exc + Files.delete(dir) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + throw exc + } + }, + ) + true + } catch (e: Exception) { + logE("statistics ack trash no-follow deletion failed: ${trash.name}", e) + false + } +} +/** prepare/rollback 必须是同 filesystem 的原子 move,且绝不覆盖同名目标。 */ +internal fun TokenStatSpool.atomicMoveForAck(from: File, to: File): Boolean { + ackAtomicMoveForTest?.invoke(from, to)?.let { return it } + // 兼容现有故障注入缝;生产为 null 时仍走真正的 ATOMIC_MOVE。 + segmentRenameForTest?.invoke(from, to)?.let { return it } + if (to.exists()) return false + return try { + Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) + true + } catch (e: AtomicMoveNotSupportedException) { + false + } catch (e: IOException) { + false + } +} +/** + * P1-2 维护/后台重试:清理删除失败被隔离的残留。ack trash 按持久状态机处置(**只有显式 + * COMMITTED 有界补删**;UNCOMMITTED 一律按 mapping+identity 回滚——P1-1:绝不根据主 + * manifest 缺失推断已提交;状态缺失/损坏或回滚长期失败的 trash 保留为 StuckAckEvidence + * 由 UI 管理,绝不自动删除);pending-delete 证据(容量内来源)移回完整证据区;tombstoned + * 段按记录的处置动作重试(over-cap → 删除,容量内 → 移回完整证据区),处置前必须按稳定 + * identity(file+bytes+sha256)校验当前文件仍是记录的段(P1-2:同名不同 hash 的健康新段 + * 绝不删/移,只移除陈旧记录)。成功后从 manifest 移除记录;文件已物理消失也移除记录 + * (除非身份仍停留在未提交 trash 中——P1-2:绝不让崩溃窗口判定把未确认的证据误判为已提交 + * 而删除)。P1-4:存在无法完整严格解析/读取的 UNCOMMITTED ack trash( + * [scanUncommittedTrashHolds] 的 hasUnknown)时,本轮**整轮跳过** stale 判定与 manifest + * 重写(缺失/不匹配条目的身份可能正被其持有),记录日志并有界返回。 + * + * P1-2 终审:**任何目录项变更(rename/delete)后 syncDir 非 OK 都不推进状态**—— + * 不返回 RECORDED、不移除 manifest 条目、不把变更视为完成:pending-delete 恢复 rename + * 非 OK 时尽力把文件移回 pending-delete 名(重建明确可重试记录)、seal_failed 删除与 + * tombstone 处置非 OK 时保留 manifest 条目,并返回 false 让 drain 退避重试。由于文件 + * 操作可能已可见,下一轮 bootstrap gate([ensureDirectoryDurabilityConfirmed])sync OK + * 后按 identity 幂等完成,绝不丢证据。失败仅记录(tombstone 本身就是有界可见错误证据), + * 绝不阻塞健康排空——本函数返回 true 时 drain 继续处理健康段。 + * + * @return false 表示本轮存在目录项未确认持久的变更(调用方 [drainCore] 退避重试); + * 其它失败(rename/delete 返回 false、状态无效、枚举失败)保留对应可重试记录并返回 true。 + */ +internal suspend fun TokenStatSpool.retryPendingCleanup(context: Context): Boolean { + val dir = spoolDir(context) + if (!dir.isDirectory) return true + var roundOk = true + // P1-3:不设 canonical isFile 前置——仅 sidecar 存在时也必须先恢复再处置受管段。 + // P1-2 fail-closed:manifest 不可读则维护中止并抛明确 IOException(drain 退避重试), + // 绝不当作“无受管记录”继续——那会让扫描器把受管段当健康段处理;ack trash 的“已提交” + // 判定也依赖主 manifest,不能拿空集冒充。manifest 不存在(从未有受管记录)视为空集。 + val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) + val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) + val lines = rawLines?.lineSequence()?.filter { it.isNotBlank() }?.toList() ?: emptyList() + // reviewer P1:ack trash 状态机(只有显式 COMMITTED 有界补删;UNCOMMITTED 一律按 + // mapping+identity 回滚——P1-1:绝不根据主 manifest 缺失推断已提交,普通 quarantine + // 证据从不在 manifest 中,缺失恒成立,旧推断会误删未确认的证据)。符号链接目录绝不 + // 进入处置(跟随链接可能删除链接目标的内容)。 + // P1-5:spool 根枚举失败(null)时本轮跳过 trash 处置并记录——枚举失败绝不是 + // “没有 trash 目录”,绝不静默放行;身份持有判定由 [scanUncommittedTrashHolds] 的 + // hasUnknown fail-closed 另行兜底(stale 清理整轮跳过)。 + val ackTrashCandidates = listDir(dir) + if (ackTrashCandidates == null) { + logE("statistics spool directory enumeration failed; deferring ack trash disposal") + } else { + ackTrashCandidates + .filter { f -> + f.isDirectory && + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) + } + .forEach { trash -> + if (!handleAckTrashDir(dir, trash)) roundOk = false + } + } + // P1-2/P1-4:处置后仍停留在未提交 trash 中的身份 → manifest 条目绝不按 stale 移除; + // 存在无法完整严格解析的 UNCOMMITTED trash 时(hasUnknown)本轮保守跳过 stale 处置 + val trashHold = scanUncommittedTrashHolds(context) + val heldInTrash = trashHold.known + dir.listFiles { f -> f.isFile && f.name.startsWith(PENDING_DELETE_PREFIX) } + ?.forEach { file -> + val target = File( + dir, + "$QUARANTINE_PREFIX${file.name.removePrefix(PENDING_DELETE_PREFIX)}", + ) + if (renameForTest(file, target)) { + if (!requireSpoolDirSync(dir)) { + // P1-2 终审:rename 已可见但目录项未确认持久——不推进状态:尽力把文件 + // 移回 pending-delete 名(重建明确可重试记录),并让本轮失败退避;崩溃 + // 后文件在任一名字下都保留证据,下一轮按名字/身份幂等完成。P1-1: + // 非 OK 同时失效 gate。 + logE( + "statistics pending-delete evidence restore rename not durable; " + + "restoring retryable record: ${target.name}", + ) + val reverseRenamed = target.exists() && renameForTest(target, file) + if (reverseRenamed) { + // P2 终审:反向 rename 同样是目录项变更——未确认持久绝不算 + // “已重建可重试记录”(变更可见时下一轮 bootstrap 重新确认后按 + // 名字幂等完成);失败保留 pending 记录并退避,绝不静默。 + if (!requireSpoolDirSync(dir)) { + logE( + "statistics pending-delete evidence restore reverse rename " + + "not durable; keeping retryable record: ${file.name}", + ) + } + } else if (target.exists()) { + logE( + "statistics pending-delete evidence restore reverse rename failed: ${file.name}", + ) + } + roundOk = false + } else { + logE("statistics pending-delete evidence restored to quarantine: ${target.name}") + } + } + } + // P2:seal copy 失败隔离的部分目标(seal_failed_*,scanner 忽略):active 保留完整 + // 内容,删除隔离副本安全无数据损失;删除失败只记录(文件作为受管失败发布证据计入 + // 证据区,占用可见且有界——quarantineEvidence/导出/ack 可管理),下一轮维护再试。 + // P1-2 终审:删除成功但目录项未确认持久 → 本轮不推进(roundOk=false,退避重试)。 + dir.listFiles { f -> f.isFile && f.name.startsWith(SEAL_FAILED_PREFIX) } + ?.forEach { file -> + if (!(segmentDeleteForTest?.invoke(file) ?: file.delete())) { + logE( + "statistics spool seal-failed target cleanup deferred; " + + "visible as managed failed-publication evidence: ${file.name}", + ) + } else if (!requireSpoolDirSync(dir)) { + logE("statistics spool seal-failed target deletion not durable: ${file.name}") + roundOk = false + } + } + if (lines.isEmpty()) return roundOk + // P1-4 fail-closed:hasUnknown 时,缺失(!file.exists())或不匹配(MISMATCH)条目 + // 的身份可能正被无法解析的 ack trash 持有——本轮绝不移除任何这类 manifest 条目。 + // 整轮跳过 MATCH 处置与 manifest 重写(简单正确),记录日志并有界返回(不持锁 + // 等待);trash 状态恢复后下一轮维护再清理。 + if (trashHold.hasUnknown) { + logE( + "statistics ack trash state partially unknown; deferring tombstone stale " + + "cleanup and manifest rewrite this round", + ) + return roundOk + } + val remaining = lines.filterNot { line -> + val entry = parseTombstoneLine(line) ?: return@filterNot false + val file = File(dir, entry.file) + when { + // 物理消失:P1-2 先查未提交 trash——身份在其中时条目必须保留(证据仍存在, + // 等待回滚或提交判定),绝不按 stale 移除。P1-2 终审:删除/移动可能在上轮 + // 可见但未确认持久——本轮先 sync 确认“消失”持久才允许移除条目。P1-1: + // 非 OK 同时失效 gate。 + !file.exists() -> { + val confirmed = requireSpoolDirSync(dir) + if (!confirmed) { + logE( + "statistics tombstone entry absence not durable; retaining entry: ${entry.file}", + ) + roundOk = false + } + confirmed && heldInTrash[entry.file] != (entry.bytes to entry.sha256) + } + else -> when (tombstoneIdentityCheck(entry, file)) { + // P1-2:身份不可校验(UNREADABLE)→ 保留记录与文件,本轮不处置 + IdentityCheck.UNREADABLE -> false + // 身份不匹配:同名新文件 ≠ 陈旧记录的前提是旧身份已无处可寻;旧身份仍 + // 停留在未提交 trash 中时条目必须保留(崩溃窗口判定依赖它),只处置新文件 + IdentityCheck.MISMATCH -> heldInTrash[entry.file] != (entry.bytes to entry.sha256) + IdentityCheck.MATCH -> + if (entry.overCap) { + val deleted = + (segmentDeleteForTest?.invoke(file) ?: file.delete()) || !file.exists() + // P1-2 终审:删除成功但目录项未确认持久 → 保留 manifest 条目 + // (可重试记录)并让本轮失败退避;绝不带着未确认删除推进状态。 + if (!deleted) { + false + } else if (!requireSpoolDirSync(dir)) { + logE( + "statistics tombstone over-cap segment deletion not durable; " + + "keeping entry: ${file.name}", + ) + roundOk = false + false + } else { + true + } + } else { + // 容量内:重试移回完整证据区(不超硬 cap 才允许) + val target = + File(dir, "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${entry.file}") + val fits = + quarantineEvidenceLocked(context).sumOf { it.length() } + file.length() <= MAX_QUARANTINE_BYTES + val renamed = fits && renameForTest(file, target) + // P1-2 终审:rename 成功但目录项未确认持久 → 保留 manifest 条目 + // (可重试记录)并让本轮失败退避;rename 可见时下一轮按消失条目 + // 路径 sync 确认后幂等移除。 + if (!renamed) { + false + } else if (!requireSpoolDirSync(dir)) { + logE( + "statistics tombstone evidence restore rename not durable; " + + "keeping entry: ${target.name}", + ) + roundOk = false + false + } else { + true + } + } + } + } + } + if (remaining.size == lines.size) return roundOk + try { + val newContent = remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件) + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") + return roundOk + } + // P1-2 终审:manifest 重写是目录项变更(严格 store)——写失败即保留全部条目 + // (不移除 manifest),并让本轮失败退避重试。 + summaryStore(manifestFile).write(newContent) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + roundOk = false + } + return roundOk +} +/** + * ack trash 状态机处置(P1-2,调用方持 lifecycleMutex)。trash 目录内的原子状态文件 + * [ACK_TRASH_STATE_FILE_NAME] 首行为 UNCOMMITTED/COMMITTED,后续为 mapping 行 + * (原名 → trash 名 + bytes + sha256)。规则(P1-1 修复): + * - 状态文件尚未写入(无 canonical 与 sidecar)且目录为空:staging 严格发生在状态写入 + * 成功之后,此时不可能有已 stage 的证据 → 空目录直接删除(无证据损失)。 + * - COMMITTED:唯一允许后台删除的状态——有界补删,失败下次再试。 + * - UNCOMMITTED:**一律**按 mapping+identity 回滚到原路径,绝不根据主 manifest 缺失推断 + * 已提交(普通 quarantine 证据从不在 manifest 中,缺失恒成立,旧推断会把未确认的证据 + * 误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚的损坏 sealed 会被扫描器重新 + * 隔离,ack 视失败但不丢证据)。mapping 必须全有或全无有效(P1-2:逐行解析、无重复、 + * 无穿越、与 trash 内证据文件集合完整对应),任一失败 → 整个 trash fail-closed 保留 + * (由 UI 作为 StuckAckEvidence 管理),绝不执行 delete/rollback/manifest 改动。 + * - 状态文件缺失/不可读/无效:非空 trash 保留并报告(fail-closed),绝不删除。 + * + * P1-2 终审:删除成功(空目录/COMMITTED)或回滚移动后目录项必须确认持久—— + * [syncDir] 非 OK 返回 false(调用方 [retryPendingCleanup] 令本轮退避重试,绝不推进); + * 删除返回 false 只记录(trash 本身就是可重试记录,下一轮再试)。 + * + * @return false 表示本轮存在目录项未确认持久的变更;其余情形(含删除失败、状态无效) + * 返回 true(保留可重试记录,不阻塞健康排空)。 + */ +internal suspend fun TokenStatSpool.handleAckTrashDir(dir: File, trash: File): Boolean { + val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) + val store = summaryStore(stateFile) + val content: String? = + if (stateFile.exists() || + File(trash, "$ACK_TRASH_STATE_FILE_NAME.new").exists() || + File(trash, "$ACK_TRASH_STATE_FILE_NAME.bak").exists() + ) { + try { + readMetadata(store, stateFile) + } catch (e: Exception) { + logE("statistics ack trash state unreadable; retaining trash: ${trash.name}", e) + null + } + } else if (listDir(trash)?.isEmpty() == true) { + // 崩溃于 stage 开始之前:trash 内没有任何证据文件(stage 是原子移动,空目录 + // = 无证据可保护),删除空目录无损失。枚举失败(listDir 返回 null)绝不当空 + // 目录——内容不可知时走下方 fail-closed 保留分支,绝不删除。stage 已开始或 + // 完成后崩溃(目录非空、状态未写)→ 同样走到保留分支。 + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics empty ack trash cleanup deferred: ${trash.name}") + } else if (!requireSpoolDirSync(dir)) { + logE("statistics empty ack trash deletion not durable: ${trash.name}") + return false + } + return true + } else { + null + } + if (content == null) { + logE("statistics ack trash state missing; retaining trash: ${trash.name}") + return true + } + val lines = content.lineSequence().filter { it.isNotBlank() }.toList() + return when (lines.firstOrNull()) { + ACK_STATE_COMMITTED -> { + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics ack trash cleanup deferred: ${trash.name}") + true + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable: ${trash.name}") + false + } else { + true + } + } + ACK_STATE_UNCOMMITTED -> { + // P1-2:全有或全无解析——任一损坏/重复/穿越/对应缺失都使整个 trash + // fail-closed 保留,绝不基于部分 mapping 做破坏性决策。 + val entries = parseAckMappingStrict(dir, trash, lines) + ?: run { + logE("statistics ack trash state mapping invalid; retaining trash: ${trash.name}") + return true + } + // P1-1:UNCOMMITTED 永远尝试回滚(identity 验证,目标被不同内容占用绝不覆盖)。 + val result = rollbackUncommittedTrash(dir, trash, entries) + if (!result.allResolved) { + logE( + "statistics ack trash rollback not fully resolved; " + + "retaining retryable trash: ${trash.name}", + ) + } + // P1-2 终审:回滚移动/删除的目录项未确认持久 → 本轮失败退避(trash 保留为 + // 可重试记录,下一轮按 identity 幂等完成);普通回滚失败(移动失败、槽位被 + // 占用等)保留记录并继续本轮,绝不阻塞健康排空。 + !result.syncFailed + } + else -> { + logE("statistics ack trash state invalid (${lines.firstOrNull() ?: ""}); retaining trash: ${trash.name}") + true + } + } +} +internal data class TrashRollbackResult( + val allResolved: Boolean, + val syncFailed: Boolean, +) +/** + * 未提交 trash 回滚(P1-2,调用方持 lifecycleMutex):按 mapping 逐条 identity 验证后 + * 恢复。trash 内文件必须仍与 mapping 身份(bytes+sha256)一致才允许移动;原槽位被不同 + * 内容占用时绝不覆盖(保留 trash 证据并 fail-closed);全部恢复成功才删除 trash 目录, + * 否则保留(递归容量统计计入占用)并报告。无法恢复的文件绝不删除。 + * + * P1-2 终审:每个移动/删除都是目录项变更——成功后 [syncDir] 非 OK 置 syncFailed + * (调用方本轮退避;变更可见时下一轮按 identity 幂等完成,崩溃后 trash 重现由状态机 + * 重放),绝不基于未确认状态声称已恢复。 + */ +internal fun TokenStatSpool.rollbackUncommittedTrash( + dir: File, + trash: File, + entries: List, +): TrashRollbackResult { + var allResolved = true + var syncFailed = false + for (entry in entries) { + // 防御:mapping 名字必须是 spool 根/trash 内的合法单层文件名 + if (!isSafeEvidenceName(dir, entry.original) || !isSafeTrashName(trash, entry.trashName)) { + allResolved = false + logE("statistics ack trash mapping has unsafe names; retaining trash: ${trash.name}") + continue + } + val trashFile = File(trash, entry.trashName) + val original = File(dir, entry.original) + if (!trashFile.exists()) { + // mapping 有记录但 trash 中无此文件:文件从未被 stage(状态/映射写于 staging + // 之前)。原槽位同身份即视为已恢复;否则无法验证 → 保留。 + if (!(original.exists() && identityMatches(original, entry))) { + allResolved = false + logE("statistics ack trash rollback cannot verify ${entry.original}; retaining trash: ${trash.name}") + } + continue + } + if (!identityMatches(trashFile, entry)) { + allResolved = false + logE("statistics ack trash file identity mismatch; retaining evidence: ${entry.original}") + continue + } + when { + !original.exists() -> { + if (!atomicMoveForAck(trashFile, original)) { + allResolved = false + logE("statistics ack trash rollback move failed for ${entry.original}; evidence stays in ${trash.name}") + } else if (!requireSpoolDirSync(dir, trash)) { + logE("statistics ack trash rollback move not durable: ${entry.original}") + syncFailed = true + } + } + identityMatches(original, entry) -> { + // 原槽位已是同身份内容:trash 副本冗余,删除副本即可 + if (!(segmentDeleteForTest?.invoke(trashFile) ?: trashFile.delete())) { + allResolved = false + logE("statistics ack trash redundant copy deletion failed: ${entry.original}") + } else if (!requireSpoolDirSync(trash)) { + logE("statistics ack trash redundant copy deletion not durable: ${entry.original}") + syncFailed = true + } + } + else -> { + // 原槽位被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed + allResolved = false + logE("statistics ack trash rollback target occupied by different content; retaining evidence: ${entry.original}") + } + } + } + if (allResolved) { + if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { + logE("statistics ack trash deletion failed after successful rollback: ${trash.name}") + } else if (!requireSpoolDirSync(dir)) { + logE("statistics ack trash deletion not durable after rollback: ${trash.name}") + syncFailed = true + } + } + return TrashRollbackResult(allResolved, syncFailed) +} +/** P1-2:文件与 mapping 身份比对(bytes + 原始字节 SHA-256,绝不跟随符号链接)。 */ +internal fun TokenStatSpool.identityMatches(file: File, entry: AckMappingEntry): Boolean { + if (!file.isFile || file.length() != entry.bytes) return false + if (Files.isSymbolicLink(file.toPath())) return false + if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false + if (segmentReadErrorForTest?.invoke(file) == true) return false + return try { + sha256Hex(file.readBytes()) == entry.sha256 + } catch (e: Exception) { + false + } +} +internal data class UncommittedTrashScan( + val known: Map>, + val hasUnknown: Boolean, +) +/** + * P1-2:仍在未提交 trash 中的身份集合(原名 → bytes+sha256),供 manifest 条目的 stale + * 判定使用——身份仍在 trash 中时条目绝不能移除,否则会把未确认的证据误判为已提交而删除。 + * 符号链接目录不读取(不跟随)。调用方持 lifecycleMutex。 + * + * P1-4 fail-closed:返回结构化结果。任何**非空**、非明确 COMMITTED、无法完整严格解析/ + * 读取的 ack trash(状态缺失/不可读、首行非法、mapping 任一损坏/缺身份/不安全/重复、 + * trash 内存在 mapping 未覆盖的证据文件)都会让 [UncommittedTrashScan.hasUnknown] = true, + * 调用方据此保守处置(stale 清理整轮跳过、scanner 不按 MISMATCH 移除条目)——该 trash + * 可能正持有已知集合之外的证据身份,绝不基于残缺信息做破坏性决策。mapping 完整严格 + * 解析成功时按全有或全无计入 [UncommittedTrashScan.known](与 [parseAckMappingStrict] + * 一致)。空目录不可能持有证据(stage 是原子移动,见 [handleAckTrashDir]),不贡献 + * 身份也不置 unknown。 + */ +internal suspend fun TokenStatSpool.scanUncommittedTrashHolds(context: Context): UncommittedTrashScan { + val dir = spoolDir(context) + if (!dir.isDirectory) return UncommittedTrashScan(emptyMap(), false) + val result = HashMap>() + var hasUnknown = false + // P1-5 fail-closed:spool 根枚举失败(null)时,任何 ack trash 目录都可能存在但 + // 不可见——身份持有情况完全不可知,置 unknown 阻止调用方做 stale/删除类决策。 + val rootFiles = listDir(dir) + if (rootFiles == null) { + logE( + "statistics spool directory enumeration failed; treating uncommitted ack trash " + + "state as unknown", + ) + return UncommittedTrashScan(emptyMap(), true) + } + rootFiles + .filter { f -> + f.isDirectory && + f.name.startsWith(ACK_TRASH_PREFIX) && + !Files.isSymbolicLink(f.toPath()) + } + .forEach { trash -> + val files = listDir(trash) + if (files == null) { + // 目录枚举失败:内部证据状态不可知 → fail-closed + hasUnknown = true + return@forEach + } + if (files.isEmpty()) return@forEach + val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) + val content = try { + readMetadata(summaryStore(stateFile), stateFile) + } catch (e: Exception) { + logE("statistics ack trash state unreadable during scan; treating as unknown", e) + null + } ?: run { + // 状态缺失/不可读:非空 trash 中的证据身份不可知 + hasUnknown = true + return@forEach + } + val lines = content.lineSequence().filter { it.isNotBlank() }.toList() + when (lines.firstOrNull()) { + // 显式 COMMITTED:删除已授权,无回滚保护义务,不贡献身份也不置 unknown + ACK_STATE_COMMITTED -> Unit + ACK_STATE_UNCOMMITTED -> { + val entries = parseAckMappingStrict(dir, trash, lines) + if (entries == null) { + // 全有或全无解析失败:该 trash 可能持有任意身份的证据 + hasUnknown = true + } else { + entries.forEach { result[it.original] = it.bytes to it.sha256 } + } + } + // 首行非法/内容为空:无法判定状态 → fail-closed + else -> hasUnknown = true + } + } + return UncommittedTrashScan(result, hasUnknown) +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt new file mode 100644 index 000000000..f7945a69e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt @@ -0,0 +1,434 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal Quarantine responsibilities extracted from [TokenStatSpool]. */ +/** Stable identity of a managed failed segment. */ +internal data class TombstoneEntry( + val file: String, + val bytes: Long, + val sha256: String, + val overCap: Boolean, +) + +internal fun TokenStatSpool.tombstoneIdentityCheck( + entry: TombstoneEntry, + file: File, +): IdentityCheck { + if (entry.sha256.isEmpty() || !file.isFile || file.length() != entry.bytes) { + return IdentityCheck.MISMATCH + } + if (segmentReadErrorForTest?.invoke(file) == true) return IdentityCheck.UNREADABLE + return try { + if (sha256Hex(file.readBytes()) == entry.sha256) IdentityCheck.MATCH else IdentityCheck.MISMATCH + } catch (e: Exception) { + IdentityCheck.UNREADABLE + } +} +internal enum class TombstoneResult { RECORDED, CAPACITY_FULL, FAILED } +internal suspend fun TokenStatSpool.quarantineEvidenceLocked(context: Context): List { + val dir = spoolDir(context.applicationContext) + val managed = readTombstoneLines(context).mapNotNull { line -> + val entry = parseTombstoneLine(line) ?: return@mapNotNull null + val file = File(dir, entry.file) + // P1-2:只有身份可校验(MATCH)的受管段才作为 evidence 暴露——UNREADABLE 绝不 + // 出现在可导出/可 ack 的列表里(身份不可校验时 ack 无法安全删除),保留 manifest。 + file.takeIf { it.isFile && tombstoneIdentityCheck(entry, file) == IdentityCheck.MATCH } + } + return (quarantineAreaFiles(dir) + managed).sortedBy { it.name } +} +/** + * 完整证据区(quarantine_* 前缀 + seal 发布失败隔离的 seal_failed_*,不含受管失败段与 + * 有界元数据/sidecar)。P1-6:证据区是 export/ack/info 的证据来源,根目录枚举失败(null) + * 时抛 [IOException](fail-closed)——绝不把失败当作空证据区返回,否则 export 会在遗漏 + * 完整证据时仍成功。P2 终审:seal_failed_*(受管失败发布证据)与 quarantine_* 同等参与 + * 可见/计数/导出/ack——长期删除失败时用户可确认删除并释放容量,绝不无限隐藏占用。 + * 注意:这里**不走** [directoryListingForTest] seam(seam 只覆盖 sealed 队列/待处理判定 + * 与 ack/trash 安全路径,见 [listDir]),生产路径的原始枚举失败同样按 null 显式失败处理。 + */ +internal fun TokenStatSpool.quarantineAreaFiles(dir: File): List { + // 目录不存在 = 证据区尚未创建,空集是真实状态(append 准入在创建目录之前检查); + // 目录**存在**但枚举失败(null)才是 fail-closed 抛错场景。 + if (!dir.isDirectory) return emptyList() + val files = dir.listFiles() + ?: throw IOException("cannot enumerate spool directory for quarantine evidence: ${dir.absolutePath}") + return files + .filter { + it.isFile && + (it.name.startsWith(QUARANTINE_PREFIX) || it.name.startsWith(SEAL_FAILED_PREFIX)) && + it.name != QUARANTINE_SUMMARY_NAME && + it.name != TOMBSTONE_MANIFEST_NAME && + !it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") && + !it.name.startsWith("$TOMBSTONE_MANIFEST_NAME.") + } + .sortedBy { it.name } +} +/** + * P1-1 append 准入检查(调用方持 lifecycleMutex):受管失败集合 + * (entry 数/受管段字节)或完整证据区字节任一到达硬上限即拒绝新统计——此时新损坏段 + * 将无处可去(不能删除/重命名、受管集合已满),继续接收只会让磁盘/重扫无界。 + */ +internal suspend fun TokenStatSpool.managedFailureCapacityExceeded(context: Context): Boolean { + val dir = spoolDir(context) + val entries = readTombstoneLines(context).mapNotNull(::parseTombstoneLine) + if (entries.isEmpty() && quarantineAreaFiles(dir).isEmpty()) return false + return entries.size >= MAX_TOMBSTONE_ENTRIES || + entries.sumOf { it.bytes.coerceAtLeast(0L) } >= MAX_MANAGED_BYTES || + quarantineAreaFiles(dir).sumOf { it.length() } >= MAX_QUARANTINE_BYTES +} +internal suspend fun TokenStatSpool.quarantineSummaryInfoLocked( + context: Context, +): TokenStatSpool.QuarantineSummaryInfo? { + val file = File(spoolDir(context.applicationContext), QUARANTINE_SUMMARY_NAME) + val content = try { + readMetadata(summaryStore(file), file) + } catch (e: Exception) { + logE("statistics quarantine summary read failed", e) + null + } ?: return null + val lines = content.lineSequence().filter { it.isNotEmpty() }.toList() + if (lines.isEmpty()) return null + return TokenStatSpool.QuarantineSummaryInfo( + recordCount = lines.size, + summaryBytes = file.length(), + ) +} +internal fun TokenStatSpool.summaryStore(file: File) = + AtomicRestoreMarkerStore( + file, + quarantineAtomicMoveForTest ?: ::atomicMoveReplacing, + // P1-3 终审:spool 的 summary/manifest/ack state 统一走严格目录同步——write 只有 + // 目录项确认持久([syncDir] == OK)才成功;read 的 sidecar 恢复 rename 同样严格。 + // P1-1 终审修复:任一非 OK 同时失效 bootstrap gate([requireSpoolDirSync])—— + // 元数据目录项未确认后下一次使用必须重新确认,绝不带着“已确认”标记继续。 + strictDirectorySync = { dir -> requireSpoolDirSync(dir) }, + ) +/** + * 有界元数据读取(P1-2,调用方持 lifecycleMutex):测试注入缝模拟读取失败(抛明确 + * IOException,调用方据此 fail-closed),生产路径委托 [AtomicRestoreMarkerStore.read] + * (崩溃安全恢复 canonical/.new/.bak/tmp 完整值)。 + */ +internal suspend fun TokenStatSpool.readMetadata(store: AtomicRestoreMarkerStore, file: File): String? { + if (metadataReadErrorForTest?.invoke(file) == true) { + throw IOException("statistics metadata read failed (injected): ${file.name}") + } + return store.read() +} +/** 首选同目录原子替换(Windows MoveFileEx / POSIX rename);不支持或失败返回 false 走回退。 */ +internal fun TokenStatSpool.atomicMoveReplacing(from: File, to: File): Boolean = try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + true +} catch (e: AtomicMoveNotSupportedException) { + false +} catch (e: IOException) { + false +} +/** Explicit post-export acknowledgment for the bounded rolling summary and every sidecar. */ +internal suspend fun TokenStatSpool.deleteQuarantineSummaryLocked(context: Context) { + val dir = spoolDir(context.applicationContext) + val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) + summaryStore(summaryFile).delete() + val remaining = listDir(dir) + ?: throw IOException("cannot verify quarantine summary deletion: ${dir.absolutePath}") + if (remaining.any { it.name == QUARANTINE_SUMMARY_NAME || it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") }) { + throw IOException("statistics quarantine summary deletion failed: ${summaryFile.absolutePath}") + } +} +/** + * 超限损坏段的硬边界替换(P1-1/P1-2):崩溃安全地发布“已裁剪到双上限”的新完整摘要 + * (旧完整或新完整,绝不截断),随后把段移出 sealed 扫描队列;段删除失败绝不阻塞健康 + * 排空(改为 pending-delete 证据或 tombstone 跳过)。摘要发布失败抛异常 → 保留旧摘要 + * 与待处理段,返回 false,绝不声称成功。 + */ +internal suspend fun TokenStatSpool.summarizeOverCapSegment( + context: Context, + segment: File, + rawBytes: ByteArray, + text: String, + corruptLineCount: Int, +): Boolean { + val summaryFile = File(spoolDir(context), QUARANTINE_SUMMARY_NAME) + val store = summaryStore(summaryFile) + return try { + val sha = sha256Hex(rawBytes) + // 崩溃安全读取旧完整摘要(中断残留会被恢复),绝不基于半写内容裁剪 + val oldContent = readMetadata(store, summaryFile) + val oldLines = + oldContent?.lineSequence()?.filter { it.isNotEmpty() }?.toList() ?: emptyList() + val record = + JSONObject() + .put("ts", System.currentTimeMillis()) + .put("file", segment.name) + .put("bytes", segment.length()) + .put("sha256", sha) + .put("lineCount", text.lineSequence().filter { it.isNotEmpty() }.count()) + .put("corruptLines", corruptLineCount) + .toString() + // 崩溃重试幂等:同一段已有记录且未超限则不再追加;超限旧摘要仍会被裁剪自愈 + val alreadyRecorded = + oldLines.any { line -> + try { + val obj = JSONObject(line) + obj.optString("file") == segment.name && obj.optString("sha256") == sha + } catch (_: Exception) { + false + } + } + val withinCaps = + oldLines.size <= MAX_QUARANTINE_SUMMARY_LINES && + oldLines.sumOf { utf8RecordBytes(it) } <= MAX_QUARANTINE_SUMMARY_BYTES + if (!alreadyRecorded || !withinCaps) { + val newContent = buildTrimmedSummary(oldLines, record) + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量(canonical/.new/.bak/tmp 四 + // 槽位),超限有界失败:保留旧摘要与待处理段,返回 false 让 drain 退避重试, + // 绝不发布任何正式文件。 + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE( + "statistics quarantine summary publish refused: metadata budget over the " + + "total cap; keeping old summary and pending segment: ${segment.name}", + ) + return false + } + store.write(newContent) + } + if (disposeOverCapSegment(context, segment, rawBytes) == TombstoneResult.FAILED) { + return false + } + logE("statistics quarantine hard cap: over-cap corrupt segment summarized and removed: ${segment.name}") + true + } catch (e: Exception) { + logE("statistics quarantine summary write failed: ${segment.name}", e) + false + } +} +/** + * 构建“已裁剪到双上限”的新完整摘要内容(行数与 UTF-8 字节总数都满足上限,保留最新记录)。 + * 真实记录约 200 字节,单行不可能超过字节上限;循环只保证至少保留最新一行。 + */ +internal fun TokenStatSpool.buildTrimmedSummary(oldLines: List, record: String): String { + var keep = oldLines.map(::normalizeOversizedSummaryLine) + record + while (keep.size > 1 && + (keep.size > MAX_QUARANTINE_SUMMARY_LINES || + keep.sumOf { utf8RecordBytes(it) } > MAX_QUARANTINE_SUMMARY_BYTES) + ) { + keep = keep.drop(1) + } + return keep.joinToString("\n") + "\n" +} +/** + * P2-1:摘要上限按 UTF-8 实际字节计(Kotlin String.length 是 UTF-16 code unit,非 ASCII + * 字符会低估);单行 UTF-8 字节超上限时替换为固定 ASCII 缩略记录(hash/bytes,不含正文), + * 输出恒 ≤ [MAX_QUARANTINE_SUMMARY_BYTES]。 + */ +internal fun TokenStatSpool.utf8RecordBytes(line: String): Int = + line.toByteArray(Charsets.UTF_8).size + 1 +internal fun TokenStatSpool.normalizeOversizedSummaryLine(line: String): String { + if (utf8RecordBytes(line) <= MAX_QUARANTINE_SUMMARY_BYTES) return line + val bytes = line.toByteArray(Charsets.UTF_8) + return JSONObject() + .put("truncated", true) + .put("bytes", bytes.size) + .put("sha256", sha256Hex(bytes)) + .toString() +} +internal suspend fun TokenStatSpool.disposeOverCapSegment( + context: Context, + segment: File, + rawBytes: ByteArray, +): TombstoneResult { + if (segmentDeleteForTest?.invoke(segment) ?: segment.delete()) { + // P1-2 终审:删除是目录项变更——sync 非 OK 绝不返回 RECORDED(否则本轮声称成功 + // 而崩溃后段可能复活;下一轮按摘要身份幂等重删)。P1-1:非 OK 同时失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap segment deletion not durable: ${segment.name}") + return TombstoneResult.FAILED + } + return TombstoneResult.RECORDED + } + if (!segment.exists()) { + // 段已消失(上一轮可见删除):先确认删除持久才允许推进队列,绝不基于未确认 + // 状态返回 RECORDED。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap segment absence not durable: ${segment.name}") + return TombstoneResult.FAILED + } + return TombstoneResult.RECORDED + } + val fitsBudget = + quarantineEvidenceLocked(context).sumOf { it.length() } + segment.length() <= MAX_QUARANTINE_BYTES + if (fitsBudget) { + val pending = File( + segment.parentFile, + "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (renameForTest(segment, pending)) { + // P1-2 终审:rename 后目录项必须确认持久,非 OK 返回 FAILED(本轮退避重试; + // rename 可见时下一轮直接跳过/按身份幂等处置,崩溃后 pending 名重现由维护重放) + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics over-cap pending-delete rename not durable: ${pending.name}") + return TombstoneResult.FAILED + } + logE("statistics over-cap segment deletion failed; retained as pending-delete evidence: ${pending.name}") + return TombstoneResult.RECORDED + } + } else { + logE("statistics over-cap segment deletion failed and full evidence exceeds the hard cap; summary retains hash/bytes: ${segment.name}") + } + logE("statistics over-cap segment pending-delete rename failed; tombstoning: ${segment.name}") + return tombstoneSegment(context, segment, rawBytes, overCap = true) +} +/** + * 读取 tombstone manifest(崩溃安全恢复)得到原始行;解析交给 [parseTombstoneLine]。 + * P1-3:不设 canonical isFile 前置——canonical 缺失而内容只在 `.new`/`.bak` sidecar + * 时也必须先经 [AtomicRestoreMarkerStore.read] 恢复完整值再返回;否则仅 sidecar 存在 + * 时 info/ack/容量/扫描会误判为空。 + * P1-2 fail-closed:读取失败必须抛明确 [IOException](不返回 empty)——调用方(append + * 容量检查、scanner、快照、维护)据此中止并退避;返回空只允许出现在“manifest 不存在 + * (无受管记录)”这一真实状态。 + */ +internal suspend fun TokenStatSpool.readTombstoneLines(context: Context): List { + val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) + val content = readMetadata(summaryStore(manifestFile), manifestFile) ?: return emptyList() + return content.lineSequence().filter { it.isNotBlank() }.toList() +} +internal fun TokenStatSpool.parseTombstoneLine(line: String): TombstoneEntry? = try { + val obj = JSONObject(line) + val file = obj.optString("file").takeIf { it.isNotEmpty() } ?: return null + TombstoneEntry( + file = file, + bytes = obj.optLong("bytes", -1L), + sha256 = obj.optString("sha256", ""), + overCap = obj.optBoolean("overCap", false), + ) +} catch (_: Exception) { + null +} +internal enum class IdentityCheck { MATCH, MISMATCH, UNREADABLE } +/** + * 有界 skip/tombstone manifest 更新(P1-1/P1-2):**不滚动**的活跃受管失败集合,条目 + * 只在文件物理消失/身份变化后由维护入口移除;达到 entry/字节硬上限时返回 + * [TombstoneResult.CAPACITY_FULL](调用方跳过该段继续健康,新统计 append 随后被拒绝), + * 写失败返回 [TombstoneResult.FAILED](drain 退避重试),绝不静默放行。 + */ +internal suspend fun TokenStatSpool.tombstoneSegment( + context: Context, + segment: File, + rawBytes: ByteArray, + overCap: Boolean, +): TombstoneResult { + val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) + val store = summaryStore(manifestFile) + return try { + val oldLines = + readMetadata(store, manifestFile)?.lineSequence()?.filter { it.isNotEmpty() }?.toList() + ?: emptyList() + val sha = sha256Hex(rawBytes) + val entry = + JSONObject() + .put("ts", System.currentTimeMillis()) + .put("file", segment.name) + // P2 终审:字节数按原始字节计算——候选文件可能已被隔离/删除(目录项 sync + // 失败路径),File.length() 对不存在的文件恒为 0,会让崩溃后重现文件的 + // 身份判定失效;rawBytes 是调用方现场读取的稳定身份。 + .put("bytes", rawBytes.size.toLong()) + .put("sha256", sha) + .put("overCap", overCap) + .toString() + // 崩溃重试幂等:同一身份已记录则不再追加(同一文件同一内容) + val alreadyRecorded = + oldLines.any { line -> + val existing = parseTombstoneLine(line) + existing?.file == segment.name && existing?.sha256 == sha + } + if (alreadyRecorded) return TombstoneResult.RECORDED + val wouldBeEntries = oldLines.size + 1 + val wouldBeBytes = (oldLines + entry).sumOf { utf8RecordBytes(it) } + if (wouldBeEntries > MAX_TOMBSTONE_ENTRIES || wouldBeBytes > MAX_TOMBSTONE_MANIFEST_BYTES) { + logE( + "statistics tombstone capacity full; segment stays in queue for a later retry: ${segment.name}", + ) + return TombstoneResult.CAPACITY_FULL + } + val newContent = (oldLines + entry).joinToString("\n") + "\n" + // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(FAILED → drain + // 退避重试,绝不发布任何正式文件)。 + if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { + logE( + "statistics tombstone manifest publish refused: metadata budget over the " + + "total cap; segment stays in queue: ${segment.name}", + ) + return TombstoneResult.FAILED + } + store.write(newContent) + TombstoneResult.RECORDED + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest write failed: ${segment.name}", e) + TombstoneResult.FAILED + } +} +/** + * 移除/裁剪后崩溃安全重写 manifest;写失败仅记录(下一次 drain 会再尝试)。 + * P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件)。 + */ +internal suspend fun TokenStatSpool.rewriteTombstoneManifest(context: Context, remainingRawLines: List) { + try { + val content = remainingRawLines.joinToString("\n") + if (remainingRawLines.isEmpty()) "" else "\n" + if (metadataWriteBudgetExceeded(context, content.toByteArray(Charsets.UTF_8).size)) { + logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") + return + } + summaryStore(File(spoolDir(context), TOMBSTONE_MANIFEST_NAME)).write(content) + } catch (e: Exception) { + logE("statistics quarantine tombstone manifest rewrite failed", e) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt new file mode 100644 index 000000000..38d7682c7 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt @@ -0,0 +1,159 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal SpoolDrain responsibilities extracted from [TokenStatSpool]. */ +/** + * 请求合并式 drain 调度(丢失唤醒修复):每次调用都在 [stateLock] 下置位 + * [drainRequested]——请求绝不丢失;仅当没有 worker 在跑/在队列([drainScheduled] + * 为 false)时才入队新任务。worker 每轮开始前消费请求,轮末在同一锁内决定 + * retire/立即 rerun/失败 backoff,请求在轮内到达时由同一 worker 接管。 + * + * RejectedExecution 恢复正确状态:请求保留(drainRequested=true,绝不丢),仅释放 + * 调度令牌(drainScheduled=false);下一次 schedule(append/replay/awaitInitialDrain) + * 会重建 executor(isShutdown 检查)并重新入队。 + */ +internal fun TokenStatSpool.scheduleDrain(context: Context, delayMs: Long = 0L) { + val generation: Long + synchronized(stateLock) { + if (writerExecutor.isShutdown) writerExecutor = newWriterExecutor() + drainRequested = true + if (drainScheduled) return + drainScheduled = true + generation = sessionGeneration + } + try { + if (rejectDrainScheduleForTest) { + throw RejectedExecutionException("drain schedule rejected (injected)") + } + val task = Runnable { runDrain(context, generation) } + if (delayMs == 0L) writerExecutor.execute(task) + else writerExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS) + } catch (e: RejectedExecutionException) { + synchronized(stateLock) { drainScheduled = false } + logE("statistics drain scheduling failed; request retained", e) + } +} +/** + * 每轮开始前消费 drain 请求(持 [stateLock])。返回 false 表示本轮无需运行: + * - 无请求:释放调度令牌并 retire; + * - 已被快照 generation 取代:不触碰任何标志——快照屏障已清 [drainScheduled], + * 新 generation 的请求由新 schedule 自行记账,旧 worker 绝不消费新请求。 + */ +internal fun TokenStatSpool.consumeDrainRequest(generation: Long): Boolean = synchronized(stateLock) { + when { + sessionGeneration != generation -> false + !drainRequested -> { + drainScheduled = false + false + } + else -> { + drainRequested = false + true + } + } +} +/** + * 轮末决策(持 [stateLock],同一锁内原子完成等待者与状态转移): + * - generation 已变:快照屏障已接管(其 drain/替换处理了被等待的数据),retire + * 且不触碰标志;等待者按成功完成。 + * - 本轮失败:完成等待者(false),释放调度令牌并计算退避延迟,稍后重试。 + * - 成功且有新请求(轮内到达):完成等待者(true)后立即 rerun,绝不丢请求。 + * - 成功且无请求:完成等待者(true),释放调度令牌并 retire。 + */ +internal fun TokenStatSpool.runDrain(context: Context, generation: Long) { + while (true) { + if (!consumeDrainRequest(generation)) return + var success = false + try { + success = runBlocking { + lifecycleMutex.withLock { + if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true + drainCore(context, generation) + } + } + } catch (e: Throwable) { + logE("statistics spool drain failed", e) + } + afterDrainRoundForTest?.invoke() + val retry: Long + val rerun: Boolean + synchronized(stateLock) { + if (sessionGeneration != generation) { + completeInitialDrainWaitersLocked(true) + return + } + completeInitialDrainWaitersLocked(success) + if (!success) { + drainScheduled = false + retry = retryDelayMs + retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) + rerun = false + } else if (drainRequested) { + retryDelayMs = RETRY_BACKOFF_BASE_MS + retry = 0L + rerun = true + } else { + retryDelayMs = RETRY_BACKOFF_BASE_MS + drainScheduled = false + retry = 0L + rerun = false + } + } + if (rerun) continue + if (retry > 0L) scheduleDrain(context, retry) + return + } +} +internal fun TokenStatSpool.completeInitialDrainWaitersLocked(success: Boolean) { + if (initialDrainWaiters.isEmpty()) return + initialDrainWaiters.forEach { waiter -> + if (waiter.isActive) waiter.complete(success) + } + initialDrainWaiters.clear() +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt new file mode 100644 index 000000000..b9cd9971b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt @@ -0,0 +1,120 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import java.io.File +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet + +/** Low-level filesystem primitives for the token-stat spool; contains no queue policy. */ +internal object SpoolFileSystem { + fun spoolDir(context: Context, directoryName: String): File = + File(context.filesDir, directoryName) + + fun syncDirectory( + dir: File, + logError: (String, Throwable?) -> Unit + ): TokenStatSpool.DirSyncResult = + try { + FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } + TokenStatSpool.DirSyncResult.OK + } catch (e: AccessDeniedException) { + logError( + "statistics spool directory fsync unsupported on this platform; " + + "fail-closed: no directory entry is confirmed durable", + e + ) + TokenStatSpool.DirSyncResult.UNSUPPORTED + } catch (e: Exception) { + logError("statistics spool directory fsync failed: ${dir.absolutePath}", e) + TokenStatSpool.DirSyncResult.FAILED + } + + fun syncFile(file: File, logError: (String, Throwable?) -> Unit): Boolean = + try { + FileChannel.open(file.toPath(), StandardOpenOption.WRITE).use { it.force(true) } + true + } catch (e: Exception) { + logError("statistics spool file fsync failed: ${file.name}", e) + false + } + + fun listDirectory( + dir: File, + seam: ((File) -> Array?)? + ): Array? = if (seam != null) seam(dir) else dir.listFiles() + + fun contentsEqual( + first: File, + second: File, + logError: (String, Throwable?) -> Unit + ): Boolean? { + if (first.length() != second.length()) return false + if (first.length() == 0L) return true + return try { + first.inputStream().use { firstInput -> + second.inputStream().use { secondInput -> + val firstBuffer = ByteArray(64 * 1024) + val secondBuffer = ByteArray(64 * 1024) + while (true) { + val firstCount = firstInput.read(firstBuffer) + val secondCount = secondInput.read(secondBuffer) + if (firstCount != secondCount) return@use false + if (firstCount < 0) return@use true + if (!firstBuffer.copyOfRange(0, firstCount) + .contentEquals(secondBuffer.copyOfRange(0, secondCount))) { + return@use false + } + } + @Suppress("UNREACHABLE_CODE") + true + } + } + } catch (e: Exception) { + logError("statistics spool seal duplicate content compare failed", e) + null + } + } + + fun totalBytes(dir: File, cap: Long): Long { + if (!dir.isDirectory) return 0L + val saturated = if (cap == Long.MAX_VALUE) cap else cap + 1L + var total = 0L + val visitor = object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (attrs.isRegularFile) { + total += attrs.size() + if (total < 0L || total > cap) { + total = saturated + return FileVisitResult.TERMINATE + } + } + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { + total = saturated + return FileVisitResult.TERMINATE + } + } + return try { + Files.walkFileTree( + dir.toPath(), + EnumSet.noneOf(FileVisitOption::class.java), + Int.MAX_VALUE, + visitor + ) + total + } catch (_: IOException) { + saturated + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt new file mode 100644 index 000000000..18a110689 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt @@ -0,0 +1,211 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal SpoolLock responsibilities extracted from [TokenStatSpool]. */ +/** + * P1-1 终审:每进程首次使用 spool 前的 durable bootstrap gate(调用方持 lifecycleMutex)。 + * + * 内存标记 [directoryDurabilityConfirmedThisProcess] 初始为 false,进程重启即清零 + * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟进程重启)。若 spool + * 目录**已存在**——无论它是本进程创建还是**上一进程**创建——都必须先 sync filesDir + * (确认 spool 目录项持久)再 sync spool 目录(确认 active/metadata 等可见目录项持久); + * 两者都 OK 之前不得写新行/返回 durable/做任何目录变更。这样上一进程已可见但未确认的 + * 目录项在本进程重新提交(崩溃后文件可能消失的窗口被关闭)。 + * + * 目录尚不存在时没有可确认的目录项,放行(首次创建协议在 [append] 中负责创建后同步 + * 父目录与新目录本身;其任一 sync 失败会把本标记保持/复位为 false,下一次使用重新走 + * 本 gate)。任一非 OK 均 fail-closed:不置位标记、返回 false,由调用方明确失败 + * (append 返回 false / drain 退避 / snapshot 抛 IOException)。 + */ +internal fun TokenStatSpool.ensureDirectoryDurabilityConfirmed(context: Context, dir: File): Boolean { + if (directoryDurabilityConfirmedThisProcess) return true + if (!dir.isDirectory) return true + val parent = dir.parentFile + // P1-1 终审:bootstrap 只在 filesDir 与 spool 目录两者都 OK 时才置位;任一非 OK + // 由 [requireSpoolDirSync] 保持/置回 false(本处进入时 flag 必为 false),绝不置位。 + if (parent == null || !requireSpoolDirSync(parent, dir)) { + logE( + "statistics spool directory durability unconfirmed; refusing writes " + + "until directory entries are re-confirmed: ${dir.absolutePath}", + ) + return false + } + directoryDurabilityConfirmedThisProcess = true + return true +} +internal suspend fun TokenStatSpool.withExclusiveSnapshotAccessInternal( + context: Context, + drainBefore: Boolean, + clearAfter: Boolean, + deferredRestoreCommit: (suspend () -> Unit)?, + block: suspend () -> T, +): T = lifecycleMutex.withLock { + val appContext = context.applicationContext + // P1-1 终审:快照/恢复前必须先确认 spool 目录项持久(上一进程可见未确认的目录项 + // 在本进程重新提交);失败明确中止,绝不带着未确认状态做 drain/替换/清理。 + if (!ensureDirectoryDurabilityConfirmed(appContext, spoolDir(appContext))) { + throw IOException( + "statistics spool directory durability could not be confirmed for snapshot", + ) + } + val generation = synchronized(stateLock) { + sessionGeneration += 1L + drainScheduled = false + if (clearAfter && deferredRestoreCommit == null) { + // P1 终审:恢复屏障开始即原子递增 restore epoch——所有在屏障前开始的请求 + // 收尾 append 时 epoch 不匹配而被明确拒绝;导出/快照(clearAfter=false) + // 不递增,进行中的请求在导出期间正常收尾。 + restoreEpoch += 1L + } + sessionGeneration + } + if (drainBefore && !drainCore(appContext, generation)) { + throw IOException("statistics spool could not be drained for snapshot") + } + if (drainBefore && hasPendingSegments(appContext)) { + throw IOException("statistics spool still contains pending events after drain") + } + if (drainBefore && hasQuarantineEvidenceForSnapshotLocked(appContext)) { + throw IOException( + "statistics quarantine evidence must be exported and acknowledged before snapshot", + ) + } + // 排他状态必须在 drain 阶段之后设置:drainBefore 自己的 insert 需要登记。 + // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 + synchronized(stateLock) { exclusiveBarrierActive = true } + try { + if (!awaitActiveInsertsEmpty()) { + val live = synchronized(stateLock) { activeInserts.size } + throw IOException( + "statistics Room insert still active ($live); " + + "snapshot/restore aborted before any file replacement", + ) + } + if (clearAfter) { + if (deferredRestoreCommit != null) { + var restoreFenceCommitted = false + try { + withContext(kotlinx.coroutines.NonCancellable) { + deferredRestoreCommit() + synchronized(stateLock) { + restoreEpoch += 1L + acceptingEventsThisProcess = false + } + restoreFenceCommitted = true + } + } catch (e: Exception) { + if (!restoreFenceCommitted) { + // The request fence is unchanged. Resume normal draining after releasing + // lifecycleMutex so durable old/new events can still reach the old DB. + scheduleDrain(appContext) + } + throw e + } + } else { + // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 + // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 + // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 + // 可继续(替换前失败可恢复)。 + synchronized(stateLock) { acceptingEventsThisProcess = false } + } + } + val result = block() + if (clearAfter) clearForRestoreLocked(appContext) + result + } finally { + synchronized(stateLock) { exclusiveBarrierActive = false } + } +} +/** + * Request/session fencing 判定(P1 终审,调用方持 lifecycleMutex):请求开始捕获的 + * [sessionEpoch] 必须等于当前 [restoreEpoch](恢复屏障开始时原子递增使旧请求失效), + * 且本进程仍接受事件(恢复替换开始后为 false 直至重启)。任一不满足 → 明确拒绝, + * 绝不写入可能已被恢复替换的 spool。 + */ +internal fun TokenStatSpool.fenceAcceptsRestore(sessionEpoch: Long): Boolean = + synchronized(stateLock) { acceptingEventsThisProcess && sessionEpoch == restoreEpoch } +/** + * 硬超时等待已登记 insert 全部结束。等待期间不持有 [stateLock](轮询只短暂取快照), + * 因此绝不阻塞普通 drain/append;[delay] 可被协程取消,超时由调用方转换为明确失败。 + */ +internal suspend fun TokenStatSpool.awaitActiveInsertsEmpty(): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) + while (true) { + if (synchronized(stateLock) { activeInserts.isEmpty() }) return true + if (System.nanoTime() >= deadline) return false + delay(QUIESCE_POLL_INTERVAL_MS) + } +} +internal fun TokenStatSpool.clearForRestoreLocked(context: Context) { + val dir = spoolDir(context) + // P1-1 终审修复:删除开始前立即失效 bootstrap gate——删除本身是目录项变更,删除后 + // 任何 sync 失败都不得让“已确认”内存标记继续生效,下一次使用必须重新确认(或重新 + // 走首次创建协议)。 + directoryDurabilityConfirmedThisProcess = false + if (dir.exists()) { + val deleted = spoolDeleteForTest?.invoke(dir) ?: dir.deleteRecursively() + if (!deleted || dir.exists()) { + throw IOException("statistics spool cleanup failed: ${dir.absolutePath}") + } + } + // P1-3 终审:spool 目录项删除(可能刚发生且可见)必须确认持久,否则 restore 失败并 + // 保留恢复状态;目录删除可见但 sync 失败时重试幂等(目录已不存在则跳过删除,本处 + // 仍 sync filesDir 确认“删除/不存在”持久后才放行)。P1-1:sync 非 OK 由 + // [requireSpoolDirSync] 同步失效 gate(本函数开头已失效,保持 false 供下次重新确认)。 + val parent = dir.parentFile + if (parent == null || !requireSpoolDirSync(parent)) { + throw IOException( + "statistics spool cleanup not durable; restore state retained: ${dir.absolutePath}", + ) + } + synchronized(stateLock) { + insertionWaiters.values.forEach { it.cancel() } + insertionWaiters.clear() + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt new file mode 100644 index 000000000..8335c2b28 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt @@ -0,0 +1,402 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal SpoolReader responsibilities extracted from [TokenStatSpool]. */ +/** Called with lifecycleMutex held. */ +internal suspend fun TokenStatSpool.drainCore(context: Context, generation: Long): Boolean { + // P1-1 终审:replay/维护/快照 drain 前必须先确认 spool 目录项持久(上一进程可见 + // 未确认的目录项在本进程重新提交);失败退避重试,绝不带着未确认状态做任何目录变更。 + if (!ensureDirectoryDurabilityConfirmed(context, spoolDir(context))) { + logE("statistics spool drain deferred: directory durability unconfirmed") + return false + } + val dao = resolveDaoSafely(context) ?: return false + // P1-2 维护/后台重试:先清理删除失败被隔离的残留(pending-delete 与 tombstoned 段)。 + // 返回 false 表示本轮存在目录项未确认持久的变更——drain 退避重试,绝不推进状态。 + if (!retryPendingCleanup(context)) { + logE("statistics spool maintenance deferred: directory changes unconfirmed") + return false + } + var lastRound: List = emptyList() + while (synchronized(stateLock) { sessionGeneration == generation }) { + val segments = sealAndList(context) ?: return false + if (segments.isEmpty()) return true + // P1-1 有界推进:受管集合已满且仍无法处置的段会停留在队列(未入受管集合)。 + // 连续两轮同一集合说明无进展——跳过并返回,健康段已排空,绝不无限重扫/持锁。 + if (segments == lastRound) { + logE( + "statistics spool drain cannot make progress (managed-failure set full?); " + + "leaving ${segments.size} unmanageable segment(s): ${segments.joinToString { it.name }}", + ) + return true + } + lastRound = segments + for (segment in segments) if (!drainSegment(context, dao, segment)) return false + } + return true +} +internal suspend fun TokenStatSpool.sealAndList(context: Context): List? { + val dir = spoolDir(context) + if (!dir.isDirectory) return emptyList() + val active = File(dir, ACTIVE_FILE_NAME) + if (active.isFile && active.length() > 0L && !sealActive(context, dir)) { + return null + } + // P1-2:tombstone 按稳定 identity(file+bytes+sha256)跳过,绝不只信文件名 + return sealedFilesToProcess(context, dir, readTombstoneLines(context)) +} +internal suspend fun TokenStatSpool.hasPendingSegments(context: Context): Boolean { + val dir = spoolDir(context) + val processableNames = + sealedFilesToProcess(context, dir, readTombstoneLines(context)).mapTo(HashSet()) { it.name } + // P1-7 fail-closed:待处理判定依赖枚举完整性——根枚举失败(null)时绝不能当作 + // “没有 pending”返回(那会让快照在仍有待处理事件时误成功)。抛 IOException 由 + // [withExclusiveSnapshotAccess] 传播,[block] 绝不执行,文件保持原样。 + val files = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for pending segments: ${dir.absolutePath}") + return files.any { + it.isFile && + it.length() > 0L && + (it.name == ACTIVE_FILE_NAME || + (it.name.startsWith(SEALED_PREFIX) && + it.name.endsWith(SEALED_SUFFIX) && + it.name in processableNames)) + } +} +/** + * Raw snapshots intentionally exclude the active spool queue. Quarantine files and their + * summary/tombstone/trash metadata are not queue data and must never be silently omitted from a + * successful snapshot. Until raw restore has a selective evidence-preservation protocol, fail + * before ZIP creation and leave every evidence byte in place for explicit export/acknowledgment. + */ +internal suspend fun TokenStatSpool.hasQuarantineEvidenceForSnapshotLocked(context: Context): Boolean { + val dir = spoolDir(context.applicationContext) + if (quarantineAreaFiles(dir).isNotEmpty()) return true + if (stuckAckTrashEvidenceLocked(context).isNotEmpty()) return true + if (readTombstoneLines(context).isNotEmpty()) return true + val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) + return readMetadata(summaryStore(summaryFile), summaryFile)?.isNotBlank() == true +} +/** + * P1-2:对 sealed 队列应用受管失败集合。身份匹配的段跳过(受管);身份不匹配或文件已 + * 消失的条目是陈旧 tombstone(旧文件已删但 manifest 未更新,随后同名不同 hash 的健康 + * 段复用)——移除陈旧记录并正常处理新文件,绝不删/跳过健康。例外(P1-2):条目身份仍 + * 停留在未提交 ack trash 中时绝不按陈旧移除——该身份的证据还存在于 trash,移除会让崩溃 + * 窗口判定把未确认的证据误判为已提交而删除。身份**不可校验**(读取失败)的段本轮跳过 + * 且保留 manifest 条目:无法证明当前文件仍是记录中的段时,既不处理也不清理,绝不基于 + * 失败猜测破坏性决策。陈旧记录移除在持锁下崩溃安全重写 manifest。 + */ +internal suspend fun TokenStatSpool.sealedFilesToProcess(context: Context, dir: File, rawLines: List): List { + val trashHold = scanUncommittedTrashHolds(context) + val heldInTrash = trashHold.known + val entries = rawLines.mapNotNull(::parseTombstoneLine) + val stale = mutableListOf() + // P1-7 fail-closed:sealed 队列枚举失败(null)时抛 IOException——drain 据此退避 + // 重试、快照/恢复中止;绝不把失败当作空队列(否则 drain 会在仍有待处理段时误成功, + // 快照 barrier 也随之误判“无 pending”)。 + val allFiles = listDir(dir) + ?: throw IOException("cannot enumerate spool directory for sealed segments: ${dir.absolutePath}") + val files = allFiles + .filter { + it.isFile && + it.name.startsWith(SEALED_PREFIX) && + it.name.endsWith(SEALED_SUFFIX) + } + .sortedBy { it.sealIndex() } + .filter { file -> + val entry = entries.firstOrNull { it.file == file.name } + when { + entry == null -> true + else -> when (tombstoneIdentityCheck(entry, file)) { + IdentityCheck.MATCH -> false + IdentityCheck.MISMATCH -> { + // P1-2:旧身份仍停留在未提交 trash 中 → 保留条目(证据未 + // 消失),但仍正常处理同名新文件(旧文件在 trash 中不可能 + // 与当前文件同名共存,身份判定互不干扰)。 + // P1-4:存在无法完整严格解析的 UNCOMMITTED trash 时同样 + // 保留条目——旧身份可能正被其持有,scanner 绝不把可能受 + // trash 持有的身份当无保护而按 stale 移除。 + if (!trashHold.hasUnknown && + heldInTrash[entry.file] != (entry.bytes to entry.sha256) + ) { + stale += entry + } + true + } + IdentityCheck.UNREADABLE -> { + logE( + "statistics tombstone identity unreadable; keeping manifest " + + "entry and skipping the segment this round: ${file.name}", + ) + false + } + } + } + } + if (stale.isNotEmpty()) { + logE( + "statistics tombstone manifest has stale identities (vanished or reused-name files); " + + "removing: ${stale.joinToString { it.file }}", + ) + rewriteTombstoneManifest( + context = context, + remainingRawLines = rawLines.filterNot { line -> parseTombstoneLine(line)?.let { it in stale } == true }, + ) + } + return files +} +internal suspend fun TokenStatSpool.drainSegment( + context: Context, + dao: TokenStatsDao, + segment: File, +): Boolean { + val rawBytes = try { + if (segmentReadErrorForTest?.invoke(segment) == true) { + throw IOException("statistics spool segment read failed (injected): ${segment.name}") + } + segment.readBytes() + } catch (e: Exception) { + logE("statistics spool segment read failed: ${segment.name}", e) + return false + } + afterSegmentReadForTest?.invoke() + // 身份哈希一律基于原始字节(readText 会对非法 UTF-8 做替换再编码,与文件字节 + // 不一致会让 tombstone 身份永远无法匹配损坏段,造成反复重扫/重复条目)。 + val text = String(rawBytes, Charsets.UTF_8) + var corrupt = false + var corruptLineCount = 0 + for (line in text.lineSequence().filter { it.isNotEmpty() }) { + val request = try { + TokenStatRequestContext.fromSpoolLine(line) + } catch (e: Exception) { + corrupt = true + corruptLineCount += 1 + logE("statistics spool line corrupt; preserving segment evidence: ${segment.name}", e) + continue + } + if (!insertSafely(context, dao, request)) return false + synchronized(stateLock) { + insertionWaiters.remove(request.eventId)?.complete(Unit) + } + } + if (corrupt) { + val existingBytes = quarantineEvidenceLocked(context).sumOf { it.length() } + if (existingBytes + segment.length() > MAX_QUARANTINE_BYTES) { + // 硬边界(P2-1):容量内保留完整证据;超限的新损坏段只保留固定大小滚动 + // 摘要(计数/hash/字节/时间,不含正文),并移除原段,磁盘占用有界。 + // 健康排空不受影响:本段处理完立即继续后续 segment。 + if (!summarizeOverCapSegment(context, segment, rawBytes, text, corruptLineCount)) { + logE("statistics quarantine hard cap: over-cap segment retained: ${segment.name}") + return false + } + return true + } + val target = File( + segment.parentFile, + "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (!renameForTest(segment, target)) { + // P1-2:证据重命名失败也不能阻塞健康排空——容量内预算允许时先移入有界 + // pending-delete 诊断区(完整证据,维护入口会移回证据区),再失败才 tombstone + val pending = File( + segment.parentFile, + "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", + ) + if (renameForTest(segment, pending)) { + // P1 终审:rename 后目录项必须确认持久,非 OK fail-closed(段内容在任一 + // 名字下保留,绝不丢原始证据;drain 退避重试下一轮)。P1-1:非 OK 同时 + // 失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE( + "statistics corrupt segment pending-delete rename not durable; " + + "deferring round: ${pending.name}", + ) + return false + } + logE("statistics corrupt segment quarantine rename failed; retained as pending-delete evidence: ${pending.name}") + return true + } + logE("statistics corrupt segment quarantine rename failed; tombstoning: ${segment.name}") + // P1-1:受管集合满时跳过该段继续健康(有界重扫),写失败才退避重试 + return when (tombstoneSegment(context, segment, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> true + TombstoneResult.FAILED -> false + } + } + // P1 终审:证据 rename 后目录项必须确认持久,非 OK fail-closed——证据内容在 + // quarantine 名下保留(绝不丢原始),本轮退避由 drain 重试。P1-1:非 OK 同时 + // 失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE( + "statistics corrupt segment quarantine rename not durable; " + + "deferring round: ${target.name}", + ) + return false + } + return true + } + if (!(segmentDeleteForTest?.invoke(segment) ?: segment.delete())) { + logE("statistics spool segment deletion failed: ${segment.name}") + return false + } + // P1 终审:删除后目录项必须确认持久,非 OK fail-closed(行已入 Room,崩溃后文件 + // 复活会被 INSERT IGNORE 幂等重放,绝不丢数据;本轮退避下一轮继续)。P1-1:非 OK + // 同时失效 gate。 + if (!requireSpoolDirSync(segment.parentFile!!)) { + logE("statistics spool segment deletion not durable: ${segment.name}") + return false + } + return true +} +/** + * Room insert with a genuinely bounded lifecycle. The write runs on the dedicated single-thread + * insert worker; the drain waits at most [insertTimeoutMs] and on timeout releases the lifecycle + * lock WITHOUT joining the worker (SQLite can ignore interrupts forever, and an unbounded join + * under the lock would freeze every append/snapshot/replay). + * + * Safety across the snapshot/restore barrier (P1-2) has two halves: + * - Generation fencing: the task captures the session generation at submission and atomically + * re-checks it together with [exclusiveBarrierActive] before touching Room. A task that runs + * after a restore bumped the generation skips entirely (its durable segment belongs to the + * pre-restore state that restore replaces). + * - Active-insert registry: the same atomic section registers the insert BEFORE Room is + * entered and the `finally` unregisters it. A snapshot/restore therefore provably waits (or + * bounded-fails) for every insert that already passed the fence, instead of merely relying on + * a check-then-act race that could let an old DAO write into replaced files. + */ +internal suspend fun TokenStatSpool.insertSafely( + context: Context, + dao: TokenStatsDao, + request: TokenStatRequestContext, +): Boolean { + val generation = synchronized(stateLock) { sessionGeneration } + val task = FutureTask { + val registered = synchronized(stateLock) { + if (sessionGeneration != generation || exclusiveBarrierActive) { + false + } else { + activeInserts[request.eventId] = generation + true + } + } + if (!registered) return@FutureTask + try { + runBlocking { TokenStatsLedger.recordWith(context, dao, request) } + synchronized(stateLock) { + insertionWaiters.remove(request.eventId)?.complete(Unit) + } + } finally { + synchronized(stateLock) { activeInserts.remove(request.eventId) } + } + } + try { + insertExecutor.execute(task) + } catch (e: RejectedExecutionException) { + logE("statistics insert worker saturated; durable segment retained: ${request.eventId}", e) + return false + } + return try { + task.get(insertTimeoutMs, TimeUnit.MILLISECONDS) + true + } catch (e: TimeoutException) { + // The worker may legitimately outlive this wait; the durable segment stays for a later + // drain and the generation fence keeps a late write out of a restored database. + task.cancel(true) + logE("statistics Room insert timed out; durable segment retained: ${request.eventId}", e) + false + } catch (e: ExecutionException) { + logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e.cause ?: e) + false + } catch (e: CancellationException) { + logE("statistics Room insert cancelled; durable segment retained: ${request.eventId}", e) + false + } catch (e: Throwable) { + logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e) + false + } +} +/** + * Database preparation with bounded single-flight semantics: at most one resolution runs at a + * time, and a timed-out resolution is reused by later drain cycles instead of spawning another + * thread (P2-1). A permanently wedged open cannot recover without a restart, but it can never + * block the lifecycle lock or leak threads. + */ +internal fun TokenStatSpool.resolveDaoSafely(context: Context): TokenStatsDao? { + val task = synchronized(stateLock) { + pendingDaoTask?.takeIf { !it.isDone } + ?: FutureTask { + (TokenStatsLedger.databaseProvider?.invoke(context) ?: AppDatabase.getDatabase(context)) + .tokenStatsDao() + }.also { created -> + pendingDaoTask = created + try { + databaseExecutor.execute(created) + } catch (e: RejectedExecutionException) { + pendingDaoTask = null + throw e + } + } + } + return try { + task.get(prepareTimeoutMs, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + task.cancel(true) + logE("statistics database preparation timed out; durable segments retained", e) + null + } catch (e: RejectedExecutionException) { + logE("statistics database preparation rejected; durable segments retained", e) + null + } catch (e: ExecutionException) { + logE("statistics database preparation failed; durable segments retained", e.cause ?: e) + null + } catch (e: Throwable) { + logE("statistics database preparation failed; durable segments retained", e) + null + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt new file mode 100644 index 000000000..08d3032e0 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt @@ -0,0 +1,525 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AccessDeniedException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileAlreadyExistsException +import java.nio.file.FileVisitOption +import java.nio.file.FileVisitResult +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.BasicFileAttributes +import java.util.EnumSet +import java.util.UUID +import java.security.MessageDigest +import java.util.concurrent.ExecutionException +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** Internal SpoolRecovery responsibilities extracted from [TokenStatSpool]. */ +internal enum class SealPublishResult { PUBLISHED, EXISTS, FAILED } +/** + * Seal 采用文件系统级原子“不替换”发布(P1-8 + P1 终审持久化协议): + * + * 1. 首选 `Files.createLink(target, active)`:同目录硬链接,目标创建原子且已存在时抛 + * [FileAlreadyExistsException](绝不替换既有 sealed 段);链接建立后按 P1 终审顺序 + * 持久化:sync 目录(链接目录项)→ 删除 active → sync 目录(删除持久化)。崩溃窗口 + * (链接已建、active 删除未发生或未持久化)两个名字指向同一 inode,由 + * [recoverSealDuplicates] 在下次 append/drain 时识别并删除 sealed 副本(内容保留在 + * active,绝不重复拼接——向 active 追加会连带改写已 seal 段)。 + * 2. 硬链接不受支持(FAT/exFAT 等)或临时失败时回退 copy 发布([publishSealedByCopy]): + * Android/Linux 的 Unix provider 以 O_CREAT|O_EXCL 原子创建目标(已存在即抛 + * [FileAlreadyExistsException]),Windows 以 CREATE_NEW 同样原子不替换。这比不带 + * REPLACE 的 `Files.move` 更强:Android 的普通 move 先做存在性预检再 rename(2) + * (rename 会静默替换预检之后出现的目标),保留 TOCTOU,不可单独依赖;ATOMIC_MOVE + * 在目标已存在时语义实现相关,同样不可依赖。copy 回退的崩溃窗口(复制完成、active + * 未删)产生两个内容相同的独立文件,同样由 [recoverSealDuplicates] 按内容识别去重。 + * + * 枚举失败(null)或恢复无法确认无重复时 seal 明确失败(fail-closed),绝不发布。 + */ +internal suspend fun TokenStatSpool.sealActive(context: Context, dir: File): Boolean { + val active = File(dir, ACTIVE_FILE_NAME) + if (!active.isFile || active.length() == 0L) return true + if (!recoverSealDuplicates(dir, active)) { + logE("statistics spool segment seal failed: seal recovery could not confirm no duplicates") + return false + } + // P1-7 fail-closed:seal 编号枚举失败(null)→ seal 明确失败,绝不回退到编号 1 + // (枚举失败时回退 1 会重名覆盖 sealed_1 等既有段,销毁其证据)。枚举成功但目标 + // 已被占用(异常残留)→ 递增到下一个安全编号,找不到则失败,绝不覆盖任何既有段。 + val index = nextSealIndex(dir) + if (index == null) { + logE("statistics spool segment seal failed: cannot enumerate spool directory: ${dir.absolutePath}") + return false + } + var candidate = index + while (true) { + val target = File(dir, "$SEALED_PREFIX$candidate$SEALED_SUFFIX") + // 可控 publication seam(P1-8):测试可在此创建同名不同内容的目标文件模拟冲突, + // 真实发布路径必须检测到占用并选择下一编号,目标原字节保持不变。 + if (beforeSealPublishForTest?.invoke(target) == false) { + logE("statistics spool segment seal failed: pre-publish hook refused: ${target.name}") + return false + } + when (publishSealedNoReplace(context, dir, active, target)) { + SealPublishResult.PUBLISHED -> return true + SealPublishResult.EXISTS -> { + candidate += 1L + if (candidate <= 0L) { + // Long 溢出防御:不再有可用编号 → 失败(绝不覆盖) + logE("statistics spool segment seal failed: no free sealed index: ${dir.absolutePath}") + return false + } + } + SealPublishResult.FAILED -> { + logE("statistics spool segment seal failed: ${target.name}") + return false + } + } + } +} +/** + * 原子“不替换”发布 active → target(调用方持 lifecycleMutex,契约见 [sealActive]): + * 首选硬链接;不受支持时回退 copy 发布([publishSealedByCopy])。目标已存在只返回 + * [SealPublishResult.EXISTS],绝不修改、替换或删除既有目标。 + * + * 持久化契约(P1 终审):两种路径都保证“target 的 data + 目录项(创建/链接/删除)已 + * fsync 确认后才可能返回 PUBLISHED”;任何前置失败保留 active(数据持有者)并返回 + * FAILED;删除 active 后的目录同步失败返回 FAILED 但保留已 durable 的 target,由 + * [recoverSealDuplicates] 恢复。 + */ +internal suspend fun TokenStatSpool.publishSealedNoReplace( + context: Context, + dir: File, + active: File, + target: File, +): SealPublishResult { + val linked = if (sealHardLinkForTest?.invoke(active, target) != false) { + try { + Files.createLink(target.toPath(), active.toPath()) + true + } catch (e: FileAlreadyExistsException) { + return SealPublishResult.EXISTS + } catch (e: Exception) { + // 平台/文件系统不支持硬链接或临时失败 → 回退 copy 发布 + false + } + } else { + // 测试注入:强制模拟硬链接不受支持 + false + } + if (linked) return publishSealedAfterHardLink(dir, active, target) + return publishSealedByCopy(context, dir, active, target) +} +/** + * 硬链接发布后置持久化(P1 终审):createLink 已原子建立同 inode 链接(active 数据在 + * append 时已 fsync)。顺序:sync 目录(持久化链接目录项)→ 删除 active → sync 目录 + * (持久化删除)。 + * + * - 删除 active 之前的任何失败:active 是唯一数据持有者,保留 active、回滚链接并返回 + * FAILED,绝不声称 PUBLISHED(否则崩溃窗口里 append 可能写进已 seal 段)。 + * - 删除 active 之后的目录同步失败:链接已 data+creation durable,active 删除可能未 + * 持久化——保留明确恢复状态(崩溃后 active 以同 inode 重现时由 [recoverSealDuplicates] + * 去重;未重现则 target 正常排空),返回 FAILED 阻止本轮后续 append 写入,绝不回滚 + * 已 durable 的 target。 + * + * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 + * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 + */ +internal fun TokenStatSpool.publishSealedAfterHardLink( + dir: File, + active: File, + target: File, +): SealPublishResult { + if (!requireSpoolDirSync(dir)) { + rollbackSealTarget(dir, target, "hardlink") + return SealPublishResult.FAILED + } + if (!deleteActiveAfterPublish(active)) { + // 链接已建但 active 删除失败:同 inode 重复。先尝试回滚链接;回滚也失败时 + // 保留给 [recoverSealDuplicates] 下次识别(内容仍在 active)。绝不可带着 + // active 返回成功——否则后续 drain 会把同一内容排空两次。 + rollbackSealTarget(dir, target, "hardlink") + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal hardlink: dir sync after active removal failed; " + + "durable link will be recovered: ${target.name}", + ) + return SealPublishResult.FAILED + } + return SealPublishResult.PUBLISHED +} +/** + * copy 回退发布(P1 终审 + P2):O_CREAT|O_EXCL / CREATE_NEW 原子创建目标(绝不替换 + * 既有目标;[FileAlreadyExistsException] → EXISTS 让调用方选下一编号)。 + * + * 持久化顺序:copy 目标 → fsync 目标数据([syncFile])→ sync 目录(目标创建持久)→ + * 删除 active → sync 目录(删除持久)。 + * - 删除 active 之前的任何失败:active 是完整内容持有者,保留 active,并按 P2 处置本次 + * 目标([disposeFailedCopyTarget]:identity 确认后隔离到 seal_failed_ 或安全 + * 删除;两者都失败则 tombstone skip,绝不当 normal sealed 排空),返回 FAILED。 + * - 删除 active 之后的目录同步失败:目标已 data+creation durable,active 删除可能未 + * 持久化(崩溃后 active 以原内容重现 → [recoverSealDuplicates] 按内容去重;未重现则 + * target 正常排空)——保留该明确恢复状态并返回 FAILED,阻止本轮后续 append 污染, + * 绝不回滚已 durable 的 target。 + * + * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 + * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 + */ +internal suspend fun TokenStatSpool.publishSealedByCopy( + context: Context, + dir: File, + active: File, + target: File, +): SealPublishResult { + val injected = sealCopyForTest?.invoke(active, target) + if (injected != null) { + if (!injected) { + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + } else { + try { + Files.copy(active.toPath(), target.toPath()) + } catch (e: FileAlreadyExistsException) { + return SealPublishResult.EXISTS + } catch (e: Exception) { + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + } + if (!syncFile(target)) { + // 目标数据未确认 durable:保留 active,处置本次目标 + disposeFailedCopyTarget(context, dir, target, active) + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + // 目标创建未确认持久:保留 active,处置本次目标 + if (!disposeFailedCopyTarget(context, dir, target, active)) { + logE( + "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", + ) + } + return SealPublishResult.FAILED + } + if (!deleteActiveAfterPublish(active)) { + // 复制完成、active 未删:两个独立文件同内容。目标已 durable(data+creation), + // 删除目标放弃 sealed 副本(active 仍是完整内容持有者,无数据损失);回滚失败 + // 留给 [recoverSealDuplicates] 按内容去重。 + rollbackSealTarget(dir, target, "copy") + return SealPublishResult.FAILED + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy: dir sync after active removal failed; " + + "durable target will be recovered: ${target.name}", + ) + return SealPublishResult.FAILED + } + return SealPublishResult.PUBLISHED +} +internal fun TokenStatSpool.deleteActiveAfterPublish(active: File): Boolean = + sealActiveDeleteForTest?.invoke(active) ?: active.delete() +/** + * seal 前置失败回滚(P2 终审):删除刚发布的 target(active 仍是完整内容持有者,删除 + * 安全无数据损失),删除后必须经 [requireSpoolDirSync] 确认目录项持久——删除是目录项 + * 变更,未确认持久绝不视为回滚完成(P1-1:非 OK 同时失效 bootstrap gate,下一次使用 + * 重新确认)。返回 false 表示回滚未完成/未确认(target 删除失败或目录项未确认持久), + * 调用方保持失败状态;残留由 [recoverSealDuplicates] 按 inode/内容去重兜底。 + */ +internal fun TokenStatSpool.rollbackSealTarget(dir: File, target: File, kind: String): Boolean { + if (!target.delete()) { + logE("statistics spool seal rollback failed ($kind); duplicate will be recovered: ${target.name}") + return false + } + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal rollback deletion not durable ($kind); " + + "gate invalidated, duplicate will be recovered: ${target.name}", + ) + return false + } + return true +} +/** + * P2 终审修复:seal copy 失败后的部分目标处置(调用方持 lifecycleMutex)。身份前提:候选 + * 编号在 copy 前由 [nextSealIndex] 确认不存在、copy 无 REPLACE 语义、lifecycleMutex 内无本 + * 进程并发——异常后目标若存在只可能是本次 copy 的部分写入;[isPrefixOf] 前缀校验防御外部 + * 进程并发占用该名字时的误隔离(identity 确认)。处置顺序: + * 1. 原子 rename 到 `seal_failed_`(scanner 忽略该前缀、计入递归总 cap、维护清理、 + * 作为受管失败发布证据可见/导出/ack);rename 后目录项 sync 非 OK——隔离文件本身即受管 + * 证据,另按候选 sealed 身份写 tombstone(崩溃后该名字以同内容重现时 scanner 跳过,绝不 + * 普通排空),返回 false(调用方失败,绝不静默)。 + * 2. rename 失败 → 安全删除(active 保留完整内容,删除部分副本无数据损失);删除后目录项 + * sync 非 OK——删除可见但未确认:按候选 sealed 身份写 tombstone 保护崩溃后可能重现的 + * 名字,返回 false。 + * 3. rename/delete 都失败 → tombstone skip(记录稳定身份,scanner 跳过该具体文件,绝不当 + * normal sealed 排空);tombstone 写失败返回 false——drain 退避重试,不做任何破坏性决策。 + * + * @return true = 已留下受管证据(seal_failed 隔离文件/tombstone 条目)或已安全删除且目录项 + * 确认持久;false = 存在目录项未确认持久的变更(tombstone 已尽力写入受管证据),调用方 + * 必须失败,绝不只记录日志。 + */ +internal suspend fun TokenStatSpool.disposeFailedCopyTarget( + context: Context, + dir: File, + target: File, + active: File, +): Boolean { + if (!target.exists()) return true + if (!isPrefixOf(target, active)) { + logE( + "statistics spool seal copy failure target identity mismatch; " + + "leaving file untouched: ${target.name}", + ) + return true + } + val isolated = File(dir, "$SEAL_FAILED_PREFIX${UUID.randomUUID().toString().replace("-", "")}") + if (renameForTest(target, isolated)) { + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy failed; isolated target rename not durable: ${isolated.name}", + ) + // P2:隔离文件本身已是受管证据(seal_failed_*,可 UI/导出/ack/维护);再按 + // 候选 sealed 身份写 tombstone,崩溃后该名字以同内容重现时 scanner 跳过。 + tombstonePartialTarget(context, target, isolated) + return false + } + logE("statistics spool seal copy failed; partial target isolated: ${isolated.name}") + return true + } + // rename 失败:先取原始字节(删除成功后将无法再读取),再尝试安全删除 + val rawBytes = try { + target.readBytes() + } catch (e: Exception) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", + e, + ) + null + } + if (segmentDeleteForTest?.invoke(target) ?: target.delete()) { + if (!requireSpoolDirSync(dir)) { + logE( + "statistics spool seal copy failed; partial target deletion not durable: ${target.name}", + ) + // P2:删除可见但未确认——按候选 sealed 身份写 tombstone,崩溃后该名字 + // 以同内容重现时 scanner 跳过,绝不普通排空。 + if (rawBytes != null) { + tombstoneSegment(context, target, rawBytes, overCap = false) + } + return false + } + logE("statistics spool seal copy failed; partial target deleted: ${target.name}") + return true + } + if (rawBytes == null) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", + ) + return false + } + return when (tombstoneSegment(context, target, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> { + logE( + "statistics spool seal copy failed; partial target tombstoned, scanner will skip: ${target.name}", + ) + true + } + TombstoneResult.FAILED -> { + logE( + "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${target.name}", + ) + false + } + } +} +/** + * P2:seal copy 失败目标的 tombstone 写入(调用方持 lifecycleMutex)。目标可能已被改名/ + * 删除,[bytesSource] 提供其原始字节;[tombstoneSegment] 按稳定身份(bytes+sha256)记录 + * [nameFile](候选 sealed 名),崩溃后该名字以同内容重现时 scanner 跳过。写失败仅记录—— + * 调用方本就返回失败,drain 退避重试。 + */ +internal suspend fun TokenStatSpool.tombstonePartialTarget( + context: Context, + nameFile: File, + bytesSource: File, +) { + val rawBytes = try { + bytesSource.readBytes() + } catch (e: Exception) { + logE( + "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${nameFile.name}", + e, + ) + return + } + when (tombstoneSegment(context, nameFile, rawBytes, overCap = false)) { + TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> Unit + TombstoneResult.FAILED -> { + logE( + "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${nameFile.name}", + ) + } + } +} +/** + * 部分目标 identity 确认(P2):目标必须是 source(active)的前缀(长度 ≤ 且逐字节 + * 相等)才允许处置;读取失败返回 false(fail-closed,绝不隔离不可确认的文件)。 + */ +internal fun TokenStatSpool.isPrefixOf(partial: File, source: File): Boolean { + if (partial.length() > source.length()) return false + if (partial.length() == 0L) return true + return try { + partial.inputStream().use { pIn -> + source.inputStream().use { sIn -> + val bufP = ByteArray(64 * 1024) + val bufS = ByteArray(64 * 1024) + var remaining = partial.length() + while (remaining > 0L) { + val want = minOf(bufP.size.toLong(), remaining).toInt() + val nP = pIn.read(bufP, 0, want) + if (nP <= 0) return false + val nS = sIn.read(bufS, 0, nP) + if (nS != nP) return false + if (!bufP.copyOfRange(0, nP).contentEquals(bufS.copyOfRange(0, nS))) { + return false + } + remaining -= nP + } + true + } + } + } catch (e: Exception) { + logE("statistics spool seal copy failure identity check failed", e) + false + } +} +/** + * 恢复 seal 崩溃窗口的重复(P1-8,调用方持 lifecycleMutex):active 与某个 sealed 段 + * 指向同一 inode(硬链接窗口:createLink 成功但 active 删除失败/崩溃)或内容完全相同 + * (copy 回退窗口:目标复制完成、active 删除未发生,两个独立 inode 同内容)时,删除 + * sealed 副本、保留 active 为唯一内容持有者——后续 append 才不会被连带写进已 seal 段, + * 同一内容也只会被排空一次。 + * + * 合法内容重复不可能发生(事件行含唯一 eventId,active 内容严格单调增长),因此 + * 内容相等只可能来自上述崩溃窗口;即使病理情况下误删副本,内容仍从 active 重新 + * seal 并排空,不丢数据。 + * + * fail-closed:spool 根枚举失败(null)、任一 sealed 候选无法 stat/读取或删除失败时 + * 返回 false——调用方(append/seal)拒绝继续,绝不带着“可能还有重复”的状态写入或发布。 + */ +internal fun TokenStatSpool.recoverSealDuplicates(dir: File, active: File): Boolean { + val files = listDir(dir) ?: return false + val activeKey = try { + Files.readAttributes( + active.toPath(), + BasicFileAttributes::class.java, + java.nio.file.LinkOption.NOFOLLOW_LINKS, + ).fileKey() + } catch (e: Exception) { + logE("statistics spool cannot stat active for seal recovery", e) + return false + } + var ok = true + var deletedAny = false + for (file in files) { + if (!file.isFile || !file.name.startsWith(SEALED_PREFIX) || !file.name.endsWith(SEALED_SUFFIX)) { + continue + } + val key = try { + Files.readAttributes( + file.toPath(), + BasicFileAttributes::class.java, + java.nio.file.LinkOption.NOFOLLOW_LINKS, + ).fileKey() + } catch (e: Exception) { + logE("statistics spool cannot stat sealed segment for seal recovery: ${file.name}", e) + ok = false + continue + } + val sameInode = key != null && key == activeKey + val contentDuplicate = if (sameInode) null else contentsEqual(file, active) + when { + sameInode || contentDuplicate == true -> { + if (!file.delete()) { + logE("statistics spool seal duplicate removal failed: ${file.name}") + ok = false + } else { + deletedAny = true + } + } + contentDuplicate == null -> { + // 无法确认是否重复(读取失败)→ fail-closed,绝不带着未知状态继续 + logE("statistics spool cannot compare sealed segment for seal recovery: ${file.name}") + ok = false + } + } + } + // P1-3 终审:重复副本删除是目录项变更——未确认持久绝不报告恢复完成(append/seal + // 据此 fail-closed)。active 仍是完整内容持有者,删除可见但未确认时崩溃后副本重现, + // 由下次恢复按 inode/内容幂等重删,绝不丢数据。P1-1:非 OK 同时失效 gate。 + if (deletedAny && !requireSpoolDirSync(dir)) { + logE("statistics spool seal duplicate removal not durable; recovery unconfirmed") + ok = false + } + return ok +} +/** + * A complete line always ends with '\n'; every append writes whole lines, so a file ending + * with '\n' has no partial tail. Only the final write of a crash can leave a tail without one. + */ +internal fun TokenStatSpool.activeEndsWithLineBreak(active: File): Boolean = + RandomAccessFile(active, "r").use { raf -> + raf.seek(raf.length() - 1L) + raf.read() == '\n'.code + } +/** 下一个建议的 sealed 编号;目录枚举失败(null)返回 null → seal 必须失败(P1-7)。 */ +internal fun TokenStatSpool.nextSealIndex(dir: File): Long? { + val files = listDir(dir) ?: return null + return files.mapNotNull { file -> + Regex("(?:quarantine_[^_]+_)?sealed_(\\d+)\\.jsonl").matchEntire(file.name) + ?.groupValues?.get(1)?.toLongOrNull() + }.maxOrNull()?.plus(1L) ?: 1L +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt new file mode 100644 index 000000000..fe76dc2b4 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt @@ -0,0 +1,44 @@ +package com.ai.assistance.operit.data.stats + +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.ScheduledThreadPoolExecutor +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +/** Worker construction and admission calculations for durable spool writes. */ +internal object SpoolWriter { + fun newDrainExecutor(): ScheduledThreadPoolExecutor = + ScheduledThreadPoolExecutor(1) { runnable -> + Thread(runnable, "operit-token-stats-writer").apply { isDaemon = true } + } + + fun newInsertExecutor(): ThreadPoolExecutor = + ThreadPoolExecutor( + 1, + 1, + 60L, + TimeUnit.SECONDS, + LinkedBlockingQueue(1) + ) { runnable -> Thread(runnable, "operit-token-stats-insert").apply { isDaemon = true } } + + fun newDatabaseExecutor(): ThreadPoolExecutor = + ThreadPoolExecutor( + 1, + 1, + 60L, + TimeUnit.SECONDS, + LinkedBlockingQueue(1) + ) { runnable -> Thread(runnable, "operit-token-stats-database").apply { isDaemon = true } } + + fun dataAdmissionMaxBytes(cap: Long, metadataReserveBytes: Long, maxLineBytes: Int): Long { + val reserve = minOf(metadataReserveBytes, cap - maxLineBytes).coerceAtLeast(0L) + return (cap - reserve).coerceAtLeast(0L) + } + + fun metadataWriteBudgetExceeded( + currentBytes: Long, + contentBytes: Int, + metadataCopyCount: Int, + cap: Long + ): Boolean = currentBytes + contentBytes.toLong() * metadataCopyCount > cap +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index 3556922ba..a2e19268f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -85,12 +85,12 @@ import org.json.JSONObject * a failure after replacement has started keeps it disabled and requires a restart. */ internal object TokenStatSpool { - private const val TAG = "TokenStatSpool" + internal const val TAG = "TokenStatSpool" internal const val SPOOL_DIR_NAME = "token_stats_spool" - private const val ACTIVE_FILE_NAME = "active.jsonl" - private const val SEALED_PREFIX = "sealed_" - private const val SEALED_SUFFIX = ".jsonl" - private const val QUARANTINE_PREFIX = "quarantine_" + internal const val ACTIVE_FILE_NAME = "active.jsonl" + internal const val SEALED_PREFIX = "sealed_" + internal const val SEALED_SUFFIX = ".jsonl" + internal const val QUARANTINE_PREFIX = "quarantine_" /** * P2:seal copy 回退中途失败的部分目标隔离前缀(`seal_failed_`)。隔离文件 scanner @@ -99,7 +99,7 @@ internal object TokenStatSpool { * 发布证据**计入 [quarantineAreaFiles]——长期删除失败时可见(quarantineEvidence/info 字节)、 * 可导出、可确认删除(ack 按 NOFOLLOW/path 根校验删除并释放容量),绝不无限隐藏占用。 */ - private const val SEAL_FAILED_PREFIX = "seal_failed_" + internal const val SEAL_FAILED_PREFIX = "seal_failed_" /** * ack 删除的事务化暂存目录前缀(reviewer P1):ack 先把全部待删文件 rename 进本轮唯一 @@ -113,7 +113,7 @@ internal object TokenStatSpool { * StuckAckEvidence 由 UI 管理(见 [stuckAckTrashEvidence]),绝不自动删除。trash 目录及 * 其内容计入递归总容量(P1-1),占用绝不隐藏。目录名不可与任何证据/元数据文件前缀冲突。 */ - private const val ACK_TRASH_PREFIX = "quarantine_ack_trash_" + internal const val ACK_TRASH_PREFIX = "quarantine_ack_trash_" /** ack trash 内的原子状态文件(P1-2):首行 = [ACK_STATE_UNCOMMITTED]/[ACK_STATE_COMMITTED],后续行 = mapping(原名 → trash 名 + bytes + sha256)。 */ internal const val ACK_TRASH_STATE_FILE_NAME = "ack_state.jsonl" @@ -148,7 +148,7 @@ internal object TokenStatSpool { internal const val MAX_QUARANTINE_BYTES = 16L * 1024 * 1024 /** Fixed-size rolling summary of over-cap corrupt segments (atomic update, never grows unbounded). */ - private const val QUARANTINE_SUMMARY_NAME = "quarantine_summary.jsonl" + internal const val QUARANTINE_SUMMARY_NAME = "quarantine_summary.jsonl" internal const val MAX_QUARANTINE_SUMMARY_BYTES = 64L * 1024 internal const val MAX_QUARANTINE_SUMMARY_LINES = 256 @@ -164,13 +164,13 @@ internal object TokenStatSpool { * [MAX_MANAGED_BYTES] 到达后停止接受新的统计 append(明确抛 * [TokenStatsPersistenceException],不产生更多段),drain 有界跳过并继续健康段。 */ - private const val TOMBSTONE_MANIFEST_NAME = "quarantine_skip_manifest.jsonl" + internal const val TOMBSTONE_MANIFEST_NAME = "quarantine_skip_manifest.jsonl" /** 活跃受管失败集合的身份硬上限:超过后新统计 append 明确失败(不能继续产生更多段)。 */ internal const val MAX_TOMBSTONE_ENTRIES = 64 /** 受管失败集合的 manifest 文件字节硬上限(64 条约 200B,纯 ASCII,正常远达不到)。 */ - private const val MAX_TOMBSTONE_MANIFEST_BYTES = 64L * 1024 + internal const val MAX_TOMBSTONE_MANIFEST_BYTES = 64L * 1024 // ── P1-1 元数据预留(总 cap 证明)──────────────────────────────────────────── // 有界元数据文件(quarantine summary / tombstone manifest)各有 4 个磁盘槽位: @@ -182,7 +182,7 @@ internal object TokenStatSpool { internal const val METADATA_COPY_COUNT = 4 /** 有界元数据文件数:quarantine summary + tombstone manifest。 */ - private const val METADATA_FILE_COUNT = 2 + internal const val METADATA_FILE_COUNT = 2 /** 单份有界元数据内容的字节硬上限(summary/manifest 中较大者,均为纯 ASCII 有界)。 */ internal val MAX_METADATA_FILE_BYTES: Long = @@ -205,30 +205,30 @@ internal object TokenStatSpool { } /** 受管失败集合中仍占用磁盘的段的总字节硬上限(64 × 单段 4MiB 封顶;段大小可注入时实时计算)。 */ - private val MAX_MANAGED_BYTES get() = MAX_TOMBSTONE_ENTRIES * MAX_SEGMENT_BYTES + internal val MAX_MANAGED_BYTES get() = MAX_TOMBSTONE_ENTRIES * MAX_SEGMENT_BYTES /** 删除失败的 over-cap 段的诊断去向:完整证据(计入硬 cap,可导出/确认删除)。 */ - private const val PENDING_DELETE_PREFIX = "quarantine_pending_delete_" - private const val RETRY_BACKOFF_BASE_MS = 1_000L - private const val RETRY_BACKOFF_CAP_MS = 30_000L + internal const val PENDING_DELETE_PREFIX = "quarantine_pending_delete_" + internal const val RETRY_BACKOFF_BASE_MS = 1_000L + internal const val RETRY_BACKOFF_CAP_MS = 30_000L internal var insertTimeoutMs: Long = 5_000L internal var prepareTimeoutMs: Long = 5_000L /** 排他快照/恢复等待已登记 insert 全部结束的硬超时;超时则操作明确失败,绝不替换文件。 */ internal var exclusiveQuiesceTimeoutMs: Long = 5_000L - private const val QUIESCE_POLL_INTERVAL_MS = 50L + internal const val QUIESCE_POLL_INTERVAL_MS = 50L /** 文件 I/O 调度缝(P2-2):导出/确认删除的复制、fsync、扫描绝不运行在调用方(Main)线程。 */ internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO - private val lifecycleMutex = Mutex() - private val stateLock = Any() + internal val lifecycleMutex = Mutex() + internal val stateLock = Any() /** * 调度令牌:有 worker 任务已入队/正在运行(用于入队去重)。快照屏障在递增 * [sessionGeneration] 时同步清空,使旧 generation 的排队 worker 失效。 */ - private var drainScheduled = false + internal var drainScheduled = false /** * 未消费的 drain 请求(丢失唤醒修复):每次 [scheduleDrain] 都在 [stateLock] 下 @@ -236,25 +236,25 @@ internal object TokenStatSpool { * 请求在轮内到达时由同一 worker 接管,绝不依赖下一次外部触发;RejectedExecution * 时请求保留、仅释放调度令牌(见 [scheduleDrain])。 */ - private var drainRequested = false + internal var drainRequested = false /** * [awaitInitialDrain] 的等待者:worker 轮末决策点持 [stateLock] 统一完成并清空; * 完成/失败都不保留——下次调用重新登记并触发新轮(失败不缓存,可重试)。 */ - private val initialDrainWaiters = ArrayList>() + internal val initialDrainWaiters = ArrayList>() - private var sessionGeneration = 0L - private var retryDelayMs = RETRY_BACKOFF_BASE_MS - private var writerExecutor = newWriterExecutor() + internal var sessionGeneration = 0L + internal var retryDelayMs = RETRY_BACKOFF_BASE_MS + internal var writerExecutor = newWriterExecutor() /** * 排他快照/恢复进行中:阻止新 insert 登记(与 insert 的登记在同一 critical section 原子判定)。 */ - private var exclusiveBarrierActive = false + internal var exclusiveBarrierActive = false /** 已通过 fence 且正在 Room 内写入的 insert(eventId -> 提交时 generation)。 */ - private val activeInserts = HashMap() + internal val activeInserts = HashMap() /** * Request/session fencing epoch(P1 终审):通用恢复屏障开始时递增;Raw restore 则在 @@ -264,7 +264,7 @@ internal object TokenStatSpool { * (clearAfter=false)不递增:进行中的请求在导出期间正常收尾。进程内单调递增, * 不随普通 [withExclusiveSnapshotAccess] 变化;测试经 [clearPendingStateForTest] 复位。 */ - private var restoreEpoch = 0L + internal var restoreEpoch = 0L /** * 本进程是否仍接受统计事件(P1 终审):恢复屏障的替换开始([block] 即将执行)时置 false, @@ -273,7 +273,7 @@ internal object TokenStatSpool { * 进程重启(含测试模拟)经 [resetExecutorsForTest]/[clearPendingStateForTest] 复位。 */ @Volatile - private var acceptingEventsThisProcess = true + internal var acceptingEventsThisProcess = true /** * Dedicated bounded insert worker. Room/SQLite writes can ignore thread interrupts, so the @@ -281,12 +281,12 @@ internal object TokenStatSpool { * retried later. The single daemon thread plus one queue slot is the hard bound (P2-1), so a * permanently wedged database cannot leak threads or hold the lifecycle lock. */ - private var insertExecutor = newInsertExecutor() + internal var insertExecutor = newInsertExecutor() /** Dedicated bounded database-preparation worker with single-flight semantics (P2-1). */ - private var databaseExecutor = newDatabaseExecutor() - private var pendingDaoTask: FutureTask? = null - private val insertionWaiters = HashMap>() + internal var databaseExecutor = newDatabaseExecutor() + internal var pendingDaoTask: FutureTask? = null + internal val insertionWaiters = HashMap>() /** 测试注入缝:返回 null 走真实删除;返回 false 模拟删除失败(P1-2 分支注入)。 */ internal var segmentDeleteForTest: ((File) -> Boolean?)? = null @@ -353,7 +353,7 @@ internal object TokenStatSpool { * 丢失,属 append 契约内。 */ @Volatile - private var directoryDurabilityConfirmedThisProcess = false + internal var directoryDurabilityConfirmedThisProcess = false /** * 测试注入缝(P2):返回 null 走真实 `Files.copy`;返回 false 模拟 copy 中途失败(seam @@ -377,16 +377,7 @@ internal object TokenStatSpool { */ internal var directoryListingForTest: ((File) -> Array?)? = null - /** 活跃受管失败集合的稳定身份(P1-2):file+bytes+sha256,三者全匹配才算同一段。 */ - private data class TombstoneEntry( - val file: String, - val bytes: Long, - val sha256: String, - val overCap: Boolean, - ) - /** tombstone 写入结果:容量满 ≠ 写失败(容量满时跳过该段继续健康,写失败才退避重试)。 */ - private enum class TombstoneResult { RECORDED, CAPACITY_FULL, FAILED } /** * 身份判定(P1-1)只允许使用**实时**从原始字节计算的 SHA-256:length+mtime 不足以 @@ -395,28 +386,11 @@ internal object TokenStatSpool { * 都现场 hash 文件原始字节(单段 ≤4MiB,成本可接受)。 */ - private fun newWriterExecutor() = - ScheduledThreadPoolExecutor(1) { runnable -> - Thread(runnable, "operit-token-stats-writer").apply { isDaemon = true } - } + internal fun newWriterExecutor() = SpoolWriter.newDrainExecutor() + + internal fun newInsertExecutor() = SpoolWriter.newInsertExecutor() - private fun newInsertExecutor() = - ThreadPoolExecutor( - 1, - 1, - 60L, - TimeUnit.SECONDS, - LinkedBlockingQueue(1), - ) { runnable -> Thread(runnable, "operit-token-stats-insert").apply { isDaemon = true } } - - private fun newDatabaseExecutor() = - ThreadPoolExecutor( - 1, - 1, - 60L, - TimeUnit.SECONDS, - LinkedBlockingQueue(1), - ) { runnable -> Thread(runnable, "operit-token-stats-database").apply { isDaemon = true } } + internal fun newDatabaseExecutor() = SpoolWriter.newDatabaseExecutor() /** * Append a complete immutable event. true means an fsync-backed durable copy exists. @@ -594,37 +568,6 @@ internal object TokenStatSpool { fun replay(context: Context) = scheduleDrain(context.applicationContext) - /** - * P1-1 终审:每进程首次使用 spool 前的 durable bootstrap gate(调用方持 lifecycleMutex)。 - * - * 内存标记 [directoryDurabilityConfirmedThisProcess] 初始为 false,进程重启即清零 - * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟进程重启)。若 spool - * 目录**已存在**——无论它是本进程创建还是**上一进程**创建——都必须先 sync filesDir - * (确认 spool 目录项持久)再 sync spool 目录(确认 active/metadata 等可见目录项持久); - * 两者都 OK 之前不得写新行/返回 durable/做任何目录变更。这样上一进程已可见但未确认的 - * 目录项在本进程重新提交(崩溃后文件可能消失的窗口被关闭)。 - * - * 目录尚不存在时没有可确认的目录项,放行(首次创建协议在 [append] 中负责创建后同步 - * 父目录与新目录本身;其任一 sync 失败会把本标记保持/复位为 false,下一次使用重新走 - * 本 gate)。任一非 OK 均 fail-closed:不置位标记、返回 false,由调用方明确失败 - * (append 返回 false / drain 退避 / snapshot 抛 IOException)。 - */ - private fun ensureDirectoryDurabilityConfirmed(context: Context, dir: File): Boolean { - if (directoryDurabilityConfirmedThisProcess) return true - if (!dir.isDirectory) return true - val parent = dir.parentFile - // P1-1 终审:bootstrap 只在 filesDir 与 spool 目录两者都 OK 时才置位;任一非 OK - // 由 [requireSpoolDirSync] 保持/置回 false(本处进入时 flag 必为 false),绝不置位。 - if (parent == null || !requireSpoolDirSync(parent, dir)) { - logE( - "statistics spool directory durability unconfirmed; refusing writes " + - "until directory entries are re-confirmed: ${dir.absolutePath}", - ) - return false - } - directoryDurabilityConfirmedThisProcess = true - return true - } /** * Raw snapshot barrier. Export uses [drainBefore] and checkpoints inside [block]; restore uses @@ -681,98 +624,7 @@ internal object TokenStatSpool { block = block, ) - private suspend fun withExclusiveSnapshotAccessInternal( - context: Context, - drainBefore: Boolean, - clearAfter: Boolean, - deferredRestoreCommit: (suspend () -> Unit)?, - block: suspend () -> T, - ): T = lifecycleMutex.withLock { - val appContext = context.applicationContext - // P1-1 终审:快照/恢复前必须先确认 spool 目录项持久(上一进程可见未确认的目录项 - // 在本进程重新提交);失败明确中止,绝不带着未确认状态做 drain/替换/清理。 - if (!ensureDirectoryDurabilityConfirmed(appContext, spoolDir(appContext))) { - throw IOException( - "statistics spool directory durability could not be confirmed for snapshot", - ) - } - val generation = synchronized(stateLock) { - sessionGeneration += 1L - drainScheduled = false - if (clearAfter && deferredRestoreCommit == null) { - // P1 终审:恢复屏障开始即原子递增 restore epoch——所有在屏障前开始的请求 - // 收尾 append 时 epoch 不匹配而被明确拒绝;导出/快照(clearAfter=false) - // 不递增,进行中的请求在导出期间正常收尾。 - restoreEpoch += 1L - } - sessionGeneration - } - if (drainBefore && !drainCore(appContext, generation)) { - throw IOException("statistics spool could not be drained for snapshot") - } - if (drainBefore && hasPendingSegments(appContext)) { - throw IOException("statistics spool still contains pending events after drain") - } - if (drainBefore && hasQuarantineEvidenceForSnapshotLocked(appContext)) { - throw IOException( - "statistics quarantine evidence must be exported and acknowledged before snapshot", - ) - } - // 排他状态必须在 drain 阶段之后设置:drainBefore 自己的 insert 需要登记。 - // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 - synchronized(stateLock) { exclusiveBarrierActive = true } - try { - if (!awaitActiveInsertsEmpty()) { - val live = synchronized(stateLock) { activeInserts.size } - throw IOException( - "statistics Room insert still active ($live); " + - "snapshot/restore aborted before any file replacement", - ) - } - if (clearAfter) { - if (deferredRestoreCommit != null) { - var restoreFenceCommitted = false - try { - withContext(kotlinx.coroutines.NonCancellable) { - deferredRestoreCommit() - synchronized(stateLock) { - restoreEpoch += 1L - acceptingEventsThisProcess = false - } - restoreFenceCommitted = true - } - } catch (e: Exception) { - if (!restoreFenceCommitted) { - // The request fence is unchanged. Resume normal draining after releasing - // lifecycleMutex so durable old/new events can still reach the old DB. - scheduleDrain(appContext) - } - throw e - } - } else { - // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 - // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 - // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 - // 可继续(替换前失败可恢复)。 - synchronized(stateLock) { acceptingEventsThisProcess = false } - } - } - val result = block() - if (clearAfter) clearForRestoreLocked(appContext) - result - } finally { - synchronized(stateLock) { exclusiveBarrierActive = false } - } - } - /** - * Request/session fencing 判定(P1 终审,调用方持 lifecycleMutex):请求开始捕获的 - * [sessionEpoch] 必须等于当前 [restoreEpoch](恢复屏障开始时原子递增使旧请求失效), - * 且本进程仍接受事件(恢复替换开始后为 false 直至重启)。任一不满足 → 明确拒绝, - * 绝不写入可能已被恢复替换的 spool。 - */ - private fun fenceAcceptsRestore(sessionEpoch: Long): Boolean = - synchronized(stateLock) { acceptingEventsThisProcess && sessionEpoch == restoreEpoch } /** * 请求开始时同步捕获当前 restore epoch(P1 终审):纯内存读取,无需 Room。请求在 @@ -783,46 +635,7 @@ internal object TokenStatSpool { /** 本进程是否仍接受新的统计事件/请求(恢复替换开始后为 false,直到进程重启)。 */ fun isAcceptingEvents(): Boolean = synchronized(stateLock) { acceptingEventsThisProcess } - /** - * 硬超时等待已登记 insert 全部结束。等待期间不持有 [stateLock](轮询只短暂取快照), - * 因此绝不阻塞普通 drain/append;[delay] 可被协程取消,超时由调用方转换为明确失败。 - */ - private suspend fun awaitActiveInsertsEmpty(): Boolean { - val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) - while (true) { - if (synchronized(stateLock) { activeInserts.isEmpty() }) return true - if (System.nanoTime() >= deadline) return false - delay(QUIESCE_POLL_INTERVAL_MS) - } - } - private fun clearForRestoreLocked(context: Context) { - val dir = spoolDir(context) - // P1-1 终审修复:删除开始前立即失效 bootstrap gate——删除本身是目录项变更,删除后 - // 任何 sync 失败都不得让“已确认”内存标记继续生效,下一次使用必须重新确认(或重新 - // 走首次创建协议)。 - directoryDurabilityConfirmedThisProcess = false - if (dir.exists()) { - val deleted = spoolDeleteForTest?.invoke(dir) ?: dir.deleteRecursively() - if (!deleted || dir.exists()) { - throw IOException("statistics spool cleanup failed: ${dir.absolutePath}") - } - } - // P1-3 终审:spool 目录项删除(可能刚发生且可见)必须确认持久,否则 restore 失败并 - // 保留恢复状态;目录删除可见但 sync 失败时重试幂等(目录已不存在则跳过删除,本处 - // 仍 sync filesDir 确认“删除/不存在”持久后才放行)。P1-1:sync 非 OK 由 - // [requireSpoolDirSync] 同步失效 gate(本函数开头已失效,保持 false 供下次重新确认)。 - val parent = dir.parentFile - if (parent == null || !requireSpoolDirSync(parent)) { - throw IOException( - "statistics spool cleanup not durable; restore state retained: ${dir.absolutePath}", - ) - } - synchronized(stateLock) { - insertionWaiters.values.forEach { it.cancel() } - insertionWaiters.clear() - } - } /** * Evidence management is explicit: callers can export these files, then acknowledge deletion. @@ -869,81 +682,9 @@ internal object TokenStatSpool { } } - private fun stuckAckTrashEvidenceLocked(context: Context): List { - val dir = spoolDir(context.applicationContext) - if (!dir.isDirectory) return emptyList() - // P1-6:根目录枚举统一走 [listDir](测试可注入失败);失败(null)即明确抛错, - // 绝不当作“没有 trash”返回(否则 export 会成功遗漏全部 stuck 证据)。 - val rootFiles = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for ack trash: ${dir.absolutePath}") - // 先完整枚举结果,再对每个候选做 NOFOLLOW 目录验证(File.isDirectory 会跟随符号链接) - return rootFiles - .filter { f -> - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) && - Files.isDirectory(f.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) - } - .mapNotNull { trash -> - // P1-6:trash 枚举失败(null)是明确失败,绝不当作空目录跳过该证据 - val files = listDir(trash) - ?: throw IOException("cannot enumerate ack trash directory: ${trash.name}") - files.takeIf { it.isNotEmpty() }?.let { trash } - } - .sortedBy { it.name } - } - private suspend fun quarantineEvidenceLocked(context: Context): List { - val dir = spoolDir(context.applicationContext) - val managed = readTombstoneLines(context).mapNotNull { line -> - val entry = parseTombstoneLine(line) ?: return@mapNotNull null - val file = File(dir, entry.file) - // P1-2:只有身份可校验(MATCH)的受管段才作为 evidence 暴露——UNREADABLE 绝不 - // 出现在可导出/可 ack 的列表里(身份不可校验时 ack 无法安全删除),保留 manifest。 - file.takeIf { it.isFile && entry.identityCheck(file) == IdentityCheck.MATCH } - } - return (quarantineAreaFiles(dir) + managed).sortedBy { it.name } - } - /** - * 完整证据区(quarantine_* 前缀 + seal 发布失败隔离的 seal_failed_*,不含受管失败段与 - * 有界元数据/sidecar)。P1-6:证据区是 export/ack/info 的证据来源,根目录枚举失败(null) - * 时抛 [IOException](fail-closed)——绝不把失败当作空证据区返回,否则 export 会在遗漏 - * 完整证据时仍成功。P2 终审:seal_failed_*(受管失败发布证据)与 quarantine_* 同等参与 - * 可见/计数/导出/ack——长期删除失败时用户可确认删除并释放容量,绝不无限隐藏占用。 - * 注意:这里**不走** [directoryListingForTest] seam(seam 只覆盖 sealed 队列/待处理判定 - * 与 ack/trash 安全路径,见 [listDir]),生产路径的原始枚举失败同样按 null 显式失败处理。 - */ - private fun quarantineAreaFiles(dir: File): List { - // 目录不存在 = 证据区尚未创建,空集是真实状态(append 准入在创建目录之前检查); - // 目录**存在**但枚举失败(null)才是 fail-closed 抛错场景。 - if (!dir.isDirectory) return emptyList() - val files = dir.listFiles() - ?: throw IOException("cannot enumerate spool directory for quarantine evidence: ${dir.absolutePath}") - return files - .filter { - it.isFile && - (it.name.startsWith(QUARANTINE_PREFIX) || it.name.startsWith(SEAL_FAILED_PREFIX)) && - it.name != QUARANTINE_SUMMARY_NAME && - it.name != TOMBSTONE_MANIFEST_NAME && - !it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") && - !it.name.startsWith("$TOMBSTONE_MANIFEST_NAME.") - } - .sortedBy { it.name } - } - /** - * P1-1 append 准入检查(调用方持 lifecycleMutex):受管失败集合 - * (entry 数/受管段字节)或完整证据区字节任一到达硬上限即拒绝新统计——此时新损坏段 - * 将无处可去(不能删除/重命名、受管集合已满),继续接收只会让磁盘/重扫无界。 - */ - private suspend fun managedFailureCapacityExceeded(context: Context): Boolean { - val dir = spoolDir(context) - val entries = readTombstoneLines(context).mapNotNull(::parseTombstoneLine) - if (entries.isEmpty() && quarantineAreaFiles(dir).isEmpty()) return false - return entries.size >= MAX_TOMBSTONE_ENTRIES || - entries.sumOf { it.bytes.coerceAtLeast(0L) } >= MAX_MANAGED_BYTES || - quarantineAreaFiles(dir).sumOf { it.length() } >= MAX_QUARANTINE_BYTES - } /** * Structured summary of over-cap corrupt segments (P2-1). Within [MAX_QUARANTINE_BYTES] the @@ -965,59 +706,9 @@ internal object TokenStatSpool { suspend fun quarantineSummaryInfo(context: Context): QuarantineSummaryInfo? = lifecycleMutex.withLock { quarantineSummaryInfoLocked(context) } - private suspend fun quarantineSummaryInfoLocked(context: Context): QuarantineSummaryInfo? { - val file = File(spoolDir(context.applicationContext), QUARANTINE_SUMMARY_NAME) - val content = try { - readMetadata(summaryStore(file), file) - } catch (e: Exception) { - logE("statistics quarantine summary read failed", e) - null - } ?: return null - val lines = content.lineSequence().filter { it.isNotEmpty() }.toList() - if (lines.isEmpty()) return null - return QuarantineSummaryInfo( - recordCount = lines.size, - summaryBytes = file.length(), - ) - } - private fun summaryStore(file: File) = - AtomicRestoreMarkerStore( - file, - quarantineAtomicMoveForTest ?: ::atomicMoveReplacing, - // P1-3 终审:spool 的 summary/manifest/ack state 统一走严格目录同步——write 只有 - // 目录项确认持久([syncDir] == OK)才成功;read 的 sidecar 恢复 rename 同样严格。 - // P1-1 终审修复:任一非 OK 同时失效 bootstrap gate([requireSpoolDirSync])—— - // 元数据目录项未确认后下一次使用必须重新确认,绝不带着“已确认”标记继续。 - strictDirectorySync = { dir -> requireSpoolDirSync(dir) }, - ) - /** - * 有界元数据读取(P1-2,调用方持 lifecycleMutex):测试注入缝模拟读取失败(抛明确 - * IOException,调用方据此 fail-closed),生产路径委托 [AtomicRestoreMarkerStore.read] - * (崩溃安全恢复 canonical/.new/.bak/tmp 完整值)。 - */ - private suspend fun readMetadata(store: AtomicRestoreMarkerStore, file: File): String? { - if (metadataReadErrorForTest?.invoke(file) == true) { - throw IOException("statistics metadata read failed (injected): ${file.name}") - } - return store.read() - } - /** 首选同目录原子替换(Windows MoveFileEx / POSIX rename);不支持或失败返回 false 走回退。 */ - private fun atomicMoveReplacing(from: File, to: File): Boolean = try { - Files.move( - from.toPath(), - to.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - true - } catch (e: AtomicMoveNotSupportedException) { - false - } catch (e: IOException) { - false - } /** * Copy evidence (and the bounded over-cap summary and tombstone manifest) for support/export. @@ -1216,7 +907,7 @@ internal object TokenStatSpool { // 物理消失:只计划移除陈旧记录 removeRawLines += rawLine } - else -> when (entry.identityCheck(file)) { + else -> when (tombstoneIdentityCheck(entry, file)) { // P1-2:身份不可校验(UNREADABLE)时 ack 绝不能成功——既不能删 // 也不能当陈旧记录移除,保留 manifest,让维护/用户稍后重试。 IdentityCheck.UNREADABLE -> { @@ -1389,443 +1080,29 @@ internal object TokenStatSpool { } } - /** Explicit post-export acknowledgment for the bounded rolling summary and every sidecar. */ - private suspend fun deleteQuarantineSummaryLocked(context: Context) { - val dir = spoolDir(context.applicationContext) - val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) - summaryStore(summaryFile).delete() - val remaining = listDir(dir) - ?: throw IOException("cannot verify quarantine summary deletion: ${dir.absolutePath}") - if (remaining.any { it.name == QUARANTINE_SUMMARY_NAME || it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") }) { - throw IOException("statistics quarantine summary deletion failed: ${summaryFile.absolutePath}") - } - } - /** - * ack 的主 manifest 重写(P1-1,调用方持 lifecycleMutex):发布前投影实际总量 + 最坏 - * sidecar 增量,超限有界失败不写文件;测试注入缝照常生效。失败抛 [IOException]。 - */ - private suspend fun rewriteAckManifestLocked(context: Context, manifestFile: File, newContent: String) { - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - throw IOException("tombstone manifest rewrite refused: metadata budget over the total cap") - } - if (metadataWriteErrorForTest?.invoke(manifestFile) == true) { - throw IOException("tombstone manifest write failed (injected)") - } - summaryStore(manifestFile).write(newContent) - } - /** - * 写入 ack trash 状态文件(P1-2,调用方持 lifecycleMutex):UNCOMMITTED + mapping(只含 - * 已 stage 文件)。P1-1:发布前投影预算,超限返回 false。写失败返回 false(调用方保留 - * 无状态 trash,维护 fail-closed 保留,绝不误删)。原子崩溃安全写入(sidecar 可恢复)。 - * - * P2 终审:mapping 身份(bytes+sha256)必须从**实际当前所在文件**捕获——回滚 move 已 - * 可见但目录项 sync 失败时,文件可能已回到原路径(trash 内已无此文件),此时从已移走 - * 的 target 盲读会得到 0 字节/空哈希的伪身份,甚至使整个状态写入失败;因此 target - * 存在读 target,否则读 original(两者内容同一,身份一致)。两者都不存在(文件消失, - * 不可能的正常路径)→ 返回 false fail-closed,绝不写残缺 mapping。 - */ - private suspend fun writeUncommittedTrashState( - context: Context, - trashDir: File, - staged: List>, - ): Boolean { - return try { - val mappingEntries = staged.map { (original, target) -> - // P2 终审:身份从实际所在位置捕获(trash 或 original),绝不盲读已移走的 target。 - val location = - when { - target.exists() -> target - original.exists() -> original - else -> null - } - if (location == null) { - logE( - "statistics ack trash state identity unavailable; refusing to write mapping: " + - "${original.name}", - ) - return false - } - AckMappingEntry( - original = original.name, - trashName = target.name, - bytes = location.length(), - sha256 = sha256Hex(location.readBytes()), - ) - } - val stateContent = buildAckStateContent(ACK_STATE_UNCOMMITTED, mappingEntries) - if (metadataWriteBudgetExceeded(context, stateContent.toByteArray(Charsets.UTF_8).size)) { - logE("statistics ack trash state publish refused: metadata budget over the total cap") - return false - } - summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)).write(stateContent) - true - } catch (e: Exception) { - logE("statistics ack trash state write failed", e) - false - } - } /** P2 终审:ack 回滚的结构化结果。success=false = 有文件未能移回原位(trash 保留为可 * 重试记录,由维护按 mapping 处置);syncFailed=true = 存在目录项未确认持久的变更 * (上层必须失败并保留 UNCOMMITTED/stuck 状态,绝不静默推进)。 */ - private data class RollbackStagedResult( - val success: Boolean, - val syncFailed: Boolean, - ) - /** - * ack 的 rename 回滚(reviewer P1 + P2 终审):把已 stage 进 trash 的文件按逆序移回原位; - * 全部成功且目录项全部确认持久才删除本轮 trash 目录(含状态/mapping 文件)。P2:每个 - * 移动/删除都是目录项变更——move 后必须严格 sync([requireSpoolDirSync],跨 spool 根与 - * trash 两个目录),任一非 OK 置 [RollbackStagedResult.syncFailed]:上层保留 - * UNCOMMITTED 状态并失败,绝不带着未确认状态声称回滚完成(变更可见时下一轮按 - * mapping+identity 幂等完成)。某个回滚失败时保留 trash 及其证据(状态仍为 UNCOMMITTED - * + mapping,维护入口按状态机判定/回滚,绝不误删),同样报告失败。 - */ - private fun rollbackStagedRenames( - staged: List>, - trashDir: File, - ): RollbackStagedResult { - val dir = trashDir.parentFile ?: return RollbackStagedResult(false, false) - var allRolledBack = true - var syncFailed = false - for ((original, target) in staged.asReversed()) { - if (!target.exists()) continue - if (original.exists() || !atomicMoveForAck(target, original)) { - allRolledBack = false - logE( - "statistics ack rollback failed for ${original.name}; evidence stays in ${trashDir.name}", - ) - continue - } - // P2 终审:回滚 move 跨 spool 根与 trash 两个目录——两者目录项都必须确认持久; - // 非 OK 置 syncFailed(调用方保留 UNCOMMITTED 状态并失败)。 - if (!requireSpoolDirSync(dir, trashDir)) { - logE("statistics ack rollback move not durable: ${original.name}") - syncFailed = true - } - } - if (allRolledBack && !syncFailed) { - if (!deleteAckTrashDirNoFollow(trashDir)) { - logE("statistics ack trash directory cleanup failed after rollback: ${trashDir.name}") - allRolledBack = false - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable after rollback: ${trashDir.name}") - syncFailed = true - } - } - return RollbackStagedResult(allRolledBack, syncFailed) - } - /** - * 目标路径归属预检(reviewer P1,防目录穿越):ack 只接受 spool 根目录下的单层相对 - * 文件名——非空、不含路径分隔符、不是 "."/"..",且解析后父目录仍是 spool 根目录。 - */ - private fun requireSafeEvidenceName(dir: File, name: String) { - if (!isSafeEvidenceName(dir, name)) { - throw IOException("unsafe acknowledged evidence name: $name") - } - } - /** 单层相对名检查(reviewer P1,防目录穿越):非空、不含分隔符、解析后父目录是 dir。 */ - private fun isSafeEvidenceName(dir: File, name: String): Boolean = - name.isNotBlank() && - name != "." && - name != ".." && - !name.contains('/') && - !name.contains('\\') && - File(dir, name).parentFile?.canonicalFile == dir.canonicalFile - - /** trash 内文件名检查(P1-2):同 [isSafeEvidenceName],父目录必须是 trash 目录本身。 */ - private fun isSafeTrashName(trash: File, name: String): Boolean = - name.isNotBlank() && - name != "." && - name != ".." && - !name.contains('/') && - !name.contains('\\') && - File(trash, name).parentFile?.canonicalFile == trash.canonicalFile + /** ack trash mapping 条目(P1-2):spool 根原名 → trash 内名 + 稳定身份(bytes+sha256)。 */ - private data class AckMappingEntry( - val original: String, - val trashName: String, - val bytes: Long, - val sha256: String, - ) - private fun ackMappingLine(entry: AckMappingEntry): String = - JSONObject() - .put("o", entry.original) - .put("t", entry.trashName) - .put("b", entry.bytes) - .put("s", entry.sha256) - .toString() - - private fun parseAckMappingLine(line: String): AckMappingEntry? = try { - val obj = JSONObject(line) - val original = obj.optString("o").takeIf { it.isNotEmpty() } ?: return null - val trashName = obj.optString("t").takeIf { it.isNotEmpty() } ?: return null - AckMappingEntry( - original = original, - trashName = trashName, - bytes = obj.optLong("b", -1L), - sha256 = obj.optString("s", ""), - ) - } catch (_: Exception) { - null - } - /** - * P1-2 修复:状态 mapping 的**全有或全无**解析(调用方持 lifecycleMutex)。任一条件失败 - * 返回 null,调用方对整个 trash fail-closed 保留(不执行 delete/rollback/manifest 改动): - * - header 之后的每一行都必须解析成功(mapNotNull 静默丢弃损坏行会漏掉未回滚的证据, - * 导致 trash 被整体删除); - * - bytes/sha256 必须完整(缺失即身份不可校验); - * - 原名/trash 名必须单层安全(防穿越),且无重复(同名两份证据无法可靠处置); - * - trash 内所有普通文件(排除状态文件及其 `.new`/`.bak`/`.tmp*` sidecar)都必须被 - * mapping 覆盖(mapping 数量与证据文件集合完整对应);存在符号链接/特殊文件也 fail-closed。 - * - * mapping 条目引用的 trash 文件**缺失**是允许的(该文件可能已在先前一次回滚中移回原槽位, - * 由 [rollbackUncommittedTrash] 按原槽位身份判定),因此这里只校验“trash 里的每个文件都 - * 有 mapping”,不要求反向一一对应。 - */ - private fun parseAckMappingStrict( - dir: File, - trash: File, - lines: List, - ): List? { - val entries = mutableListOf() - val originals = HashSet() - val trashNames = HashSet() - for (raw in lines.drop(1)) { - val entry = parseAckMappingLine(raw) ?: return null - if (entry.bytes < 0L || entry.sha256.isEmpty()) return null - if (!isSafeEvidenceName(dir, entry.original)) return null - if (!isSafeTrashName(trash, entry.trashName)) return null - if (!originals.add(entry.original)) return null - if (!trashNames.add(entry.trashName)) return null - entries += entry - } - val stateBase = ACK_TRASH_STATE_FILE_NAME - // P1-5 fail-closed:trash 目录枚举失败(null)时内部证据集合不可知——mapping 无法 - // 证明覆盖了全部证据文件,任何 rollback 后对 trash 的整体删除都会丢失未枚举的证据, - // 立即返回 null 使整个 trash 被保留。 - val trashFiles = listDir(trash) - if (trashFiles == null) { - logE("statistics ack trash directory enumeration failed; retaining trash: ${trash.name}") - return null - } - val unaccounted = trashFiles.any { file -> - val name = file.name - val isStateSidecar = - name == stateBase || - name == "$stateBase.new" || - name == "$stateBase.bak" || - name.startsWith("$stateBase.tmp") - if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { - // 符号链接/特殊文件:无法按身份管理,fail-closed - true - } else if (isStateSidecar) { - false - } else { - name !in trashNames - } - } - return if (unaccounted) null else entries - } - /** 状态文件内容:首行状态 + 每行一条 mapping(状态与 mapping 一次原子写入,无半写窗口)。 */ - private fun buildAckStateContent(state: String, entries: List): String = - state + "\n" + entries.joinToString("\n") { ackMappingLine(it) } + "\n" - - /** ack 只管理 spool 根目录中的普通文件,不跟随符号链接或其他特殊路径。 */ - private fun requireManageableEvidenceFile(dir: File, file: File) { - val dirPath = dir.canonicalFile.toPath() - val filePath = file.toPath() - if (file.parentFile?.canonicalFile?.toPath() != dirPath || - Files.isSymbolicLink(filePath) || - !Files.isRegularFile(filePath, java.nio.file.LinkOption.NOFOLLOW_LINKS) || - file.canonicalFile.parentFile?.toPath() != dirPath - ) { - throw IOException("unmanageable acknowledged evidence path: ${file.name}") - } - } - /** - * P1-3:stuck ack trash 目录的删除前校验(防目录穿越/符号链接):只接受 spool 根内匹配 - * [ACK_TRASH_PREFIX] 的真实普通目录(NOFOLLOW_LINKS),拒绝符号链接与特殊路径。 - */ - private fun requireAckTrashDirForDelete(dir: File, trash: File) { - if (!trash.name.startsWith(ACK_TRASH_PREFIX)) { - throw IOException("not an ack trash directory: ${trash.name}") - } - if (Files.isSymbolicLink(trash.toPath())) { - throw IOException("ack trash must not be a symbolic link: ${trash.name}") - } - if (!Files.isDirectory(trash.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { - throw IOException("ack trash must be a real directory: ${trash.name}") - } - if (trash.canonicalFile.parentFile?.toPath() != dir.canonicalFile.toPath()) { - throw IOException("ack trash escapes the spool root: ${trash.name}") - } - } - /** - * P1-3:递归删除 ack trash 目录(NOFOLLOW):不跟随符号链接(链接本身被删除,绝不触及 - * 其目标),只删除普通文件与空目录;任何遍历/删除失败返回 false(调用方保留并报错)。 - */ - private fun deleteAckTrashDirNoFollow(trash: File): Boolean { - return try { - Files.walkFileTree( - trash.toPath(), - EnumSet.noneOf(FileVisitOption::class.java), - Int.MAX_VALUE, - object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.delete(file) - return FileVisitResult.CONTINUE - } - override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { - if (exc != null) throw exc - Files.delete(dir) - return FileVisitResult.CONTINUE - } - override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { - throw exc - } - }, - ) - true - } catch (e: Exception) { - logE("statistics ack trash no-follow deletion failed: ${trash.name}", e) - false - } - } - /** prepare/rollback 必须是同 filesystem 的原子 move,且绝不覆盖同名目标。 */ - private fun atomicMoveForAck(from: File, to: File): Boolean { - ackAtomicMoveForTest?.invoke(from, to)?.let { return it } - // 兼容现有故障注入缝;生产为 null 时仍走真正的 ATOMIC_MOVE。 - segmentRenameForTest?.invoke(from, to)?.let { return it } - if (to.exists()) return false - return try { - Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) - true - } catch (e: AtomicMoveNotSupportedException) { - false - } catch (e: IOException) { - false - } - } - /** - * 请求合并式 drain 调度(丢失唤醒修复):每次调用都在 [stateLock] 下置位 - * [drainRequested]——请求绝不丢失;仅当没有 worker 在跑/在队列([drainScheduled] - * 为 false)时才入队新任务。worker 每轮开始前消费请求,轮末在同一锁内决定 - * retire/立即 rerun/失败 backoff,请求在轮内到达时由同一 worker 接管。 - * - * RejectedExecution 恢复正确状态:请求保留(drainRequested=true,绝不丢),仅释放 - * 调度令牌(drainScheduled=false);下一次 schedule(append/replay/awaitInitialDrain) - * 会重建 executor(isShutdown 检查)并重新入队。 - */ - private fun scheduleDrain(context: Context, delayMs: Long = 0L) { - val generation: Long - synchronized(stateLock) { - if (writerExecutor.isShutdown) writerExecutor = newWriterExecutor() - drainRequested = true - if (drainScheduled) return - drainScheduled = true - generation = sessionGeneration - } - try { - if (rejectDrainScheduleForTest) { - throw RejectedExecutionException("drain schedule rejected (injected)") - } - val task = Runnable { runDrain(context, generation) } - if (delayMs == 0L) writerExecutor.execute(task) - else writerExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS) - } catch (e: RejectedExecutionException) { - synchronized(stateLock) { drainScheduled = false } - logE("statistics drain scheduling failed; request retained", e) - } - } - /** - * 每轮开始前消费 drain 请求(持 [stateLock])。返回 false 表示本轮无需运行: - * - 无请求:释放调度令牌并 retire; - * - 已被快照 generation 取代:不触碰任何标志——快照屏障已清 [drainScheduled], - * 新 generation 的请求由新 schedule 自行记账,旧 worker 绝不消费新请求。 - */ - private fun consumeDrainRequest(generation: Long): Boolean = synchronized(stateLock) { - when { - sessionGeneration != generation -> false - !drainRequested -> { - drainScheduled = false - false - } - else -> { - drainRequested = false - true - } - } - } - /** - * 轮末决策(持 [stateLock],同一锁内原子完成等待者与状态转移): - * - generation 已变:快照屏障已接管(其 drain/替换处理了被等待的数据),retire - * 且不触碰标志;等待者按成功完成。 - * - 本轮失败:完成等待者(false),释放调度令牌并计算退避延迟,稍后重试。 - * - 成功且有新请求(轮内到达):完成等待者(true)后立即 rerun,绝不丢请求。 - * - 成功且无请求:完成等待者(true),释放调度令牌并 retire。 - */ - private fun runDrain(context: Context, generation: Long) { - while (true) { - if (!consumeDrainRequest(generation)) return - var success = false - try { - success = runBlocking { - lifecycleMutex.withLock { - if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true - drainCore(context, generation) - } - } - } catch (e: Throwable) { - logE("statistics spool drain failed", e) - } - afterDrainRoundForTest?.invoke() - val retry: Long - val rerun: Boolean - synchronized(stateLock) { - if (sessionGeneration != generation) { - completeInitialDrainWaitersLocked(true) - return - } - completeInitialDrainWaitersLocked(success) - if (!success) { - drainScheduled = false - retry = retryDelayMs - retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) - rerun = false - } else if (drainRequested) { - retryDelayMs = RETRY_BACKOFF_BASE_MS - retry = 0L - rerun = true - } else { - retryDelayMs = RETRY_BACKOFF_BASE_MS - drainScheduled = false - retry = 0L - rerun = false - } - } - if (rerun) continue - if (retry > 0L) scheduleDrain(context, retry) - return - } - } /** * 可等待的初始 drain(P1 关键链路,启动 readiness 使用):请求一轮 drain(已有 @@ -1849,1043 +1126,99 @@ internal object TokenStatSpool { } } - private fun completeInitialDrainWaitersLocked(success: Boolean) { - if (initialDrainWaiters.isEmpty()) return - initialDrainWaiters.forEach { waiter -> - if (waiter.isActive) waiter.complete(success) - } - initialDrainWaiters.clear() - } - /** Called with lifecycleMutex held. */ - private suspend fun drainCore(context: Context, generation: Long): Boolean { - // P1-1 终审:replay/维护/快照 drain 前必须先确认 spool 目录项持久(上一进程可见 - // 未确认的目录项在本进程重新提交);失败退避重试,绝不带着未确认状态做任何目录变更。 - if (!ensureDirectoryDurabilityConfirmed(context, spoolDir(context))) { - logE("statistics spool drain deferred: directory durability unconfirmed") - return false - } - val dao = resolveDaoSafely(context) ?: return false - // P1-2 维护/后台重试:先清理删除失败被隔离的残留(pending-delete 与 tombstoned 段)。 - // 返回 false 表示本轮存在目录项未确认持久的变更——drain 退避重试,绝不推进状态。 - if (!retryPendingCleanup(context)) { - logE("statistics spool maintenance deferred: directory changes unconfirmed") - return false - } - var lastRound: List = emptyList() - while (synchronized(stateLock) { sessionGeneration == generation }) { - val segments = sealAndList(context) ?: return false - if (segments.isEmpty()) return true - // P1-1 有界推进:受管集合已满且仍无法处置的段会停留在队列(未入受管集合)。 - // 连续两轮同一集合说明无进展——跳过并返回,健康段已排空,绝不无限重扫/持锁。 - if (segments == lastRound) { - logE( - "statistics spool drain cannot make progress (managed-failure set full?); " + - "leaving ${segments.size} unmanageable segment(s): ${segments.joinToString { it.name }}", - ) - return true - } - lastRound = segments - for (segment in segments) if (!drainSegment(context, dao, segment)) return false - } - return true - } - private suspend fun sealAndList(context: Context): List? { - val dir = spoolDir(context) - if (!dir.isDirectory) return emptyList() - val active = File(dir, ACTIVE_FILE_NAME) - if (active.isFile && active.length() > 0L && !sealActive(context, dir)) { - return null - } - // P1-2:tombstone 按稳定 identity(file+bytes+sha256)跳过,绝不只信文件名 - return sealedFilesToProcess(context, dir, readTombstoneLines(context)) - } - private suspend fun hasPendingSegments(context: Context): Boolean { - val dir = spoolDir(context) - val processableNames = - sealedFilesToProcess(context, dir, readTombstoneLines(context)).mapTo(HashSet()) { it.name } - // P1-7 fail-closed:待处理判定依赖枚举完整性——根枚举失败(null)时绝不能当作 - // “没有 pending”返回(那会让快照在仍有待处理事件时误成功)。抛 IOException 由 - // [withExclusiveSnapshotAccess] 传播,[block] 绝不执行,文件保持原样。 - val files = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for pending segments: ${dir.absolutePath}") - return files.any { - it.isFile && - it.length() > 0L && - (it.name == ACTIVE_FILE_NAME || - (it.name.startsWith(SEALED_PREFIX) && - it.name.endsWith(SEALED_SUFFIX) && - it.name in processableNames)) - } - } - /** - * Raw snapshots intentionally exclude the active spool queue. Quarantine files and their - * summary/tombstone/trash metadata are not queue data and must never be silently omitted from a - * successful snapshot. Until raw restore has a selective evidence-preservation protocol, fail - * before ZIP creation and leave every evidence byte in place for explicit export/acknowledgment. - */ - private suspend fun hasQuarantineEvidenceForSnapshotLocked(context: Context): Boolean { - val dir = spoolDir(context.applicationContext) - if (quarantineAreaFiles(dir).isNotEmpty()) return true - if (stuckAckTrashEvidenceLocked(context).isNotEmpty()) return true - if (readTombstoneLines(context).isNotEmpty()) return true - val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) - return readMetadata(summaryStore(summaryFile), summaryFile)?.isNotBlank() == true - } + + + + /** seal 原子发布结果:成功 / 目标已存在(调用方换下一编号)/ 其他失败(终止本轮)。 */ + + + + + + + + /** - * P1-2:对 sealed 队列应用受管失败集合。身份匹配的段跳过(受管);身份不匹配或文件已 - * 消失的条目是陈旧 tombstone(旧文件已删但 manifest 未更新,随后同名不同 hash 的健康 - * 段复用)——移除陈旧记录并正常处理新文件,绝不删/跳过健康。例外(P1-2):条目身份仍 - * 停留在未提交 ack trash 中时绝不按陈旧移除——该身份的证据还存在于 trash,移除会让崩溃 - * 窗口判定把未确认的证据误判为已提交而删除。身份**不可校验**(读取失败)的段本轮跳过 - * 且保留 manifest 条目:无法证明当前文件仍是记录中的段时,既不处理也不清理,绝不基于 - * 失败猜测破坏性决策。陈旧记录移除在持锁下崩溃安全重写 manifest。 + * 目录同步结果(P1 终审):OK 已持久;FAILED 真实失败;UNSUPPORTED 平台明确不支持目录 + * fsync——与 FAILED 一样 fail-closed(**绝不**当作成功继续删除唯一 fsynced active 或 + * 声称 PUBLISHED)。不存在“原地排空”平台模式:生产平台(Android/Linux)支持目录 + * fd fsync,Windows 仅 JVM 测试环境。 */ - private suspend fun sealedFilesToProcess(context: Context, dir: File, rawLines: List): List { - val trashHold = scanUncommittedTrashHolds(context) - val heldInTrash = trashHold.known - val entries = rawLines.mapNotNull(::parseTombstoneLine) - val stale = mutableListOf() - // P1-7 fail-closed:sealed 队列枚举失败(null)时抛 IOException——drain 据此退避 - // 重试、快照/恢复中止;绝不把失败当作空队列(否则 drain 会在仍有待处理段时误成功, - // 快照 barrier 也随之误判“无 pending”)。 - val allFiles = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for sealed segments: ${dir.absolutePath}") - val files = allFiles - .filter { - it.isFile && - it.name.startsWith(SEALED_PREFIX) && - it.name.endsWith(SEALED_SUFFIX) - } - .sortedBy { it.sealIndex() } - .filter { file -> - val entry = entries.firstOrNull { it.file == file.name } - when { - entry == null -> true - else -> when (entry.identityCheck(file)) { - IdentityCheck.MATCH -> false - IdentityCheck.MISMATCH -> { - // P1-2:旧身份仍停留在未提交 trash 中 → 保留条目(证据未 - // 消失),但仍正常处理同名新文件(旧文件在 trash 中不可能 - // 与当前文件同名共存,身份判定互不干扰)。 - // P1-4:存在无法完整严格解析的 UNCOMMITTED trash 时同样 - // 保留条目——旧身份可能正被其持有,scanner 绝不把可能受 - // trash 持有的身份当无保护而按 stale 移除。 - if (!trashHold.hasUnknown && - heldInTrash[entry.file] != (entry.bytes to entry.sha256) - ) { - stale += entry - } - true - } - IdentityCheck.UNREADABLE -> { - logE( - "statistics tombstone identity unreadable; keeping manifest " + - "entry and skipping the segment this round: ${file.name}", - ) - false - } - } - } - } - if (stale.isNotEmpty()) { - logE( - "statistics tombstone manifest has stale identities (vanished or reused-name files); " + - "removing: ${stale.joinToString { it.file }}", - ) - rewriteTombstoneManifest( - context = context, - remainingRawLines = rawLines.filterNot { line -> parseTombstoneLine(line)?.let { it in stale } == true }, - ) - } - return files - } + internal enum class DirSyncResult { OK, FAILED, UNSUPPORTED } /** - * Seal 采用文件系统级原子“不替换”发布(P1-8 + P1 终审持久化协议): - * - * 1. 首选 `Files.createLink(target, active)`:同目录硬链接,目标创建原子且已存在时抛 - * [FileAlreadyExistsException](绝不替换既有 sealed 段);链接建立后按 P1 终审顺序 - * 持久化:sync 目录(链接目录项)→ 删除 active → sync 目录(删除持久化)。崩溃窗口 - * (链接已建、active 删除未发生或未持久化)两个名字指向同一 inode,由 - * [recoverSealDuplicates] 在下次 append/drain 时识别并删除 sealed 副本(内容保留在 - * active,绝不重复拼接——向 active 追加会连带改写已 seal 段)。 - * 2. 硬链接不受支持(FAT/exFAT 等)或临时失败时回退 copy 发布([publishSealedByCopy]): - * Android/Linux 的 Unix provider 以 O_CREAT|O_EXCL 原子创建目标(已存在即抛 - * [FileAlreadyExistsException]),Windows 以 CREATE_NEW 同样原子不替换。这比不带 - * REPLACE 的 `Files.move` 更强:Android 的普通 move 先做存在性预检再 rename(2) - * (rename 会静默替换预检之后出现的目标),保留 TOCTOU,不可单独依赖;ATOMIC_MOVE - * 在目标已存在时语义实现相关,同样不可依赖。copy 回退的崩溃窗口(复制完成、active - * 未删)产生两个内容相同的独立文件,同样由 [recoverSealDuplicates] 按内容识别去重。 + * 目录 fsync(P1 终审,调用方持 lifecycleMutex)。Android/Linux:`FileChannel.open(dir, + * READ)` + `force(true)` 即 fsync(2) 目录 fd,持久化目录项(新建/硬链接/删除)。 + * Windows:JDK 无法打开目录句柄(CreateFile 拒绝目录,实测抛 [AccessDeniedException]) + * ——平台明确不支持,返回 [DirSyncResult.UNSUPPORTED]。 * - * 枚举失败(null)或恢复无法确认无重复时 seal 明确失败(fail-closed),绝不发布。 + * 调用方契约(P1 终审):只有 [DirSyncResult.OK] 才能继续发布/删除;FAILED 与 + * UNSUPPORTED 一律 fail-closed——目录项未确认持久时绝不删除唯一 fsynced active、绝不 + * 声称 durable/PUBLISHED。生产路径不缓存平台能力、不进入任何特殊模式:每次目录项 + * 变更(新建/rename/link/delete)都调用本方法确认。 */ - private suspend fun sealActive(context: Context, dir: File): Boolean { - val active = File(dir, ACTIVE_FILE_NAME) - if (!active.isFile || active.length() == 0L) return true - if (!recoverSealDuplicates(dir, active)) { - logE("statistics spool segment seal failed: seal recovery could not confirm no duplicates") - return false - } - // P1-7 fail-closed:seal 编号枚举失败(null)→ seal 明确失败,绝不回退到编号 1 - // (枚举失败时回退 1 会重名覆盖 sealed_1 等既有段,销毁其证据)。枚举成功但目标 - // 已被占用(异常残留)→ 递增到下一个安全编号,找不到则失败,绝不覆盖任何既有段。 - val index = nextSealIndex(dir) - if (index == null) { - logE("statistics spool segment seal failed: cannot enumerate spool directory: ${dir.absolutePath}") - return false - } - var candidate = index - while (true) { - val target = File(dir, "$SEALED_PREFIX$candidate$SEALED_SUFFIX") - // 可控 publication seam(P1-8):测试可在此创建同名不同内容的目标文件模拟冲突, - // 真实发布路径必须检测到占用并选择下一编号,目标原字节保持不变。 - if (beforeSealPublishForTest?.invoke(target) == false) { - logE("statistics spool segment seal failed: pre-publish hook refused: ${target.name}") - return false - } - when (publishSealedNoReplace(context, dir, active, target)) { - SealPublishResult.PUBLISHED -> return true - SealPublishResult.EXISTS -> { - candidate += 1L - if (candidate <= 0L) { - // Long 溢出防御:不再有可用编号 → 失败(绝不覆盖) - logE("statistics spool segment seal failed: no free sealed index: ${dir.absolutePath}") - return false - } - } - SealPublishResult.FAILED -> { - logE("statistics spool segment seal failed: ${target.name}") - return false - } - } - } + internal fun syncDir(dir: File): DirSyncResult { + val seam = dirSyncForTest + if (seam != null) return seam(dir) ?: realSyncDir(dir) + return realSyncDir(dir) } - /** seal 原子发布结果:成功 / 目标已存在(调用方换下一编号)/ 其他失败(终止本轮)。 */ - private enum class SealPublishResult { PUBLISHED, EXISTS, FAILED } + internal fun realSyncDir(dir: File): DirSyncResult { + return SpoolFileSystem.syncDirectory(dir, ::logE) + } /** - * 原子“不替换”发布 active → target(调用方持 lifecycleMutex,契约见 [sealActive]): - * 首选硬链接;不受支持时回退 copy 发布([publishSealedByCopy])。目标已存在只返回 - * [SealPublishResult.EXISTS],绝不修改、替换或删除既有目标。 - * - * 持久化契约(P1 终审):两种路径都保证“target 的 data + 目录项(创建/链接/删除)已 - * fsync 确认后才可能返回 PUBLISHED”;任何前置失败保留 active(数据持有者)并返回 - * FAILED;删除 active 后的目录同步失败返回 FAILED 但保留已 durable 的 target,由 - * [recoverSealDuplicates] 恢复。 + * P1-1 终审修复:spool 目录项持久确认的统一入口(调用方持 lifecycleMutex)。任一目录 + * sync 非 OK 立即把 bootstrap gate 标记 [directoryDurabilityConfirmedThisProcess] 置 false + * ——此后任何声称 durable 前都必须重新确认目录项,绝不带着“已确认”内存标记继续。 + * 所有 spool 目录项变更(新建/rename/delete,含 ack 跨 spool 根与 trash 两个目录)后的 + * 目录 sync 都必须经本入口确认。 */ - private suspend fun publishSealedNoReplace( - context: Context, - dir: File, - active: File, - target: File, - ): SealPublishResult { - val linked = if (sealHardLinkForTest?.invoke(active, target) != false) { - try { - Files.createLink(target.toPath(), active.toPath()) - true - } catch (e: FileAlreadyExistsException) { - return SealPublishResult.EXISTS - } catch (e: Exception) { - // 平台/文件系统不支持硬链接或临时失败 → 回退 copy 发布 - false - } - } else { - // 测试注入:强制模拟硬链接不受支持 - false - } - if (linked) return publishSealedAfterHardLink(dir, active, target) - return publishSealedByCopy(context, dir, active, target) + internal fun requireSpoolDirSync(vararg dirs: File): Boolean { + val ok = dirs.all { syncDir(it) == DirSyncResult.OK } + if (!ok) directoryDurabilityConfirmedThisProcess = false + return ok } /** - * 硬链接发布后置持久化(P1 终审):createLink 已原子建立同 inode 链接(active 数据在 - * append 时已 fsync)。顺序:sync 目录(持久化链接目录项)→ 删除 active → sync 目录 - * (持久化删除)。 - * - * - 删除 active 之前的任何失败:active 是唯一数据持有者,保留 active、回滚链接并返回 - * FAILED,绝不声称 PUBLISHED(否则崩溃窗口里 append 可能写进已 seal 段)。 - * - 删除 active 之后的目录同步失败:链接已 data+creation durable,active 删除可能未 - * 持久化——保留明确恢复状态(崩溃后 active 以同 inode 重现时由 [recoverSealDuplicates] - * 去重;未重现则 target 正常排空),返回 FAILED 阻止本轮后续 append 写入,绝不回滚 - * 已 durable 的 target。 - * - * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 - * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 + * 文件 fsync(P1 终审):`FileChannel.force(true)` 持久化数据与元数据;失败返回 false + * (调用方保留 active、处置目标、返回 FAILED,绝不声称 PUBLISHED)。 */ - private fun publishSealedAfterHardLink( - dir: File, - active: File, - target: File, - ): SealPublishResult { - if (!requireSpoolDirSync(dir)) { - rollbackSealTarget(dir, target, "hardlink") - return SealPublishResult.FAILED - } - if (!deleteActiveAfterPublish(active)) { - // 链接已建但 active 删除失败:同 inode 重复。先尝试回滚链接;回滚也失败时 - // 保留给 [recoverSealDuplicates] 下次识别(内容仍在 active)。绝不可带着 - // active 返回成功——否则后续 drain 会把同一内容排空两次。 - rollbackSealTarget(dir, target, "hardlink") - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal hardlink: dir sync after active removal failed; " + - "durable link will be recovered: ${target.name}", - ) - return SealPublishResult.FAILED - } - return SealPublishResult.PUBLISHED - } - - /** - * copy 回退发布(P1 终审 + P2):O_CREAT|O_EXCL / CREATE_NEW 原子创建目标(绝不替换 - * 既有目标;[FileAlreadyExistsException] → EXISTS 让调用方选下一编号)。 - * - * 持久化顺序:copy 目标 → fsync 目标数据([syncFile])→ sync 目录(目标创建持久)→ - * 删除 active → sync 目录(删除持久)。 - * - 删除 active 之前的任何失败:active 是完整内容持有者,保留 active,并按 P2 处置本次 - * 目标([disposeFailedCopyTarget]:identity 确认后隔离到 seal_failed_ 或安全 - * 删除;两者都失败则 tombstone skip,绝不当 normal sealed 排空),返回 FAILED。 - * - 删除 active 之后的目录同步失败:目标已 data+creation durable,active 删除可能未 - * 持久化(崩溃后 active 以原内容重现 → [recoverSealDuplicates] 按内容去重;未重现则 - * target 正常排空)——保留该明确恢复状态并返回 FAILED,阻止本轮后续 append 污染, - * 绝不回滚已 durable 的 target。 - * - * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 - * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 - */ - private suspend fun publishSealedByCopy( - context: Context, - dir: File, - active: File, - target: File, - ): SealPublishResult { - val injected = sealCopyForTest?.invoke(active, target) - if (injected != null) { - if (!injected) { - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - } else { - try { - Files.copy(active.toPath(), target.toPath()) - } catch (e: FileAlreadyExistsException) { - return SealPublishResult.EXISTS - } catch (e: Exception) { - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - } - if (!syncFile(target)) { - // 目标数据未确认 durable:保留 active,处置本次目标 - disposeFailedCopyTarget(context, dir, target, active) - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - // 目标创建未确认持久:保留 active,处置本次目标 - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - if (!deleteActiveAfterPublish(active)) { - // 复制完成、active 未删:两个独立文件同内容。目标已 durable(data+creation), - // 删除目标放弃 sealed 副本(active 仍是完整内容持有者,无数据损失);回滚失败 - // 留给 [recoverSealDuplicates] 按内容去重。 - rollbackSealTarget(dir, target, "copy") - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy: dir sync after active removal failed; " + - "durable target will be recovered: ${target.name}", - ) - return SealPublishResult.FAILED - } - return SealPublishResult.PUBLISHED - } - - private fun deleteActiveAfterPublish(active: File): Boolean = - sealActiveDeleteForTest?.invoke(active) ?: active.delete() - - /** - * seal 前置失败回滚(P2 终审):删除刚发布的 target(active 仍是完整内容持有者,删除 - * 安全无数据损失),删除后必须经 [requireSpoolDirSync] 确认目录项持久——删除是目录项 - * 变更,未确认持久绝不视为回滚完成(P1-1:非 OK 同时失效 bootstrap gate,下一次使用 - * 重新确认)。返回 false 表示回滚未完成/未确认(target 删除失败或目录项未确认持久), - * 调用方保持失败状态;残留由 [recoverSealDuplicates] 按 inode/内容去重兜底。 - */ - private fun rollbackSealTarget(dir: File, target: File, kind: String): Boolean { - if (!target.delete()) { - logE("statistics spool seal rollback failed ($kind); duplicate will be recovered: ${target.name}") - return false - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal rollback deletion not durable ($kind); " + - "gate invalidated, duplicate will be recovered: ${target.name}", - ) - return false - } - return true - } - - /** - * P2 终审修复:seal copy 失败后的部分目标处置(调用方持 lifecycleMutex)。身份前提:候选 - * 编号在 copy 前由 [nextSealIndex] 确认不存在、copy 无 REPLACE 语义、lifecycleMutex 内无本 - * 进程并发——异常后目标若存在只可能是本次 copy 的部分写入;[isPrefixOf] 前缀校验防御外部 - * 进程并发占用该名字时的误隔离(identity 确认)。处置顺序: - * 1. 原子 rename 到 `seal_failed_`(scanner 忽略该前缀、计入递归总 cap、维护清理、 - * 作为受管失败发布证据可见/导出/ack);rename 后目录项 sync 非 OK——隔离文件本身即受管 - * 证据,另按候选 sealed 身份写 tombstone(崩溃后该名字以同内容重现时 scanner 跳过,绝不 - * 普通排空),返回 false(调用方失败,绝不静默)。 - * 2. rename 失败 → 安全删除(active 保留完整内容,删除部分副本无数据损失);删除后目录项 - * sync 非 OK——删除可见但未确认:按候选 sealed 身份写 tombstone 保护崩溃后可能重现的 - * 名字,返回 false。 - * 3. rename/delete 都失败 → tombstone skip(记录稳定身份,scanner 跳过该具体文件,绝不当 - * normal sealed 排空);tombstone 写失败返回 false——drain 退避重试,不做任何破坏性决策。 - * - * @return true = 已留下受管证据(seal_failed 隔离文件/tombstone 条目)或已安全删除且目录项 - * 确认持久;false = 存在目录项未确认持久的变更(tombstone 已尽力写入受管证据),调用方 - * 必须失败,绝不只记录日志。 - */ - private suspend fun disposeFailedCopyTarget( - context: Context, - dir: File, - target: File, - active: File, - ): Boolean { - if (!target.exists()) return true - if (!isPrefixOf(target, active)) { - logE( - "statistics spool seal copy failure target identity mismatch; " + - "leaving file untouched: ${target.name}", - ) - return true - } - val isolated = File(dir, "$SEAL_FAILED_PREFIX${UUID.randomUUID().toString().replace("-", "")}") - if (renameForTest(target, isolated)) { - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy failed; isolated target rename not durable: ${isolated.name}", - ) - // P2:隔离文件本身已是受管证据(seal_failed_*,可 UI/导出/ack/维护);再按 - // 候选 sealed 身份写 tombstone,崩溃后该名字以同内容重现时 scanner 跳过。 - tombstonePartialTarget(context, target, isolated) - return false - } - logE("statistics spool seal copy failed; partial target isolated: ${isolated.name}") - return true - } - // rename 失败:先取原始字节(删除成功后将无法再读取),再尝试安全删除 - val rawBytes = try { - target.readBytes() - } catch (e: Exception) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", - e, - ) - null - } - if (segmentDeleteForTest?.invoke(target) ?: target.delete()) { - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy failed; partial target deletion not durable: ${target.name}", - ) - // P2:删除可见但未确认——按候选 sealed 身份写 tombstone,崩溃后该名字 - // 以同内容重现时 scanner 跳过,绝不普通排空。 - if (rawBytes != null) { - tombstoneSegment(context, target, rawBytes, overCap = false) - } - return false - } - logE("statistics spool seal copy failed; partial target deleted: ${target.name}") - return true - } - if (rawBytes == null) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", - ) - return false - } - return when (tombstoneSegment(context, target, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> { - logE( - "statistics spool seal copy failed; partial target tombstoned, scanner will skip: ${target.name}", - ) - true - } - TombstoneResult.FAILED -> { - logE( - "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${target.name}", - ) - false - } - } - } - - /** - * P2:seal copy 失败目标的 tombstone 写入(调用方持 lifecycleMutex)。目标可能已被改名/ - * 删除,[bytesSource] 提供其原始字节;[tombstoneSegment] 按稳定身份(bytes+sha256)记录 - * [nameFile](候选 sealed 名),崩溃后该名字以同内容重现时 scanner 跳过。写失败仅记录—— - * 调用方本就返回失败,drain 退避重试。 - */ - private suspend fun tombstonePartialTarget( - context: Context, - nameFile: File, - bytesSource: File, - ) { - val rawBytes = try { - bytesSource.readBytes() - } catch (e: Exception) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${nameFile.name}", - e, - ) - return - } - when (tombstoneSegment(context, nameFile, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> Unit - TombstoneResult.FAILED -> { - logE( - "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${nameFile.name}", - ) - } - } - } - - /** - * 部分目标 identity 确认(P2):目标必须是 source(active)的前缀(长度 ≤ 且逐字节 - * 相等)才允许处置;读取失败返回 false(fail-closed,绝不隔离不可确认的文件)。 - */ - private fun isPrefixOf(partial: File, source: File): Boolean { - if (partial.length() > source.length()) return false - if (partial.length() == 0L) return true - return try { - partial.inputStream().use { pIn -> - source.inputStream().use { sIn -> - val bufP = ByteArray(64 * 1024) - val bufS = ByteArray(64 * 1024) - var remaining = partial.length() - while (remaining > 0L) { - val want = minOf(bufP.size.toLong(), remaining).toInt() - val nP = pIn.read(bufP, 0, want) - if (nP <= 0) return false - val nS = sIn.read(bufS, 0, nP) - if (nS != nP) return false - if (!bufP.copyOfRange(0, nP).contentEquals(bufS.copyOfRange(0, nS))) { - return false - } - remaining -= nP - } - true - } - } - } catch (e: Exception) { - logE("statistics spool seal copy failure identity check failed", e) - false - } - } - - /** - * 目录同步结果(P1 终审):OK 已持久;FAILED 真实失败;UNSUPPORTED 平台明确不支持目录 - * fsync——与 FAILED 一样 fail-closed(**绝不**当作成功继续删除唯一 fsynced active 或 - * 声称 PUBLISHED)。不存在“原地排空”平台模式:生产平台(Android/Linux)支持目录 - * fd fsync,Windows 仅 JVM 测试环境。 - */ - internal enum class DirSyncResult { OK, FAILED, UNSUPPORTED } - - /** - * 目录 fsync(P1 终审,调用方持 lifecycleMutex)。Android/Linux:`FileChannel.open(dir, - * READ)` + `force(true)` 即 fsync(2) 目录 fd,持久化目录项(新建/硬链接/删除)。 - * Windows:JDK 无法打开目录句柄(CreateFile 拒绝目录,实测抛 [AccessDeniedException]) - * ——平台明确不支持,返回 [DirSyncResult.UNSUPPORTED]。 - * - * 调用方契约(P1 终审):只有 [DirSyncResult.OK] 才能继续发布/删除;FAILED 与 - * UNSUPPORTED 一律 fail-closed——目录项未确认持久时绝不删除唯一 fsynced active、绝不 - * 声称 durable/PUBLISHED。生产路径不缓存平台能力、不进入任何特殊模式:每次目录项 - * 变更(新建/rename/link/delete)都调用本方法确认。 - */ - private fun syncDir(dir: File): DirSyncResult { - val seam = dirSyncForTest - if (seam != null) return seam(dir) ?: realSyncDir(dir) - return realSyncDir(dir) - } - - private fun realSyncDir(dir: File): DirSyncResult { - return try { - FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } - DirSyncResult.OK - } catch (e: AccessDeniedException) { - logE( - "statistics spool directory fsync unsupported on this platform; " + - "fail-closed: no directory entry is confirmed durable", - e, - ) - DirSyncResult.UNSUPPORTED - } catch (e: Exception) { - logE("statistics spool directory fsync failed: ${dir.absolutePath}", e) - DirSyncResult.FAILED - } - } - - /** - * P1-1 终审修复:spool 目录项持久确认的统一入口(调用方持 lifecycleMutex)。任一目录 - * sync 非 OK 立即把 bootstrap gate 标记 [directoryDurabilityConfirmedThisProcess] 置 false - * ——此后任何声称 durable 前都必须重新确认目录项,绝不带着“已确认”内存标记继续。 - * 所有 spool 目录项变更(新建/rename/delete,含 ack 跨 spool 根与 trash 两个目录)后的 - * 目录 sync 都必须经本入口确认。 - */ - private fun requireSpoolDirSync(vararg dirs: File): Boolean { - val ok = dirs.all { syncDir(it) == DirSyncResult.OK } - if (!ok) directoryDurabilityConfirmedThisProcess = false - return ok - } - - /** - * 文件 fsync(P1 终审):`FileChannel.force(true)` 持久化数据与元数据;失败返回 false - * (调用方保留 active、处置目标、返回 FAILED,绝不声称 PUBLISHED)。 - */ - private fun syncFile(file: File): Boolean { + internal fun syncFile(file: File): Boolean { val seam = fileSyncForTest if (seam != null) return seam(file) ?: realSyncFile(file) return realSyncFile(file) } - private fun realSyncFile(file: File): Boolean = try { - FileChannel.open(file.toPath(), StandardOpenOption.WRITE).use { it.force(true) } - true - } catch (e: Exception) { - logE("statistics spool file fsync failed: ${file.name}", e) - false - } + internal fun realSyncFile(file: File): Boolean = SpoolFileSystem.syncFile(file, ::logE) - /** - * 恢复 seal 崩溃窗口的重复(P1-8,调用方持 lifecycleMutex):active 与某个 sealed 段 - * 指向同一 inode(硬链接窗口:createLink 成功但 active 删除失败/崩溃)或内容完全相同 - * (copy 回退窗口:目标复制完成、active 删除未发生,两个独立 inode 同内容)时,删除 - * sealed 副本、保留 active 为唯一内容持有者——后续 append 才不会被连带写进已 seal 段, - * 同一内容也只会被排空一次。 - * - * 合法内容重复不可能发生(事件行含唯一 eventId,active 内容严格单调增长),因此 - * 内容相等只可能来自上述崩溃窗口;即使病理情况下误删副本,内容仍从 active 重新 - * seal 并排空,不丢数据。 - * - * fail-closed:spool 根枚举失败(null)、任一 sealed 候选无法 stat/读取或删除失败时 - * 返回 false——调用方(append/seal)拒绝继续,绝不带着“可能还有重复”的状态写入或发布。 - */ - private fun recoverSealDuplicates(dir: File, active: File): Boolean { - val files = listDir(dir) ?: return false - val activeKey = try { - Files.readAttributes( - active.toPath(), - BasicFileAttributes::class.java, - java.nio.file.LinkOption.NOFOLLOW_LINKS, - ).fileKey() - } catch (e: Exception) { - logE("statistics spool cannot stat active for seal recovery", e) - return false - } - var ok = true - var deletedAny = false - for (file in files) { - if (!file.isFile || !file.name.startsWith(SEALED_PREFIX) || !file.name.endsWith(SEALED_SUFFIX)) { - continue - } - val key = try { - Files.readAttributes( - file.toPath(), - BasicFileAttributes::class.java, - java.nio.file.LinkOption.NOFOLLOW_LINKS, - ).fileKey() - } catch (e: Exception) { - logE("statistics spool cannot stat sealed segment for seal recovery: ${file.name}", e) - ok = false - continue - } - val sameInode = key != null && key == activeKey - val contentDuplicate = if (sameInode) null else contentsEqual(file, active) - when { - sameInode || contentDuplicate == true -> { - if (!file.delete()) { - logE("statistics spool seal duplicate removal failed: ${file.name}") - ok = false - } else { - deletedAny = true - } - } - contentDuplicate == null -> { - // 无法确认是否重复(读取失败)→ fail-closed,绝不带着未知状态继续 - logE("statistics spool cannot compare sealed segment for seal recovery: ${file.name}") - ok = false - } - } - } - // P1-3 终审:重复副本删除是目录项变更——未确认持久绝不报告恢复完成(append/seal - // 据此 fail-closed)。active 仍是完整内容持有者,删除可见但未确认时崩溃后副本重现, - // 由下次恢复按 inode/内容幂等重删,绝不丢数据。P1-1:非 OK 同时失效 gate。 - if (deletedAny && !requireSpoolDirSync(dir)) { - logE("statistics spool seal duplicate removal not durable; recovery unconfirmed") - ok = false - } - return ok - } /** 逐字节比较(长度先短路;读失败返回 null,调用方按 fail-closed 处理)。 */ - private fun contentsEqual(a: File, b: File): Boolean? { - if (a.length() != b.length()) return false - if (a.length() == 0L) return true - return try { - a.inputStream().use { aIn -> - b.inputStream().use { bIn -> - var equal = true - val bufA = ByteArray(64 * 1024) - val bufB = ByteArray(64 * 1024) - while (true) { - val nA = aIn.read(bufA) - val nB = bIn.read(bufB) - if (nA != nB) { - equal = false - break - } - if (nA < 0) break - if (!bufA.copyOfRange(0, nA).contentEquals(bufB.copyOfRange(0, nB))) { - equal = false - break - } - } - equal - } - } - } catch (e: Exception) { - logE("statistics spool seal duplicate content compare failed", e) - null - } + internal fun contentsEqual(a: File, b: File): Boolean? { + return SpoolFileSystem.contentsEqual(a, b, ::logE) } - /** - * A complete line always ends with '\n'; every append writes whole lines, so a file ending - * with '\n' has no partial tail. Only the final write of a crash can leave a tail without one. - */ - private fun activeEndsWithLineBreak(active: File): Boolean = - RandomAccessFile(active, "r").use { raf -> - raf.seek(raf.length() - 1L) - raf.read() == '\n'.code - } - /** 下一个建议的 sealed 编号;目录枚举失败(null)返回 null → seal 必须失败(P1-7)。 */ - private fun nextSealIndex(dir: File): Long? { - val files = listDir(dir) ?: return null - return files.mapNotNull { file -> - Regex("(?:quarantine_[^_]+_)?sealed_(\\d+)\\.jsonl").matchEntire(file.name) - ?.groupValues?.get(1)?.toLongOrNull() - }.maxOrNull()?.plus(1L) ?: 1L - } - private fun File.sealIndex(): Long = + internal fun File.sealIndex(): Long = name.removePrefix(SEALED_PREFIX).removeSuffix(SEALED_SUFFIX).toLongOrNull() ?: Long.MAX_VALUE - private suspend fun drainSegment( - context: Context, - dao: TokenStatsDao, - segment: File, - ): Boolean { - val rawBytes = try { - if (segmentReadErrorForTest?.invoke(segment) == true) { - throw IOException("statistics spool segment read failed (injected): ${segment.name}") - } - segment.readBytes() - } catch (e: Exception) { - logE("statistics spool segment read failed: ${segment.name}", e) - return false - } - afterSegmentReadForTest?.invoke() - // 身份哈希一律基于原始字节(readText 会对非法 UTF-8 做替换再编码,与文件字节 - // 不一致会让 tombstone 身份永远无法匹配损坏段,造成反复重扫/重复条目)。 - val text = String(rawBytes, Charsets.UTF_8) - var corrupt = false - var corruptLineCount = 0 - for (line in text.lineSequence().filter { it.isNotEmpty() }) { - val request = try { - TokenStatRequestContext.fromSpoolLine(line) - } catch (e: Exception) { - corrupt = true - corruptLineCount += 1 - logE("statistics spool line corrupt; preserving segment evidence: ${segment.name}", e) - continue - } - if (!insertSafely(context, dao, request)) return false - synchronized(stateLock) { - insertionWaiters.remove(request.eventId)?.complete(Unit) - } - } - if (corrupt) { - val existingBytes = quarantineEvidenceLocked(context).sumOf { it.length() } - if (existingBytes + segment.length() > MAX_QUARANTINE_BYTES) { - // 硬边界(P2-1):容量内保留完整证据;超限的新损坏段只保留固定大小滚动 - // 摘要(计数/hash/字节/时间,不含正文),并移除原段,磁盘占用有界。 - // 健康排空不受影响:本段处理完立即继续后续 segment。 - if (!summarizeOverCapSegment(context, segment, rawBytes, text, corruptLineCount)) { - logE("statistics quarantine hard cap: over-cap segment retained: ${segment.name}") - return false - } - return true - } - val target = File( - segment.parentFile, - "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (!renameForTest(segment, target)) { - // P1-2:证据重命名失败也不能阻塞健康排空——容量内预算允许时先移入有界 - // pending-delete 诊断区(完整证据,维护入口会移回证据区),再失败才 tombstone - val pending = File( - segment.parentFile, - "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (renameForTest(segment, pending)) { - // P1 终审:rename 后目录项必须确认持久,非 OK fail-closed(段内容在任一 - // 名字下保留,绝不丢原始证据;drain 退避重试下一轮)。P1-1:非 OK 同时 - // 失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE( - "statistics corrupt segment pending-delete rename not durable; " + - "deferring round: ${pending.name}", - ) - return false - } - logE("statistics corrupt segment quarantine rename failed; retained as pending-delete evidence: ${pending.name}") - return true - } - logE("statistics corrupt segment quarantine rename failed; tombstoning: ${segment.name}") - // P1-1:受管集合满时跳过该段继续健康(有界重扫),写失败才退避重试 - return when (tombstoneSegment(context, segment, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> true - TombstoneResult.FAILED -> false - } - } - // P1 终审:证据 rename 后目录项必须确认持久,非 OK fail-closed——证据内容在 - // quarantine 名下保留(绝不丢原始),本轮退避由 drain 重试。P1-1:非 OK 同时 - // 失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE( - "statistics corrupt segment quarantine rename not durable; " + - "deferring round: ${target.name}", - ) - return false - } - return true - } - if (!(segmentDeleteForTest?.invoke(segment) ?: segment.delete())) { - logE("statistics spool segment deletion failed: ${segment.name}") - return false - } - // P1 终审:删除后目录项必须确认持久,非 OK fail-closed(行已入 Room,崩溃后文件 - // 复活会被 INSERT IGNORE 幂等重放,绝不丢数据;本轮退避下一轮继续)。P1-1:非 OK - // 同时失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics spool segment deletion not durable: ${segment.name}") - return false - } - return true - } - private fun renameForTest(from: File, to: File): Boolean = + internal fun renameForTest(from: File, to: File): Boolean = segmentRenameForTest?.invoke(from, to) ?: from.renameTo(to) - /** - * 超限损坏段的硬边界替换(P1-1/P1-2):崩溃安全地发布“已裁剪到双上限”的新完整摘要 - * (旧完整或新完整,绝不截断),随后把段移出 sealed 扫描队列;段删除失败绝不阻塞健康 - * 排空(改为 pending-delete 证据或 tombstone 跳过)。摘要发布失败抛异常 → 保留旧摘要 - * 与待处理段,返回 false,绝不声称成功。 - */ - private suspend fun summarizeOverCapSegment( - context: Context, - segment: File, - rawBytes: ByteArray, - text: String, - corruptLineCount: Int, - ): Boolean { - val summaryFile = File(spoolDir(context), QUARANTINE_SUMMARY_NAME) - val store = summaryStore(summaryFile) - return try { - val sha = sha256Hex(rawBytes) - // 崩溃安全读取旧完整摘要(中断残留会被恢复),绝不基于半写内容裁剪 - val oldContent = readMetadata(store, summaryFile) - val oldLines = - oldContent?.lineSequence()?.filter { it.isNotEmpty() }?.toList() ?: emptyList() - val record = - JSONObject() - .put("ts", System.currentTimeMillis()) - .put("file", segment.name) - .put("bytes", segment.length()) - .put("sha256", sha) - .put("lineCount", text.lineSequence().filter { it.isNotEmpty() }.count()) - .put("corruptLines", corruptLineCount) - .toString() - // 崩溃重试幂等:同一段已有记录且未超限则不再追加;超限旧摘要仍会被裁剪自愈 - val alreadyRecorded = - oldLines.any { line -> - try { - val obj = JSONObject(line) - obj.optString("file") == segment.name && obj.optString("sha256") == sha - } catch (_: Exception) { - false - } - } - val withinCaps = - oldLines.size <= MAX_QUARANTINE_SUMMARY_LINES && - oldLines.sumOf { utf8RecordBytes(it) } <= MAX_QUARANTINE_SUMMARY_BYTES - if (!alreadyRecorded || !withinCaps) { - val newContent = buildTrimmedSummary(oldLines, record) - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量(canonical/.new/.bak/tmp 四 - // 槽位),超限有界失败:保留旧摘要与待处理段,返回 false 让 drain 退避重试, - // 绝不发布任何正式文件。 - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE( - "statistics quarantine summary publish refused: metadata budget over the " + - "total cap; keeping old summary and pending segment: ${segment.name}", - ) - return false - } - store.write(newContent) - } - if (disposeOverCapSegment(context, segment, rawBytes) == TombstoneResult.FAILED) { - return false - } - logE("statistics quarantine hard cap: over-cap corrupt segment summarized and removed: ${segment.name}") - true - } catch (e: Exception) { - logE("statistics quarantine summary write failed: ${segment.name}", e) - false - } - } - /** - * 构建“已裁剪到双上限”的新完整摘要内容(行数与 UTF-8 字节总数都满足上限,保留最新记录)。 - * 真实记录约 200 字节,单行不可能超过字节上限;循环只保证至少保留最新一行。 - */ - private fun buildTrimmedSummary(oldLines: List, record: String): String { - var keep = oldLines.map(::normalizeOversizedSummaryLine) + record - while (keep.size > 1 && - (keep.size > MAX_QUARANTINE_SUMMARY_LINES || - keep.sumOf { utf8RecordBytes(it) } > MAX_QUARANTINE_SUMMARY_BYTES) - ) { - keep = keep.drop(1) - } - return keep.joinToString("\n") + "\n" - } - /** - * P2-1:摘要上限按 UTF-8 实际字节计(Kotlin String.length 是 UTF-16 code unit,非 ASCII - * 字符会低估);单行 UTF-8 字节超上限时替换为固定 ASCII 缩略记录(hash/bytes,不含正文), - * 输出恒 ≤ [MAX_QUARANTINE_SUMMARY_BYTES]。 - */ - private fun utf8RecordBytes(line: String): Int = - line.toByteArray(Charsets.UTF_8).size + 1 - - private fun normalizeOversizedSummaryLine(line: String): String { - if (utf8RecordBytes(line) <= MAX_QUARANTINE_SUMMARY_BYTES) return line - val bytes = line.toByteArray(Charsets.UTF_8) - return JSONObject() - .put("truncated", true) - .put("bytes", bytes.size) - .put("sha256", sha256Hex(bytes)) - .toString() - } - private suspend fun disposeOverCapSegment( - context: Context, - segment: File, - rawBytes: ByteArray, - ): TombstoneResult { - if (segmentDeleteForTest?.invoke(segment) ?: segment.delete()) { - // P1-2 终审:删除是目录项变更——sync 非 OK 绝不返回 RECORDED(否则本轮声称成功 - // 而崩溃后段可能复活;下一轮按摘要身份幂等重删)。P1-1:非 OK 同时失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap segment deletion not durable: ${segment.name}") - return TombstoneResult.FAILED - } - return TombstoneResult.RECORDED - } - if (!segment.exists()) { - // 段已消失(上一轮可见删除):先确认删除持久才允许推进队列,绝不基于未确认 - // 状态返回 RECORDED。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap segment absence not durable: ${segment.name}") - return TombstoneResult.FAILED - } - return TombstoneResult.RECORDED - } - val fitsBudget = - quarantineEvidenceLocked(context).sumOf { it.length() } + segment.length() <= MAX_QUARANTINE_BYTES - if (fitsBudget) { - val pending = File( - segment.parentFile, - "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (renameForTest(segment, pending)) { - // P1-2 终审:rename 后目录项必须确认持久,非 OK 返回 FAILED(本轮退避重试; - // rename 可见时下一轮直接跳过/按身份幂等处置,崩溃后 pending 名重现由维护重放) - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap pending-delete rename not durable: ${pending.name}") - return TombstoneResult.FAILED - } - logE("statistics over-cap segment deletion failed; retained as pending-delete evidence: ${pending.name}") - return TombstoneResult.RECORDED - } - } else { - logE("statistics over-cap segment deletion failed and full evidence exceeds the hard cap; summary retains hash/bytes: ${segment.name}") - } - logE("statistics over-cap segment pending-delete rename failed; tombstoning: ${segment.name}") - return tombstoneSegment(context, segment, rawBytes, overCap = true) - } - /** - * 读取 tombstone manifest(崩溃安全恢复)得到原始行;解析交给 [parseTombstoneLine]。 - * P1-3:不设 canonical isFile 前置——canonical 缺失而内容只在 `.new`/`.bak` sidecar - * 时也必须先经 [AtomicRestoreMarkerStore.read] 恢复完整值再返回;否则仅 sidecar 存在 - * 时 info/ack/容量/扫描会误判为空。 - * P1-2 fail-closed:读取失败必须抛明确 [IOException](不返回 empty)——调用方(append - * 容量检查、scanner、快照、维护)据此中止并退避;返回空只允许出现在“manifest 不存在 - * (无受管记录)”这一真实状态。 - */ - private suspend fun readTombstoneLines(context: Context): List { - val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) - val content = readMetadata(summaryStore(manifestFile), manifestFile) ?: return emptyList() - return content.lineSequence().filter { it.isNotBlank() }.toList() - } - private fun parseTombstoneLine(line: String): TombstoneEntry? = try { - val obj = JSONObject(line) - val file = obj.optString("file").takeIf { it.isNotEmpty() } ?: return null - TombstoneEntry( - file = file, - bytes = obj.optLong("bytes", -1L), - sha256 = obj.optString("sha256", ""), - overCap = obj.optBoolean("overCap", false), - ) - } catch (_: Exception) { - null - } + /** 段身份校验结果(P1-2):读取失败 = UNREADABLE,绝不误判为陈旧而删/隔离/清理。 */ - private enum class IdentityCheck { MATCH, MISMATCH, UNREADABLE } /** * P1-2:稳定身份校验——文件名相同且字节数相同且原始字节 SHA-256 相同才是同一段 @@ -2894,698 +1227,29 @@ internal object TokenStatSpool { * P1-1:SHA 永远现场从原始字节计算,绝不复用 length+mtime 缓存——同名同长同 mtime * 的替换内容必须被识别为不同身份(陈旧记录被清理,健康段绝不删/跳/隔离)。 */ - private fun TombstoneEntry.identityCheck(file: File): IdentityCheck { - if (sha256.isEmpty() || !file.isFile || file.length() != bytes) return IdentityCheck.MISMATCH - if (segmentReadErrorForTest?.invoke(file) == true) return IdentityCheck.UNREADABLE - return try { - if (sha256Hex(file.readBytes()) == sha256) IdentityCheck.MATCH else IdentityCheck.MISMATCH - } catch (e: Exception) { - IdentityCheck.UNREADABLE - } - } - /** - * 有界 skip/tombstone manifest 更新(P1-1/P1-2):**不滚动**的活跃受管失败集合,条目 - * 只在文件物理消失/身份变化后由维护入口移除;达到 entry/字节硬上限时返回 - * [TombstoneResult.CAPACITY_FULL](调用方跳过该段继续健康,新统计 append 随后被拒绝), - * 写失败返回 [TombstoneResult.FAILED](drain 退避重试),绝不静默放行。 - */ - private suspend fun tombstoneSegment( - context: Context, - segment: File, - rawBytes: ByteArray, - overCap: Boolean, - ): TombstoneResult { - val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) - val store = summaryStore(manifestFile) - return try { - val oldLines = - readMetadata(store, manifestFile)?.lineSequence()?.filter { it.isNotEmpty() }?.toList() - ?: emptyList() - val sha = sha256Hex(rawBytes) - val entry = - JSONObject() - .put("ts", System.currentTimeMillis()) - .put("file", segment.name) - // P2 终审:字节数按原始字节计算——候选文件可能已被隔离/删除(目录项 sync - // 失败路径),File.length() 对不存在的文件恒为 0,会让崩溃后重现文件的 - // 身份判定失效;rawBytes 是调用方现场读取的稳定身份。 - .put("bytes", rawBytes.size.toLong()) - .put("sha256", sha) - .put("overCap", overCap) - .toString() - // 崩溃重试幂等:同一身份已记录则不再追加(同一文件同一内容) - val alreadyRecorded = - oldLines.any { line -> - val existing = parseTombstoneLine(line) - existing?.file == segment.name && existing?.sha256 == sha - } - if (alreadyRecorded) return TombstoneResult.RECORDED - val wouldBeEntries = oldLines.size + 1 - val wouldBeBytes = (oldLines + entry).sumOf { utf8RecordBytes(it) } - if (wouldBeEntries > MAX_TOMBSTONE_ENTRIES || wouldBeBytes > MAX_TOMBSTONE_MANIFEST_BYTES) { - logE( - "statistics tombstone capacity full; segment stays in queue for a later retry: ${segment.name}", - ) - return TombstoneResult.CAPACITY_FULL - } - val newContent = (oldLines + entry).joinToString("\n") + "\n" - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(FAILED → drain - // 退避重试,绝不发布任何正式文件)。 - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE( - "statistics tombstone manifest publish refused: metadata budget over the " + - "total cap; segment stays in queue: ${segment.name}", - ) - return TombstoneResult.FAILED - } - store.write(newContent) - TombstoneResult.RECORDED - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest write failed: ${segment.name}", e) - TombstoneResult.FAILED - } - } - /** - * 移除/裁剪后崩溃安全重写 manifest;写失败仅记录(下一次 drain 会再尝试)。 - * P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件)。 - */ - private suspend fun rewriteTombstoneManifest(context: Context, remainingRawLines: List) { - try { - val content = remainingRawLines.joinToString("\n") + if (remainingRawLines.isEmpty()) "" else "\n" - if (metadataWriteBudgetExceeded(context, content.toByteArray(Charsets.UTF_8).size)) { - logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") - return - } - summaryStore(File(spoolDir(context), TOMBSTONE_MANIFEST_NAME)).write(content) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - } - } - /** - * P1-2 维护/后台重试:清理删除失败被隔离的残留。ack trash 按持久状态机处置(**只有显式 - * COMMITTED 有界补删**;UNCOMMITTED 一律按 mapping+identity 回滚——P1-1:绝不根据主 - * manifest 缺失推断已提交;状态缺失/损坏或回滚长期失败的 trash 保留为 StuckAckEvidence - * 由 UI 管理,绝不自动删除);pending-delete 证据(容量内来源)移回完整证据区;tombstoned - * 段按记录的处置动作重试(over-cap → 删除,容量内 → 移回完整证据区),处置前必须按稳定 - * identity(file+bytes+sha256)校验当前文件仍是记录的段(P1-2:同名不同 hash 的健康新段 - * 绝不删/移,只移除陈旧记录)。成功后从 manifest 移除记录;文件已物理消失也移除记录 - * (除非身份仍停留在未提交 trash 中——P1-2:绝不让崩溃窗口判定把未确认的证据误判为已提交 - * 而删除)。P1-4:存在无法完整严格解析/读取的 UNCOMMITTED ack trash( - * [scanUncommittedTrashHolds] 的 hasUnknown)时,本轮**整轮跳过** stale 判定与 manifest - * 重写(缺失/不匹配条目的身份可能正被其持有),记录日志并有界返回。 - * - * P1-2 终审:**任何目录项变更(rename/delete)后 syncDir 非 OK 都不推进状态**—— - * 不返回 RECORDED、不移除 manifest 条目、不把变更视为完成:pending-delete 恢复 rename - * 非 OK 时尽力把文件移回 pending-delete 名(重建明确可重试记录)、seal_failed 删除与 - * tombstone 处置非 OK 时保留 manifest 条目,并返回 false 让 drain 退避重试。由于文件 - * 操作可能已可见,下一轮 bootstrap gate([ensureDirectoryDurabilityConfirmed])sync OK - * 后按 identity 幂等完成,绝不丢证据。失败仅记录(tombstone 本身就是有界可见错误证据), - * 绝不阻塞健康排空——本函数返回 true 时 drain 继续处理健康段。 - * - * @return false 表示本轮存在目录项未确认持久的变更(调用方 [drainCore] 退避重试); - * 其它失败(rename/delete 返回 false、状态无效、枚举失败)保留对应可重试记录并返回 true。 - */ - private suspend fun retryPendingCleanup(context: Context): Boolean { - val dir = spoolDir(context) - if (!dir.isDirectory) return true - var roundOk = true - // P1-3:不设 canonical isFile 前置——仅 sidecar 存在时也必须先恢复再处置受管段。 - // P1-2 fail-closed:manifest 不可读则维护中止并抛明确 IOException(drain 退避重试), - // 绝不当作“无受管记录”继续——那会让扫描器把受管段当健康段处理;ack trash 的“已提交” - // 判定也依赖主 manifest,不能拿空集冒充。manifest 不存在(从未有受管记录)视为空集。 - val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) - val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) - val lines = rawLines?.lineSequence()?.filter { it.isNotBlank() }?.toList() ?: emptyList() - // reviewer P1:ack trash 状态机(只有显式 COMMITTED 有界补删;UNCOMMITTED 一律按 - // mapping+identity 回滚——P1-1:绝不根据主 manifest 缺失推断已提交,普通 quarantine - // 证据从不在 manifest 中,缺失恒成立,旧推断会误删未确认的证据)。符号链接目录绝不 - // 进入处置(跟随链接可能删除链接目标的内容)。 - // P1-5:spool 根枚举失败(null)时本轮跳过 trash 处置并记录——枚举失败绝不是 - // “没有 trash 目录”,绝不静默放行;身份持有判定由 [scanUncommittedTrashHolds] 的 - // hasUnknown fail-closed 另行兜底(stale 清理整轮跳过)。 - val ackTrashCandidates = listDir(dir) - if (ackTrashCandidates == null) { - logE("statistics spool directory enumeration failed; deferring ack trash disposal") - } else { - ackTrashCandidates - .filter { f -> - f.isDirectory && - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) - } - .forEach { trash -> - if (!handleAckTrashDir(dir, trash)) roundOk = false - } - } - // P1-2/P1-4:处置后仍停留在未提交 trash 中的身份 → manifest 条目绝不按 stale 移除; - // 存在无法完整严格解析的 UNCOMMITTED trash 时(hasUnknown)本轮保守跳过 stale 处置 - val trashHold = scanUncommittedTrashHolds(context) - val heldInTrash = trashHold.known - dir.listFiles { f -> f.isFile && f.name.startsWith(PENDING_DELETE_PREFIX) } - ?.forEach { file -> - val target = File( - dir, - "$QUARANTINE_PREFIX${file.name.removePrefix(PENDING_DELETE_PREFIX)}", - ) - if (renameForTest(file, target)) { - if (!requireSpoolDirSync(dir)) { - // P1-2 终审:rename 已可见但目录项未确认持久——不推进状态:尽力把文件 - // 移回 pending-delete 名(重建明确可重试记录),并让本轮失败退避;崩溃 - // 后文件在任一名字下都保留证据,下一轮按名字/身份幂等完成。P1-1: - // 非 OK 同时失效 gate。 - logE( - "statistics pending-delete evidence restore rename not durable; " + - "restoring retryable record: ${target.name}", - ) - val reverseRenamed = target.exists() && renameForTest(target, file) - if (reverseRenamed) { - // P2 终审:反向 rename 同样是目录项变更——未确认持久绝不算 - // “已重建可重试记录”(变更可见时下一轮 bootstrap 重新确认后按 - // 名字幂等完成);失败保留 pending 记录并退避,绝不静默。 - if (!requireSpoolDirSync(dir)) { - logE( - "statistics pending-delete evidence restore reverse rename " + - "not durable; keeping retryable record: ${file.name}", - ) - } - } else if (target.exists()) { - logE( - "statistics pending-delete evidence restore reverse rename failed: ${file.name}", - ) - } - roundOk = false - } else { - logE("statistics pending-delete evidence restored to quarantine: ${target.name}") - } - } - } - // P2:seal copy 失败隔离的部分目标(seal_failed_*,scanner 忽略):active 保留完整 - // 内容,删除隔离副本安全无数据损失;删除失败只记录(文件作为受管失败发布证据计入 - // 证据区,占用可见且有界——quarantineEvidence/导出/ack 可管理),下一轮维护再试。 - // P1-2 终审:删除成功但目录项未确认持久 → 本轮不推进(roundOk=false,退避重试)。 - dir.listFiles { f -> f.isFile && f.name.startsWith(SEAL_FAILED_PREFIX) } - ?.forEach { file -> - if (!(segmentDeleteForTest?.invoke(file) ?: file.delete())) { - logE( - "statistics spool seal-failed target cleanup deferred; " + - "visible as managed failed-publication evidence: ${file.name}", - ) - } else if (!requireSpoolDirSync(dir)) { - logE("statistics spool seal-failed target deletion not durable: ${file.name}") - roundOk = false - } - } - if (lines.isEmpty()) return roundOk - // P1-4 fail-closed:hasUnknown 时,缺失(!file.exists())或不匹配(MISMATCH)条目 - // 的身份可能正被无法解析的 ack trash 持有——本轮绝不移除任何这类 manifest 条目。 - // 整轮跳过 MATCH 处置与 manifest 重写(简单正确),记录日志并有界返回(不持锁 - // 等待);trash 状态恢复后下一轮维护再清理。 - if (trashHold.hasUnknown) { - logE( - "statistics ack trash state partially unknown; deferring tombstone stale " + - "cleanup and manifest rewrite this round", - ) - return roundOk - } - val remaining = lines.filterNot { line -> - val entry = parseTombstoneLine(line) ?: return@filterNot false - val file = File(dir, entry.file) - when { - // 物理消失:P1-2 先查未提交 trash——身份在其中时条目必须保留(证据仍存在, - // 等待回滚或提交判定),绝不按 stale 移除。P1-2 终审:删除/移动可能在上轮 - // 可见但未确认持久——本轮先 sync 确认“消失”持久才允许移除条目。P1-1: - // 非 OK 同时失效 gate。 - !file.exists() -> { - val confirmed = requireSpoolDirSync(dir) - if (!confirmed) { - logE( - "statistics tombstone entry absence not durable; retaining entry: ${entry.file}", - ) - roundOk = false - } - confirmed && heldInTrash[entry.file] != (entry.bytes to entry.sha256) - } - else -> when (entry.identityCheck(file)) { - // P1-2:身份不可校验(UNREADABLE)→ 保留记录与文件,本轮不处置 - IdentityCheck.UNREADABLE -> false - // 身份不匹配:同名新文件 ≠ 陈旧记录的前提是旧身份已无处可寻;旧身份仍 - // 停留在未提交 trash 中时条目必须保留(崩溃窗口判定依赖它),只处置新文件 - IdentityCheck.MISMATCH -> heldInTrash[entry.file] != (entry.bytes to entry.sha256) - IdentityCheck.MATCH -> - if (entry.overCap) { - val deleted = - (segmentDeleteForTest?.invoke(file) ?: file.delete()) || !file.exists() - // P1-2 终审:删除成功但目录项未确认持久 → 保留 manifest 条目 - // (可重试记录)并让本轮失败退避;绝不带着未确认删除推进状态。 - if (!deleted) { - false - } else if (!requireSpoolDirSync(dir)) { - logE( - "statistics tombstone over-cap segment deletion not durable; " + - "keeping entry: ${file.name}", - ) - roundOk = false - false - } else { - true - } - } else { - // 容量内:重试移回完整证据区(不超硬 cap 才允许) - val target = - File(dir, "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${entry.file}") - val fits = - quarantineEvidenceLocked(context).sumOf { it.length() } + file.length() <= MAX_QUARANTINE_BYTES - val renamed = fits && renameForTest(file, target) - // P1-2 终审:rename 成功但目录项未确认持久 → 保留 manifest 条目 - // (可重试记录)并让本轮失败退避;rename 可见时下一轮按消失条目 - // 路径 sync 确认后幂等移除。 - if (!renamed) { - false - } else if (!requireSpoolDirSync(dir)) { - logE( - "statistics tombstone evidence restore rename not durable; " + - "keeping entry: ${target.name}", - ) - roundOk = false - false - } else { - true - } - } - } - } - } - if (remaining.size == lines.size) return roundOk - try { - val newContent = remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件) - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") - return roundOk - } - // P1-2 终审:manifest 重写是目录项变更(严格 store)——写失败即保留全部条目 - // (不移除 manifest),并让本轮失败退避重试。 - summaryStore(manifestFile).write(newContent) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - roundOk = false - } - return roundOk - } - /** - * ack trash 状态机处置(P1-2,调用方持 lifecycleMutex)。trash 目录内的原子状态文件 - * [ACK_TRASH_STATE_FILE_NAME] 首行为 UNCOMMITTED/COMMITTED,后续为 mapping 行 - * (原名 → trash 名 + bytes + sha256)。规则(P1-1 修复): - * - 状态文件尚未写入(无 canonical 与 sidecar)且目录为空:staging 严格发生在状态写入 - * 成功之后,此时不可能有已 stage 的证据 → 空目录直接删除(无证据损失)。 - * - COMMITTED:唯一允许后台删除的状态——有界补删,失败下次再试。 - * - UNCOMMITTED:**一律**按 mapping+identity 回滚到原路径,绝不根据主 manifest 缺失推断 - * 已提交(普通 quarantine 证据从不在 manifest 中,缺失恒成立,旧推断会把未确认的证据 - * 误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚的损坏 sealed 会被扫描器重新 - * 隔离,ack 视失败但不丢证据)。mapping 必须全有或全无有效(P1-2:逐行解析、无重复、 - * 无穿越、与 trash 内证据文件集合完整对应),任一失败 → 整个 trash fail-closed 保留 - * (由 UI 作为 StuckAckEvidence 管理),绝不执行 delete/rollback/manifest 改动。 - * - 状态文件缺失/不可读/无效:非空 trash 保留并报告(fail-closed),绝不删除。 - * - * P1-2 终审:删除成功(空目录/COMMITTED)或回滚移动后目录项必须确认持久—— - * [syncDir] 非 OK 返回 false(调用方 [retryPendingCleanup] 令本轮退避重试,绝不推进); - * 删除返回 false 只记录(trash 本身就是可重试记录,下一轮再试)。 - * - * @return false 表示本轮存在目录项未确认持久的变更;其余情形(含删除失败、状态无效) - * 返回 true(保留可重试记录,不阻塞健康排空)。 - */ - private suspend fun handleAckTrashDir(dir: File, trash: File): Boolean { - val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) - val store = summaryStore(stateFile) - val content: String? = - if (stateFile.exists() || - File(trash, "$ACK_TRASH_STATE_FILE_NAME.new").exists() || - File(trash, "$ACK_TRASH_STATE_FILE_NAME.bak").exists() - ) { - try { - readMetadata(store, stateFile) - } catch (e: Exception) { - logE("statistics ack trash state unreadable; retaining trash: ${trash.name}", e) - null - } - } else if (listDir(trash)?.isEmpty() == true) { - // 崩溃于 stage 开始之前:trash 内没有任何证据文件(stage 是原子移动,空目录 - // = 无证据可保护),删除空目录无损失。枚举失败(listDir 返回 null)绝不当空 - // 目录——内容不可知时走下方 fail-closed 保留分支,绝不删除。stage 已开始或 - // 完成后崩溃(目录非空、状态未写)→ 同样走到保留分支。 - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics empty ack trash cleanup deferred: ${trash.name}") - } else if (!requireSpoolDirSync(dir)) { - logE("statistics empty ack trash deletion not durable: ${trash.name}") - return false - } - return true - } else { - null - } - if (content == null) { - logE("statistics ack trash state missing; retaining trash: ${trash.name}") - return true - } - val lines = content.lineSequence().filter { it.isNotBlank() }.toList() - return when (lines.firstOrNull()) { - ACK_STATE_COMMITTED -> { - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics ack trash cleanup deferred: ${trash.name}") - true - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable: ${trash.name}") - false - } else { - true - } - } - ACK_STATE_UNCOMMITTED -> { - // P1-2:全有或全无解析——任一损坏/重复/穿越/对应缺失都使整个 trash - // fail-closed 保留,绝不基于部分 mapping 做破坏性决策。 - val entries = parseAckMappingStrict(dir, trash, lines) - ?: run { - logE("statistics ack trash state mapping invalid; retaining trash: ${trash.name}") - return true - } - // P1-1:UNCOMMITTED 永远尝试回滚(identity 验证,目标被不同内容占用绝不覆盖)。 - val result = rollbackUncommittedTrash(dir, trash, entries) - if (!result.allResolved) { - logE( - "statistics ack trash rollback not fully resolved; " + - "retaining retryable trash: ${trash.name}", - ) - } - // P1-2 终审:回滚移动/删除的目录项未确认持久 → 本轮失败退避(trash 保留为 - // 可重试记录,下一轮按 identity 幂等完成);普通回滚失败(移动失败、槽位被 - // 占用等)保留记录并继续本轮,绝不阻塞健康排空。 - !result.syncFailed - } - else -> { - logE("statistics ack trash state invalid (${lines.firstOrNull() ?: ""}); retaining trash: ${trash.name}") - true - } - } - } /** * 未提交 trash 回滚结果(P1-2 终审):allResolved=false 表示有文件无法恢复(trash 保留 * 为可重试记录);syncFailed=true 表示存在目录项未确认持久的变更(本轮必须退避重试)。 */ - private data class TrashRollbackResult( - val allResolved: Boolean, - val syncFailed: Boolean, - ) - /** - * 未提交 trash 回滚(P1-2,调用方持 lifecycleMutex):按 mapping 逐条 identity 验证后 - * 恢复。trash 内文件必须仍与 mapping 身份(bytes+sha256)一致才允许移动;原槽位被不同 - * 内容占用时绝不覆盖(保留 trash 证据并 fail-closed);全部恢复成功才删除 trash 目录, - * 否则保留(递归容量统计计入占用)并报告。无法恢复的文件绝不删除。 - * - * P1-2 终审:每个移动/删除都是目录项变更——成功后 [syncDir] 非 OK 置 syncFailed - * (调用方本轮退避;变更可见时下一轮按 identity 幂等完成,崩溃后 trash 重现由状态机 - * 重放),绝不基于未确认状态声称已恢复。 - */ - private fun rollbackUncommittedTrash( - dir: File, - trash: File, - entries: List, - ): TrashRollbackResult { - var allResolved = true - var syncFailed = false - for (entry in entries) { - // 防御:mapping 名字必须是 spool 根/trash 内的合法单层文件名 - if (!isSafeEvidenceName(dir, entry.original) || !isSafeTrashName(trash, entry.trashName)) { - allResolved = false - logE("statistics ack trash mapping has unsafe names; retaining trash: ${trash.name}") - continue - } - val trashFile = File(trash, entry.trashName) - val original = File(dir, entry.original) - if (!trashFile.exists()) { - // mapping 有记录但 trash 中无此文件:文件从未被 stage(状态/映射写于 staging - // 之前)。原槽位同身份即视为已恢复;否则无法验证 → 保留。 - if (!(original.exists() && identityMatches(original, entry))) { - allResolved = false - logE("statistics ack trash rollback cannot verify ${entry.original}; retaining trash: ${trash.name}") - } - continue - } - if (!identityMatches(trashFile, entry)) { - allResolved = false - logE("statistics ack trash file identity mismatch; retaining evidence: ${entry.original}") - continue - } - when { - !original.exists() -> { - if (!atomicMoveForAck(trashFile, original)) { - allResolved = false - logE("statistics ack trash rollback move failed for ${entry.original}; evidence stays in ${trash.name}") - } else if (!requireSpoolDirSync(dir, trash)) { - logE("statistics ack trash rollback move not durable: ${entry.original}") - syncFailed = true - } - } - identityMatches(original, entry) -> { - // 原槽位已是同身份内容:trash 副本冗余,删除副本即可 - if (!(segmentDeleteForTest?.invoke(trashFile) ?: trashFile.delete())) { - allResolved = false - logE("statistics ack trash redundant copy deletion failed: ${entry.original}") - } else if (!requireSpoolDirSync(trash)) { - logE("statistics ack trash redundant copy deletion not durable: ${entry.original}") - syncFailed = true - } - } - else -> { - // 原槽位被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed - allResolved = false - logE("statistics ack trash rollback target occupied by different content; retaining evidence: ${entry.original}") - } - } - } - if (allResolved) { - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics ack trash deletion failed after successful rollback: ${trash.name}") - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable after rollback: ${trash.name}") - syncFailed = true - } - } - return TrashRollbackResult(allResolved, syncFailed) - } - /** P1-2:文件与 mapping 身份比对(bytes + 原始字节 SHA-256,绝不跟随符号链接)。 */ - private fun identityMatches(file: File, entry: AckMappingEntry): Boolean { - if (!file.isFile || file.length() != entry.bytes) return false - if (Files.isSymbolicLink(file.toPath())) return false - if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false - if (segmentReadErrorForTest?.invoke(file) == true) return false - return try { - sha256Hex(file.readBytes()) == entry.sha256 - } catch (e: Exception) { - false - } - } /** P1-4:未提交 ack trash 扫描结果:已知身份 + 是否存在无法完整严格解析/读取的 trash。 */ - private data class UncommittedTrashScan( - val known: Map>, - val hasUnknown: Boolean, - ) - /** - * P1-2:仍在未提交 trash 中的身份集合(原名 → bytes+sha256),供 manifest 条目的 stale - * 判定使用——身份仍在 trash 中时条目绝不能移除,否则会把未确认的证据误判为已提交而删除。 - * 符号链接目录不读取(不跟随)。调用方持 lifecycleMutex。 - * - * P1-4 fail-closed:返回结构化结果。任何**非空**、非明确 COMMITTED、无法完整严格解析/ - * 读取的 ack trash(状态缺失/不可读、首行非法、mapping 任一损坏/缺身份/不安全/重复、 - * trash 内存在 mapping 未覆盖的证据文件)都会让 [UncommittedTrashScan.hasUnknown] = true, - * 调用方据此保守处置(stale 清理整轮跳过、scanner 不按 MISMATCH 移除条目)——该 trash - * 可能正持有已知集合之外的证据身份,绝不基于残缺信息做破坏性决策。mapping 完整严格 - * 解析成功时按全有或全无计入 [UncommittedTrashScan.known](与 [parseAckMappingStrict] - * 一致)。空目录不可能持有证据(stage 是原子移动,见 [handleAckTrashDir]),不贡献 - * 身份也不置 unknown。 - */ - private suspend fun scanUncommittedTrashHolds(context: Context): UncommittedTrashScan { - val dir = spoolDir(context) - if (!dir.isDirectory) return UncommittedTrashScan(emptyMap(), false) - val result = HashMap>() - var hasUnknown = false - // P1-5 fail-closed:spool 根枚举失败(null)时,任何 ack trash 目录都可能存在但 - // 不可见——身份持有情况完全不可知,置 unknown 阻止调用方做 stale/删除类决策。 - val rootFiles = listDir(dir) - if (rootFiles == null) { - logE( - "statistics spool directory enumeration failed; treating uncommitted ack trash " + - "state as unknown", - ) - return UncommittedTrashScan(emptyMap(), true) - } - rootFiles - .filter { f -> - f.isDirectory && - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) - } - .forEach { trash -> - val files = listDir(trash) - if (files == null) { - // 目录枚举失败:内部证据状态不可知 → fail-closed - hasUnknown = true - return@forEach - } - if (files.isEmpty()) return@forEach - val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) - val content = try { - readMetadata(summaryStore(stateFile), stateFile) - } catch (e: Exception) { - logE("statistics ack trash state unreadable during scan; treating as unknown", e) - null - } ?: run { - // 状态缺失/不可读:非空 trash 中的证据身份不可知 - hasUnknown = true - return@forEach - } - val lines = content.lineSequence().filter { it.isNotBlank() }.toList() - when (lines.firstOrNull()) { - // 显式 COMMITTED:删除已授权,无回滚保护义务,不贡献身份也不置 unknown - ACK_STATE_COMMITTED -> Unit - ACK_STATE_UNCOMMITTED -> { - val entries = parseAckMappingStrict(dir, trash, lines) - if (entries == null) { - // 全有或全无解析失败:该 trash 可能持有任意身份的证据 - hasUnknown = true - } else { - entries.forEach { result[it.original] = it.bytes to it.sha256 } - } - } - // 首行非法/内容为空:无法判定状态 → fail-closed - else -> hasUnknown = true - } - } - return UncommittedTrashScan(result, hasUnknown) - } - private fun sha256Hex(bytes: ByteArray): String { + internal fun sha256Hex(bytes: ByteArray): String { val digest = MessageDigest.getInstance("SHA-256").digest(bytes) return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } } - /** - * Room insert with a genuinely bounded lifecycle. The write runs on the dedicated single-thread - * insert worker; the drain waits at most [insertTimeoutMs] and on timeout releases the lifecycle - * lock WITHOUT joining the worker (SQLite can ignore interrupts forever, and an unbounded join - * under the lock would freeze every append/snapshot/replay). - * - * Safety across the snapshot/restore barrier (P1-2) has two halves: - * - Generation fencing: the task captures the session generation at submission and atomically - * re-checks it together with [exclusiveBarrierActive] before touching Room. A task that runs - * after a restore bumped the generation skips entirely (its durable segment belongs to the - * pre-restore state that restore replaces). - * - Active-insert registry: the same atomic section registers the insert BEFORE Room is - * entered and the `finally` unregisters it. A snapshot/restore therefore provably waits (or - * bounded-fails) for every insert that already passed the fence, instead of merely relying on - * a check-then-act race that could let an old DAO write into replaced files. - */ - private suspend fun insertSafely( - context: Context, - dao: TokenStatsDao, - request: TokenStatRequestContext, - ): Boolean { - val generation = synchronized(stateLock) { sessionGeneration } - val task = FutureTask { - val registered = synchronized(stateLock) { - if (sessionGeneration != generation || exclusiveBarrierActive) { - false - } else { - activeInserts[request.eventId] = generation - true - } - } - if (!registered) return@FutureTask - try { - runBlocking { TokenStatsLedger.recordWith(context, dao, request) } - synchronized(stateLock) { - insertionWaiters.remove(request.eventId)?.complete(Unit) - } - } finally { - synchronized(stateLock) { activeInserts.remove(request.eventId) } - } - } - try { - insertExecutor.execute(task) - } catch (e: RejectedExecutionException) { - logE("statistics insert worker saturated; durable segment retained: ${request.eventId}", e) - return false - } - return try { - task.get(insertTimeoutMs, TimeUnit.MILLISECONDS) - true - } catch (e: TimeoutException) { - // The worker may legitimately outlive this wait; the durable segment stays for a later - // drain and the generation fence keeps a late write out of a restored database. - task.cancel(true) - logE("statistics Room insert timed out; durable segment retained: ${request.eventId}", e) - false - } catch (e: ExecutionException) { - logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e.cause ?: e) - false - } catch (e: CancellationException) { - logE("statistics Room insert cancelled; durable segment retained: ${request.eventId}", e) - false - } catch (e: Throwable) { - logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e) - false - } - } - /** - * Database preparation with bounded single-flight semantics: at most one resolution runs at a - * time, and a timed-out resolution is reused by later drain cycles instead of spawning another - * thread (P2-1). A permanently wedged open cannot recover without a restart, but it can never - * block the lifecycle lock or leak threads. - */ - private fun resolveDaoSafely(context: Context): TokenStatsDao? { - val task = synchronized(stateLock) { - pendingDaoTask?.takeIf { !it.isDone } - ?: FutureTask { - (TokenStatsLedger.databaseProvider?.invoke(context) ?: AppDatabase.getDatabase(context)) - .tokenStatsDao() - }.also { created -> - pendingDaoTask = created - try { - databaseExecutor.execute(created) - } catch (e: RejectedExecutionException) { - pendingDaoTask = null - throw e - } - } - } - return try { - task.get(prepareTimeoutMs, TimeUnit.MILLISECONDS) - } catch (e: TimeoutException) { - task.cancel(true) - logE("statistics database preparation timed out; durable segments retained", e) - null - } catch (e: RejectedExecutionException) { - logE("statistics database preparation rejected; durable segments retained", e) - null - } catch (e: ExecutionException) { - logE("statistics database preparation failed; durable segments retained", e.cause ?: e) - null - } catch (e: Throwable) { - logE("statistics database preparation failed; durable segments retained", e) - null - } - } - private fun spoolDir(context: Context) = File(context.filesDir, SPOOL_DIR_NAME) + internal fun spoolDir(context: Context) = SpoolFileSystem.spoolDir(context, SPOOL_DIR_NAME) /** * 安全关键路径的目录枚举统一入口(调用方持 lifecycleMutex):注入缝为 null 时与 @@ -3597,9 +1261,8 @@ internal object TokenStatSpool { * 失败”,必须原样传递,绝不能回退到真实枚举(否则 fail-closed 注入失效,测试无法覆盖 * 该失败分支)。 */ - private fun listDir(dir: File): Array? { - val seam = directoryListingForTest - return if (seam != null) seam(dir) else dir.listFiles() + internal fun listDir(dir: File): Array? { + return SpoolFileSystem.listDirectory(dir, directoryListingForTest) } /** @@ -3609,38 +1272,8 @@ internal object TokenStatSpool { * 调用方投影必拒绝,无需精确值;目录不存在返回 0;遍历失败按超限处理(fail-closed, * 绝不因扫描失败而低估容量)。文件数受总 cap 约束有界,无需维护缓存。 */ - private fun totalSpoolBytes(dir: File, cap: Long): Long { - if (!dir.isDirectory) return 0L - val saturated = if (cap == Long.MAX_VALUE) cap else cap + 1L - var total = 0L - val visitor = object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - if (attrs.isRegularFile) { - total += attrs.size() - if (total < 0L || total > cap) { - total = saturated - return FileVisitResult.TERMINATE - } - } - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { - total = saturated - return FileVisitResult.TERMINATE - } - } - return try { - Files.walkFileTree( - dir.toPath(), - EnumSet.noneOf(FileVisitOption::class.java), - Int.MAX_VALUE, - visitor, - ) - total - } catch (e: IOException) { - saturated - } + internal fun totalSpoolBytes(dir: File, cap: Long): Long { + return SpoolFileSystem.totalBytes(dir, cap) } /** @@ -3648,9 +1281,8 @@ internal object TokenStatSpool { * (至少为数据保留一条完整行 [MAX_LINE_BYTES] 的空间,避免准入区间为负),生产值 * 恒等于 [METADATA_RESERVE_BYTES]。 */ - private fun dataAdmissionMaxBytes(cap: Long): Long { - val reserve = minOf(METADATA_RESERVE_BYTES, cap - MAX_LINE_BYTES).coerceAtLeast(0L) - return (cap - reserve).coerceAtLeast(0L) + internal fun dataAdmissionMaxBytes(cap: Long): Long { + return SpoolWriter.dataAdmissionMaxBytes(cap, METADATA_RESERVE_BYTES, MAX_LINE_BYTES) } /** @@ -3661,9 +1293,14 @@ internal object TokenStatSpool { * 内所有元数据读写都持 lifecycleMutex,任意时刻至多一个 AtomicRestoreMarkerStore 写进行中 * (Atomic tmp 唯一文件并发数 = 1),因此按单写者投影即可证明全部实际字节恒 ≤ 总上限。 */ - private fun metadataWriteBudgetExceeded(context: Context, contentBytes: Int): Boolean { + internal fun metadataWriteBudgetExceeded(context: Context, contentBytes: Int): Boolean { val cap = totalSpoolMaxBytesForTest ?: TOTAL_SPOOL_MAX_BYTES - return totalSpoolBytes(spoolDir(context), cap) + contentBytes.toLong() * METADATA_COPY_COUNT > cap + return SpoolWriter.metadataWriteBudgetExceeded( + currentBytes = totalSpoolBytes(spoolDir(context), cap), + contentBytes = contentBytes, + metadataCopyCount = METADATA_COPY_COUNT, + cap = cap + ) } internal fun shutdownWriterForTest() = synchronized(stateLock) { @@ -3714,7 +1351,7 @@ internal object TokenStatSpool { internal fun drainScheduledForTest(): Boolean = synchronized(stateLock) { drainScheduled } internal fun initialDrainWaiterCountForTest(): Int = synchronized(stateLock) { initialDrainWaiters.size } - private fun logE(message: String, error: Throwable? = null) { + internal fun logE(message: String, error: Throwable? = null) { try { if (error == null) AppLogger.e(TAG, message) else AppLogger.e(TAG, message, error) } catch (_: Throwable) { diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt new file mode 100644 index 000000000..1da5dff58 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt @@ -0,0 +1,2539 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class CleanupReliabilityTest : TokenStatReliabilityTestBase() { + @Test + fun `quarantine export and delete file work never runs on the caller main thread`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 超过 16MiB 的证据:满上限 + 额外段(复制/fsync 足够大,能卡住 Main) + RandomAccessFile(File(spool, "quarantine_existing_sealed_1.jsonl"), "rw").use { + it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) + } + File(spool, "quarantine_existing_sealed_2.jsonl").writeText("legacy-over-cap\n") + + val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } + Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) + val ioThreads = ConcurrentHashMap.newKeySet() + val previousIo = TokenStatSpool.ioDispatcher + TokenStatSpool.ioDispatcher = object : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + Dispatchers.IO.dispatch(context) { + ioThreads += Thread.currentThread().name + block.run() + } + } + } + try { + withContext(Dispatchers.Main) { + val exported = + TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertTrue(exported.size >= 2) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exported.map { it.name }.toSet()) + } + assertTrue("file I/O must actually dispatch", ioThreads.isNotEmpty()) + assertFalse( + "evidence file I/O must never run on the main thread: $ioThreads", + ioThreads.any { it == "test-main-thread" } + ) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + } finally { + TokenStatSpool.ioDispatcher = previousIo + Dispatchers.resetMain() + mainExecutor.shutdown() + } + } + + @Test + fun `quarantine summary publishes atomically via fallback when atomic move unsupported`() = + runBlocking { + val previous = TokenStatSpool.quarantineAtomicMoveForTest + // 强制 ATOMIC_MOVE 不支持(P1-1):必须走 old/new/backup 回退且结果完整 + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + // 目标已存在(旧摘要)时回退协议必须保留旧值直到新值就绪 + File(spool, "quarantine_summary.jsonl").writeText("{\"old\":\"value\"}\n") + File(spool, "sealed_2.jsonl").writeText("{forced-fallback-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertEquals(2, summary.recordCount) + val text = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest record must survive the fallback publish", text.contains("sealed_2.jsonl")) + assertTrue("old record must be preserved in the rebuilt summary", text.contains("\"old\":\"value\"")) + assertTrue(text.contains("sha256")) + assertFalse("fallback must not leave staged sidecars", File(spool, "quarantine_summary.jsonl.new").exists()) + assertFalse("fallback must not leave backup sidecars", File(spool, "quarantine_summary.jsonl.bak").exists()) + } finally { + TokenStatSpool.quarantineAtomicMoveForTest = previous + } + } + + @Test + fun `twice-rename-failure original sealed is managed evidence exportable and ack-deleted`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{twice-rename-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-evidence")) + "\n") + // P1-3:两次重命名都失败(进证据区 + pending-delete 都失败)→ tombstone 原段 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-evidence") + // 等 drain 完成损坏段处置:tombstone 记录落盘(原段保留在磁盘上, + // 不能等它消失——受管失败段本就不消失) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "tombstone must be recorded for the twice-rename-failed segment", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + assertEquals(1, database.tokenStatsDao().countEvents()) + // tombstoned 原 sealed 必须作为 managed evidence 参与计数/字节 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "original sealed must appear as managed evidence", + evidence.any { it.name == "sealed_1.jsonl" }, + ) + // 导出包含原文件(原文件名,身份可追溯)并附 manifest + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertTrue(exported.any { it.name == "sealed_1.jsonl" }) + assertTrue(exported.any { it.name == "quarantine_skip_manifest.jsonl" }) + assertTrue( + "exported managed evidence must retain the corrupt content", + exported.single { it.name == "sealed_1.jsonl" }.readText().contains("twice-rename-bad"), + ) + // ack 确认删除:按 identity 删除原文件并移除对应 manifest 记录 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) + assertFalse("acked managed evidence must be deleted", File(spool, "sealed_1.jsonl").exists()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertFalse( + "manifest entry must be removed after ack", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + assertTrue(TokenStatSpool.quarantineEvidence(context).isEmpty()) + // 健康继续 + TokenTrackingAIService.recordSafely(context, request("after-evidence-ack")) + awaitEvent("after-evidence-ack") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + } + + @Test + fun `export recovers canonical summary and manifest when only sidecars remain`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P2-2:崩溃窗口——canonical 缺失,内容只在 .new sidecar(完整、已 fsync) + val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" + File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) + val manifestContent = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":2,\"sha256\":\"def\",\"overCap\":true}\n" + File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals( + summaryContent, + exported.single { it.name == "quarantine_summary.jsonl" }.readText(), + ) + assertEquals( + manifestContent, + exported.single { it.name == "quarantine_skip_manifest.jsonl" }.readText(), + ) + // canonical 也已被恢复,后续信息/ack 不再依赖 sidecar + assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) + assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) + } + } + + @Test + fun `evidence info and ack recover manifest and summary from new sidecar without export`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val oldBody = "{sidecar-new-only\n" + File(spool, "sealed_1.jsonl").writeText(oldBody) + val sha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestContent = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha\",\"overCap\":false}\n" + // 崩溃窗口:canonical 缺失,内容只在 .new(完整、已 fsync) + File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) + val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" + File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) + + // 不先 export:直接调用 evidence/info/ack + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "managed evidence must be visible after sidecar recovery", + evidence.any { it.name == "sealed_1.jsonl" }, + ) + val info = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("summary info must recover from sidecar", info) + assertEquals(1, info!!.recordCount) + // canonical 已恢复且 sidecar 身份被清理 + assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) + assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) + assertFalse(File(spool, "quarantine_skip_manifest.jsonl.new").exists()) + assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) + // ack 按身份删除 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "manifest entry must be removed after ack", + File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_1.jsonl"), + ) + } + } + + @Test + fun `each export uses its own empty directory and stale exports never leak`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_first_sealed_1.jsonl").writeText("first-bad\n") + val exportA = File(root, "token_stats_quarantine_A") + val exportedA = TokenStatSpool.exportQuarantineEvidence(context, exportA) + assertTrue(exportedA.any { it.name.startsWith("quarantine_first_") }) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exportedA.map { it.name }.toSet()) + // 第二次导出到新目录:只含本次证据,上一次的残留绝不混入/冒充 + File(spool, "quarantine_second_sealed_2.jsonl").writeText("second-bad\n") + val exportB = File(root, "token_stats_quarantine_B") + val exportedB = TokenStatSpool.exportQuarantineEvidence(context, exportB) + assertTrue(exportedB.any { it.name.startsWith("quarantine_second_") }) + assertFalse( + "a previous export must never leak into the new export directory", + exportB.listFiles().orEmpty().any { it.name.startsWith("quarantine_first_") }, + ) + assertFalse( + "previous export must never be reported as this run's result", + exportedB.any { it.name.startsWith("quarantine_first_") }, + ) + } + + @Test + fun `export into a non-empty destination is refused without touching its content`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_x_sealed_1.jsonl").writeText("bad\n") + val dest = File(root, "token_stats_quarantine_existing").apply { mkdirs() } + val userFile = File(dest, "user-notes.txt").apply { writeText("do not touch") } + try { + TokenStatSpool.exportQuarantineEvidence(context, dest) + fail("export into a non-empty destination must be refused") + } catch (e: IOException) { + assertTrue("refusal must name the reason", e.message!!.contains("not empty")) + } + assertEquals("do not touch", userFile.readText()) + assertFalse( + "no evidence may be written into a refused destination", + dest.listFiles().orEmpty().any { it.name.startsWith("quarantine_") }, + ) + } + + @Test + fun `segment read failure keeps managed entries and ack refuses until identity is readable`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{unreadable-bad\n") + // 正常建立受管失败段(重命名失败 → tombstone) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + + // 段原始字节读取失败(身份校验 UNREADABLE):受管段不处理、entry 保留 + TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_1.jsonl" } + TokenStatSpool.replay(context) + delay(700) + assertTrue( + "unreadable managed segment must be skipped, never processed", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue( + "manifest entry must be retained for the unreadable segment", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // evidence 列表不暴露身份不可校验的受管段(不可安全导出/ack) + assertTrue( + TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, + ) + // ack 不能成功 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail while the segment identity is unreadable") + } catch (e: IOException) { + assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) + } + assertTrue(File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // 恢复后正常:ack 按身份删除并移除记录 + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "entry must be removed after a successful ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } finally { + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack manifest read failure preserves quarantine evidence managed evidence and manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // quarantine area 完整证据(无 manifest 记录)+ 受管失败段(重命名失败 → tombstone) + File(spool, "quarantine_area_sealed_1.jsonl").writeText("area-bad\n") + File(spool, "sealed_2.jsonl").writeText("{managed-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // manifest 不可读 → 整个 ack 失败:quarantine + managed + manifest 全部保留 + TokenStatSpool.metadataReadErrorForTest = { file -> + file.name == "quarantine_skip_manifest.jsonl" + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("quarantine_area_sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail while the manifest is unreadable") + } catch (e: IOException) { + } + assertTrue( + "quarantine evidence must survive a failed ack", + File(spool, "quarantine_area_sealed_1.jsonl").exists(), + ) + assertTrue( + "managed evidence must survive a failed ack", + File(spool, "sealed_2.jsonl").exists(), + ) + assertTrue( + "manifest entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + assertTrue( + "no ack trash directory may be left behind", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } finally { + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack with later unreadable managed identity keeps earlier match and all entries`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{first-bad\n") + File(spool, "sealed_2.jsonl").writeText("{second-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 后一个段身份不可校验(UNREADABLE)→ 整个 ack 失败:前一个 MATCH 也不删除 + TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_2.jsonl" } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail when any managed identity is unreadable") + } catch (e: IOException) { + assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) + } + assertTrue( + "earlier matched segment must not be deleted on a partial failure", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue(File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "both entries must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // 恢复后一次 ack 按身份删除两个段并移除两条记录 + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_2.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + } finally { + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack staging rename failure rolls back staged renames and keeps manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rollback-a\n") + File(spool, "sealed_2.jsonl").writeText("{rollback-b\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 第 2 个文件的 stage rename 失败 → 第 1 个已 stage 的文件必须回滚, + // manifest 不改;回滚 rename 的目标是 spool 根目录,不受注入影响 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == "sealed_2.jsonl" -> false + else -> null + } + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must fail when staging a rename fails") + } catch (e: IOException) { + assertTrue("ack must report the staging failure", e.message!!.contains("stage")) + } + assertTrue( + "staged file must be rolled back after a failed rename", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue(File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "no trash directory may remain after rollback", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + assertTrue( + "both entries must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // 恢复真实 rename 后 ack 成功:按身份删除两个段并移除两条记录 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_2.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ack manifest write failure rolls back all staged files and keeps old manifest`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val managed = File(spool, "sealed_1.jsonl").apply { writeText("{managed-bad\n") } + val quarantine = + File(spool, "quarantine_area_sealed_2.jsonl").apply { writeText("area-bad\n") } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val oldManifest = + "{\"file\":\"${managed.name}\",\"bytes\":${managed.length()}," + + "\"sha256\":\"${sha256Hex(managed.readBytes())}\",\"overCap\":false}\n" + manifest.writeText(oldManifest) + TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(managed.name, quarantine.name), + ) + fail("ack must fail when the manifest cannot be published") + } catch (e: IOException) { + assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) + } finally { + TokenStatSpool.metadataWriteErrorForTest = null + } + assertTrue("managed evidence must be restored", managed.isFile) + assertTrue("quarantine evidence must be restored", quarantine.isFile) + assertEquals("old manifest must remain byte-for-byte intact", oldManifest, manifest.readText()) + assertTrue( + "no trash directory may remain after a successful rollback", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } + } + + @Test + fun `ack rollback move with not durable dir sync keeps uncommitted trash and maintenance recovers it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText("{rollback-sync-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + var calls = 0 + try { + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + TokenStatSpool.segmentRenameForTest = null + // 阶段 2:manifest 重写失败触发回滚;回滚 move 的目录项 sync(第 7 次: + // 1 次 manifest 严格读取 + 1 次 trash 创建 + 2 次暂存 + 2 次状态写入) + // 失败 → trash 保留 UNCOMMITTED 状态、上层失败,绝不静默(P2) + TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 7) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail when the rollback dir sync is not OK") + } catch (e: IOException) { + assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained after a not-durable rollback", 1, trashDirs.size) + val state = File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue( + "state must remain UNCOMMITTED for maintenance rollback", + state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + // 回滚 move 已可见(证据回到原路径)但目录项未确认:mapping 仍持有身份, + // 维护按状态机幂等完成 + assertTrue("evidence is back at its original path", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 3:恢复后维护按 UNCOMMITTED + mapping 完成回滚并删除 trash;损坏 + // sealed 随后被扫描器重新隔离为完整证据(与 ack 崩溃窗口协议一致) + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be resolved by maintenance after recovery", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val body = "{rollback-sync-bad\n" + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < evidenceDeadline && + TokenStatSpool.quarantineEvidence(context).none { it.readText() == body } + ) { + delay(20) + } + assertEquals( + "evidence must be re-quarantined exactly once after the rollback", + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `committed ack trash residue counts into the total cap and maintenance cleans it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousDelete = TokenStatSpool.spoolDeleteForTest + try { + // 大证据文件:ack 后留在 trash(删除被强制失败),必须计入总容量 + val evidence = File(spool, "quarantine_trash_cap_sealed_1.jsonl") + RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB,28KiB 残留 + // 证据已让每次 append 的递归投影超限——旧实现只数顶层会放行到实际 36KiB + TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 + TokenStatSpool.spoolDeleteForTest = { false } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("committed trash must remain when deletion is forced to fail", 1, trashDirs.size) + assertEquals( + "commit flip must be persisted in the trash state file", + TokenStatSpool.ACK_STATE_COMMITTED + "\n", + File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).readText(), + ) + + // 行先于失败 DAO 生成(行生成需要真实价格读取);DAO 只负责排空失败 + val lines = (0 until 200).map { index -> + line(request("trash-cap-$index")) to "trash-cap-$index" + } + // DAO 永久失败 → sealed 段只增不减;递归总容量必须计入 trash 残留 + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertEquals( + "every append must be refused while the trash residue holds the admission budget: $rejected", + lines.size, + rejected, + ) + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + fun recursiveTotal(): Long = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "recursive total including trash must never exceed the cap: ${recursiveTotal()}", + recursiveTotal() <= cap, + ) + // 冻结断言:拒绝后不再发布任何字节 + val frozen = recursiveTotal() + repeat(10) { + try { + TokenStatSpool.append(context, lines[0].first, "refused-trash-$it") + fail("append after trash-inclusive cap must keep failing") + } catch (e: TokenStatsPersistenceException) { + } + } + assertEquals(frozen, recursiveTotal()) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 维护补删恢复:删除恢复后 replay 清掉 committed trash + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.replay(context) + val cleanupDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < cleanupDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "committed trash must be removed by maintenance once deletion works", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + // DAO 恢复后排空与 append 都恢复正常 + TokenStatsLedger.databaseProvider = { database } + assertTrue( + TokenStatSpool.append( + context, + line(request("after-trash-recovery")), + "after-trash-recovery", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-trash-recovery") + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.spoolDeleteForTest = previousDelete + } + } + } + + @Test + fun `ack refuses when trash state metadata would push the total over the cap`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + try { + // 大量小证据文件 → mapping 状态文件较大;cap 只留 4KiB 头部空间, + // 4 槽位最坏投影(mapping ~10KiB × 4)必然超限 + val files = (0 until 60).map { index -> + File(spool, "quarantine_many_$index.jsonl").apply { writeText("bad-$index\n") } + } + val totalNow = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + TokenStatSpool.totalSpoolMaxBytesForTest = totalNow + 4 * 1024 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, files.map { it.name }.toSet()) + fail("ack must fail when the trash state metadata does not fit the total cap") + } catch (e: IOException) { + } + // 全部证据仍在原位、没有 trash 目录残留、总量不超限(stage 已回滚) + files.forEach { assertTrue("evidence must stay in place: ${it.name}", it.exists()) } + assertTrue( + "no trash directory may remain after the refused ack", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + assertTrue( + "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L), + ) + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `ack staging failure with rollback failure keeps uncommitted trash and maintenance recovers it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rb-fail-a\n") + File(spool, "sealed_2.jsonl").writeText("{rb-fail-b\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + + // 第 2 个文件 stage 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == "sealed_2.jsonl" -> false + to.parentFile?.name != null && + !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && + to.name == "sealed_1.jsonl" -> false + else -> null + } + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "sealed_2.jsonl"), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained after rollback failure", 1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("staged evidence must stay in trash", File(trash, "sealed_1.jsonl").exists()) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertTrue("sealed_2 must stay in place (stage never happened)", File(spool, "sealed_2.jsonl").exists()) + assertTrue( + "trash state must be UNCOMMITTED with a mapping", + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + .readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + assertTrue( + "manifest entry must remain", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertTrue( + "manifest entry must remain", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + + // replay 维护(rename 仍被注入失败):不删 trash、不删证据、manifest 条目保留 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must never delete uncommitted trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive maintenance", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertFalse( + "sealed_2 was restored to the evidence area by maintenance", + File(spool, "sealed_2.jsonl").exists(), + ) + + // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚并自愈 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be gone after a successful maintenance rollback", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + awaitManifestWithout(spool, "sealed_1.jsonl") + awaitManifestWithout(spool, "sealed_2.jsonl") + // 两份证据都回到完整证据区(可导出/可 ack) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.name.contains("sealed_1.jsonl") }) + assertTrue(evidence.any { it.name.contains("sealed_2.jsonl") }) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `crash window with published manifest rolls back uncommitted trash and scanner re-quarantines`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 手工构造崩溃窗口:主 manifest 已发布(不含该身份),但 commit 标记未写。 + // P1-1:UNCOMMITTED 绝不根据 manifest 缺失推断已提交——必须回滚证据, + // 回滚后的损坏 sealed 会被扫描器重新隔离(ack 视失败但不丢证据)。 + val body = "{crash-window-bad\n" + val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", + ) + // manifest 不存在 = 条目已全部移除(旧实现会据此误判 committed 并删除证据) + TokenStatSpool.replay(context) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && trash.exists()) delay(20) + assertFalse( + "maintenance must roll back uncommitted crash-window trash", + trash.exists(), + ) + // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据,绝不丢失 + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var reQuarantined = false + while (System.nanoTime() < evidenceDeadline && !reQuarantined) { + reQuarantined = TokenStatSpool.quarantineEvidence(context).any { + it.name.contains("sealed_1.jsonl") && it.readText() == body + } + if (!reQuarantined) delay(20) + } + assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) + } + } + + @Test + fun `partially corrupt ack trash mapping is fail-closed and maintenance retains everything`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{p12-a\n" + val body2 = "{p12-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + // 首行有效 mapping + 一行损坏 mapping:mapNotNull 会静默丢弃损坏行, + // 只回滚 1 个文件并删除 trash——旧实现会丢失第 2 份证据(P1-2) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + TokenStatSpool.replay(context) + delay(700) + assertTrue("partially corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse("no rollback may happen from a partial mapping", File(spool, "sealed_1.jsonl").exists()) + // UI 可见:作为 stuck 受管证据列出 + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + + // 修复为重复 mapping(同一原名两条)→ 仍然 fail-closed 保留 + val lineA = + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + lineA, + ) + TokenStatSpool.replay(context) + delay(700) + assertTrue("duplicate mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + + // 完整修复 mapping(两份证据都被覆盖)→ 维护回滚并自愈 + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) + // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + } + } + + @Test + fun `partial mapping with unreadable trash enumeration is fail-closed and manifest stays verbatim`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{enum-null-a\n" + val body2 = "{enum-null-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha1\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + // 首行有效 mapping + 一行损坏:全有或全无解析必然失败;trash 枚举再失败时, + // 即使 mapping 已覆盖可见证据,未枚举的证据也无法排除 → 仍必须 fail-closed + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) + File(spool, "sealed_9.jsonl").writeText(line(request("enum-null-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + awaitEvent("enum-null-healthy") + delay(700) + assertTrue("trash must be retained while its enumeration fails", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse( + "no rollback may happen from a partial mapping with failed enumeration", + File(spool, "sealed_1.jsonl").exists(), + ) + assertFalse("no un-enumerated evidence may be deleted", File(spool, "sealed_2.jsonl").exists()) + assertEquals( + "manifest must be preserved verbatim", + manifestLine, + safeManifestText(manifest), + ) + // P1-6 fail-closed:stuck 证据枚举走同一 seam——枚举失败时 UI 查询必须 + // 明确抛错,绝不能返回部分/空列表误导用户删除 + try { + TokenStatSpool.stuckAckTrashEvidence(context) + fail("stuck ack trash evidence must fail while trash enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + try { + TokenStatSpool.stuckAckTrashBytes(context) + fail("stuck ack trash bytes must fail while trash enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 有界:重复维护轮不改写 manifest、不处置 trash + TokenStatSpool.replay(context) + delay(700) + assertTrue(trash.exists()) + assertEquals( + "repeated maintenance rounds must not rewrite the manifest", + manifestLine, + safeManifestText(manifest), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复枚举 + 完整 mapping → 维护回滚并自愈(cleanup 成功) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once enumeration and mapping recover", trash.exists()) + // P1-6:枚举成功且无 trash 时才是真正的空列表 + assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var evidence: List = emptyList() + while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { + evidence = TokenStatSpool.quarantineEvidence(context) + if (evidence.size != 2) delay(20) + } + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + } + } + + @Test + fun `spool root enumeration failure makes trash state unknown and blocks stale removal`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 消失原件的 manifest 条目:根枚举失败时无法证明旧身份不被未枚举的 trash 持有 + val oldBody = "{root-enum-stale\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + // UNCOMMITTED trash 真实持有该身份(根枚举失败时完全不可见) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + // 健康段:根枚举失败期间 drain 必须 fail-closed 退避——段保留、绝不入 Room + File(spool, "sealed_9.jsonl").writeText(line(request("root-enum-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + delay(700) + assertNull( + "no segment may drain while the root enumeration fails", + database.tokenStatsDao().getEvent("root-enum-healthy"), + ) + assertTrue("healthy segment must be preserved", File(spool, "sealed_9.jsonl").exists()) + assertEquals( + "stale removal must be blocked while the root enumeration fails", + manifestLine, + safeManifestText(manifest), + ) + assertTrue( + "trash must be retained while the root enumeration fails", + trash.exists(), + ) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + // 有界:重复维护轮保持原样 + TokenStatSpool.replay(context) + delay(700) + assertEquals(manifestLine, safeManifestText(manifest)) + assertTrue(trash.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 枚举恢复:健康段排空;身份确实被 trash 持有 → 回滚后按 MATCH 处置,条目最终移除 + TokenStatSpool.replay(context) + awaitEvent("root-enum-healthy") + awaitManifestWithout(spool, "sealed_1.jsonl") + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once enumeration recovers", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var restored = false + while (System.nanoTime() < evidenceDeadline && !restored) { + restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } + if (!restored) delay(20) + } + assertTrue("held evidence must be restored after recovery", restored) + } + } + + @Test + fun `maintenance defers rollback while trash enumeration fails and cleanup succeeds after the seam recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{enum-recovery-bad\n" + val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", + ) + // mapping 完全有效也必须在枚举失败时 fail-closed:无法证明没有未枚举的证据 + // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) + File(spool, "sealed_9.jsonl").writeText(line(request("enum-recovery-healthy")) + "\n") + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + awaitEvent("enum-recovery-healthy") + delay(700) + assertTrue( + "valid mapping must still be fail-closed while enumeration fails", + trash.exists(), + ) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertFalse( + "no rollback may happen while enumeration fails", + File(spool, "sealed_1.jsonl").exists(), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam → rollback cleanup 成功:trash 删除、证据回到原槽位、被扫描器隔离 + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be deleted after the successful rollback", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var reQuarantined = false + while (System.nanoTime() < evidenceDeadline && !reQuarantined) { + reQuarantined = TokenStatSpool.quarantineEvidence(context).any { + it.isFile && it.readText() == body + } + if (!reQuarantined) delay(20) + } + assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) + } + } + + @Test + fun `seal publish with active delete failure is rolled back and later recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("delete-fail-a")) + val lineB = line(request("delete-fail-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var failures = 0 + TokenStatSpool.sealActiveDeleteForTest = { + failures += 1 + false + } + try { + // seal:createLink 成功 → 删除 active 失败 → 回滚链接 → seal 失败 + // → append 明确失败(B 未发布、无伪 durable) + assertFalse( + "append must fail when the post-publish active delete fails", + TokenStatSpool.append(context, lineB, "delete-fail-b"), + ) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + } + assertEquals(1, failures) + // 回滚成功:无 sealed 残留;active 保持原内容 + assertTrue( + "rolled-back seal must leave no sealed residue", + spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }, + ) + assertEquals(lineA + "\n", File(spool, "active.jsonl").readText()) + // 恢复后:既有事件恰一次入 Room,被拒绝的 B 从未发布 + TokenStatSpool.replay(context) + awaitEvent("delete-fail-a") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue( + "append must succeed after the delete failure recovers", + TokenStatSpool.append(context, lineB, "delete-fail-b"), + ) + awaitEvent("delete-fail-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink unsupported falls back to atomic no-replace copy publish`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("copy-fallback-a")) + val lineB = line(request("copy-fallback-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + // 停掉后台 writer:seal 在 append 内同步完成(copy 回退),断言不被并发 drain 干扰 + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal via the copy fallback and succeed", + TokenStatSpool.append(context, lineB, "copy-fallback-b"), + ) + } finally { + TokenStatSpool.sealHardLinkForTest = null + } + // copy 发布成功:sealed_1 = active 原内容,active = 新事件 + assertEquals(lineA + "\n", File(spool, "sealed_1.jsonl").readText()) + assertEquals(lineB + "\n", File(spool, "active.jsonl").readText()) + // 恢复 writer 后全部事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("copy-fallback-a") + awaitEvent("copy-fallback-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("copy-fallback-a", "copy-fallback-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback crash window content duplicate is recovered and drains once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("copy-window-a")) + val lineB = line(request("copy-window-b")) + val lineC = line(request("copy-window-c")) + try { + val content = lineA + "\n" + lineB + "\n" + // 模拟 copy 回退崩溃窗口:sealed_1 复制完成、active 删除未发生 + // (两个独立 inode 同内容) + File(spool, "active.jsonl").writeText(content) + File(spool, "sealed_1.jsonl").writeText(content) + TokenStatSpool.MAX_SEGMENT_BYTES = content.length.toLong() + 1 + TokenStatSpool.shutdownWriterForTest() + // append 必须先按内容识别并删除 sealed 副本;随后的 seal 把内容重新封为 + // 唯一的 sealed_1(单份,绝不重复拼接、绝不污染旧副本) + assertTrue(TokenStatSpool.append(context, lineC, "copy-window-c")) + assertEquals( + "sealed segment must hold the single copy of the old active content", + content, + File(spool, "sealed_1.jsonl").readText(), + ) + assertEquals( + "new event must be durable in active", + lineC + "\n", + File(spool, "active.jsonl").readText(), + ) + TokenStatSpool.replay(context) + awaitEvent("copy-window-a") + awaitEvent("copy-window-b") + awaitEvent("copy-window-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("copy-window-a", "copy-window-b", "copy-window-c"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback target fsync failure retains active and recovers exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("target-fsync-a")) + val lineB = line(request("target-fsync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.fileSyncForTest = { false } + TokenStatSpool.shutdownWriterForTest() + try { + // copy 完成后目标 fsync 失败:必须保留 active、处置目标、明确失败 + assertFalse( + "append must fail when the sealed target fsync fails", + TokenStatSpool.append(context, lineB, "target-fsync-b"), + ) + } finally { + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "no normal sealed segment may be left from the failed publish", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + // 目标被隔离为 seal_failed_*(identity 确认通过,内容 = active 前缀/相等) + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) + // 恢复:维护清理隔离副本,既有事件恰一次入 Room;被拒事件随后发布成功 + TokenStatSpool.replay(context) + awaitEvent("target-fsync-a") + awaitSegmentGone(spool, isolated.name) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "target-fsync-b")) + awaitEvent("target-fsync-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback first dir sync failure retains active and recovers exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dirsync-fail-a")) + val lineB = line(request("dirsync-fail-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + try { + // 目标创建未确认持久(目录 sync 失败):必须保留 active、处置目标、明确失败 + assertFalse( + "append must fail when the target-creating dir sync fails", + TokenStatSpool.append(context, lineB, "dirsync-fail-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED,恢复 + // 路径必须回到注入的 OK 才能运行正常 seal 发布/排空协议) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "no normal sealed segment may be left from the failed publish", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + TokenStatSpool.replay(context) + awaitEvent("dirsync-fail-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "dirsync-fail-b")) + awaitEvent("dirsync-fail-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback post-active-delete dir sync failure keeps durable target and drains once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("post-sync-a")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(目标 + // 创建)成功,第四次(active 删除)失败 + if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + // 目标已 data+creation durable 后删除 active 的目录同步失败:返回 FAILED + // 阻止本轮后续 append 污染,但绝不回滚已 durable 的 target + assertFalse( + "append must fail when the post-delete dir sync fails", + TokenStatSpool.append(context, line(request("post-sync-b")), "post-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals(4, dirSyncCalls) + assertEquals( + "durable target must be kept with the full content", + lineA + "\n", + File(spool, "sealed_1.jsonl").readText(), + ) + assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) + // 恢复:target 是唯一内容持有者,正常排空,事件恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("post-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("post-sync-c")), "post-sync-c")) + awaitEvent("post-sync-c") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal first dir sync failure rolls back link retains active and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("link-sync-a")) + val lineB = line(request("link-sync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + try { + // 链接目录项未确认持久:必须回滚链接、保留 active、明确失败 + assertFalse( + "append must fail when the link-creating dir sync fails", + TokenStatSpool.append(context, lineB, "link-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "rolled-back seal must leave no hardlink residue", + spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, + ) + TokenStatSpool.replay(context) + awaitEvent("link-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, lineB, "link-sync-b")) + awaitEvent("link-sync-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal copy partial cleanup failures tombstone the target never drain it and recover as bounded evidence`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("tombstone-partial-a")) + val lineB = line(request("tombstone-partial-b")) + val partial = lineA + "\n" // 严格部分:只是 active 第一行的前缀内容,身份与完整内容不同 + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + TokenStatSpool.segmentDeleteForTest = { f -> + if (f.name.startsWith("sealed_")) false else null + } + try { + assertFalse( + "append must fail when the seal copy fails mid-way", + TokenStatSpool.append(context, line(request("tombstone-partial-c")), "tombstone-partial-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + } + // rename/delete 都失败 → tombstone skip:manifest 记录身份,scanner 跳过 + val partialFile = File(spool, "sealed_1.jsonl") + assertTrue("partial target must stay at the candidate name", partialFile.exists()) + assertEquals("partial bytes must be preserved", partial, partialFile.readText()) + assertTrue( + "partial target must be recorded in the tombstone manifest", + safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, + ) + // 带 seams 恢复:tombstoned 部分目标被跳过(文件保留、绝不普通排空),健康 + // 内容封到下一编号并恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("tombstone-partial-a") + awaitEvent("tombstone-partial-b") + assertEquals(2, database.tokenStatsDao().countEvents()) + assertTrue("tombstoned partial must still exist", partialFile.exists()) + // 移除失败 seam 后维护把部分目标移入完整证据区(有界证据)并移除 manifest 条目 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_1.jsonl") + awaitManifestWithout(spool, "sealed_1.jsonl") + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "isolated partial must become bounded quarantine evidence", + evidence.any { it.isFile && it.readText() == partial }, + ) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy fallback with durable dir syncs publishes and drains each event exactly once`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dir-durable-a")) + val lineB = line(request("dir-durable-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + TokenStatSpool.DirSyncResult.OK // 模拟 Android/Linux 目录 fsync 成功 + } + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal via copy with durable dir syncs", + TokenStatSpool.append(context, lineB, "dir-durable-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + // P1 终审:封段发布 2 次目录同步(目标创建、active 删除)+ append 侧 1 次 + // (seal 删除 active 后新 active 属首次创建,目录项必须确认持久)+ P1-1 + // bootstrap gate 2 次(filesDir + spool 目录,本测试进程首次使用) + assertEquals(5, dirSyncCalls) + assertEquals("sealed_1 must hold the old content", lineA + "\n", File(spool, "sealed_1.jsonl").readText()) + assertEquals("active must hold the new event", lineB + "\n", File(spool, "active.jsonl").readText()) + TokenStatSpool.replay(context) + awaitEvent("dir-durable-a") + awaitEvent("dir-durable-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("dir-durable-a", "dir-durable-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal failed target deletion failure stays visible exportable ackable and ack frees the cap`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // seal 发布失败隔离的部分目标(受管失败发布证据)与普通证据并存 + val failed = File(spool, "seal_failed_${UUID.randomUUID().toString().replace("-", "")}") + failed.writeText("{partial-copy-evidence\n") + val regular = File(spool, "quarantine_existing_sealed_1.jsonl") + regular.writeText("{regular-evidence\n") + // 维护删除失败(seam):隔离副本保留、下一轮重试,绝不自动消失 + TokenStatSpool.segmentDeleteForTest = { f -> + if (f.name.startsWith("seal_failed_")) false else null + } + try { + TokenStatSpool.replay(context) + delay(700) + assertTrue("deletion failure must keep the failed target", failed.exists()) + // 可见:quarantineEvidence 含 seal_failed_*,字节计入证据总量 + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue("seal_failed target must be visible as evidence", evidence.any { it.name == failed.name }) + assertTrue("regular evidence must stay visible", evidence.any { it.name == regular.name }) + assertTrue( + "seal_failed bytes must count toward the evidence total", + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } >= failed.length(), + ) + // 导出包含隔离目标 + val exported = + TokenStatSpool.exportQuarantineEvidence(context, File(root, "p2-evidence-export")) + assertTrue("seal_failed target must be exportable", exported.any { it.name == failed.name }) + // 用户确认删除(NOFOLLOW/path 根校验在 ack 内部)→ 证据消失、容量释放 + val bytesBefore = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(failed.name)) + assertFalse("ack must delete the seal_failed target", failed.exists()) + val bytesAfter = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } + assertTrue("ack must release the held evidence bytes", bytesAfter < bytesBefore) + assertTrue( + "remaining evidence must still be intact", + TokenStatSpool.quarantineEvidence(context).any { it.name == regular.name }, + ) + } finally { + TokenStatSpool.segmentDeleteForTest = null + } + } + } + + @Test + fun `corrupt uncommitted trash mapping never drops held manifest identity for vanished original`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P1 场景:sealed_1 是受管失败段(tombstone 条目),文件已被 ack stage 进 + // UNCOMMITTED trash 后崩溃(主 manifest 已发布、commit 翻转未写),随后状态 + // mapping 损坏(一条有效 + 一条损坏)。根文件缺失时,旧实现因 held 集合为空 + // 会把 sealed_1 条目按 stale 移除——fail-closed 被违背。 + val body1 = "{corrupt-held-a\n" + val body2 = "{corrupt-held-b\n" + val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) + val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$sha1\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(body1) + File(trash, "sealed_2.jsonl").writeText(body2) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{corrupt-json\n", + ) + + TokenStatSpool.replay(context) + delay(700) + assertTrue("corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertTrue(File(trash, "sealed_2.jsonl").exists()) + assertFalse( + "no rollback may happen from a partial mapping", + File(spool, "sealed_1.jsonl").exists(), + ) + assertEquals( + "manifest sealed_1 entry must be preserved verbatim", + manifestLine, + safeManifestText(manifest), + ) + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + + // 后续维护轮保持有界:hasUnknown 时整轮跳过 manifest 重写,条目逐字不变 + TokenStatSpool.replay(context) + delay(700) + assertEquals( + "repeated maintenance rounds must not rewrite the manifest", + manifestLine, + safeManifestText(manifest), + ) + + // 修复 mapping(两份证据都被覆盖)→ 维护回滚到根并安全重新隔离/处置: + // sealed_1 与 manifest 身份 MATCH → 移入完整证据区并移除条目;sealed_2 被 + // 扫描器重新隔离。manifest 与证据状态最终一致。 + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", + ) + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) + awaitManifestWithout(spool, "sealed_1.jsonl") + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var evidence: List = emptyList() + while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { + evidence = TokenStatSpool.quarantineEvidence(context) + if (evidence.size != 2) delay(20) + } + assertEquals(2, evidence.size) + assertTrue(evidence.any { it.isFile && it.readText() == body1 }) + assertTrue(evidence.any { it.isFile && it.readText() == body2 }) + } + } + + @Test + fun `scanner keeps manifest identity when corrupt uncommitted trash may hold the reused-name original`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 旧身份仍可能被损坏 mapping 的 UNCOMMITTED trash 持有:根目录同名新文件与 + // manifest 条目 MISMATCH 时,scanner 绝不能按 stale 移除条目(否则旧身份 + // 失去保护,回滚后重新隔离也无法与受管集合对应)。 + val oldBody = "{scanner-held-old\n" + val newBody = "{scanner-held-new\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n" + + "{corrupt-json\n", + ) + File(spool, "sealed_1.jsonl").writeText(newBody) + + TokenStatSpool.replay(context) + // 同名新文件照常被处理进完整证据区(内容不变),但 manifest 条目必须保留 + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var quarantined = false + while (System.nanoTime() < evidenceDeadline && !quarantined) { + quarantined = TokenStatSpool.quarantineEvidence(context).any { + it.isFile && it.readText() == newBody + } + if (!quarantined) delay(20) + } + assertTrue("the reused-name new file must be processed into the evidence area", quarantined) + assertTrue("corrupt mapping must keep the trash", trash.exists()) + assertTrue(File(trash, "sealed_1.jsonl").exists()) + assertEquals( + "manifest entry must be retained while the old identity may be held in trash", + manifestLine, + safeManifestText(manifest), + ) + } + } + + @Test + fun `stateless non-empty ack trash is visible exportable ackable and append recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB;28KiB 无状态 + // trash 残留必须让每次 append 的递归投影超限(占用绝不隐藏,P1-3) + TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + val evidence = File(trash, "sealed_stuck_1.jsonl") + RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } + // 无状态非空 trash:maintenance fail-closed 保留(绝不删除),UI 可见 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must retain a stateless non-empty trash", trash.exists()) + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + assertEquals(listOf(trash), TokenStatSpool.quarantineEvidence(context)) + assertEquals(28L * 1024, TokenStatSpool.stuckAckTrashBytes(context)) + + // 释放前:cap 被 trash 占用 → 新统计 append 明确拒绝 + val lines = (0 until 200).map { index -> + line(request("stuck-cap-$index")) to "stuck-cap-$index" + } + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertEquals(lines.size, rejected) + + // export 将 trash 目录内容复制到唯一子目录(含状态/sidecar) + val base = File(root, "export-stuck").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination) + assertTrue(exported.any { it.name == trash.name }) + val exportedTrash = File(destination, trash.name) + assertTrue(exportedTrash.isDirectory) + assertEquals(28L * 1024, File(exportedTrash, evidence.name).length()) + + // 确认删除 stuck trash(显式授权,无需 mapping)→ 容量释放 → append 恢复 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(trash.name)) + assertFalse("ack must delete the acknowledged stuck trash", trash.exists()) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-stuck-ack")), + "after-stuck-ack", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-stuck-ack") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `rollback never overwrites an occupied slot and recovers after the slot frees`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃窗口 + 回滚目标被同名不同内容的新文件占用 + val oldBody = "{old-occupied-bad\n" + val newBody = "{new-occupant-bad\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_1.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + // 主 manifest 仍含旧身份(ack 未提交)→ 必须回滚而非删除 + File(spool, "quarantine_skip_manifest.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + File(spool, "sealed_1.jsonl").writeText(newBody) + + TokenStatSpool.replay(context) + // 回滚目标被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + File(spool, "sealed_1.jsonl").exists() + ) { + delay(20) + } + assertTrue( + "occupied-slot rollback must retain the trash evidence", + trash.exists() && File(trash, "sealed_1.jsonl").exists(), + ) + // 新内容未被覆盖:作为健康处理进入完整证据区(身份仍是新内容) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "the new occupant must be processed into the evidence area untouched", + evidence.any { it.name.contains("sealed_1.jsonl") }, + ) + assertTrue( + "the new occupant content must be intact", + evidence.first { it.name.contains("sealed_1.jsonl") }.readText() == newBody, + ) + assertTrue( + "manifest entry must be retained while the old identity is held in trash", + safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, + ) + + // 槽位释放后(新文件已移入证据区)→ replay:回滚成功并自愈 + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back once the slot frees", trash.exists()) + awaitManifestWithout(spool, "sealed_1.jsonl") + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals("both the old and the new evidence must be present", 2, restored.size) + assertTrue(restored.any { it.readText() == oldBody }) + assertTrue(restored.any { it.readText() == newBody }) + } + } + + @Test + fun `ack refuses path traversal names without touching spool files`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_safe_sealed_1.jsonl").writeText("safe-bad\n") + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("../outside.jsonl")) + fail("ack must refuse traversal names") + } catch (e: IOException) { + assertTrue("refusal must name the unsafe target", e.message!!.contains("unsafe")) + } + assertTrue(File(spool, "quarantine_safe_sealed_1.jsonl").exists()) + } + + @Test + fun `first summary publish at the total cap edge with fallback sidecars keeps total bounded`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 + // 强制回退协议(P1-1:canonical/.new/.bak/tmp sidecar 瞬态同时存在) + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 证据区打满(16MiB 硬 cap)→ 新损坏段必须走 summarize 路径(首次 summary 写) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + // 数据总量:16MiB 证据 + 7.5MiB 损坏段 ≈ 23.5MiB,接近 24MiB 总上限边缘 + val segment = File(spool, "sealed_2.jsonl") + RandomAccessFile(segment, "rw").use { + it.setLength(7L * 1024 * 1024 + 512L * 1024) + } + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + val totalBytes: () -> Long = { + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + TokenStatSpool.replay(context) + // 轮询:整个处置过程实际 top-level 总字节始终 ≤ 总上限 + val pollDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (segment.exists() && System.nanoTime() < pollDeadline) { + assertTrue( + "total must stay within the cap while draining: ${totalBytes()}", + totalBytes() <= cap, + ) + delay(20) + } + assertTrue("total must stay within the cap at rest: ${totalBytes()}", totalBytes() <= cap) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("first summary must be published at the cap edge", summary) + assertTrue( + "summary must carry the over-cap segment record", + File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl"), + ) + // sidecar 已清理(回退发布完成) + assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) + assertFalse(File(spool, "quarantine_summary.jsonl.bak").exists()) + // 维护(ack 证据区)后 append 恢复 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-cap-edge-summary")), + "after-cap-edge-summary", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-cap-edge-summary") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic + } + } + } + + @Test + fun `seal-failed target cleanup with FAILED and UNSUPPORTED dir sync does not advance and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val isolated = File(spool, "seal_failed_$tag-partial") + isolated.writeText("{partial-$tag\n") + File(spool, "sealed_9.jsonl").writeText(line(request("syncfail-sealfailed-healthy-$tag")) + "\n") + // bootstrap gate(2) OK,seal_failed 删除后的目录项 sync(第 3 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + delay(900) + // 删除可见但未确认:本轮不推进(健康段也不排空);隔离副本不丢证据 + assertFalse("seal-failed target deletion is visible", isolated.exists()) + assertTrue("healthy segment must stay pending while the round is not durable", File(spool, "sealed_9.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项确认持久后健康段恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitEvent("syncfail-sealfailed-healthy-$tag") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone over-cap delete with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_$tag.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_2.jsonl").writeText("{$tag-managed-bad\n") + // 阶段 1:删除失败 + 证据区已满 → over-cap tombstone 条目(正常协议) + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name == "sealed_2.jsonl") false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_2.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + // 阶段 1 的 drain 可能仍在收尾(摘要/条目发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + // 阶段 2:维护删除成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; + // 本阶段第 1 次 sync 是 manifest 严格读取,第 2 次才是删除的目录项)→ manifest + // 条目保留(可重试记录)、本轮不推进 + TokenStatSpool.segmentDeleteForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 2) result else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "manifest entry must be retained while the deletion is unconfirmed", + safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, + ) + assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:确认“消失”持久后条目幂等移除,摘要记录不重复 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitManifestWithout(spool, "sealed_2.jsonl") + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack staging with FAILED and UNSUPPORTED dir sync fails closed keeps evidence and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + // 两个失败点:trash 目录创建后的目录项 sync(failCall=1)、首个证据移动后的 + // 目录项 sync(failCall=2,跨 spool 根与 trash 两个目录) + for (failCall in 1..2) { + spool.deleteRecursively() + spool.mkdirs() + TokenStatSpool.clearPendingStateForTest() + File(spool, "sealed_1.jsonl").writeText("{$tag-ackstage-bad\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "phase-1 tombstone entry must exist before ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先 + // 静默至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + TokenStatSpool.segmentRenameForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == failCall) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail when a staging boundary dir sync is not OK; calls=$calls failCall=$failCall result=$result") + } catch (e: IOException) { + } + // 操作失败、状态保留:证据未丢、manifest 未改 + assertTrue("managed evidence must stay in place", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "manifest entry must survive the failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + } + // 清理失败迭代留下的空 trash(mkdir 已可见但目录项 sync 未确认;维护入口对 + // 空 trash 同样安全删除,此处等价地清理后重试) + spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + .forEach { it.deleteRecursively() } + // 恢复:目录项 sync OK 后 ack 恰一次完成(证据删除、条目移除) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertTrue( + "no trash residue after a successful ack", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack commit flip with FAILED and UNSUPPORTED dir sync fails closed retains uncommitted trash and maintenance recovers it`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val sealedBody = "{$tag-flip-bad\n" + val areaBody = "area-body-$tag\n" + File(spool, "sealed_1.jsonl").writeText(sealedBody) + File(spool, "quarantine_area_$tag.jsonl").writeText(areaBody) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue( + "phase-1 tombstone entry must exist before ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + TokenStatSpool.segmentRenameForTest = null + // 第 11 次 sync = COMMITTED 翻转的暂存目录项(manifest 严格读取 1 + mkdir 1 + // + staging 4 + 状态文件 2 + manifest 重写 2 + 翻转 staging 1)——翻转未确认 + // 持久 → ack 失败、状态保留 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 11) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf("sealed_1.jsonl", "quarantine_area_$tag.jsonl"), + ) + fail("ack must fail when the commit flip is not durable; calls=$calls result=$result") + } catch (e: IOException) { + assertTrue(e.message!!.contains("commit")) + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained", 1, trashDirs.size) + val trash = trashDirs.single() + val state = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue( + "state must remain UNCOMMITTED for maintenance rollback", + state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), + ) + assertTrue("staged managed evidence stays in trash", File(trash, "sealed_1.jsonl").exists()) + assertTrue("staged area evidence stays in trash", File(trash, "quarantine_area_$tag.jsonl").exists()) + assertTrue( + "manifest entries were already published", + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true, + ) + // 维护恢复:UNCOMMITTED 按 mapping+identity 回滚 → 证据各恰一次回到原路径 + // (损坏 sealed 随后被扫描器重新隔离为证据) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be rolled back by maintenance", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.count { it.readText() == sealedBody }) + assertEquals(1, evidence.count { it.readText() == areaBody }) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `ack trash delete with FAILED and UNSUPPORTED dir sync fails closed and retry is idempotent`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val evidence = File(spool, "quarantine_ackdelete_$tag.jsonl") + evidence.writeText("{$tag-ackdelete\n") + // 第 8 次 sync = COMMITTED 翻转后 trash 删除的目录项(mkdir 1 + staging 2 + + // 状态文件 2 + 翻转 2 + 删除 sync 1)——删除可见但未确认 → ack 失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 8) result else TokenStatSpool.DirSyncResult.OK + } + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + fail("ack must fail when the trash deletion is not durable") + } catch (e: IOException) { + assertTrue(e.message!!.contains("deletion not durable")) + } + assertFalse("trash deletion is visible", evidence.exists()) + assertTrue( + "no trash residue", + spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, + ) + // 重试幂等:证据已可见删除,再次 ack 无操作成功(崩溃后 COMMITTED trash 重现 + // 由维护有界补删) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) + assertFalse(evidence.exists()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone manifest publish with FAILED and UNSUPPORTED dir sync fails closed keeps old manifest and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText("{$tag-publish-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-publish-healthy-$tag")) + "\n") + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + // bootstrap gate(2) OK,tombstone manifest 严格发布的暂存目录项 sync(第 3 次) + // 失败 → 发布 FAILED(不是 RECORDED):manifest 未发布、段保留、健康段不排空 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + delay(900) + assertFalse("manifest must not be published", manifest.exists()) + assertTrue("original segment must be retained", File(spool, "sealed_1.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项 sync OK 后按正常协议完成——损坏段作为证据恰一次隔离、健康段 + // 恰一次入 Room(manifest 从未发布,无重复条目) + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitEvent("syncfail-publish-healthy-$tag") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == "{$tag-publish-bad\n" }, + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `seal duplicate cleanup with FAILED and UNSUPPORTED dir sync fails closed until confirmed and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("syncfail-dup-a-$tag")) + val lineB = line(request("syncfail-dup-b-$tag")) + File(spool, "active.jsonl").writeText(lineA + "\n") + File(spool, "sealed_1.jsonl").writeText(lineA + "\n") // copy 回退崩溃窗口副本 + // bootstrap gate(2) OK,重复副本删除后的目录项 sync(第 3 次)失败 → 恢复 + // 未确认:append fail-closed,绝不带着“可能还有重复”的状态写入 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + assertFalse(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) + assertTrue("active is intact", File(spool, "active.jsonl").readText() == lineA + "\n") + assertFalse("duplicate removal is visible", File(spool, "sealed_1.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:无重复 → 正常追加,两事件各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) + TokenStatSpool.replay(context) + awaitEvent("syncfail-dup-a-$tag") + awaitEvent("syncfail-dup-b-$tag") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("syncfail-dup-a-$tag", "syncfail-dup-b-$tag"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + // ==== P2 终审:目录遗漏修复(回滚删除/反向 rename 的严格目录同步、mapping 身份捕获) ==== + + @Test + fun `seal rollback deletion sync failure fails closed and next append re-bootstraps before writing`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("rollback-sync-a")) + val lineB = line(request("rollback-sync-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 硬链接创建目录项 OK → active 删除失败 → 回滚删除链接:删除后的目录项 + // sync(第 4 次)失败(P2 终审)→ 回滚未确认持久、gate 同步失效、seal 明确失败 + var dirSyncCalls = 0 + TokenStatSpool.sealActiveDeleteForTest = { false } + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 1-2 bootstrap gate;3 链接创建目录项 OK;4 回滚删除的目录项 FAILED + if (dirSyncCalls == 4) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the seal rollback deletion is not durable", + TokenStatSpool.append(context, lineB, "rollback-sync-b"), + ) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals("rollback deletion must be sync-confirmed (P2)", 4, dirSyncCalls) + assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) + assertFalse( + "rolled-back seal must leave no sealed residue", + spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, + ) + // 恢复:gate 已失效——下一次 append 先 bootstrap 重新确认目录项再正常写入 + assertTrue(TokenStatSpool.append(context, lineB, "rollback-sync-b")) + TokenStatSpool.replay(context) + awaitEvent("rollback-sync-a") + awaitEvent("rollback-sync-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `ack rollback mixed move success with sync failure writes complete mapping from actual locations and maintenance recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = "{p23-a\n" + val body2 = "{p23-b\n" + val body3 = "{p23-c\n" + val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText(body1) } + val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText(body2) } + val ev3 = File(spool, "quarantine_ord_c_sealed_3.jsonl").apply { writeText(body3) } + // ev1/ev2 成功 stage;ev3 stage 失败触发回滚。回滚时 ev2 移回失败(留在 trash), + // ev1 移回成功但目录项 sync 失败(第 7 次)——此时再写 UNCOMMITTED 状态时 + // ev1 已不在 trash,mapping 身份必须从实际所在位置(original)捕获(P2 终审), + // 绝不能从已移走的 target 盲读(会得到 0 字节/空哈希甚至写失败) + var calls = 0 + TokenStatSpool.ackAtomicMoveForTest = { from, to -> + when { + to.name == ev3.name -> false + to.name == ev2.name && from.parentFile?.name?.startsWith("quarantine_ack_trash_") == true -> + false + else -> null + } + } + TokenStatSpool.dirSyncForTest = { + calls += 1 + // 1 trash 创建;2-5 stage;6-7 回滚 ev1 的双目录 sync(第 7 次失败) + if (calls == 7) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + try { + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(ev1.name, ev2.name, ev3.name), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals("uncommitted trash must be retained", 1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("ev2 rollback failed so it stays in trash", File(trash, ev2.name).exists()) + assertTrue("ev1 rollback move is visible at the original path", ev1.exists()) + assertTrue("ev3 was never staged", ev3.exists()) + // 状态 mapping 必须完整且身份正确(P2 终审:从实际所在位置捕获) + val stateFile = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) + assertTrue("state must be written after the not-durable rollback", stateFile.isFile) + val lines = stateFile.readText().lineSequence().filter { it.isNotBlank() }.toList() + assertEquals(TokenStatSpool.ACK_STATE_UNCOMMITTED, lines.first()) + assertEquals("mapping must cover both staged files", 2, lines.size - 1) + val entryA = JSONObject(lines[1]) + assertEquals(ev1.name, entryA.getString("o")) + assertEquals(body1.toByteArray(Charsets.UTF_8).size.toLong(), entryA.getLong("b")) + assertEquals(sha256Hex(body1.toByteArray(Charsets.UTF_8)), entryA.getString("s")) + val entryB = JSONObject(lines[2]) + assertEquals(ev2.name, entryB.getString("o")) + assertEquals(body2.toByteArray(Charsets.UTF_8).size.toLong(), entryB.getLong("b")) + assertEquals(sha256Hex(body2.toByteArray(Charsets.UTF_8)), entryB.getString("s")) + // UI 可管理:stuck trash 作为受管证据可见 + assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) + // 维护按 mapping+identity 完整回滚:trash 删除、全部证据回到证据区 + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && + spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + ) { + delay(20) + } + assertTrue( + "trash must be resolved by maintenance once moves recover", + spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(3, evidence.size) + assertTrue(evidence.any { it.readText() == body1 }) + assertTrue(evidence.any { it.readText() == body2 }) + assertTrue(evidence.any { it.readText() == body3 }) + assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) + assertEquals(0, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.dirSyncForTest = null + } + } + } + + // ==== P1 关键链路:drain 请求合并(丢失唤醒修复)==== + +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt new file mode 100644 index 000000000..de4f4348a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt @@ -0,0 +1,758 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class QuarantineReliabilityTest : TokenStatReliabilityTestBase() { + @Test + fun `summary only evidence can be explicitly acknowledged before snapshot`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summary = File(spool, "quarantine_summary.jsonl") + summary.writeText("{\"count\":1}\n") + var blockRan = false + + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must not silently omit the quarantine summary") + } catch (e: IOException) { + assertTrue(e.message!!.contains("quarantine evidence")) + } + assertFalse(blockRan) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context = context, + names = emptySet(), + deleteSummary = true, + ) + + assertFalse(summary.exists()) + assertEquals(null, TokenStatSpool.quarantineSummaryInfo(context)) + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + assertTrue(blockRan) + } + + @Test + fun `two corrupt segments quarantine uniquely and healthy segment drains`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{bad-one\n") + File(spool, "sealed_2.jsonl").writeText("{bad-two\n") + File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-corrupt")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-corrupt") + } + assertEquals(1, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(2, evidence.size) + assertEquals(2, evidence.map { it.name }.toSet().size) + } + + @Test + fun `quarantine at cap summarizes over-cap segment and keeps within-cap full evidence`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{new-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + // 硬边界:容量内完整证据保留;超限损坏段替换为固定大小摘要并移除原段 + assertTrue(existing.exists()) + assertFalse("over-cap corrupt segment must be replaced by its summary", File(spool, "sealed_2.jsonl").exists()) + assertEquals(1, TokenStatSpool.quarantineEvidence(context).size) + assertTrue( + "evidence disk usage must stay within the hard cap", + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("over-cap evidence must be reported as a bounded summary", summary) + assertEquals(1, summary!!.recordCount) + + // 导出包含摘要文件;确认删除只作用于完整证据(摘要保留为滚动记录) + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals(2, exported.size) + assertTrue(exported.any { it.name == "quarantine_summary.jsonl" }) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) + assertFalse(existing.exists()) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } + + @Test + fun `quarantine hard cap keeps disk bounded far beyond cap and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + repeat(8) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{bad-$index\n") } + File(spool, "sealed_10.jsonl").writeText(line(request("healthy-beyond-cap")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitEvent("healthy-beyond-cap") + } + // 远超上限时:磁盘占用有界(完整证据不超上限)、摘要累计、健康段照常排空 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-beyond-cap", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence disk usage must stay within the hard cap", + evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(summary) + assertEquals(8, summary!!.recordCount) + assertTrue( + "summary must have a fixed upper bound", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + ) + + // 导出/删除入口在满容量时可调用,摘要随导出提供 + val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) + assertEquals(2, exported.size) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) + assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) + } + + @Test + fun `quarantine summary is rolling and never contains corrupt content`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val total = TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + 50 + repeat(total) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{corrupt-body-$index\n") } + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(summary) + assertTrue( + "summary must roll at a fixed line cap: ${summary!!.recordCount}", + summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + ) + assertTrue( + "summary must have a fixed byte cap", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + ) + val summaryText = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest records must survive the roll", summaryText.contains("sealed_${total + 1}.jsonl")) + assertTrue("summary must carry hash, bytes and line counts", summaryText.contains("sha256")) + assertFalse("summary must never embed corrupt content", summaryText.contains("corrupt-body")) + assertTrue(existing.exists()) + assertTrue( + TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES + ) + } + + @Test + fun `quarantine summary publish failure keeps old summary and pending segment`() = runBlocking { + val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest + TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summaryFile = File(spool, "quarantine_summary.jsonl") + summaryFile.writeText("{\"old\":\"preserved\"}\n") + // 让回退提交失败:.bak 位置放一个非空目录,renameTo 无法覆盖(发布失败路径) + val bakDir = File(spool, "quarantine_summary.jsonl.bak") + bakDir.mkdirs() + File(bakDir, "lock").writeText("x") + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val segment = File(spool, "sealed_2.jsonl") + segment.writeText("{fail-publish-bad\n") + try { + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + delay(800) + } + // 发布失败:旧摘要保持完整、待处理段保留、错误可见(不声称成功) + assertEquals("{\"old\":\"preserved\"}\n", summaryFile.readText()) + assertTrue("pending segment must be retained on publish failure", segment.exists()) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } finally { + TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic + File(bakDir, "lock").delete() + bakDir.delete() + File(spool, "quarantine_summary.jsonl.new").delete() + } + } + + @Test + fun `quarantine summary survives interruption at each replacement step`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val summaryFile = File(spool, "quarantine_summary.jsonl") + val oldContent = "{\"k\":\"old\"}\n" + val newContent = "{\"k\":\"new\"}\n" + + // 窗口 A:target 缺失、.new 完整就绪(target→bak 之后、.new→target 之前崩溃) + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.new").writeText(newContent) + assertTrue(summaryFile.delete()) + val infoA = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull(infoA) + val recoveredA = summaryFile.readText().trim() + assertTrue( + "interruption must recover complete old or new: $recoveredA", + recoveredA == oldContent.trim() || recoveredA == newContent.trim(), + ) + + // 窗口 B:target 缺失、.bak=完整旧(bak 已就绪但恢复前崩溃) + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) + assertTrue(summaryFile.delete()) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(oldContent.trim(), summaryFile.readText().trim()) + + // 窗口 C:target=完整新、.bak=残留旧(提交后、清理前崩溃) + summaryFile.writeText(newContent) + File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(newContent.trim(), summaryFile.readText().trim()) + assertFalse("stale backup must be cleaned after a successful read", File(spool, "quarantine_summary.jsonl.bak").exists()) + + // 窗口 D:仅 .tmp 残留(tmp 写入后崩溃)→ target 完整旧 + summaryFile.writeText(oldContent) + File(spool, "quarantine_summary.jsonl.tmpstale").writeText(newContent) + assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) + assertEquals(oldContent.trim(), summaryFile.readText().trim()) + } + + @Test + fun `quarantine summary byte cap enforced with oversized pre-existing summary`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 预置超字节上限但行数很少的旧摘要(旧版本残留/手工膨胀),裁剪必须自愈 + val bigLine = "{\"padding\":\"${"x".repeat(30 * 1024)}\"}\n" + File(spool, "quarantine_summary.jsonl").writeText(bigLine.repeat(3)) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{byte-cap-bad\n") + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertTrue( + "summary must shrink below the byte cap: ${summary.summaryBytes}", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, + ) + assertTrue(summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES) + assertTrue("newest record must survive the byte roll", File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) + } + + @Test + fun `quarantine summary retry after crash does not duplicate record`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + val body = "{retry-bad\n" + File(spool, "sealed_2.jsonl").writeText(body) + // 模拟“上次摘要已发布、段删除前崩溃”:摘要已有同一段(file+sha256)的完整记录 + val sha = + java.security.MessageDigest.getInstance("SHA-256") + .digest(body.toByteArray(Charsets.UTF_8)) + .joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + File(spool, "quarantine_summary.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${body.length}," + + "\"sha256\":\"$sha\",\"lineCount\":1,\"corruptLines\":1}\n", + ) + Mockito.mockStatic(AppLogger::class.java).use { + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + } + // 崩溃重试幂等:不重复追加记录,段正常处置 + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertEquals("crash retry must not duplicate the record", 1, summary.recordCount) + assertTrue(File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) + } + + @Test + fun `within-cap corrupt rename failure is kept as bounded pending-delete evidence and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{rename-fail-bad\n") + File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-pending")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + // 只让“移入证据区”的重命名失败,pending-delete 重命名放行(容量内预算允许) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-pending") + awaitNoSealedSegments(spool) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + // 健康事件恰一次入库;删除/重命名失败的段已移出 sealed 扫描队列为有界证据 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-after-pending", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + val pending = evidence.filter { it.name.startsWith("quarantine_pending_delete_") } + assertEquals("failed rename must be retained as pending-delete evidence", 1, pending.size) + assertTrue("full evidence must be preserved", pending.single().readText().contains("rename-fail-bad")) + assertTrue( + "error evidence must stay within the hard cap", + evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, + ) + assertFalse("no tombstone needed while the pending budget fits", File(spool, "quarantine_skip_manifest.jsonl").exists()) + + // 维护/后台重试:恢复重命名能力后,下一次 drain 把 pending 证据移回证据区 + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "pending-delete evidence must be restored to the evidence area", + restored.any { it.name.startsWith("quarantine_") && !it.name.startsWith("quarantine_pending_delete_") }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `over-cap delete failure with full evidence area tombstone the segment and healthy drain continues`() = + runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{tombstone-bad\n") + File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-tombstone")) + "\n") + Mockito.mockStatic(AppLogger::class.java).use { + // 注入删除失败:只对 over-cap 损坏段生效(P1-2),健康段删除不受影响 + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name == "sealed_2.jsonl") false else null + } + try { + TokenStatSpool.replay(context) + awaitEvent("healthy-after-tombstone") + } finally { + TokenStatSpool.segmentDeleteForTest = null + } + } + // 后续健康事件恰一次入库;删除失败的 over-cap 段被 tombstone 跳过(摘要已有 hash/bytes) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("healthy-after-tombstone", database.tokenStatsDao().getAllEvents().single().eventId) + assertTrue( + "tombstoned segment must be recorded in the bounded manifest", + File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_2.jsonl"), + ) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "quarantine evidence area must stay within the hard cap (managed set separately bounded)", + evidence.filter { it.name.startsWith("quarantine_") }.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, + ) + // P1-3:tombstoned 原 sealed 作为 managed evidence 可见(参与 UI 计数/导出/删除) + assertTrue( + "tombstoned original sealed must appear as managed evidence", + evidence.any { it.name == "sealed_2.jsonl" }, + ) + assertTrue( + TokenStatSpool.quarantineEvidence(context).none { it.name.startsWith("quarantine_pending_delete_") }, + ) + + // 维护/后台重试:恢复删除能力后,下一次 drain 删除 tombstoned 段并移除记录 + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_2.jsonl") + awaitManifestWithout(spool, "sealed_2.jsonl") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + } + + @Test + fun `permanent dispose failures fill the managed set bounded then refuse appends and recover`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P1-1:删除与重命名永久失败(只针对 sealed 段:损坏处置、维护移回全部失败) + TokenStatSpool.segmentDeleteForTest = { file -> + if (file.name.startsWith("sealed_")) false else null + } + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + try { + // 超过受管集合上限的损坏段:受管集合封顶,剩余段有界跳过 + repeat(TokenStatSpool.MAX_TOMBSTONE_ENTRIES + 5) { index -> + File(spool, "sealed_${index + 1}.jsonl").writeText("{permanent-fail-$index\n") + } + val drainStart = System.nanoTime() + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val manifestCount: () -> Int = { + safeManifestText(manifest) + ?.lineSequence()?.filter { it.isNotBlank() }?.count() ?: 0 + } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + manifestCount() != TokenStatSpool.MAX_TOMBSTONE_ENTRIES + ) { + delay(20) + } + val drainMs = (System.nanoTime() - drainStart) / 1_000_000 + assertTrue("drain must return bounded: ${drainMs}ms", drainMs < 10_000) + delay(500) + val entryCount = manifestCount() + assertEquals( + "managed set must cap at the hard limit, never roll identities away", + TokenStatSpool.MAX_TOMBSTONE_ENTRIES, + entryCount, + ) + // 总占用有界:受管段(≤4MiB/段 × 上限)+ manifest(≤64KiB)+ 证据区 + val totalBytes = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "total spool usage must be bounded: $totalBytes", + totalBytes <= TokenStatSpool.MAX_TOMBSTONE_ENTRIES * (TokenStatSpool.MAX_SEGMENT_BYTES + 4096) + + TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + 1_048_576, + ) + + // 超限新业务明确失败且无伪 durable + try { + TokenStatSpool.append(context, line(request("refused-after-cap")), "refused-after-cap") + fail("append beyond managed capacity must throw TokenStatsPersistenceException") + } catch (e: TokenStatsPersistenceException) { + } + assertEquals(0, database.tokenStatsDao().countEvents()) + + // snapshot barrier 有界失败(未受管段仍在队列),绝不死锁 + val snapStart = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { } + fail("snapshot must not claim drained while unmanageable segments remain") + } catch (e: IOException) { + assertTrue(e.message!!.contains("pending events")) + } + val snapMs = (System.nanoTime() - snapStart) / 1_000_000 + assertTrue("snapshot must be bounded: ${snapMs}ms", snapMs < 10_000) + + // 恢复文件系统:maintenance 清理受管段与陈旧条目 → 容量释放 → 新业务可继续 + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + awaitManifestWithout(spool, "sealed_") + TokenTrackingAIService.recordSafely(context, request("after-managed-recovery")) + awaitEvent("after-managed-recovery") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "after-managed-recovery", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + } finally { + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `stale tombstone identity never deletes or skips a reused-name healthy segment`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 旧损坏段 sealed_1 曾因处置失败被 tombstone(身份 = 旧内容 hash) + val oldBody = "{old-corrupt\n" + File(spool, "sealed_1.jsonl").writeText(oldBody) + // 崩溃窗口:旧文件被外部删除,manifest 尚未更新 + assertTrue(File(spool, "sealed_1.jsonl").delete()) + File(spool, "quarantine_skip_manifest.jsonl").writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"${sha256Hex(oldBody.toByteArray(Charsets.UTF_8))}\",\"overCap\":false}\n", + ) + // 新健康段复用同名(不同 hash) + File(spool, "sealed_1.jsonl").writeText(line(request("reused-name-healthy")) + "\n") + TokenStatSpool.replay(context) + awaitEvent("reused-name-healthy") + awaitSegmentGone(spool, "sealed_1.jsonl") + // P1-2:健康段恰一次真实入库,绝不被 tombstone 跳过或删除 + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("reused-name-healthy", database.tokenStatsDao().getAllEvents().single().eventId) + // 陈旧记录被移除 + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertFalse( + "stale tombstone must be removed", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + } + } + + @Test + fun `quarantine summary byte cap counts UTF-8 bytes for non-ASCII lines`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // P2-1:预置摘要的 UTF-16 长度低于 64KiB,但 UTF-8 字节超上限(每字符 3 字节) + val chineseLine = "{\"padding\":\"${"统".repeat(22 * 1024)}\"}\n" + File(spool, "quarantine_summary.jsonl").writeText(chineseLine) + val existing = File(spool, "quarantine_existing_sealed_1.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{utf8-cap-bad\n") + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + val summary = TokenStatSpool.quarantineSummaryInfo(context)!! + assertTrue( + "summary UTF-8 bytes must respect the cap: ${summary.summaryBytes}", + summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, + ) + val text = File(spool, "quarantine_summary.jsonl").readText() + assertTrue("newest record must survive the roll", text.contains("sealed_2.jsonl")) + assertTrue( + "oversized non-ASCII line must be replaced by a fixed ASCII truncated record", + text.contains("\"truncated\":true"), + ) + assertFalse("truncated record must never embed content", text.contains("统")) + } + } + + @Test + fun `append capacity check recovers a full managed set from backup sidecar`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃窗口:canonical 缺失,只有 .bak(完整旧值)——受管集合已满 + val content = (1..TokenStatSpool.MAX_TOMBSTONE_ENTRIES).joinToString("\n") { index -> + "{\"ts\":1,\"file\":\"sealed_$index.jsonl\",\"bytes\":3," + + "\"sha256\":\"${sha256Hex("x$index".toByteArray(Charsets.UTF_8))}\",\"overCap\":true}" + } + "\n" + File(spool, "quarantine_skip_manifest.jsonl.bak").writeText(content) + try { + // append 容量检查必须看到恢复后的满受管集合:明确拒绝且不发布新文件 + TokenStatSpool.append(context, line(request("refused-bak-recovery")), "refused-bak-recovery") + fail("append must fail when the recovered managed set is full") + } catch (e: TokenStatsPersistenceException) { + } + assertEquals(0, database.tokenStatsDao().countEvents()) + // canonical 已恢复且 .bak 身份被清理 + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertEquals( + TokenStatSpool.MAX_TOMBSTONE_ENTRIES, + manifest.readText().lineSequence().filter { it.isNotBlank() }.count(), + ) + assertFalse(File(spool, "quarantine_skip_manifest.jsonl.bak").exists()) + } + } + + @Test + fun `seal copy partial target isolation with not durable dir sync writes tombstone evidence and fails closed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dispose-sync-a")) + val lineB = line(request("dispose-sync-b")) + val partial = lineA + "\n" + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + // gate(2) OK;隔离 rename 后的目录项 sync(第 3 次)失败 → dispose 返回 + // 失败并写 tombstone(候选 sealed 身份受管证据),绝不只留日志(P2) + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the partial target disposal is not durable", + TokenStatSpool.append(context, line(request("dispose-sync-c")), "dispose-sync-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + // 隔离文件本身是受管证据(seal_failed_*,可见/导出/ack/维护),tombstone + // 按候选 sealed 身份记录:崩溃后 sealed_1 以同内容重现时 scanner 跳过 + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertTrue( + "candidate identity must be tombstoned so a reappeared sealed_1 is skipped", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertEquals( + "active must be retained", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:维护清理隔离副本,健康内容各恰一次入 Room(tombstone 条目随文件 + // 消失确认后移除) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitEvent("dispose-sync-a") + awaitEvent("dispose-sync-b") + awaitSegmentGone(spool, isolated.name) + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal copy partial target deletion with not durable dir sync writes tombstone evidence and fails closed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("dispose-del-a")) + val lineB = line(request("dispose-del-b")) + val partial = lineA + "\n" + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + // 隔离 rename 失败 → 走安全删除;删除后的目录项 sync(第 3 次)失败 → + // 按候选 sealed 身份写 tombstone 并返回失败(P2:绝不只留日志) + TokenStatSpool.segmentRenameForTest = { from, _ -> + if (from.name.startsWith("sealed_")) false else null + } + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the partial target deletion is not durable", + TokenStatSpool.append(context, line(request("dispose-del-c")), "dispose-del-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + } + // 删除可见但未确认:候选名字不再存在,tombstone 记录其稳定身份(崩溃后 + // 以同内容重现时 scanner 跳过,绝不普通排空) + assertFalse("partial target deletion is visible", File(spool, "sealed_1.jsonl").exists()) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + assertTrue( + "candidate identity must be tombstoned for the crash-reappearance window", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertEquals( + "active must be retained", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:tombstone 条目随文件消失确认后移除,健康内容各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitEvent("dispose-del-a") + awaitEvent("dispose-del-b") + awaitManifestWithout(spool, "sealed_1.jsonl") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `over-cap dispose delete with FAILED and UNSUPPORTED dir sync keeps summary retryable and recovers without duplicate`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val existing = File(spool, "quarantine_existing_$tag.jsonl") + RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } + File(spool, "sealed_2.jsonl").writeText("{$tag-overcap-bad\n") + // bootstrap gate(2) + 摘要严格发布(2) 成功,删除后目录项 sync(第 5 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 4) TokenStatSpool.DirSyncResult.OK else result + } + TokenStatSpool.replay(context) + awaitSummaryPublishedAndSegmentGone(spool, "sealed_2.jsonl") + // 摘要已发布(可见)、段已删除(可见)但目录项未确认:本轮不得声称完成—— + // 无事件入 Room;恢复后摘要不重复。目录 sync 未恢复前严格读取不信任 canonical + // (P1-2),此处直接断言摘要文件可见。 + assertTrue( + "summary canonical must be published and visible", + File(spool, "quarantine_summary.jsonl").isFile, + ) + assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录项 sync OK 后幂等完成(摘要记录不重复、无遗留队列) + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt new file mode 100644 index 000000000..6623aec5b --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt @@ -0,0 +1,778 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class RestoreReliabilityTest : TokenStatReliabilityTestBase() { + @Test + fun `restore barrier waits for segment read and old task cannot insert afterward`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("old-before-restore")) + "\n") + val read = CountDownLatch(1) + val release = CountDownLatch(1) + TokenStatSpool.afterSegmentReadForTest = { + read.countDown() + release.await(10, TimeUnit.SECONDS) + } + TokenStatSpool.replay(context) + assertTrue(read.await(10, TimeUnit.SECONDS)) + + val restore = async { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + // Simulates the restored database contents replacing everything inserted before + // this exclusive section. No old worker may insert after this point. + database.tokenStatsDao().deleteAllEvents() + } + } + delay(100) + assertFalse("restore must wait for the in-flight old drain", restore.isCompleted) + release.countDown() + restore.await() + delay(100) + assertNull(database.tokenStatsDao().getEvent("old-before-restore")) + } + + @Test + fun `deferred restore commit failure preserves old and new request accounting`() = runBlocking { + val oldRequest = request("old-request-after-commit-failure") + val oldEpoch = oldRequest.sessionEpoch + try { + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = {}, + commitReplacement = { throw IOException("REPLACING write failed") }, + ) { + fail("replacement must not run when commit fails") + } + fail("commit failure must propagate") + } catch (e: IOException) { + assertEquals("REPLACING write failed", e.message) + } + + assertEquals(oldEpoch, TokenStatSpool.captureRestoreEpoch()) + assertTrue(TokenStatSpool.isAcceptingEvents()) + TokenTrackingAIService.recordSafely(context, oldRequest) + TokenTrackingAIService.recordSafely(context, request("new-request-after-commit-failure")) + awaitEvent("old-request-after-commit-failure") + awaitEvent("new-request-after-commit-failure") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + + @Test + fun `restore with a live Room insert fails bounded before replacement and later restore is clean`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousInsert = TokenStatSpool.insertTimeoutMs + val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs + TokenStatSpool.insertTimeoutMs = 100 + TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("evt-live-a")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n") + + // 真实 Room + 真实 spool 文件;DAO 层在 fence 之后、事务写入前挂起 + // (模拟 SQLite 已持有连接、忽略中断的旧 insert),释放后委托真实 DAO + val realDao = database.tokenStatsDao() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val blockingDao = mock() + whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> + entered.countDown() + // SQLite 忽略中断:阻塞中的 insert 必须继续等待,不能被 task.cancel 打断 + while (true) { + try { + if (release.await(1, TimeUnit.SECONDS)) break + } catch (_: InterruptedException) { + } + } + runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } + } + whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> + runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } + } + whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> + runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } + } + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) + TokenStatsLedger.databaseProvider = { proxy } + + TokenStatSpool.replay(context) + assertTrue( + "insert must have passed the fence and be inside Room", + entered.await(10, TimeUnit.SECONDS) + ) + assertEquals(1, TokenStatSpool.activeInsertCountForTest()) + + // insert timeout 已释放 lifecycleMutex;restore 门闩必须有界失败, + // 替换块绝不执行(数据库不被覆盖/污染),durable 段保留 + val startedRestore = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + fail("replacement must never run while an old insert is live") + } + fail("restore must fail bounded") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 + assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) + assertEquals(0, database.tokenStatsDao().countEvents()) + assertTrue( + "durable segment must survive a failed restore", + File(spool, "sealed_1.jsonl").exists() + ) + + // 释放旧 insert:它只能写入未被替换的旧库;registry 清空后重试 restore 干净通过 + release.countDown() + awaitEvent("evt-live-a") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + // 模拟恢复数据库替换:旧事件必须已从排空路径彻底消失 + database.tokenStatsDao().deleteAllEvents() + } + assertEquals(0, database.tokenStatsDao().countEvents()) + // 自定义 SQLiteDriver 的 Room 没有 SupportSQLiteOpenHelper:直接复用 JVM 驱动 + // 打开同一数据库文件校验完整性 + val integrity = + JdbcSQLiteDriver().open(File(root, "app_database").absolutePath).use { connection -> + connection.prepare("PRAGMA integrity_check").use { statement -> + statement.step() + statement.getText(0) + } + } + assertEquals("restored database must pass integrity check", "ok", integrity) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.insertTimeoutMs = previousInsert + TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce + } + } + } + + @Test + fun `restore cleanup deletion failure is explicit`() = runBlocking { + File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs(); resolve("active.jsonl").writeText("x") } + TokenStatSpool.spoolDeleteForTest = { false } + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore cleanup failure must propagate") + } catch (e: IOException) { + assertTrue(e.message!!.contains("cleanup failed")) + } + } + + @Test + fun `snapshot barrier moves spool only event into Room exactly once`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-backup")) + "\n") + + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }) + } + // A replay after the snapshot/restore boundary is idempotent and cannot duplicate it. + TokenStatSpool.replay(context) + delay(100) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `snapshot fails before block while quarantine evidence would be excluded`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{corrupt snapshot evidence\n") + var blockRan = false + + Mockito.mockStatic(AppLogger::class.java).use { + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must not silently omit quarantine evidence") + } catch (e: IOException) { + assertTrue(e.message!!.contains("quarantine evidence")) + } + } + + assertFalse("snapshot block must not run", blockRan) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue(evidence.single().readText().contains("corrupt snapshot evidence")) + } + + @Test + fun `manifest read failure fails closed scanner ack and append and recovers after restore`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText("{managed-bad\n") + // 先正常建立受管失败段(重命名失败 → tombstone 记录落盘) + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + try { + TokenStatSpool.replay(context) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + + // 注入 manifest 读取失败:scanner/容量/维护全部中止退避,受管段不处理 + TokenStatSpool.metadataReadErrorForTest = { file -> + file.name == "quarantine_skip_manifest.jsonl" + } + TokenStatSpool.replay(context) + delay(700) + assertTrue( + "managed segment must not be processed while the manifest is unreadable", + File(spool, "sealed_1.jsonl").exists(), + ) + assertTrue( + "manifest entry must be retained", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // ack 报错:manifest 不可读时不能确认删除 + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + fail("ack must fail while the manifest is unreadable") + } catch (e: IOException) { + } + assertTrue( + "entry must survive a failed ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + + // 容量检查 fail-closed:append 不发布、不声称 durable + assertFalse( + "append must fail closed while the manifest is unreadable", + TokenStatSpool.append( + context, + line(request("fail-closed-append")), + "fail-closed-append", + ), + ) + assertFalse( + File(spool, "active.jsonl").isFile && File(spool, "active.jsonl").length() > 0L, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复后正常:append 重新 durable,ack 按身份删除并清理记录 + TokenStatSpool.metadataReadErrorForTest = null + assertTrue( + TokenStatSpool.append( + context, + line(request("after-manifest-recovery")), + "after-manifest-recovery", + ), + ) + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertFalse(File(spool, "sealed_1.jsonl").exists()) + assertFalse( + "entry must be removed after a successful ack", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + TokenStatSpool.replay(context) + awaitEvent("after-manifest-recovery") + } finally { + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `ordinary evidence stage failure with rollback failure keeps uncommitted trash and maintenance restores it`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 普通(非受管)quarantine 证据:从不在 tombstone manifest 中。P1-1 修复前, + // 维护会因 manifest 缺失推断“已提交”而删除 trash(丢失未确认的证据)。 + val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText("{ord-a\n") } + val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText("{ord-b\n") } + // 第 2 个文件 stage rename 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 + TokenStatSpool.segmentRenameForTest = { _, to -> + when { + to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && + to.name == ev2.name -> false + to.parentFile?.name != null && + !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && + to.name == ev1.name -> false + else -> null + } + } + try { + try { + TokenStatSpool.acknowledgeAndDeleteQuarantine( + context, + setOf(ev1.name, ev2.name), + ) + fail("ack must report the staging failure") + } catch (e: IOException) { + } + val trashDirs = spool.listFiles().orEmpty() + .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } + assertEquals(1, trashDirs.size) + val trash = trashDirs.single() + assertTrue("staged evidence must stay in trash", File(trash, ev1.name).exists()) + assertFalse(ev1.exists()) + assertTrue("ev2 stage never happened", ev2.exists()) + + // replay 维护(回滚 rename 仍被注入失败):绝不删除 trash 与证据 + TokenStatSpool.replay(context) + delay(700) + assertTrue("maintenance must never delete uncommitted ordinary evidence", trash.exists()) + assertTrue(File(trash, ev1.name).exists()) + assertFalse("no partial rollback may occur", ev1.exists()) + + // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚,证据不删最终恢复 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.replay(context) + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be gone after a successful maintenance rollback", trash.exists()) + assertTrue("ev1 must be restored to the evidence area", ev1.exists()) + assertTrue("ev2 must stay in the evidence area", ev2.exists()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(setOf(ev1.name, ev2.name), evidence.map { it.name }.toSet()) + } finally { + TokenStatSpool.segmentRenameForTest = null + } + } + } + + @Test + fun `export fails closed when the spool root enumeration fails and recovers after the seam is restored`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 两个非空 stuck trash + 一个普通隔离文件:根枚举失败时 export 绝不能 + // 成功遗漏任何证据(P1-6) + val trashA = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trashA.mkdirs() + val evidenceA = File(trashA, "sealed_1.jsonl") + evidenceA.writeText("{root-null-a\n") + val trashB = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trashB.mkdirs() + val evidenceB = File(trashB, "sealed_2.jsonl") + evidenceB.writeText("{root-null-b\n") + val quarantineFile = File(spool, "quarantine_sealed_3.jsonl") + quarantineFile.writeText("{root-null-ev\n") + val base = File(root, "export-root-null").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + try { + TokenStatSpool.exportQuarantineEvidence(context, destination) + fail("export must fail when the spool root enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 源证据全部保留 + assertTrue(trashA.exists()) + assertTrue(evidenceA.exists()) + assertTrue(trashB.exists()) + assertTrue(evidenceB.exists()) + assertTrue(quarantineFile.exists()) + // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 + assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) + assertFalse(destination.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam 后完整 export 含全部证据(stuck trash 子目录 + 隔离文件) + val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) + assertTrue(exported.any { it.name == trashA.name }) + assertTrue(exported.any { it.name == trashB.name }) + assertTrue(exported.any { it.name == quarantineFile.name }) + val exportedTrash = File(destination2, trashA.name) + assertTrue(exportedTrash.isDirectory) + assertTrue(File(exportedTrash, evidenceA.name).readText() == "{root-null-a\n") + } + } + + @Test + fun `export fails closed when an ack trash directory enumeration fails and recovers after the seam is restored`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + val evidence = File(trash, "sealed_1.jsonl") + evidence.writeText("{child-null-evidence\n") + // 普通隔离文件先于 trash 被复制:child 枚举失败时目标目录已含部分内容 + val quarantineFile = File(spool, "quarantine_sealed_2.jsonl") + quarantineFile.writeText("{child-null-quarantine\n") + val base = File(root, "export-child-null").apply { mkdirs() } + val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == trash) null else dir.listFiles() + } + try { + try { + TokenStatSpool.exportQuarantineEvidence(context, destination) + fail("export must fail when an ack trash directory enumeration fails") + } catch (e: IOException) { + assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) + } + // 源证据全部保留 + assertTrue(trash.exists()) + assertTrue(evidence.exists()) + assertTrue(quarantineFile.exists()) + // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 + assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) + assertFalse(destination.exists()) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复 seam 后完整 export 含全部证据 + val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } + val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) + assertTrue(exported.any { it.name == trash.name }) + assertTrue(exported.any { it.name == quarantineFile.name }) + val exportedTrash = File(destination2, trash.name) + assertTrue(exportedTrash.isDirectory) + assertTrue(File(exportedTrash, evidence.name).readText() == "{child-null-evidence\n") + } + } + + @Test + fun `metadata publish refused at the hard cap edge stays bounded and maintenance restores appends`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 证据区打满 16MiB:12MiB + 4MiB 两个文件(ack 其中一个后仍满 → 继续 summarize) + val existingBig = File(spool, "quarantine_a_sealed_1.jsonl") + RandomAccessFile(existingBig, "rw").use { it.setLength(12L * 1024 * 1024) } + val existingSmall = File(spool, "quarantine_b_sealed_2.jsonl") + RandomAccessFile(existingSmall, "rw").use { it.setLength(4L * 1024 * 1024) } + // 数据总量恰好等于总上限:首次 summary 发布的投影(+4×content)必超限 + val segment = File(spool, "sealed_3.jsonl") + RandomAccessFile(segment, "rw").use { it.setLength(8L * 1024 * 1024) } + val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L + TokenStatSpool.replay(context) + delay(900) + // 有界失败:不写正式 summary、段保留、总量不超过上限 + assertFalse( + "summary must not be published when the metadata budget is exhausted", + File(spool, "quarantine_summary.jsonl").exists(), + ) + assertTrue("pending segment must be retained", segment.exists()) + assertTrue( + "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= cap, + ) + // 维护释放空间(此时总量恰好 = cap,ack 的 trash 状态元数据投影按 P1-1 + // 必被拒——见 ack 状态预算测试;这里模拟外部/维护释放:移除一个证据文件) + // → 重试成功发布摘要 → append 恢复 + assertTrue(existingSmall.delete()) + TokenStatSpool.replay(context) + awaitSegmentGone(spool, "sealed_3.jsonl") + val summary = TokenStatSpool.quarantineSummaryInfo(context) + assertNotNull("summary must be published after maintenance frees the budget", summary) + assertTrue( + TokenStatSpool.append( + context, + line(request("after-budget-recovery")), + "after-budget-recovery", + ), + ) + TokenStatSpool.replay(context) + awaitEvent("after-budget-recovery") + } finally { + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `restore cleanup dir sync failure invalidates the gate so consecutive appends fail without writing and recover exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineB = line(request("gate-restore-b")) + val lineC = line(request("gate-restore-c")) + var calls = 0 + try { + // 阶段 0:gate=true——经快照 barrier 完成 bootstrap 两次确认(filesDir + + // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态) + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { + File(spool, "active.jsonl").writeText(line(request("gate-restore-a")) + "\n") + } + assertEquals(2, calls) + // 阶段 1:restore 清理删除 spool 目录,删除后 filesDir 目录项 sync(第 3 次) + // 失败 → restore 明确失败;删除开始前 gate 必须已失效(P1-1 修复) + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore must fail when the cleanup dir sync fails") + } catch (e: IOException) { + assertTrue(e.message!!.contains("durable")) + } + assertEquals(3, calls) + assertFalse("spool deletion is visible", spool.exists()) + // 阶段 2:restore 替换已开始(清理失败属替换后失败)——P1 终审 fence + // 拒绝本进程一切后续 append(accepting=false,直到重启),任何事件绝不 + // 写入;若 fence 失效,bootstrap gate 也已失效,同样全部失败 + assertFalse(TokenStatSpool.append(context, lineB, "gate-restore-b")) + assertFalse(TokenStatSpool.append(context, lineC, "gate-restore-c")) + assertFalse( + "no event may be written while dir entries are unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复 OK。P1 终审:恢复替换已开始(清理失败属于替换后失败), + // 同进程事件被明确拒绝——先模拟进程重启(reset 状态)才允许写入; + // 重启后目录项重新确认(bootstrap),两事件各恰一次入 Room。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.clearPendingStateForTest() + assertTrue(TokenStatSpool.append(context, lineB, "gate-restore-b")) + assertTrue(TokenStatSpool.append(context, lineC, "gate-restore-c")) + TokenStatSpool.replay(context) + awaitEvent("gate-restore-b") + awaitEvent("gate-restore-c") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `pending-delete evidence restore with FAILED and UNSUPPORTED dir sync rebuilds retryable record and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{$tag-pending-bad\n" + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-pending-healthy-$tag")) + "\n") + // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + TokenStatSpool.replay(context) + awaitEvent("syncfail-pending-healthy-$tag") + assertEquals(1, database.tokenStatsDao().countEvents()) + val pending = spool.listFiles().orEmpty().single { + it.isFile && it.name.startsWith("quarantine_pending_delete_") + } + // 阶段 2:维护恢复 rename 后目录项 sync 失败(bootstrap gate 已在阶段 1 确认, + // 本阶段第一次 sync 就是恢复 rename 的目录项)→ 尽力移回 pending-delete 名 + // (重建可重试记录),本轮不推进 + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.dirSyncForTest = { result } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "pending-delete record must be rebuilt when the restore rename is not durable", + pending.exists(), + ) + // pending-delete 文件本身是受管证据(计入 quarantineEvidence),但必须仍是 + // pending-delete 名(未被推进到完整证据区) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence must still be the pending-delete record", + evidence.single().name.startsWith("quarantine_pending_delete_"), + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 恢复:rename 目录项确认持久后证据恰一次回到完整证据区 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, restored.size) + assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `tombstone evidence restore rename with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{$tag-evidence-bad\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-evidence-healthy-$tag")) + "\n") + // 阶段 1:两次 rename 都失败 → tombstone(容量内,overCap=false);健康段排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_")) false else null + } + TokenStatSpool.replay(context) + awaitEvent("syncfail-evidence-healthy-$tag") + val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < entryDeadline && + safeManifestText(manifest)?.contains("sealed_1.jsonl") != true + ) { + delay(20) + } + assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 + // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 + delay(300) + // 阶段 2:恢复 rename 成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; + // 本阶段第 1 次 sync 是 manifest 严格读取、第 2 次是容量判定读取、第 3 次才是 + // restore rename 的目录项)→ 条目保留、本轮不推进 + TokenStatSpool.segmentRenameForTest = null + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls == 3) result else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "manifest entry must be retained while the restore rename is unconfirmed", + safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, + ) + assertFalse("sealed original is gone (rename visible)", File(spool, "sealed_1.jsonl").exists()) + assertTrue( + "evidence must already be at the quarantine name", + TokenStatSpool.quarantineEvidence(context).any { it.readText() == body }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 恢复:确认 rename 持久后条目幂等移除,证据恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitManifestWithout(spool, "sealed_1.jsonl") + assertEquals( + 1, + TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + + @Test + fun `restore cleanup with FAILED and UNSUPPORTED dir sync fails closed and retry after recovery is idempotent`() = + runBlocking { + suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "active.jsonl").writeText("{$tag-restore\n") + // bootstrap gate(2) OK,spool 目录删除后的 filesDir 目录项 sync(第 3 次)失败 + var calls = 0 + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result + } + try { + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + fail("restore must fail when the spool cleanup is not durable") + } catch (e: IOException) { + assertTrue("restore state must be retained", e.message!!.contains("durable")) + } + assertFalse("spool deletion is visible", spool.exists()) + // 重试幂等:目录已不存在时跳过删除,确认持久后 restore 成功 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { } + assertFalse(spool.exists()) + } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } + runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } + } + +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt new file mode 100644 index 000000000..0e01968f4 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt @@ -0,0 +1,378 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class SpoolConcurrencyTest : TokenStatReliabilityTestBase() { + @Test + fun `generation handles same millisecond clock rollback and request spanning reset`() = runBlocking { + val dao = database.tokenStatsDao() + val oldSameMillisecond = request("old-same-ms", generation = 0L, startedAt = 5_000L) + val oldFinishesAfterReset = request("old-spanning", generation = 0L, startedAt = 9_000L) + + dao.resetAllStatisticsTx() + assertEquals(1L, dao.currentResetGeneration()) + TokenStatsLedger.recordWith(context, dao, oldSameMillisecond) + TokenStatsLedger.recordWith(context, dao, oldFinishesAfterReset) + assertEquals(0, dao.countEvents()) + + // New request after reset is accepted even if its wall clock moved backwards. + TokenStatsLedger.recordWith( + context, + dao, + request("new-clock-rollback", generation = 1L, startedAt = 1L), + ) + assertEquals(1, dao.countEvents()) + assertEquals(1L, dao.getEvent("new-clock-rollback")!!.acceptedGeneration) + } + + @Test + fun `interrupt ignoring insert never locks spool and restore barrier stays clean`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousInsert = TokenStatSpool.insertTimeoutMs + TokenStatSpool.insertTimeoutMs = 100 + try { + val spoolDir = File(root, TokenStatSpool.SPOOL_DIR_NAME) + spoolDir.mkdirs() + // 先构建行(价格解析走真实 DAO),再安装忽略中断但可释放的 insert 挂起 + val lineA = line(request("evt-hung-a")) + val lineB = line(request("evt-hung-b")) + val realDao = database.tokenStatsDao() + val release = CountDownLatch(1) + val blockingDao = mock() + whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> + // SQLite 忽略中断:cancel(true) 无法终止;释放后委托真实 DAO 完成 + gateIgnoringInterrupts(release) + runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } + } + whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> + runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } + } + whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> + runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } + } + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) + TokenStatsLedger.databaseProvider = { proxy } + + // append A durable;drain 启动后 insert 挂起(忽略中断) + assertTrue(TokenStatSpool.append(context, lineA, "evt-hung-a")) + val latchDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < latchDeadline) { + delay(10) + } + assertEquals(1, TokenStatSpool.pendingLatchCountForTest()) + + // 硬上限(insertTimeoutMs)之后:锁必须已释放,append 不再被阻塞 + val startedSecond = System.nanoTime() + assertTrue(TokenStatSpool.append(context, lineB, "evt-hung-b")) + val secondElapsedMs = (System.nanoTime() - startedSecond) / 1_000_000 + assertTrue("append must never block on the hung insert: ${secondElapsedMs}ms", secondElapsedMs < 10_000) + + // restore barrier:wedged insert 仍存活(已通过 fence、正在 Room 内)时, + // 必须有界失败且绝不替换文件;等待结束后旧 insert 仍登记在 registry + val startedRestore = System.nanoTime() + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } + fail("restore must fail bounded while an old insert is still live") + } catch (e: IOException) { + assertTrue("restore must report the live insert", e.message!!.contains("still active")) + } + val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 + assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) + assertEquals(1, TokenStatSpool.activeInsertCountForTest()) + + // 模拟重启前必须释放并确认旧 insert 线程终止:释放门闩 → registry 真正清空 + release.countDown() + val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { + delay(10) + } + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + + // 丢弃已完成的旧 worker(shutdown 后线程真实终止),再模拟进程重启; + // 被卡任务由新排空重放幂等完成(失败的 restore 从未替换数据库) + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + assertEquals(0, TokenStatSpool.activeInsertCountForTest()) + assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) + + TokenStatsLedger.databaseProvider = { database } + TokenTrackingAIService.recordSafely(context, request("evt-after-restore")) + awaitEvent("evt-after-restore") + awaitEvent("evt-hung-a") + awaitEvent("evt-hung-b") + assertEquals(3, database.tokenStatsDao().countEvents()) + + // 重试 restore:registry 已空,替换模拟可执行,恢复后的 DB 无旧事件 + TokenStatSpool.withExclusiveSnapshotAccess( + context, + drainBefore = false, + clearAfter = true, + ) { + database.tokenStatsDao().deleteAllEvents() + } + assertEquals(0, database.tokenStatsDao().countEvents()) + assertNull(database.tokenStatsDao().getEvent("evt-hung-a")) + assertNull(database.tokenStatsDao().getEvent("evt-hung-b")) + assertNull(database.tokenStatsDao().getEvent("evt-after-restore")) + + // 恢复后的新事件正常落账且只出现一次。P1 终审:恢复替换已开始(accepting= + // false),同进程后续事件被明确拒绝——必须先模拟进程重启(reset 状态)才 + // 允许写入;这正是 UI“稍后重启”窗口的语义。 + TokenStatSpool.resetExecutorsForTest() + TokenTrackingAIService.recordSafely(context, request("evt-post-restore")) + awaitEvent("evt-post-restore") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("evt-post-restore", database.tokenStatsDao().getAllEvents().single().eventId) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.insertTimeoutMs = previousInsert + } + } + } + + @Test + fun `database preparation timeouts stay single flight with bounded threads`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousPrepare = TokenStatSpool.prepareTimeoutMs + TokenStatSpool.prepareTimeoutMs = 50 + try { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("evt-db-prep-hang")) + "\n") + // 数据库准备挂起且忽略中断(可释放):每次 drain 循环都必须单飞复用同一任务 + val release = CountDownLatch(1) + TokenStatsLedger.databaseProvider = { + gateIgnoringInterrupts(release) + database + } + TokenStatSpool.replay(context) + // 第 1 个退避周期 + delay(1_200) + TokenStatSpool.replay(context) + // 第 2 个退避周期 + delay(2_200) + val dbThreads = Thread.getAllStackTraces().keys.count { + it.isAlive && it.name.startsWith("operit-token-stats-database") + } + assertTrue("database preparation must stay single-flight: $dbThreads", dbThreads <= 1) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 释放被卡住的准备任务并确认旧 worker 真实终止后再模拟重启 + release.countDown() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + + // 恢复后(重置 worker 模拟重启)事件仍能落账 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitEvent("evt-db-prep-hang") + assertEquals(1, database.tokenStatsDao().countEvents()) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.prepareTimeoutMs = previousPrepare + } + } + } + + @Test + fun `concurrent exports keep unique directories and one failing export never deletes the other success`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + File(spool, "quarantine_aa_sealed_1.jsonl").writeText("aa-bad\n") + File(spool, "quarantine_bb_sealed_2.jsonl").writeText("bb-bad\n") + val base = File(root, "export-runs").apply { mkdirs() } + // P2:UI 命名 = 时间戳前缀 + UUID;同一毫秒前缀下 UUID 保证目录唯一, + // 目录用 Files.createDirectory 独占创建(已存在即失败) + fun uniqueExportDir(ts: String): File = + File(base, "token_stats_quarantine_${ts}_${UUID.randomUUID().toString().replace("-", "")}") + .also { Files.createDirectory(it.toPath()) } + val destA = uniqueExportDir("20260806_120000") + val destB = uniqueExportDir("20260806_120000") + // 两个导出经 lifecycleMutex 串行:A 的 manifest 两次读取(manifestContent + + // evidence 列表)放行,B 的 manifestContent 读取(第 3 次)注入失败 + var manifestReads = 0 + TokenStatSpool.metadataReadErrorForTest = { file -> + if (file.name == "quarantine_skip_manifest.jsonl") { + manifestReads += 1 + manifestReads == 3 + } else { + false + } + } + try { + val exportA = async { TokenStatSpool.exportQuarantineEvidence(context, destA) } + val exportB = async { + try { + TokenStatSpool.exportQuarantineEvidence(context, destB) + fail("export B must fail with the injected manifest read failure") + } catch (e: IOException) { + } + } + val exportedA = exportA.await() + exportB.await() + // 各自目录独立且完整:A 成功导出两份证据 + assertEquals( + setOf("quarantine_aa_sealed_1.jsonl", "quarantine_bb_sealed_2.jsonl"), + exportedA.map { it.name }.toSet(), + ) + assertEquals(exportedA.size, destA.listFiles().orEmpty().size) + // 失败的导出绝不删除另一成功导出的目录/内容;自身目录也未被删除 + assertTrue("successful export directory must stay intact", destA.isDirectory) + assertTrue(destA.listFiles().orEmpty().all { it.isFile }) + assertTrue("failed export directory must not be deleted by the spool", destB.isDirectory) + + // 恢复后正常:新的导出成功且只含本次证据 + TokenStatSpool.metadataReadErrorForTest = null + val retried = TokenStatSpool.exportQuarantineEvidence( + context, + uniqueExportDir("20260806_120001"), + ) + assertTrue(retried.any { it.name.startsWith("quarantine_aa_") }) + assertTrue(retried.any { it.name.startsWith("quarantine_bb_") }) + } finally { + TokenStatSpool.metadataReadErrorForTest = null + } + } + } + + // ── P1 终审:durable bootstrap gate / 维护目录项严格同步 ────────────────────── + + @Test + fun `schedule during an in-flight drain round is not lost and the worker reruns`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("rerun-a")) + val lineB = line(request("rerun-b")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") + var rounds = 0 + var replayInjected = false + TokenStatSpool.afterDrainRoundForTest = { + rounds += 1 + // 第一轮结束、轮末决策之前注入一次 replay:请求必须被保留并由同一 worker + // 立即 rerun(旧实现:drainScheduled=true 直接丢弃该请求,轮数恒为 1)。 + if (!replayInjected) { + replayInjected = true + TokenStatSpool.replay(context) + } + } + try { + TokenStatSpool.replay(context) + // 两轮结束:第 1 轮排空数据,第 2 轮消费注入的请求(维护轮)后 retire + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (rounds < 2 && System.nanoTime() < deadline) delay(10) + assertEquals("injected replay must trigger a rerun round", 2, rounds) + awaitEvent("rerun-a") + awaitEvent("rerun-b") + awaitNoSealedSegments(spool) + assertFalse("request must be consumed by the rerun", TokenStatSpool.drainRequestPendingForTest()) + assertFalse("worker must retire after the rerun", TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.afterDrainRoundForTest = null + } + } + + @Test + fun `rejected drain schedule retains the request and recovers on the next schedule`() = runBlocking { + TokenStatSpool.rejectDrainScheduleForTest = true + try { + val lineA = line(request("rejected-schedule-a")) + assertTrue("append must succeed durably despite rejected scheduling", TokenStatSpool.append(context, lineA, "rejected-schedule-a")) + assertTrue("request must be retained after rejection", TokenStatSpool.drainRequestPendingForTest()) + assertFalse("schedule token must be released after rejection", TokenStatSpool.drainScheduledForTest()) + // 恢复调度能力后 replay:请求不丢,事件最终入 Room + TokenStatSpool.rejectDrainScheduleForTest = false + TokenStatSpool.replay(context) + awaitEvent("rejected-schedule-a") + assertFalse(TokenStatSpool.drainRequestPendingForTest()) + assertFalse(TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.rejectDrainScheduleForTest = false + } + } + + @Test + fun `await initial drain joins concurrent waiters and failed rounds are retryable`() = runBlocking { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("init-drain-a")) + val lineB = line(request("init-drain-b")) + File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") + // 失败轮:bootstrap gate 目录 sync 失败 → drainCore false → 等待者按失败完成 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + try { + assertFalse("failed round must complete the waiter with false", TokenStatSpool.awaitInitialDrain(context, 5_000)) + // 失败不缓存:恢复后重试成功;并发调用 join 同一轮 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + val r1 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } + val r2 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } + assertTrue("retry must succeed", r1.await()) + assertTrue("concurrent join must see the same success", r2.await()) + awaitEvent("init-drain-a") + awaitEvent("init-drain-b") + awaitNoSealedSegments(spool) + assertFalse(TokenStatSpool.drainRequestPendingForTest()) + assertFalse(TokenStatSpool.drainScheduledForTest()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + + @Test + fun `timed out initial drain waiter is removed when scheduling stays rejected`() = runBlocking { + TokenStatSpool.rejectDrainScheduleForTest = true + try { + assertFalse(TokenStatSpool.awaitInitialDrain(context, 25)) + assertEquals(0, TokenStatSpool.initialDrainWaiterCountForTest()) + assertTrue("drain request remains retryable", TokenStatSpool.drainRequestPendingForTest()) + } finally { + TokenStatSpool.rejectDrainScheduleForTest = false + } + } + +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt new file mode 100644 index 000000000..d9b5b7dca --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt @@ -0,0 +1,855 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class SpoolRecoveryTest : TokenStatReliabilityTestBase() { + @Test + fun `spool root enumeration failure aborts snapshot and seal without touching segments then recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body1 = line(request("enum-null-sealed")) + "\n" + val body2 = line(request("enum-null-active")) + "\n" + val sealed1 = File(spool, "sealed_1.jsonl") + sealed1.writeText(body1) + val active = File(spool, "active.jsonl") + active.writeText(body2) + val activeText = active.readText() + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + // active 已有内容:下一次 append 必走 seal 路径(P1-7 场景) + TokenStatSpool.MAX_SEGMENT_BYTES = active.length() + 1 + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + // 1) 快照的 drain 阶段 fail-closed → block 绝不执行、文件原字节不变 + var blockRan = false + try { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + blockRan = true + } + fail("snapshot must fail while the spool root enumeration fails") + } catch (e: IOException) { + assertTrue( + "failure must come from the snapshot drain barrier", + e.message!!.contains("drain"), + ) + } + assertFalse("snapshot block must not run", blockRan) + assertEquals("sealed_1 must stay byte-identical", body1, sealed1.readText()) + assertEquals("active must stay byte-identical", activeText, active.readText()) + + // 2) seal 绝不覆盖:需要 seal 的 append 明确失败,sealed_1/active 原样 + assertFalse( + "append requiring a seal must fail while enumeration fails", + TokenStatSpool.append( + context, + line(request("enum-null-extra")), + "enum-null-extra", + ), + ) + assertEquals("sealed_1 must never be overwritten", body1, sealed1.readText()) + assertEquals("active must not be sealed or truncated", activeText, active.readText()) + + // 3) drain 保留:后台 drain 轮退避,文件与事件原样 + TokenStatSpool.replay(context) + delay(700) + assertEquals("sealed_1 must be preserved by the failing drain", body1, sealed1.readText()) + assertEquals("active must be preserved by the failing drain", activeText, active.readText()) + assertNull( + "sealed event must not reach Room while enumeration fails", + database.tokenStatsDao().getEvent("enum-null-sealed"), + ) + assertNull( + "active event must not reach Room while enumeration fails", + database.tokenStatsDao().getEvent("enum-null-active"), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 4) 恢复 seam → 两事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("enum-null-sealed") + awaitEvent("enum-null-active") + assertEquals( + "each preserved event must be recorded exactly once", + 2, + database.tokenStatsDao().countEvents(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal never overwrites an occupied target and picks the next safe index`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val bodyA = line(request("seal-collide-a")) + "\n" + val bodyB = line(request("seal-collide-b")) + "\n" + File(spool, "sealed_1.jsonl").writeText(bodyA) + val sealed2 = File(spool, "sealed_2.jsonl") + sealed2.writeText(bodyB) + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + try { + // active 已含一条完整行;枚举缝隐藏 sealed_2 → 计算出的 next=2 已被 + // 占用,seal 必须跳到 3,绝不覆盖 sealed_2 + val bodyC = line(request("seal-collide-c")) + "\n" + File(spool, "active.jsonl").writeText(bodyC) + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) { + dir.listFiles()?.filter { it.name != "sealed_2.jsonl" }?.toTypedArray() + } else { + dir.listFiles() + } + } + try { + assertTrue( + "append must seal active to a free index and succeed", + TokenStatSpool.append( + context, + line(request("seal-collide-d")), + "seal-collide-d", + ), + ) + // seam 仍生效:并发 drain 与 seal 都看不见 sealed_2 → 占用目标不可能被覆盖 + assertEquals( + "occupied sealed target must never be overwritten", + bodyB, + sealed2.readText(), + ) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 恢复枚举后全部事件各恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("seal-collide-a") + awaitEvent("seal-collide-b") + awaitEvent("seal-collide-c") + awaitEvent("seal-collide-d") + assertEquals( + "each event must be recorded exactly once", + 4, + database.tokenStatsDao().countEvents(), + ) + } finally { + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `seal publish conflict keeps target bytes and seals active at a higher index`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val seamBody = "{pre-existing-conflict\n" + try { + // active 已含两条完整行;下一次 append 必触发 seal(候选编号 1) + val lineA = line(request("seal-seam-a")) + val lineB = line(request("seal-seam-b")) + val lineC = line(request("seal-seam-c")) + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var hooks = 0 + TokenStatSpool.beforeSealPublishForTest = { target -> + hooks += 1 + if (hooks == 1) { + // 候选选定后、实际 publish 前出现同名不同内容的目标(模拟异常残留) + target.writeText(seamBody) + true + } else { + null + } + } + // 停掉后台 writer:seal 仍在 append 内同步完成,但断言阶段不会被并发 drain + // 改写/隔离文件(确定性) + TokenStatSpool.shutdownWriterForTest() + try { + assertTrue( + "append must seal active to a free index and succeed", + TokenStatSpool.append(context, lineC, "seal-seam-c"), + ) + } finally { + TokenStatSpool.beforeSealPublishForTest = null + } + // 冲突目标原字节不变;active 数据安全落到更高编号 sealed_2;新事件在 active + assertEquals( + "conflict target must keep its original bytes", + seamBody, + File(spool, "sealed_1.jsonl").readText(), + ) + assertEquals( + "active data must be sealed to a higher index", + lineA + "\n" + lineB + "\n", + File(spool, "sealed_2.jsonl").readText(), + ) + assertEquals( + "new event must be durable in active", + lineC + "\n", + File(spool, "active.jsonl").readText(), + ) + // 全部事件各恰一次入 Room;冲突残留被隔离为完整证据、字节不变 + TokenStatSpool.replay(context) + awaitEvent("seal-seam-a") + awaitEvent("seal-seam-b") + awaitEvent("seal-seam-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "conflict residue must be preserved byte-identical as evidence", + evidence.any { it.isFile && it.readText() == seamBody }, + ) + } finally { + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal crash window recovers before append and each event drains exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("hardlink-window-a")) + val lineB = line(request("hardlink-window-b")) + val active = File(spool, "active.jsonl") + active.writeText(lineA + "\n") + // 模拟崩溃窗口:createLink(sealed_1, active) 成功但 active 删除前崩溃 → 同 inode + Files.createLink(File(spool, "sealed_1.jsonl").toPath(), active.toPath()) + // 停掉后台 writer:恢复发生在 append 内同步完成,断言不被并发 drain 干扰 + TokenStatSpool.shutdownWriterForTest() + // append 必须先恢复重复(删除 sealed 副本)再写入,绝不能把新内容写进已 seal 段 + assertTrue(TokenStatSpool.append(context, lineB, "hardlink-window-b")) + assertFalse( + "sealed duplicate must be removed before append, never polluted", + File(spool, "sealed_1.jsonl").exists(), + ) + TokenStatSpool.replay(context) + awaitEvent("hardlink-window-a") + awaitEvent("hardlink-window-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("hardlink-window-a", "hardlink-window-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `hardlink seal post-active-delete dir sync failure keeps durable link and drains once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("link-post-sync-a")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(链接 + // 创建)成功,第四次(active 删除)失败 + if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the post-delete dir sync fails", + TokenStatSpool.append(context, line(request("link-post-sync-b")), "link-post-sync-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + assertEquals(4, dirSyncCalls) + assertTrue( + "durable link must be kept", + File(spool, "sealed_1.jsonl").exists(), + ) + assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) + // 恢复:link 是唯一内容持有者(同 inode),正常排空,事件恰一次入 Room + TokenStatSpool.replay(context) + awaitEvent("link-post-sync-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("link-post-sync-c")), "link-post-sync-c")) + awaitEvent("link-post-sync-c") + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `hardlink seal dir sync UNSUPPORTED never deletes active and never publishes`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("unsupported-link-a")) + val lineB = line(request("unsupported-link-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 平台明确不支持目录 fsync:发布路径必须 fail-closed(UNSUPPORTED ≠ 成功), + // 硬链接已建立但目录项未持久 → 回滚链接、保留唯一 fsynced active、绝不 PUBLISHED。 + // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), + // 第三次是链接创建的目录项,第四次是回滚删除链接的目录项(P2 终审: + // 回滚删除同样是目录项变更,必须确认持久,非 OK 同时失效 gate)。 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the platform does not support dir fsync", + TokenStatSpool.append(context, lineB, "unsupported-link-b"), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + assertEquals(4, dirSyncCalls) + assertEquals( + "active must be retained byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "UNSUPPORTED must never publish a sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `copy seal dir sync UNSUPPORTED never deletes active and never publishes`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("unsupported-copy-a")) + val lineB = line(request("unsupported-copy-b")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + // copy 回退的目录同步同样 fail-closed:目标已处置、active 保留、绝不 PUBLISHED。 + // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), + // 第三次是 copy 目标创建的目录项。 + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail when the platform does not support dir fsync", + TokenStatSpool.append(context, lineB, "unsupported-copy-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED)—— + // 后续 quarantineEvidence 读取需要恢复 tombstone 写入残留的 `.new` + // sidecar(P2 受管证据),strict 读取要求目录 sync OK 才能返回。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealHardLinkForTest = null + } + // 5 次目录 sync = bootstrap gate(2) + copy 目标创建(1) + 失败目标隔离 + // rename(1) + tombstone 暂存(1,P2:隔离后目录项未确认 → 按候选 sealed + // 身份写 tombstone 受管证据,绝不只留日志) + assertEquals(5, dirSyncCalls) + assertEquals( + "active must be retained byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "UNSUPPORTED must never publish a sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + // 部分目标被隔离为 seal_failed_*(受管失败发布证据,立即可见) + val isolated = + spool.listFiles().orEmpty().single { it.isFile && it.name.startsWith("seal_failed_") } + assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) + assertTrue( + "isolated target must be visible as quarantine evidence", + TokenStatSpool.quarantineEvidence(context).any { it.name == isolated.name }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `unsupported dir sync fails closed never clears active and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("failclosed-a")) + val lineB = line(request("failclosed-b")) + val lineC = line(request("failclosed-c")) + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + // 平台明确不支持目录 fsync:封段发布必须 fail-closed——绝不原地清空/删除 + // 唯一 fsynced active,也绝不返回 durable + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must fail closed when the platform does not support dir fsync", + TokenStatSpool.append(context, lineC, "failclosed-c"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // active 原字节保留;无 sealed/seal_failed 发布残留;无事件入 Room + assertEquals( + "active must be retained byte-identical", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertTrue( + "fail-closed mode must never publish sealed segments", + spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("sealed_") }, + ) + assertTrue( + "fail-closed mode must never create seal_failed targets", + spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("seal_failed_") }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录 fsync 恢复 OK 后 append 成功,全部事件各恰一次入 Room + assertTrue(TokenStatSpool.append(context, lineC, "failclosed-c")) + TokenStatSpool.replay(context) + awaitEvent("failclosed-a") + awaitEvent("failclosed-b") + awaitEvent("failclosed-c") + awaitNoSealedSegments(spool) + assertEquals(3, database.tokenStatsDao().countEvents()) + assertEquals( + "each event must be recorded exactly once", + setOf("failclosed-a", "failclosed-b", "failclosed-c"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `first spool directory creation with unsupported dir sync returns false and retries after recovery`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + assertFalse("spool must not pre-exist", spool.exists()) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + assertFalse( + "append must not return durable when the first spool dir creation cannot be confirmed", + TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // 已创建的目录可保留供重试,但从未声明 durable;active 尚未写入 + assertTrue("created spool dir may be retained for retry", spool.isDirectory) + assertFalse("active must not be written before the dir entry is durable", File(spool, "active.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复能力 OK:重试成功,事件恰一次入 Room + assertTrue( + TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), + ) + TokenStatSpool.replay(context) + awaitEvent("first-dir-a") + awaitNoSealedSegments(spool) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("first-dir-a"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `first active file creation with unsupported dir sync returns false retains line and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + var dirSyncCalls = 0 + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录,已确认);第三次 + // (首建 active 的目录项)平台不支持——内容已写+fsync 但目录项未确认 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.UNSUPPORTED + } + TokenStatSpool.shutdownWriterForTest() + val lineA = line(request("first-active-a")) + try { + assertFalse( + "append must not return durable when the first active creation dir sync is unsupported", + TokenStatSpool.append(context, lineA, "first-active-a"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // 写+fd.sync 已发生但目录项未确认:本次不 durable,源 line 内容保留在 active + assertEquals(3, dirSyncCalls) + assertEquals( + "source line must be retained on disk", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复能力 OK:下一次 append 先经 bootstrap gate 重新确认目录项再写新事件, + // 两者各恰一次 + assertTrue( + TokenStatSpool.append(context, line(request("first-active-b")), "first-active-b"), + ) + TokenStatSpool.replay(context) + awaitEvent("first-active-a") + awaitEvent("first-active-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("first-active-a", "first-active-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } + } + + @Test + fun `unsupported dir sync fails closed for every append until recovery then drains exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val count = 24 + try { + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.MAX_SEGMENT_BYTES = 700 + TokenStatSpool.shutdownWriterForTest() + // 目录 fsync 不支持:首次 spool/active 创建无法确认目录项——每次 append + // 都 fail-closed(绝不返回 durable、绝不永久挂起、绝不清空已写入内容) + repeat(count) { index -> + assertFalse( + "append must fail closed under unsupported dir sync without stalling: $index", + TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), + ) + } + assertTrue("created spool dir may be retained for retry", spool.isDirectory) + assertFalse( + "active must not be written before any directory entry is durable", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:能力恢复 OK 后重试/replay,全部事件各恰一次入 Room + // (P1 终审:Windows JVM 真实探测恒为 UNSUPPORTED,必须回到注入的 OK) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + repeat(count) { index -> + assertTrue( + TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), + ) + } + TokenStatSpool.replay(context) + repeat(count) { index -> awaitEvent("win-failclosed-$index") } + awaitNoSealedSegments(spool) + assertEquals(count, database.tokenStatsDao().countEvents()) + assertEquals( + count, + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet().size, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `corrupt active tail with unsupported dir sync fails closed retaining original bytes then recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("corrupt-failclosed-a")) + val lineB = line(request("corrupt-failclosed-b")) + // 崩溃残留:active 尾部半行 JSON,无换行 + val original = lineA + "\n" + "{\"v\":2,\"eventId\":\"corrupt-failclosed-tail\"" + try { + File(spool, "active.jsonl").writeText(original) + TokenStatSpool.MAX_SEGMENT_BYTES = original.length.toLong() + 1 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } + TokenStatSpool.shutdownWriterForTest() + try { + // 损坏尾行需要封段处置:目录项未确认持久前绝不隔离证据、绝不插入 + // 健康行、绝不截断/清空 active(copy+file sync 之后必须 dir sync OK + // 才允许继续) + assertFalse( + "append must fail closed when sealing a corrupt tail needs dir fsync", + TokenStatSpool.append(context, lineB, "corrupt-failclosed-b"), + ) + } finally { + // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + } + // active 原字节不动(含损坏尾行);尚无证据被切走;无事件入 Room + assertEquals( + "active must retain the original bytes including the corrupt tail", + original, + File(spool, "active.jsonl").readText(), + ) + assertTrue( + "no evidence may be cut before its directory entry is durable", + TokenStatSpool.quarantineEvidence(context).isEmpty(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 恢复:目录 fsync OK 后损坏尾行作为完整证据隔离(至少一个 durable 位置), + // 健康事件各恰一次入 Room + assertTrue(TokenStatSpool.append(context, lineB, "corrupt-failclosed-b")) + TokenStatSpool.replay(context) + awaitEvent("corrupt-failclosed-a") + awaitEvent("corrupt-failclosed-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertTrue( + "corrupt tail evidence must be preserved with the original bytes", + evidence.any { it.readText() == original }, + ) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `bootstrap gate re-confirms unconfirmed spool dir entry after simulated restart and never merges events`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + val lineA = line(request("bootstrap-dir-a")) + val lineB = line(request("bootstrap-dir-b")) + try { + // 上一进程:首次创建 spool 目录,父目录/新目录的目录项 sync 失败(磁盘 + // 可见但未确认持久)——append 明确失败,active 未写入 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + TokenStatSpool.shutdownWriterForTest() + assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) + assertTrue("created spool dir is visible on disk", spool.isDirectory) + assertFalse("no line may be written before dir entries are durable", File(spool, "active.jsonl").exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),磁盘状态保留 + TokenStatSpool.clearPendingStateForTest() + // 目录项仍无法确认:本次 append 必须失败,绝不写新行(第二事件此前从未写入) + assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) + assertFalse( + "no line may be written while the spool dir entry is unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复:bootstrap gate 重新确认目录项后,两个事件各恰一次入 Room + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) + assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) + TokenStatSpool.replay(context) + awaitEvent("bootstrap-dir-a") + awaitEvent("bootstrap-dir-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("bootstrap-dir-a", "bootstrap-dir-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `bootstrap gate re-confirms unconfirmed active entry after simulated restart and keeps bytes until confirmed`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("bootstrap-active-a")) + val lineB = line(request("bootstrap-active-b")) + var dirSyncCalls = 0 + try { + // 上一进程:bootstrap gate 两次确认通过,但首建 active 的目录项 sync 失败 + // (内容已写+fsync、磁盘可见、未确认) + TokenStatSpool.dirSyncForTest = { + dirSyncCalls += 1 + if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-active-a")) + assertEquals(3, dirSyncCalls) + assertEquals( + "unconfirmed line must stay visible on disk", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),active 字节保留 + TokenStatSpool.clearPendingStateForTest() + // 目录项仍无法确认:本次 append 失败且 active 字节不变(绝不再追加新行) + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) + assertEquals( + "active bytes must be unchanged while the dir entry is unconfirmed", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // 恢复:gate 重新确认后追加第二事件(此前从未写入),两事件各恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) + TokenStatSpool.replay(context) + awaitEvent("bootstrap-active-a") + awaitEvent("bootstrap-active-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + assertEquals( + setOf("bootstrap-active-a", "bootstrap-active-b"), + database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), + ) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + @Test + fun `maintenance seal dir sync failure after gate true forces the next append to re-bootstrap before writing`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val lineA = line(request("gate-maint-a")) + val lineB = line(request("gate-maint-b")) + var calls = 0 + try { + File(spool, "active.jsonl").writeText(lineA + "\n") + // 阶段 1:bootstrap gate 两次确认 OK(gate=true),随后维护 drain 的封段 + // 发布目录项 sync(第 3 次)失败 → 维护失败;gate 必须同步失效(P1-1) + TokenStatSpool.dirSyncForTest = { + calls += 1 + if (calls != 3) TokenStatSpool.DirSyncResult.OK + else TokenStatSpool.DirSyncResult.FAILED + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + val sealDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (calls < 3 && System.nanoTime() < sealDeadline) delay(10) + TokenStatSpool.shutdownWriterForTest() + assertTrue("seal must have been attempted", calls >= 3) + // 阶段 2:gate 已失效且目录 sync 持续失败——下一次 append 必须重新 + // bootstrap;bootstrap 失败 → append 明确失败且 active 字节不变 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } + assertFalse(TokenStatSpool.append(context, lineB, "gate-maint-b")) + assertEquals( + "active must stay byte-identical", + lineA + "\n", + File(spool, "active.jsonl").readText(), + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复——bootstrap 重新确认后 append 成功,事件各恰一次 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + assertTrue(TokenStatSpool.append(context, lineB, "gate-maint-b")) + TokenStatSpool.replay(context) + awaitEvent("gate-maint-a") + awaitEvent("gate-maint-b") + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.dirSyncForTest = null + } + } + } + + /** + * P1-2/P1-3 终审通用 runner:以 [result](FAILED/UNSUPPORTED)运行一次完整场景,保证 + * 前后内存/磁盘/数据库状态隔离(spool 目录重建 + 内存标记复位 + 事件表清空),并在 + * finally 还原全部注入缝。场景开始前恢复“目录 fsync 支持且成功”的平台常态 + * (Windows JVM 真实探测恒为 UNSUPPORTED),使场景内部的 phase-1 正常协议可用。 + */ +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt new file mode 100644 index 000000000..cf70a8fdd --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt @@ -0,0 +1,486 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal class SpoolWriterReliabilityTest : TokenStatReliabilityTestBase() { + @Test + fun `price override read timeout is durable unknown not default`() = runBlocking { + database.tokenStatsDao().upsertPriceOverride( + scope = TokenPriceResolver.SCOPE_CONFIG, + provider = "DEEPSEEK", + model = "deepseek-chat", + configId = "cfg", + billingMode = BillingMode.TOKEN.name, + pricingCurrency = "USD", + inputPricePerMillion = 99.0, + cachedInputPricePerMillion = 99.0, + outputPricePerMillion = 99.0, + ) + TokenStatsLedger.legacyPriceProvider = { _, _ -> delay(Long.MAX_VALUE); null } + TokenStatSpool.prepareTimeoutMs = 50L + + TokenTrackingAIService.recordSafely(context, request("price-timeout")) + awaitEvent("price-timeout") + val event = database.tokenStatsDao().getEvent("price-timeout")!! + assertEquals(PricingSource.UNKNOWN.name, event.pricingSource) + assertNull(event.inputPricePerMillion) + assertNull(event.costInPricingCurrency) + assertTrue(event.diagnosticsJson!!.contains("pricing_read_timeout")) + } + + @Test + fun `more than two thousand append failures never return durable`() = runBlocking { + File(root, TokenStatSpool.SPOOL_DIR_NAME).writeText("not a directory") + Mockito.mockStatic(AppLogger::class.java).use { + repeat(2_001) { index -> + try { + TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")) + fail("append failure must throw") + } catch (_: TokenStatsPersistenceException) { + } + } + } + assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + + @Test + fun `crash half line never splices the next healthy event`() = runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 崩溃残留:active 尾部半行 JSON,无换行 + File(spool, "active.jsonl").writeText("{\"v\":2,\"eventId\":\"evt-crash-half\"") + TokenTrackingAIService.recordSafely(context, request("evt-healthy-after-crash")) + TokenStatSpool.replay(context) + awaitEvent("evt-healthy-after-crash") + // 健康事件恰好一次进入 Room,残缺证据完整保留在 quarantine + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals("evt-healthy-after-crash", database.tokenStatsDao().getAllEvents().single().eventId) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue("partial evidence must be preserved", evidence.single().readText().contains("evt-crash-half")) + } + } + + @Test + fun `same-name same-size same-mtime replacement is never skipped isolated or acked away`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val healthyLine = line(request("replacement-healthy")) + val healthyBytes = (healthyLine + "\n").toByteArray(Charsets.UTF_8).size + // 旧损坏段与健康新段字节数完全一致(P1-1:仅凭 length+mtime 的缓存才会被骗) + val oldBody = "{old-corrupt-" + "x".repeat(healthyBytes - "{old-corrupt-".length - 1) + "\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val fixedMtime = 1_700_000_000_000L + + val file = File(spool, "sealed_1.jsonl") + file.writeText(oldBody) + file.setLastModified(fixedMtime) + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText( + "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + // 先建立旧身份(旧实现中身份哈希缓存在此记住 length+mtime+sha) + TokenStatSpool.quarantineEvidence(context) + + // 同名同长同 mtime 替换为不同内容(健康行) + assertTrue(file.delete()) + file.writeText(healthyLine + "\n") + file.setLastModified(fixedMtime) + + // ack:陈旧记录被移除,健康文件绝不删除 + TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) + assertTrue("ack must never delete a replaced same-name healthy segment", file.exists()) + assertFalse( + "stale tombstone must be removed by ack", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + + // 重建陈旧记录,让维护入口与扫描器都看到它 + manifest.writeText( + "{\"ts\":2,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n", + ) + // replay:维护清理不删不隔离、扫描器不跳过,健康事件恰一次 + TokenStatSpool.replay(context) + awaitEvent("replacement-healthy") + awaitSegmentGone(spool, "sealed_1.jsonl") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "replacement-healthy", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + assertFalse( + "stale tombstone must be removed after replay", + manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), + ) + assertTrue( + "healthy segment must never be isolated as evidence", + TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, + ) + } + } + + @Test + fun `total spool cap stops appends while dao permanently fails and recovers after drain`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + // 行先于失败 DAO 生成(行生成需要真实价格读取),DAO 只负责排空失败 + val lines = (0 until 400).map { index -> + line(request("cap-$index")) to "cap-$index" + } + TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 + // 总 cap:约 3 个小段 + 行余量;DAO 永久失败 → sealed 段只增不减 + TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + try { + var rejected = 0 + for ((text, eventId) in lines) { + try { + TokenStatSpool.append(context, text, eventId) + } catch (e: TokenStatsPersistenceException) { + rejected++ + } + } + assertTrue("append must be refused once the total cap is reached: $rejected", rejected > 0) + val totalAtRejection = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + assertTrue( + "total spool bytes must never exceed the cap: $totalAtRejection", + totalAtRejection <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0), + ) + // 固定 cap 前停止:拒绝后不再发布任何新字节(seal 只是改名不增字节) + val frozen = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } + repeat(20) { + try { + TokenStatSpool.append(context, lines[0].first, "refused-$it") + fail("append after cap must keep failing") + } catch (e: TokenStatsPersistenceException) { + } + } + assertEquals( + "no new spool bytes may be published after the cap", + frozen, + spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }, + ) + assertEquals(0, database.tokenStatsDao().countEvents()) + + // drain 成功(DAO 恢复)后空间释放,append 继续 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitNoSealedSegments(spool) + TokenTrackingAIService.recordSafely(context, request("after-total-cap-recovery")) + awaitEvent("after-total-cap-recovery") + assertEquals( + "after-total-cap-recovery", + database.tokenStatsDao().getEvent("after-total-cap-recovery")!!.eventId, + ) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `single legal line exactly at the total cap is accepted and the next is refused`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest + // cap 与单行合法上限一致:恰好一行可写入,第二行必须明确拒绝 + TokenStatSpool.totalSpoolMaxBytesForTest = TokenStatSpool.MAX_LINE_BYTES.toLong() + // 行必须先于失败 DAO 生成(行生成需要真实价格读取);排空失败段才留在 + // spool,第二次 append 才会在总容量检查处触顶 + val padded = + padLineTo( + line(request("single-line-at-cap")), + TokenStatSpool.MAX_LINE_BYTES, + ) + val secondLine = line(request("refused-after-single")) + val failingDao = mock() + whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) + whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) + val proxy = mock() + whenever(proxy.tokenStatsDao()).thenReturn(failingDao) + TokenStatsLedger.databaseProvider = { proxy } + try { + assertEquals( + TokenStatSpool.MAX_LINE_BYTES, + (padded + "\n").toByteArray(Charsets.UTF_8).size, + ) + assertTrue(TokenStatSpool.append(context, padded, "single-line-at-cap")) + try { + TokenStatSpool.append(context, secondLine, "refused-after-single") + fail("append beyond the total cap must throw TokenStatsPersistenceException") + } catch (e: TokenStatsPersistenceException) { + } + // DAO 恢复后排空成功:单行事件恰一次,被拒绝的行从未发布 + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.replay(context) + awaitEvent("single-line-at-cap") + assertEquals(1, database.tokenStatsDao().countEvents()) + assertEquals( + "single-line-at-cap", + database.tokenStatsDao().getAllEvents().single().eventId, + ) + } finally { + TokenStatsLedger.databaseProvider = { database } + TokenStatSpool.totalSpoolMaxBytesForTest = previousCap + } + } + } + + @Test + fun `drain stays bounded and healthy appends stay durable while enumeration fails then recovers`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + // 健康段:根枚举失败期间 drain fail-closed 退避——段保留、不入 Room + File(spool, "sealed_1.jsonl").writeText(line(request("enum-drain-1")) + "\n") + // 陈旧候选:消失原件身份仍在 manifest + val oldBody = "{enum-drain-stale\n" + val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) + val manifestLine = + "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + + "\"sha256\":\"$oldSha\",\"overCap\":false}\n" + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + manifest.writeText(manifestLine) + // UNCOMMITTED trash 真实持有 sealed_2(valid mapping) + val trash = + File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") + trash.mkdirs() + File(trash, "sealed_2.jsonl").writeText(oldBody) + File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( + TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + + "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", + ) + TokenStatSpool.directoryListingForTest = { dir -> + if (dir == spool) null else dir.listFiles() + } + try { + TokenStatSpool.replay(context) + delay(700) + assertNull( + "no segment may drain while the root enumeration fails", + database.tokenStatsDao().getEvent("enum-drain-1"), + ) + assertTrue("healthy segment must be preserved", File(spool, "sealed_1.jsonl").exists()) + // 有界:trash 不处置、manifest 不重写 + assertTrue(trash.exists()) + assertEquals(manifestLine, safeManifestText(manifest)) + // 健康 append 在枚举失败期间仍然 durable(事件留在 active,不排空) + assertTrue( + TokenStatSpool.append( + context, + line(request("enum-append-2")), + "enum-append-2", + ), + ) + delay(700) + assertNull( + "appended event must stay durable but not drain while the root enumeration fails", + database.tokenStatsDao().getEvent("enum-append-2"), + ) + assertTrue("appended event must stay in active.jsonl", File(spool, "active.jsonl").exists()) + assertTrue(trash.exists()) + assertEquals(manifestLine, safeManifestText(manifest)) + } finally { + TokenStatSpool.directoryListingForTest = null + } + // 枚举恢复后处理:健康段与 active 排空;stale 清理与回滚完成 + TokenStatSpool.replay(context) + awaitEvent("enum-drain-1") + awaitEvent("enum-append-2") + awaitManifestWithout(spool, "sealed_2.jsonl") + val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) + assertFalse("trash must be rolled back after enumeration recovers", trash.exists()) + val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + var restored = false + while (System.nanoTime() < evidenceDeadline && !restored) { + restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } + if (!restored) delay(20) + } + assertTrue("held identity evidence must be restored after recovery", restored) + } + } + + @Test + fun `mid-copy partial target is isolated never drained or overwritten and active is retained`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES + val lineA = line(request("partial-a")) + val lineB = line(request("partial-b")) + val partial = lineA + "\n" // copy 中途只写入了完整行的前缀内容 + try { + File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") + TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 + TokenStatSpool.sealHardLinkForTest = { _, _ -> false } + TokenStatSpool.sealCopyForTest = { _, target -> + target.writeText(partial) + false + } + TokenStatSpool.shutdownWriterForTest() + try { + // copy 中途失败留下部分目标:append 必须明确失败且 active 保留 + assertFalse( + "append must fail when the seal copy fails mid-way", + TokenStatSpool.append(context, line(request("partial-c")), "partial-c"), + ) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + } + assertEquals( + "active must be retained with the full content", + lineA + "\n" + lineB + "\n", + File(spool, "active.jsonl").readText(), + ) + assertFalse( + "partial target must not remain as a normal sealed segment", + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, + ) + val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } + assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) + // 恢复:隔离副本由维护清理;部分内容绝不入 Room(完整内容只排空一次) + TokenStatSpool.replay(context) + awaitEvent("partial-a") + awaitEvent("partial-b") + awaitSegmentGone(spool, isolated.name) + awaitNoSealedSegments(spool) + assertEquals(2, database.tokenStatsDao().countEvents()) + } finally { + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes + } + } + } + + @Test + fun `pending-delete reverse rename not durable keeps retryable record and recovers exactly once`() = + runBlocking { + Mockito.mockStatic(AppLogger::class.java).use { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val body = "{reverse-rename-bad\n" + File(spool, "sealed_1.jsonl").writeText(body) + File(spool, "sealed_2.jsonl").writeText(line(request("reverse-rename-healthy")) + "\n") + // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 + TokenStatSpool.segmentRenameForTest = { _, to -> + if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { + false + } else { + null + } + } + TokenStatSpool.replay(context) + awaitEvent("reverse-rename-healthy") + val pending = spool.listFiles().orEmpty().single { + it.isFile && it.name.startsWith("quarantine_pending_delete_") + } + TokenStatSpool.segmentRenameForTest = null + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 2:维护恢复 rename 可见但目录项 sync 失败 → 尽力反向 rename 回 + // pending-delete 名(重建明确可重试记录);反向 rename 的目录项同样必须严格 + // sync(P2 终审),未确认持久绝不视为已重建 → 本轮退避,记录保留 + var failSyncs = true + TokenStatSpool.dirSyncForTest = { + if (failSyncs) TokenStatSpool.DirSyncResult.FAILED + else TokenStatSpool.DirSyncResult.OK + } + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + delay(900) + assertTrue( + "pending-delete record must be rebuilt when the restore rename is not durable", + pending.exists(), + ) + val evidence = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, evidence.size) + assertTrue( + "evidence must still be the pending-delete record", + evidence.single().name.startsWith("quarantine_pending_delete_"), + ) + // 退避期间任何 append 都不发布新字节(gate 已失效,bootstrap 重新确认前拒绝) + assertFalse( + TokenStatSpool.append(context, line(request("reverse-rename-blocked")), "reverse-rename-blocked"), + ) + assertFalse( + "no event may be written while dir entries are unconfirmed", + File(spool, "active.jsonl").exists(), + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + // 阶段 3:恢复——记录移回完整证据区,事件仍恰一次,后续 append 正常 + failSyncs = false + TokenStatSpool.shutdownWriterForTest() + TokenStatSpool.replay(context) + awaitNoPendingEvidence(spool) + val restored = TokenStatSpool.quarantineEvidence(context) + assertEquals(1, restored.size) + assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) + assertEquals(1, database.tokenStatsDao().countEvents()) + assertTrue(TokenStatSpool.append(context, line(request("reverse-rename-post")), "reverse-rename-post")) + TokenStatSpool.replay(context) + awaitEvent("reverse-rename-post") + assertEquals(2, database.tokenStatsDao().countEvents()) + } + } + +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt deleted file mode 100644 index c3070177c..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTest.kt +++ /dev/null @@ -1,5784 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class TokenStatReliabilityTest { - private lateinit var root: File - private lateinit var context: Context - private lateinit var database: AppDatabase - - @Before - fun setUp() { - root = kotlin.io.path.createTempDirectory("token-stat-reliability").toFile() - context = mock().also { ctx -> - whenever(ctx.applicationContext).thenReturn(ctx) - whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") - whenever(ctx.filesDir).thenReturn(root) - whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } - } - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - TokenStatsLedger.databaseProvider = { database } - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - TokenStatSpool.clearPendingStateForTest() - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.afterSegmentReadForTest = null - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.quarantineAtomicMoveForTest = null - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.directoryListingForTest = null - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.fileSyncForTest = null - // P1 终审:Windows JVM 测试统一注入“目录 fsync 支持且成功”(平台无关)——生产 - // Android/Linux 支持目录 fd fsync;UNSUPPORTED/FAILED 只由显式 fail-closed 测试在 - // 测试体内注入并在 finally 还原,不存在“原地排空”平台模式。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealCopyForTest = null - } - - @After - fun tearDown() { - // P1-1 终审修复:测试可能以“目录项未确认持久”状态结束(gate=false),tearDown 的快照 - // barrier 会重新 bootstrap——必须先恢复“目录 fsync 支持且成功”的平台常态(Windows JVM - // 真实探测恒为 UNSUPPORTED),否则 gate 在 tearDown 中失败并掩盖测试结果。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - runBlocking { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } - } - TokenStatsLedger.databaseProvider = null - TokenStatsLedger.legacyPriceProvider = null - TokenStatSpool.afterSegmentReadForTest = null - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.quarantineAtomicMoveForTest = null - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.directoryListingForTest = null - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.afterDrainRoundForTest = null - TokenStatSpool.rejectDrainScheduleForTest = false - TokenStatSpool.prepareTimeoutMs = 5_000L - TokenStatSpool.insertTimeoutMs = 5_000L - TokenStatSpool.exclusiveQuiesceTimeoutMs = 5_000L - // 每个测试结束必须无遗留 spool worker 线程:shutdown 已释放的 worker 后确认终止 - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - database.close() - } - - private fun request( - id: String, - generation: Long = 0L, - startedAt: Long = 1_000L, - ) = TokenStatRequestContext( - eventId = id, - category = TokenStatCategory.CHAT, - configId = "cfg", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = startedAt, - acceptedGeneration = generation, - // P1 终审:请求“开始”时同步捕获 restore epoch(与生产 newRequest 一致);恢复屏障 - // 递增 epoch 后,捕获于屏障前的旧请求在 append 时被明确拒绝。 - sessionEpoch = TokenStatSpool.captureRestoreEpoch(), - ).apply { - onUsage( - ProviderUsageSnapshot( - uncachedInputTokens = 10L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - outputTokens = 5L, - source = "test", - ), - ) - finish(TokenStatStatus.COMPLETED, startedAt) - } - - private suspend fun line(request: TokenStatRequestContext): String = - TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) - - private suspend fun awaitEvent(id: String) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent(id) == null && System.nanoTime() < deadline) delay(20) - } - - /** - * 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 - * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 - */ - private fun gateIgnoringInterrupts(gate: CountDownLatch) { - while (true) { - try { - if (gate.await(1, TimeUnit.SECONDS)) return - } catch (_: InterruptedException) { - } - } - } - - /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ - private fun awaitNoSpoolWorkerThreads() { - fun live(): List = - Thread.getAllStackTraces().entries - .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } - .map { it.key.name } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) - while (System.nanoTime() < deadline) { - if (live().isEmpty()) return - Thread.sleep(20) - } - fail("spool worker threads leaked: ${live()}") - } - - @Test - fun `price override read timeout is durable unknown not default`() = runBlocking { - database.tokenStatsDao().upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 99.0, - cachedInputPricePerMillion = 99.0, - outputPricePerMillion = 99.0, - ) - TokenStatsLedger.legacyPriceProvider = { _, _ -> delay(Long.MAX_VALUE); null } - TokenStatSpool.prepareTimeoutMs = 50L - - TokenTrackingAIService.recordSafely(context, request("price-timeout")) - awaitEvent("price-timeout") - val event = database.tokenStatsDao().getEvent("price-timeout")!! - assertEquals(PricingSource.UNKNOWN.name, event.pricingSource) - assertNull(event.inputPricePerMillion) - assertNull(event.costInPricingCurrency) - assertTrue(event.diagnosticsJson!!.contains("pricing_read_timeout")) - } - - @Test - fun `more than two thousand append failures never return durable`() = runBlocking { - File(root, TokenStatSpool.SPOOL_DIR_NAME).writeText("not a directory") - Mockito.mockStatic(AppLogger::class.java).use { - repeat(2_001) { index -> - try { - TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")) - fail("append failure must throw") - } catch (_: TokenStatsPersistenceException) { - } - } - } - assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - - @Test - fun `generation handles same millisecond clock rollback and request spanning reset`() = runBlocking { - val dao = database.tokenStatsDao() - val oldSameMillisecond = request("old-same-ms", generation = 0L, startedAt = 5_000L) - val oldFinishesAfterReset = request("old-spanning", generation = 0L, startedAt = 9_000L) - - dao.resetAllStatisticsTx() - assertEquals(1L, dao.currentResetGeneration()) - TokenStatsLedger.recordWith(context, dao, oldSameMillisecond) - TokenStatsLedger.recordWith(context, dao, oldFinishesAfterReset) - assertEquals(0, dao.countEvents()) - - // New request after reset is accepted even if its wall clock moved backwards. - TokenStatsLedger.recordWith( - context, - dao, - request("new-clock-rollback", generation = 1L, startedAt = 1L), - ) - assertEquals(1, dao.countEvents()) - assertEquals(1L, dao.getEvent("new-clock-rollback")!!.acceptedGeneration) - } - - @Test - fun `restore barrier waits for segment read and old task cannot insert afterward`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("old-before-restore")) + "\n") - val read = CountDownLatch(1) - val release = CountDownLatch(1) - TokenStatSpool.afterSegmentReadForTest = { - read.countDown() - release.await(10, TimeUnit.SECONDS) - } - TokenStatSpool.replay(context) - assertTrue(read.await(10, TimeUnit.SECONDS)) - - val restore = async { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - // Simulates the restored database contents replacing everything inserted before - // this exclusive section. No old worker may insert after this point. - database.tokenStatsDao().deleteAllEvents() - } - } - delay(100) - assertFalse("restore must wait for the in-flight old drain", restore.isCompleted) - release.countDown() - restore.await() - delay(100) - assertNull(database.tokenStatsDao().getEvent("old-before-restore")) - } - - @Test - fun `deferred restore commit failure preserves old and new request accounting`() = runBlocking { - val oldRequest = request("old-request-after-commit-failure") - val oldEpoch = oldRequest.sessionEpoch - try { - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = {}, - commitReplacement = { throw IOException("REPLACING write failed") }, - ) { - fail("replacement must not run when commit fails") - } - fail("commit failure must propagate") - } catch (e: IOException) { - assertEquals("REPLACING write failed", e.message) - } - - assertEquals(oldEpoch, TokenStatSpool.captureRestoreEpoch()) - assertTrue(TokenStatSpool.isAcceptingEvents()) - TokenTrackingAIService.recordSafely(context, oldRequest) - TokenTrackingAIService.recordSafely(context, request("new-request-after-commit-failure")) - awaitEvent("old-request-after-commit-failure") - awaitEvent("new-request-after-commit-failure") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - - @Test - fun `interrupt ignoring insert never locks spool and restore barrier stays clean`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousInsert = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 100 - try { - val spoolDir = File(root, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - // 先构建行(价格解析走真实 DAO),再安装忽略中断但可释放的 insert 挂起 - val lineA = line(request("evt-hung-a")) - val lineB = line(request("evt-hung-b")) - val realDao = database.tokenStatsDao() - val release = CountDownLatch(1) - val blockingDao = mock() - whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> - // SQLite 忽略中断:cancel(true) 无法终止;释放后委托真实 DAO 完成 - gateIgnoringInterrupts(release) - runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } - } - whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> - runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } - } - whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> - runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } - } - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) - TokenStatsLedger.databaseProvider = { proxy } - - // append A durable;drain 启动后 insert 挂起(忽略中断) - assertTrue(TokenStatSpool.append(context, lineA, "evt-hung-a")) - val latchDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < latchDeadline) { - delay(10) - } - assertEquals(1, TokenStatSpool.pendingLatchCountForTest()) - - // 硬上限(insertTimeoutMs)之后:锁必须已释放,append 不再被阻塞 - val startedSecond = System.nanoTime() - assertTrue(TokenStatSpool.append(context, lineB, "evt-hung-b")) - val secondElapsedMs = (System.nanoTime() - startedSecond) / 1_000_000 - assertTrue("append must never block on the hung insert: ${secondElapsedMs}ms", secondElapsedMs < 10_000) - - // restore barrier:wedged insert 仍存活(已通过 fence、正在 Room 内)时, - // 必须有界失败且绝不替换文件;等待结束后旧 insert 仍登记在 registry - val startedRestore = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } - fail("restore must fail bounded while an old insert is still live") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 - assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) - assertEquals(1, TokenStatSpool.activeInsertCountForTest()) - - // 模拟重启前必须释放并确认旧 insert 线程终止:释放门闩 → registry 真正清空 - release.countDown() - val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { - delay(10) - } - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - - // 丢弃已完成的旧 worker(shutdown 后线程真实终止),再模拟进程重启; - // 被卡任务由新排空重放幂等完成(失败的 restore 从未替换数据库) - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) - - TokenStatsLedger.databaseProvider = { database } - TokenTrackingAIService.recordSafely(context, request("evt-after-restore")) - awaitEvent("evt-after-restore") - awaitEvent("evt-hung-a") - awaitEvent("evt-hung-b") - assertEquals(3, database.tokenStatsDao().countEvents()) - - // 重试 restore:registry 已空,替换模拟可执行,恢复后的 DB 无旧事件 - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - database.tokenStatsDao().deleteAllEvents() - } - assertEquals(0, database.tokenStatsDao().countEvents()) - assertNull(database.tokenStatsDao().getEvent("evt-hung-a")) - assertNull(database.tokenStatsDao().getEvent("evt-hung-b")) - assertNull(database.tokenStatsDao().getEvent("evt-after-restore")) - - // 恢复后的新事件正常落账且只出现一次。P1 终审:恢复替换已开始(accepting= - // false),同进程后续事件被明确拒绝——必须先模拟进程重启(reset 状态)才 - // 允许写入;这正是 UI“稍后重启”窗口的语义。 - TokenStatSpool.resetExecutorsForTest() - TokenTrackingAIService.recordSafely(context, request("evt-post-restore")) - awaitEvent("evt-post-restore") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("evt-post-restore", database.tokenStatsDao().getAllEvents().single().eventId) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.insertTimeoutMs = previousInsert - } - } - } - - @Test - fun `crash half line never splices the next healthy event`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃残留:active 尾部半行 JSON,无换行 - File(spool, "active.jsonl").writeText("{\"v\":2,\"eventId\":\"evt-crash-half\"") - TokenTrackingAIService.recordSafely(context, request("evt-healthy-after-crash")) - TokenStatSpool.replay(context) - awaitEvent("evt-healthy-after-crash") - // 健康事件恰好一次进入 Room,残缺证据完整保留在 quarantine - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("evt-healthy-after-crash", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue("partial evidence must be preserved", evidence.single().readText().contains("evt-crash-half")) - } - } - - @Test - fun `restore with a live Room insert fails bounded before replacement and later restore is clean`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousInsert = TokenStatSpool.insertTimeoutMs - val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs - TokenStatSpool.insertTimeoutMs = 100 - TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("evt-live-a")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n") - - // 真实 Room + 真实 spool 文件;DAO 层在 fence 之后、事务写入前挂起 - // (模拟 SQLite 已持有连接、忽略中断的旧 insert),释放后委托真实 DAO - val realDao = database.tokenStatsDao() - val entered = CountDownLatch(1) - val release = CountDownLatch(1) - val blockingDao = mock() - whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> - entered.countDown() - // SQLite 忽略中断:阻塞中的 insert 必须继续等待,不能被 task.cancel 打断 - while (true) { - try { - if (release.await(1, TimeUnit.SECONDS)) break - } catch (_: InterruptedException) { - } - } - runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } - } - whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> - runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } - } - whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> - runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } - } - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) - TokenStatsLedger.databaseProvider = { proxy } - - TokenStatSpool.replay(context) - assertTrue( - "insert must have passed the fence and be inside Room", - entered.await(10, TimeUnit.SECONDS) - ) - assertEquals(1, TokenStatSpool.activeInsertCountForTest()) - - // insert timeout 已释放 lifecycleMutex;restore 门闩必须有界失败, - // 替换块绝不执行(数据库不被覆盖/污染),durable 段保留 - val startedRestore = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - fail("replacement must never run while an old insert is live") - } - fail("restore must fail bounded") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 - assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) - assertEquals(0, database.tokenStatsDao().countEvents()) - assertTrue( - "durable segment must survive a failed restore", - File(spool, "sealed_1.jsonl").exists() - ) - - // 释放旧 insert:它只能写入未被替换的旧库;registry 清空后重试 restore 干净通过 - release.countDown() - awaitEvent("evt-live-a") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - // 模拟恢复数据库替换:旧事件必须已从排空路径彻底消失 - database.tokenStatsDao().deleteAllEvents() - } - assertEquals(0, database.tokenStatsDao().countEvents()) - // 自定义 SQLiteDriver 的 Room 没有 SupportSQLiteOpenHelper:直接复用 JVM 驱动 - // 打开同一数据库文件校验完整性 - val integrity = - JdbcSQLiteDriver().open(File(root, "app_database").absolutePath).use { connection -> - connection.prepare("PRAGMA integrity_check").use { statement -> - statement.step() - statement.getText(0) - } - } - assertEquals("restored database must pass integrity check", "ok", integrity) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.insertTimeoutMs = previousInsert - TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce - } - } - } - - @Test - fun `quarantine export and delete file work never runs on the caller main thread`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 超过 16MiB 的证据:满上限 + 额外段(复制/fsync 足够大,能卡住 Main) - RandomAccessFile(File(spool, "quarantine_existing_sealed_1.jsonl"), "rw").use { - it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) - } - File(spool, "quarantine_existing_sealed_2.jsonl").writeText("legacy-over-cap\n") - - val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } - Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) - val ioThreads = ConcurrentHashMap.newKeySet() - val previousIo = TokenStatSpool.ioDispatcher - TokenStatSpool.ioDispatcher = object : CoroutineDispatcher() { - override fun dispatch(context: CoroutineContext, block: Runnable) { - Dispatchers.IO.dispatch(context) { - ioThreads += Thread.currentThread().name - block.run() - } - } - } - try { - withContext(Dispatchers.Main) { - val exported = - TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertTrue(exported.size >= 2) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exported.map { it.name }.toSet()) - } - assertTrue("file I/O must actually dispatch", ioThreads.isNotEmpty()) - assertFalse( - "evidence file I/O must never run on the main thread: $ioThreads", - ioThreads.any { it == "test-main-thread" } - ) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - } finally { - TokenStatSpool.ioDispatcher = previousIo - Dispatchers.resetMain() - mainExecutor.shutdown() - } - } - - @Test - fun `database preparation timeouts stay single flight with bounded threads`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousPrepare = TokenStatSpool.prepareTimeoutMs - TokenStatSpool.prepareTimeoutMs = 50 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("evt-db-prep-hang")) + "\n") - // 数据库准备挂起且忽略中断(可释放):每次 drain 循环都必须单飞复用同一任务 - val release = CountDownLatch(1) - TokenStatsLedger.databaseProvider = { - gateIgnoringInterrupts(release) - database - } - TokenStatSpool.replay(context) - // 第 1 个退避周期 - delay(1_200) - TokenStatSpool.replay(context) - // 第 2 个退避周期 - delay(2_200) - val dbThreads = Thread.getAllStackTraces().keys.count { - it.isAlive && it.name.startsWith("operit-token-stats-database") - } - assertTrue("database preparation must stay single-flight: $dbThreads", dbThreads <= 1) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 释放被卡住的准备任务并确认旧 worker 真实终止后再模拟重启 - release.countDown() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - - // 恢复后(重置 worker 模拟重启)事件仍能落账 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitEvent("evt-db-prep-hang") - assertEquals(1, database.tokenStatsDao().countEvents()) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.prepareTimeoutMs = previousPrepare - } - } - } - - @Test - fun `restore cleanup deletion failure is explicit`() = runBlocking { - File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs(); resolve("active.jsonl").writeText("x") } - TokenStatSpool.spoolDeleteForTest = { false } - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore cleanup failure must propagate") - } catch (e: IOException) { - assertTrue(e.message!!.contains("cleanup failed")) - } - } - - @Test - fun `snapshot barrier moves spool only event into Room exactly once`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-backup")) + "\n") - - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }) - } - // A replay after the snapshot/restore boundary is idempotent and cannot duplicate it. - TokenStatSpool.replay(context) - delay(100) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `snapshot fails before block while quarantine evidence would be excluded`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{corrupt snapshot evidence\n") - var blockRan = false - - Mockito.mockStatic(AppLogger::class.java).use { - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must not silently omit quarantine evidence") - } catch (e: IOException) { - assertTrue(e.message!!.contains("quarantine evidence")) - } - } - - assertFalse("snapshot block must not run", blockRan) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue(evidence.single().readText().contains("corrupt snapshot evidence")) - } - - @Test - fun `summary only evidence can be explicitly acknowledged before snapshot`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summary = File(spool, "quarantine_summary.jsonl") - summary.writeText("{\"count\":1}\n") - var blockRan = false - - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must not silently omit the quarantine summary") - } catch (e: IOException) { - assertTrue(e.message!!.contains("quarantine evidence")) - } - assertFalse(blockRan) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context = context, - names = emptySet(), - deleteSummary = true, - ) - - assertFalse(summary.exists()) - assertEquals(null, TokenStatSpool.quarantineSummaryInfo(context)) - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - assertTrue(blockRan) - } - - @Test - fun `two corrupt segments quarantine uniquely and healthy segment drains`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{bad-one\n") - File(spool, "sealed_2.jsonl").writeText("{bad-two\n") - File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-corrupt")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-corrupt") - } - assertEquals(1, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertEquals(2, evidence.map { it.name }.toSet().size) - } - - @Test - fun `quarantine at cap summarizes over-cap segment and keeps within-cap full evidence`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{new-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - // 硬边界:容量内完整证据保留;超限损坏段替换为固定大小摘要并移除原段 - assertTrue(existing.exists()) - assertFalse("over-cap corrupt segment must be replaced by its summary", File(spool, "sealed_2.jsonl").exists()) - assertEquals(1, TokenStatSpool.quarantineEvidence(context).size) - assertTrue( - "evidence disk usage must stay within the hard cap", - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("over-cap evidence must be reported as a bounded summary", summary) - assertEquals(1, summary!!.recordCount) - - // 导出包含摘要文件;确认删除只作用于完整证据(摘要保留为滚动记录) - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals(2, exported.size) - assertTrue(exported.any { it.name == "quarantine_summary.jsonl" }) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) - assertFalse(existing.exists()) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } - - @Test - fun `quarantine hard cap keeps disk bounded far beyond cap and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - repeat(8) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{bad-$index\n") } - File(spool, "sealed_10.jsonl").writeText(line(request("healthy-beyond-cap")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitEvent("healthy-beyond-cap") - } - // 远超上限时:磁盘占用有界(完整证据不超上限)、摘要累计、健康段照常排空 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-beyond-cap", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence disk usage must stay within the hard cap", - evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(summary) - assertEquals(8, summary!!.recordCount) - assertTrue( - "summary must have a fixed upper bound", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES - ) - - // 导出/删除入口在满容量时可调用,摘要随导出提供 - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals(2, exported.size) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - } - - @Test - fun `quarantine summary is rolling and never contains corrupt content`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val total = TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + 50 - repeat(total) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{corrupt-body-$index\n") } - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(summary) - assertTrue( - "summary must roll at a fixed line cap: ${summary!!.recordCount}", - summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES - ) - assertTrue( - "summary must have a fixed byte cap", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES - ) - val summaryText = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest records must survive the roll", summaryText.contains("sealed_${total + 1}.jsonl")) - assertTrue("summary must carry hash, bytes and line counts", summaryText.contains("sha256")) - assertFalse("summary must never embed corrupt content", summaryText.contains("corrupt-body")) - assertTrue(existing.exists()) - assertTrue( - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - } - - @Test - fun `quarantine summary publishes atomically via fallback when atomic move unsupported`() = - runBlocking { - val previous = TokenStatSpool.quarantineAtomicMoveForTest - // 强制 ATOMIC_MOVE 不支持(P1-1):必须走 old/new/backup 回退且结果完整 - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - // 目标已存在(旧摘要)时回退协议必须保留旧值直到新值就绪 - File(spool, "quarantine_summary.jsonl").writeText("{\"old\":\"value\"}\n") - File(spool, "sealed_2.jsonl").writeText("{forced-fallback-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertEquals(2, summary.recordCount) - val text = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest record must survive the fallback publish", text.contains("sealed_2.jsonl")) - assertTrue("old record must be preserved in the rebuilt summary", text.contains("\"old\":\"value\"")) - assertTrue(text.contains("sha256")) - assertFalse("fallback must not leave staged sidecars", File(spool, "quarantine_summary.jsonl.new").exists()) - assertFalse("fallback must not leave backup sidecars", File(spool, "quarantine_summary.jsonl.bak").exists()) - } finally { - TokenStatSpool.quarantineAtomicMoveForTest = previous - } - } - - @Test - fun `quarantine summary publish failure keeps old summary and pending segment`() = runBlocking { - val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summaryFile = File(spool, "quarantine_summary.jsonl") - summaryFile.writeText("{\"old\":\"preserved\"}\n") - // 让回退提交失败:.bak 位置放一个非空目录,renameTo 无法覆盖(发布失败路径) - val bakDir = File(spool, "quarantine_summary.jsonl.bak") - bakDir.mkdirs() - File(bakDir, "lock").writeText("x") - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val segment = File(spool, "sealed_2.jsonl") - segment.writeText("{fail-publish-bad\n") - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - delay(800) - } - // 发布失败:旧摘要保持完整、待处理段保留、错误可见(不声称成功) - assertEquals("{\"old\":\"preserved\"}\n", summaryFile.readText()) - assertTrue("pending segment must be retained on publish failure", segment.exists()) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } finally { - TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic - File(bakDir, "lock").delete() - bakDir.delete() - File(spool, "quarantine_summary.jsonl.new").delete() - } - } - - @Test - fun `quarantine summary survives interruption at each replacement step`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summaryFile = File(spool, "quarantine_summary.jsonl") - val oldContent = "{\"k\":\"old\"}\n" - val newContent = "{\"k\":\"new\"}\n" - - // 窗口 A:target 缺失、.new 完整就绪(target→bak 之后、.new→target 之前崩溃) - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.new").writeText(newContent) - assertTrue(summaryFile.delete()) - val infoA = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(infoA) - val recoveredA = summaryFile.readText().trim() - assertTrue( - "interruption must recover complete old or new: $recoveredA", - recoveredA == oldContent.trim() || recoveredA == newContent.trim(), - ) - - // 窗口 B:target 缺失、.bak=完整旧(bak 已就绪但恢复前崩溃) - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) - assertTrue(summaryFile.delete()) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(oldContent.trim(), summaryFile.readText().trim()) - - // 窗口 C:target=完整新、.bak=残留旧(提交后、清理前崩溃) - summaryFile.writeText(newContent) - File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(newContent.trim(), summaryFile.readText().trim()) - assertFalse("stale backup must be cleaned after a successful read", File(spool, "quarantine_summary.jsonl.bak").exists()) - - // 窗口 D:仅 .tmp 残留(tmp 写入后崩溃)→ target 完整旧 - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.tmpstale").writeText(newContent) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(oldContent.trim(), summaryFile.readText().trim()) - } - - @Test - fun `quarantine summary byte cap enforced with oversized pre-existing summary`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 预置超字节上限但行数很少的旧摘要(旧版本残留/手工膨胀),裁剪必须自愈 - val bigLine = "{\"padding\":\"${"x".repeat(30 * 1024)}\"}\n" - File(spool, "quarantine_summary.jsonl").writeText(bigLine.repeat(3)) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{byte-cap-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertTrue( - "summary must shrink below the byte cap: ${summary.summaryBytes}", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, - ) - assertTrue(summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES) - assertTrue("newest record must survive the byte roll", File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) - } - - @Test - fun `quarantine summary retry after crash does not duplicate record`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val body = "{retry-bad\n" - File(spool, "sealed_2.jsonl").writeText(body) - // 模拟“上次摘要已发布、段删除前崩溃”:摘要已有同一段(file+sha256)的完整记录 - val sha = - java.security.MessageDigest.getInstance("SHA-256") - .digest(body.toByteArray(Charsets.UTF_8)) - .joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } - File(spool, "quarantine_summary.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${body.length}," + - "\"sha256\":\"$sha\",\"lineCount\":1,\"corruptLines\":1}\n", - ) - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - // 崩溃重试幂等:不重复追加记录,段正常处置 - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertEquals("crash retry must not duplicate the record", 1, summary.recordCount) - assertTrue(File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) - } - - @Test - fun `within-cap corrupt rename failure is kept as bounded pending-delete evidence and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rename-fail-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-pending")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - // 只让“移入证据区”的重命名失败,pending-delete 重命名放行(容量内预算允许) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-pending") - awaitNoSealedSegments(spool) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - // 健康事件恰一次入库;删除/重命名失败的段已移出 sealed 扫描队列为有界证据 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-after-pending", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - val pending = evidence.filter { it.name.startsWith("quarantine_pending_delete_") } - assertEquals("failed rename must be retained as pending-delete evidence", 1, pending.size) - assertTrue("full evidence must be preserved", pending.single().readText().contains("rename-fail-bad")) - assertTrue( - "error evidence must stay within the hard cap", - evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, - ) - assertFalse("no tombstone needed while the pending budget fits", File(spool, "quarantine_skip_manifest.jsonl").exists()) - - // 维护/后台重试:恢复重命名能力后,下一次 drain 把 pending 证据移回证据区 - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "pending-delete evidence must be restored to the evidence area", - restored.any { it.name.startsWith("quarantine_") && !it.name.startsWith("quarantine_pending_delete_") }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `over-cap delete failure with full evidence area tombstone the segment and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{tombstone-bad\n") - File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-tombstone")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - // 注入删除失败:只对 over-cap 损坏段生效(P1-2),健康段删除不受影响 - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name == "sealed_2.jsonl") false else null - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-tombstone") - } finally { - TokenStatSpool.segmentDeleteForTest = null - } - } - // 后续健康事件恰一次入库;删除失败的 over-cap 段被 tombstone 跳过(摘要已有 hash/bytes) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-after-tombstone", database.tokenStatsDao().getAllEvents().single().eventId) - assertTrue( - "tombstoned segment must be recorded in the bounded manifest", - File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_2.jsonl"), - ) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "quarantine evidence area must stay within the hard cap (managed set separately bounded)", - evidence.filter { it.name.startsWith("quarantine_") }.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, - ) - // P1-3:tombstoned 原 sealed 作为 managed evidence 可见(参与 UI 计数/导出/删除) - assertTrue( - "tombstoned original sealed must appear as managed evidence", - evidence.any { it.name == "sealed_2.jsonl" }, - ) - assertTrue( - TokenStatSpool.quarantineEvidence(context).none { it.name.startsWith("quarantine_pending_delete_") }, - ) - - // 维护/后台重试:恢复删除能力后,下一次 drain 删除 tombstoned 段并移除记录 - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_2.jsonl") - awaitManifestWithout(spool, "sealed_2.jsonl") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } - - @Test - fun `permanent dispose failures fill the managed set bounded then refuse appends and recover`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P1-1:删除与重命名永久失败(只针对 sealed 段:损坏处置、维护移回全部失败) - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name.startsWith("sealed_")) false else null - } - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - try { - // 超过受管集合上限的损坏段:受管集合封顶,剩余段有界跳过 - repeat(TokenStatSpool.MAX_TOMBSTONE_ENTRIES + 5) { index -> - File(spool, "sealed_${index + 1}.jsonl").writeText("{permanent-fail-$index\n") - } - val drainStart = System.nanoTime() - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val manifestCount: () -> Int = { - safeManifestText(manifest) - ?.lineSequence()?.filter { it.isNotBlank() }?.count() ?: 0 - } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - manifestCount() != TokenStatSpool.MAX_TOMBSTONE_ENTRIES - ) { - delay(20) - } - val drainMs = (System.nanoTime() - drainStart) / 1_000_000 - assertTrue("drain must return bounded: ${drainMs}ms", drainMs < 10_000) - delay(500) - val entryCount = manifestCount() - assertEquals( - "managed set must cap at the hard limit, never roll identities away", - TokenStatSpool.MAX_TOMBSTONE_ENTRIES, - entryCount, - ) - // 总占用有界:受管段(≤4MiB/段 × 上限)+ manifest(≤64KiB)+ 证据区 - val totalBytes = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "total spool usage must be bounded: $totalBytes", - totalBytes <= TokenStatSpool.MAX_TOMBSTONE_ENTRIES * (TokenStatSpool.MAX_SEGMENT_BYTES + 4096) + - TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + 1_048_576, - ) - - // 超限新业务明确失败且无伪 durable - try { - TokenStatSpool.append(context, line(request("refused-after-cap")), "refused-after-cap") - fail("append beyond managed capacity must throw TokenStatsPersistenceException") - } catch (e: TokenStatsPersistenceException) { - } - assertEquals(0, database.tokenStatsDao().countEvents()) - - // snapshot barrier 有界失败(未受管段仍在队列),绝不死锁 - val snapStart = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { } - fail("snapshot must not claim drained while unmanageable segments remain") - } catch (e: IOException) { - assertTrue(e.message!!.contains("pending events")) - } - val snapMs = (System.nanoTime() - snapStart) / 1_000_000 - assertTrue("snapshot must be bounded: ${snapMs}ms", snapMs < 10_000) - - // 恢复文件系统:maintenance 清理受管段与陈旧条目 → 容量释放 → 新业务可继续 - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - awaitManifestWithout(spool, "sealed_") - TokenTrackingAIService.recordSafely(context, request("after-managed-recovery")) - awaitEvent("after-managed-recovery") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "after-managed-recovery", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - } finally { - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `stale tombstone identity never deletes or skips a reused-name healthy segment`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 旧损坏段 sealed_1 曾因处置失败被 tombstone(身份 = 旧内容 hash) - val oldBody = "{old-corrupt\n" - File(spool, "sealed_1.jsonl").writeText(oldBody) - // 崩溃窗口:旧文件被外部删除,manifest 尚未更新 - assertTrue(File(spool, "sealed_1.jsonl").delete()) - File(spool, "quarantine_skip_manifest.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"${sha256Hex(oldBody.toByteArray(Charsets.UTF_8))}\",\"overCap\":false}\n", - ) - // 新健康段复用同名(不同 hash) - File(spool, "sealed_1.jsonl").writeText(line(request("reused-name-healthy")) + "\n") - TokenStatSpool.replay(context) - awaitEvent("reused-name-healthy") - awaitSegmentGone(spool, "sealed_1.jsonl") - // P1-2:健康段恰一次真实入库,绝不被 tombstone 跳过或删除 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("reused-name-healthy", database.tokenStatsDao().getAllEvents().single().eventId) - // 陈旧记录被移除 - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertFalse( - "stale tombstone must be removed", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - } - } - - @Test - fun `twice-rename-failure original sealed is managed evidence exportable and ack-deleted`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{twice-rename-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-evidence")) + "\n") - // P1-3:两次重命名都失败(进证据区 + pending-delete 都失败)→ tombstone 原段 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-evidence") - // 等 drain 完成损坏段处置:tombstone 记录落盘(原段保留在磁盘上, - // 不能等它消失——受管失败段本就不消失) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "tombstone must be recorded for the twice-rename-failed segment", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - assertEquals(1, database.tokenStatsDao().countEvents()) - // tombstoned 原 sealed 必须作为 managed evidence 参与计数/字节 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "original sealed must appear as managed evidence", - evidence.any { it.name == "sealed_1.jsonl" }, - ) - // 导出包含原文件(原文件名,身份可追溯)并附 manifest - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertTrue(exported.any { it.name == "sealed_1.jsonl" }) - assertTrue(exported.any { it.name == "quarantine_skip_manifest.jsonl" }) - assertTrue( - "exported managed evidence must retain the corrupt content", - exported.single { it.name == "sealed_1.jsonl" }.readText().contains("twice-rename-bad"), - ) - // ack 确认删除:按 identity 删除原文件并移除对应 manifest 记录 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) - assertFalse("acked managed evidence must be deleted", File(spool, "sealed_1.jsonl").exists()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertFalse( - "manifest entry must be removed after ack", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - assertTrue(TokenStatSpool.quarantineEvidence(context).isEmpty()) - // 健康继续 - TokenTrackingAIService.recordSafely(context, request("after-evidence-ack")) - awaitEvent("after-evidence-ack") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - } - - @Test - fun `quarantine summary byte cap counts UTF-8 bytes for non-ASCII lines`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P2-1:预置摘要的 UTF-16 长度低于 64KiB,但 UTF-8 字节超上限(每字符 3 字节) - val chineseLine = "{\"padding\":\"${"统".repeat(22 * 1024)}\"}\n" - File(spool, "quarantine_summary.jsonl").writeText(chineseLine) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{utf8-cap-bad\n") - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertTrue( - "summary UTF-8 bytes must respect the cap: ${summary.summaryBytes}", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, - ) - val text = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest record must survive the roll", text.contains("sealed_2.jsonl")) - assertTrue( - "oversized non-ASCII line must be replaced by a fixed ASCII truncated record", - text.contains("\"truncated\":true"), - ) - assertFalse("truncated record must never embed content", text.contains("统")) - } - } - - @Test - fun `export recovers canonical summary and manifest when only sidecars remain`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P2-2:崩溃窗口——canonical 缺失,内容只在 .new sidecar(完整、已 fsync) - val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" - File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) - val manifestContent = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":2,\"sha256\":\"def\",\"overCap\":true}\n" - File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals( - summaryContent, - exported.single { it.name == "quarantine_summary.jsonl" }.readText(), - ) - assertEquals( - manifestContent, - exported.single { it.name == "quarantine_skip_manifest.jsonl" }.readText(), - ) - // canonical 也已被恢复,后续信息/ack 不再依赖 sidecar - assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) - assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) - } - } - - @Test - fun `same-name same-size same-mtime replacement is never skipped isolated or acked away`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val healthyLine = line(request("replacement-healthy")) - val healthyBytes = (healthyLine + "\n").toByteArray(Charsets.UTF_8).size - // 旧损坏段与健康新段字节数完全一致(P1-1:仅凭 length+mtime 的缓存才会被骗) - val oldBody = "{old-corrupt-" + "x".repeat(healthyBytes - "{old-corrupt-".length - 1) + "\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val fixedMtime = 1_700_000_000_000L - - val file = File(spool, "sealed_1.jsonl") - file.writeText(oldBody) - file.setLastModified(fixedMtime) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - // 先建立旧身份(旧实现中身份哈希缓存在此记住 length+mtime+sha) - TokenStatSpool.quarantineEvidence(context) - - // 同名同长同 mtime 替换为不同内容(健康行) - assertTrue(file.delete()) - file.writeText(healthyLine + "\n") - file.setLastModified(fixedMtime) - - // ack:陈旧记录被移除,健康文件绝不删除 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertTrue("ack must never delete a replaced same-name healthy segment", file.exists()) - assertFalse( - "stale tombstone must be removed by ack", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - - // 重建陈旧记录,让维护入口与扫描器都看到它 - manifest.writeText( - "{\"ts\":2,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - // replay:维护清理不删不隔离、扫描器不跳过,健康事件恰一次 - TokenStatSpool.replay(context) - awaitEvent("replacement-healthy") - awaitSegmentGone(spool, "sealed_1.jsonl") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "replacement-healthy", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - assertFalse( - "stale tombstone must be removed after replay", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - assertTrue( - "healthy segment must never be isolated as evidence", - TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, - ) - } - } - - @Test - fun `total spool cap stops appends while dao permanently fails and recovers after drain`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - // 行先于失败 DAO 生成(行生成需要真实价格读取),DAO 只负责排空失败 - val lines = (0 until 400).map { index -> - line(request("cap-$index")) to "cap-$index" - } - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap:约 3 个小段 + 行余量;DAO 永久失败 → sealed 段只增不减 - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - try { - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertTrue("append must be refused once the total cap is reached: $rejected", rejected > 0) - val totalAtRejection = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "total spool bytes must never exceed the cap: $totalAtRejection", - totalAtRejection <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0), - ) - // 固定 cap 前停止:拒绝后不再发布任何新字节(seal 只是改名不增字节) - val frozen = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - repeat(20) { - try { - TokenStatSpool.append(context, lines[0].first, "refused-$it") - fail("append after cap must keep failing") - } catch (e: TokenStatsPersistenceException) { - } - } - assertEquals( - "no new spool bytes may be published after the cap", - frozen, - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // drain 成功(DAO 恢复)后空间释放,append 继续 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - TokenTrackingAIService.recordSafely(context, request("after-total-cap-recovery")) - awaitEvent("after-total-cap-recovery") - assertEquals( - "after-total-cap-recovery", - database.tokenStatsDao().getEvent("after-total-cap-recovery")!!.eventId, - ) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `single legal line exactly at the total cap is accepted and the next is refused`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - // cap 与单行合法上限一致:恰好一行可写入,第二行必须明确拒绝 - TokenStatSpool.totalSpoolMaxBytesForTest = TokenStatSpool.MAX_LINE_BYTES.toLong() - // 行必须先于失败 DAO 生成(行生成需要真实价格读取);排空失败段才留在 - // spool,第二次 append 才会在总容量检查处触顶 - val padded = - padLineTo( - line(request("single-line-at-cap")), - TokenStatSpool.MAX_LINE_BYTES, - ) - val secondLine = line(request("refused-after-single")) - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - try { - assertEquals( - TokenStatSpool.MAX_LINE_BYTES, - (padded + "\n").toByteArray(Charsets.UTF_8).size, - ) - assertTrue(TokenStatSpool.append(context, padded, "single-line-at-cap")) - try { - TokenStatSpool.append(context, secondLine, "refused-after-single") - fail("append beyond the total cap must throw TokenStatsPersistenceException") - } catch (e: TokenStatsPersistenceException) { - } - // DAO 恢复后排空成功:单行事件恰一次,被拒绝的行从未发布 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitEvent("single-line-at-cap") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "single-line-at-cap", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `evidence info and ack recover manifest and summary from new sidecar without export`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val oldBody = "{sidecar-new-only\n" - File(spool, "sealed_1.jsonl").writeText(oldBody) - val sha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestContent = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha\",\"overCap\":false}\n" - // 崩溃窗口:canonical 缺失,内容只在 .new(完整、已 fsync) - File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) - val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" - File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) - - // 不先 export:直接调用 evidence/info/ack - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "managed evidence must be visible after sidecar recovery", - evidence.any { it.name == "sealed_1.jsonl" }, - ) - val info = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("summary info must recover from sidecar", info) - assertEquals(1, info!!.recordCount) - // canonical 已恢复且 sidecar 身份被清理 - assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) - assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) - assertFalse(File(spool, "quarantine_skip_manifest.jsonl.new").exists()) - assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) - // ack 按身份删除 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "manifest entry must be removed after ack", - File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_1.jsonl"), - ) - } - } - - @Test - fun `append capacity check recovers a full managed set from backup sidecar`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃窗口:canonical 缺失,只有 .bak(完整旧值)——受管集合已满 - val content = (1..TokenStatSpool.MAX_TOMBSTONE_ENTRIES).joinToString("\n") { index -> - "{\"ts\":1,\"file\":\"sealed_$index.jsonl\",\"bytes\":3," + - "\"sha256\":\"${sha256Hex("x$index".toByteArray(Charsets.UTF_8))}\",\"overCap\":true}" - } + "\n" - File(spool, "quarantine_skip_manifest.jsonl.bak").writeText(content) - try { - // append 容量检查必须看到恢复后的满受管集合:明确拒绝且不发布新文件 - TokenStatSpool.append(context, line(request("refused-bak-recovery")), "refused-bak-recovery") - fail("append must fail when the recovered managed set is full") - } catch (e: TokenStatsPersistenceException) { - } - assertEquals(0, database.tokenStatsDao().countEvents()) - // canonical 已恢复且 .bak 身份被清理 - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertEquals( - TokenStatSpool.MAX_TOMBSTONE_ENTRIES, - manifest.readText().lineSequence().filter { it.isNotBlank() }.count(), - ) - assertFalse(File(spool, "quarantine_skip_manifest.jsonl.bak").exists()) - } - } - - @Test - fun `each export uses its own empty directory and stale exports never leak`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_first_sealed_1.jsonl").writeText("first-bad\n") - val exportA = File(root, "token_stats_quarantine_A") - val exportedA = TokenStatSpool.exportQuarantineEvidence(context, exportA) - assertTrue(exportedA.any { it.name.startsWith("quarantine_first_") }) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exportedA.map { it.name }.toSet()) - // 第二次导出到新目录:只含本次证据,上一次的残留绝不混入/冒充 - File(spool, "quarantine_second_sealed_2.jsonl").writeText("second-bad\n") - val exportB = File(root, "token_stats_quarantine_B") - val exportedB = TokenStatSpool.exportQuarantineEvidence(context, exportB) - assertTrue(exportedB.any { it.name.startsWith("quarantine_second_") }) - assertFalse( - "a previous export must never leak into the new export directory", - exportB.listFiles().orEmpty().any { it.name.startsWith("quarantine_first_") }, - ) - assertFalse( - "previous export must never be reported as this run's result", - exportedB.any { it.name.startsWith("quarantine_first_") }, - ) - } - - @Test - fun `export into a non-empty destination is refused without touching its content`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_x_sealed_1.jsonl").writeText("bad\n") - val dest = File(root, "token_stats_quarantine_existing").apply { mkdirs() } - val userFile = File(dest, "user-notes.txt").apply { writeText("do not touch") } - try { - TokenStatSpool.exportQuarantineEvidence(context, dest) - fail("export into a non-empty destination must be refused") - } catch (e: IOException) { - assertTrue("refusal must name the reason", e.message!!.contains("not empty")) - } - assertEquals("do not touch", userFile.readText()) - assertFalse( - "no evidence may be written into a refused destination", - dest.listFiles().orEmpty().any { it.name.startsWith("quarantine_") }, - ) - } - - @Test - fun `manifest read failure fails closed scanner ack and append and recovers after restore`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{managed-bad\n") - // 先正常建立受管失败段(重命名失败 → tombstone 记录落盘) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - - // 注入 manifest 读取失败:scanner/容量/维护全部中止退避,受管段不处理 - TokenStatSpool.metadataReadErrorForTest = { file -> - file.name == "quarantine_skip_manifest.jsonl" - } - TokenStatSpool.replay(context) - delay(700) - assertTrue( - "managed segment must not be processed while the manifest is unreadable", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue( - "manifest entry must be retained", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // ack 报错:manifest 不可读时不能确认删除 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail while the manifest is unreadable") - } catch (e: IOException) { - } - assertTrue( - "entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // 容量检查 fail-closed:append 不发布、不声称 durable - assertFalse( - "append must fail closed while the manifest is unreadable", - TokenStatSpool.append( - context, - line(request("fail-closed-append")), - "fail-closed-append", - ), - ) - assertFalse( - File(spool, "active.jsonl").isFile && File(spool, "active.jsonl").length() > 0L, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复后正常:append 重新 durable,ack 按身份删除并清理记录 - TokenStatSpool.metadataReadErrorForTest = null - assertTrue( - TokenStatSpool.append( - context, - line(request("after-manifest-recovery")), - "after-manifest-recovery", - ), - ) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "entry must be removed after a successful ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - TokenStatSpool.replay(context) - awaitEvent("after-manifest-recovery") - } finally { - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `segment read failure keeps managed entries and ack refuses until identity is readable`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{unreadable-bad\n") - // 正常建立受管失败段(重命名失败 → tombstone) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - - // 段原始字节读取失败(身份校验 UNREADABLE):受管段不处理、entry 保留 - TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_1.jsonl" } - TokenStatSpool.replay(context) - delay(700) - assertTrue( - "unreadable managed segment must be skipped, never processed", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue( - "manifest entry must be retained for the unreadable segment", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // evidence 列表不暴露身份不可校验的受管段(不可安全导出/ack) - assertTrue( - TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, - ) - // ack 不能成功 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail while the segment identity is unreadable") - } catch (e: IOException) { - assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) - } - assertTrue(File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // 恢复后正常:ack 按身份删除并移除记录 - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "entry must be removed after a successful ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } finally { - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack manifest read failure preserves quarantine evidence managed evidence and manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // quarantine area 完整证据(无 manifest 记录)+ 受管失败段(重命名失败 → tombstone) - File(spool, "quarantine_area_sealed_1.jsonl").writeText("area-bad\n") - File(spool, "sealed_2.jsonl").writeText("{managed-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // manifest 不可读 → 整个 ack 失败:quarantine + managed + manifest 全部保留 - TokenStatSpool.metadataReadErrorForTest = { file -> - file.name == "quarantine_skip_manifest.jsonl" - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("quarantine_area_sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail while the manifest is unreadable") - } catch (e: IOException) { - } - assertTrue( - "quarantine evidence must survive a failed ack", - File(spool, "quarantine_area_sealed_1.jsonl").exists(), - ) - assertTrue( - "managed evidence must survive a failed ack", - File(spool, "sealed_2.jsonl").exists(), - ) - assertTrue( - "manifest entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - assertTrue( - "no ack trash directory may be left behind", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } finally { - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack with later unreadable managed identity keeps earlier match and all entries`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{first-bad\n") - File(spool, "sealed_2.jsonl").writeText("{second-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 后一个段身份不可校验(UNREADABLE)→ 整个 ack 失败:前一个 MATCH 也不删除 - TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_2.jsonl" } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail when any managed identity is unreadable") - } catch (e: IOException) { - assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) - } - assertTrue( - "earlier matched segment must not be deleted on a partial failure", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue(File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "both entries must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // 恢复后一次 ack 按身份删除两个段并移除两条记录 - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_2.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - } finally { - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack staging rename failure rolls back staged renames and keeps manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rollback-a\n") - File(spool, "sealed_2.jsonl").writeText("{rollback-b\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 第 2 个文件的 stage rename 失败 → 第 1 个已 stage 的文件必须回滚, - // manifest 不改;回滚 rename 的目标是 spool 根目录,不受注入影响 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == "sealed_2.jsonl" -> false - else -> null - } - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail when staging a rename fails") - } catch (e: IOException) { - assertTrue("ack must report the staging failure", e.message!!.contains("stage")) - } - assertTrue( - "staged file must be rolled back after a failed rename", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue(File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "no trash directory may remain after rollback", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - assertTrue( - "both entries must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // 恢复真实 rename 后 ack 成功:按身份删除两个段并移除两条记录 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_2.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack manifest write failure rolls back all staged files and keeps old manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val managed = File(spool, "sealed_1.jsonl").apply { writeText("{managed-bad\n") } - val quarantine = - File(spool, "quarantine_area_sealed_2.jsonl").apply { writeText("area-bad\n") } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val oldManifest = - "{\"file\":\"${managed.name}\",\"bytes\":${managed.length()}," + - "\"sha256\":\"${sha256Hex(managed.readBytes())}\",\"overCap\":false}\n" - manifest.writeText(oldManifest) - TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(managed.name, quarantine.name), - ) - fail("ack must fail when the manifest cannot be published") - } catch (e: IOException) { - assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) - } finally { - TokenStatSpool.metadataWriteErrorForTest = null - } - assertTrue("managed evidence must be restored", managed.isFile) - assertTrue("quarantine evidence must be restored", quarantine.isFile) - assertEquals("old manifest must remain byte-for-byte intact", oldManifest, manifest.readText()) - assertTrue( - "no trash directory may remain after a successful rollback", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } - } - - @Test - fun `ack rollback move with not durable dir sync keeps uncommitted trash and maintenance recovers it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText("{rollback-sync-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - var calls = 0 - try { - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - TokenStatSpool.segmentRenameForTest = null - // 阶段 2:manifest 重写失败触发回滚;回滚 move 的目录项 sync(第 7 次: - // 1 次 manifest 严格读取 + 1 次 trash 创建 + 2 次暂存 + 2 次状态写入) - // 失败 → trash 保留 UNCOMMITTED 状态、上层失败,绝不静默(P2) - TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 7) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail when the rollback dir sync is not OK") - } catch (e: IOException) { - assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained after a not-durable rollback", 1, trashDirs.size) - val state = File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue( - "state must remain UNCOMMITTED for maintenance rollback", - state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - // 回滚 move 已可见(证据回到原路径)但目录项未确认:mapping 仍持有身份, - // 维护按状态机幂等完成 - assertTrue("evidence is back at its original path", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 3:恢复后维护按 UNCOMMITTED + mapping 完成回滚并删除 trash;损坏 - // sealed 随后被扫描器重新隔离为完整证据(与 ack 崩溃窗口协议一致) - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be resolved by maintenance after recovery", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val body = "{rollback-sync-bad\n" - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < evidenceDeadline && - TokenStatSpool.quarantineEvidence(context).none { it.readText() == body } - ) { - delay(20) - } - assertEquals( - "evidence must be re-quarantined exactly once after the rollback", - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `committed ack trash residue counts into the total cap and maintenance cleans it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousDelete = TokenStatSpool.spoolDeleteForTest - try { - // 大证据文件:ack 后留在 trash(删除被强制失败),必须计入总容量 - val evidence = File(spool, "quarantine_trash_cap_sealed_1.jsonl") - RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB,28KiB 残留 - // 证据已让每次 append 的递归投影超限——旧实现只数顶层会放行到实际 36KiB - TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 - TokenStatSpool.spoolDeleteForTest = { false } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("committed trash must remain when deletion is forced to fail", 1, trashDirs.size) - assertEquals( - "commit flip must be persisted in the trash state file", - TokenStatSpool.ACK_STATE_COMMITTED + "\n", - File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).readText(), - ) - - // 行先于失败 DAO 生成(行生成需要真实价格读取);DAO 只负责排空失败 - val lines = (0 until 200).map { index -> - line(request("trash-cap-$index")) to "trash-cap-$index" - } - // DAO 永久失败 → sealed 段只增不减;递归总容量必须计入 trash 残留 - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertEquals( - "every append must be refused while the trash residue holds the admission budget: $rejected", - lines.size, - rejected, - ) - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - fun recursiveTotal(): Long = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "recursive total including trash must never exceed the cap: ${recursiveTotal()}", - recursiveTotal() <= cap, - ) - // 冻结断言:拒绝后不再发布任何字节 - val frozen = recursiveTotal() - repeat(10) { - try { - TokenStatSpool.append(context, lines[0].first, "refused-trash-$it") - fail("append after trash-inclusive cap must keep failing") - } catch (e: TokenStatsPersistenceException) { - } - } - assertEquals(frozen, recursiveTotal()) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 维护补删恢复:删除恢复后 replay 清掉 committed trash - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.replay(context) - val cleanupDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < cleanupDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "committed trash must be removed by maintenance once deletion works", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - // DAO 恢复后排空与 append 都恢复正常 - TokenStatsLedger.databaseProvider = { database } - assertTrue( - TokenStatSpool.append( - context, - line(request("after-trash-recovery")), - "after-trash-recovery", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-trash-recovery") - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.spoolDeleteForTest = previousDelete - } - } - } - - @Test - fun `ack refuses when trash state metadata would push the total over the cap`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - try { - // 大量小证据文件 → mapping 状态文件较大;cap 只留 4KiB 头部空间, - // 4 槽位最坏投影(mapping ~10KiB × 4)必然超限 - val files = (0 until 60).map { index -> - File(spool, "quarantine_many_$index.jsonl").apply { writeText("bad-$index\n") } - } - val totalNow = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - TokenStatSpool.totalSpoolMaxBytesForTest = totalNow + 4 * 1024 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, files.map { it.name }.toSet()) - fail("ack must fail when the trash state metadata does not fit the total cap") - } catch (e: IOException) { - } - // 全部证据仍在原位、没有 trash 目录残留、总量不超限(stage 已回滚) - files.forEach { assertTrue("evidence must stay in place: ${it.name}", it.exists()) } - assertTrue( - "no trash directory may remain after the refused ack", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - assertTrue( - "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L), - ) - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `ack staging failure with rollback failure keeps uncommitted trash and maintenance recovers it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rb-fail-a\n") - File(spool, "sealed_2.jsonl").writeText("{rb-fail-b\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 第 2 个文件 stage 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == "sealed_2.jsonl" -> false - to.parentFile?.name != null && - !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && - to.name == "sealed_1.jsonl" -> false - else -> null - } - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained after rollback failure", 1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("staged evidence must stay in trash", File(trash, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertTrue("sealed_2 must stay in place (stage never happened)", File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "trash state must be UNCOMMITTED with a mapping", - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - .readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - assertTrue( - "manifest entry must remain", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertTrue( - "manifest entry must remain", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // replay 维护(rename 仍被注入失败):不删 trash、不删证据、manifest 条目保留 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must never delete uncommitted trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive maintenance", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertFalse( - "sealed_2 was restored to the evidence area by maintenance", - File(spool, "sealed_2.jsonl").exists(), - ) - - // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚并自愈 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be gone after a successful maintenance rollback", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - awaitManifestWithout(spool, "sealed_1.jsonl") - awaitManifestWithout(spool, "sealed_2.jsonl") - // 两份证据都回到完整证据区(可导出/可 ack) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.name.contains("sealed_1.jsonl") }) - assertTrue(evidence.any { it.name.contains("sealed_2.jsonl") }) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `crash window with published manifest rolls back uncommitted trash and scanner re-quarantines`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 手工构造崩溃窗口:主 manifest 已发布(不含该身份),但 commit 标记未写。 - // P1-1:UNCOMMITTED 绝不根据 manifest 缺失推断已提交——必须回滚证据, - // 回滚后的损坏 sealed 会被扫描器重新隔离(ack 视失败但不丢证据)。 - val body = "{crash-window-bad\n" - val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", - ) - // manifest 不存在 = 条目已全部移除(旧实现会据此误判 committed 并删除证据) - TokenStatSpool.replay(context) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && trash.exists()) delay(20) - assertFalse( - "maintenance must roll back uncommitted crash-window trash", - trash.exists(), - ) - // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据,绝不丢失 - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var reQuarantined = false - while (System.nanoTime() < evidenceDeadline && !reQuarantined) { - reQuarantined = TokenStatSpool.quarantineEvidence(context).any { - it.name.contains("sealed_1.jsonl") && it.readText() == body - } - if (!reQuarantined) delay(20) - } - assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) - } - } - - @Test - fun `ordinary evidence stage failure with rollback failure keeps uncommitted trash and maintenance restores it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 普通(非受管)quarantine 证据:从不在 tombstone manifest 中。P1-1 修复前, - // 维护会因 manifest 缺失推断“已提交”而删除 trash(丢失未确认的证据)。 - val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText("{ord-a\n") } - val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText("{ord-b\n") } - // 第 2 个文件 stage rename 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == ev2.name -> false - to.parentFile?.name != null && - !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && - to.name == ev1.name -> false - else -> null - } - } - try { - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(ev1.name, ev2.name), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals(1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("staged evidence must stay in trash", File(trash, ev1.name).exists()) - assertFalse(ev1.exists()) - assertTrue("ev2 stage never happened", ev2.exists()) - - // replay 维护(回滚 rename 仍被注入失败):绝不删除 trash 与证据 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must never delete uncommitted ordinary evidence", trash.exists()) - assertTrue(File(trash, ev1.name).exists()) - assertFalse("no partial rollback may occur", ev1.exists()) - - // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚,证据不删最终恢复 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be gone after a successful maintenance rollback", trash.exists()) - assertTrue("ev1 must be restored to the evidence area", ev1.exists()) - assertTrue("ev2 must stay in the evidence area", ev2.exists()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(setOf(ev1.name, ev2.name), evidence.map { it.name }.toSet()) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `partially corrupt ack trash mapping is fail-closed and maintenance retains everything`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{p12-a\n" - val body2 = "{p12-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - // 首行有效 mapping + 一行损坏 mapping:mapNotNull 会静默丢弃损坏行, - // 只回滚 1 个文件并删除 trash——旧实现会丢失第 2 份证据(P1-2) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - TokenStatSpool.replay(context) - delay(700) - assertTrue("partially corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse("no rollback may happen from a partial mapping", File(spool, "sealed_1.jsonl").exists()) - // UI 可见:作为 stuck 受管证据列出 - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - - // 修复为重复 mapping(同一原名两条)→ 仍然 fail-closed 保留 - val lineA = - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + lineA, - ) - TokenStatSpool.replay(context) - delay(700) - assertTrue("duplicate mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - - // 完整修复 mapping(两份证据都被覆盖)→ 维护回滚并自愈 - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) - // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - } - } - - @Test - fun `partial mapping with unreadable trash enumeration is fail-closed and manifest stays verbatim`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{enum-null-a\n" - val body2 = "{enum-null-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha1\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - // 首行有效 mapping + 一行损坏:全有或全无解析必然失败;trash 枚举再失败时, - // 即使 mapping 已覆盖可见证据,未枚举的证据也无法排除 → 仍必须 fail-closed - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) - File(spool, "sealed_9.jsonl").writeText(line(request("enum-null-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - awaitEvent("enum-null-healthy") - delay(700) - assertTrue("trash must be retained while its enumeration fails", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse( - "no rollback may happen from a partial mapping with failed enumeration", - File(spool, "sealed_1.jsonl").exists(), - ) - assertFalse("no un-enumerated evidence may be deleted", File(spool, "sealed_2.jsonl").exists()) - assertEquals( - "manifest must be preserved verbatim", - manifestLine, - safeManifestText(manifest), - ) - // P1-6 fail-closed:stuck 证据枚举走同一 seam——枚举失败时 UI 查询必须 - // 明确抛错,绝不能返回部分/空列表误导用户删除 - try { - TokenStatSpool.stuckAckTrashEvidence(context) - fail("stuck ack trash evidence must fail while trash enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - try { - TokenStatSpool.stuckAckTrashBytes(context) - fail("stuck ack trash bytes must fail while trash enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 有界:重复维护轮不改写 manifest、不处置 trash - TokenStatSpool.replay(context) - delay(700) - assertTrue(trash.exists()) - assertEquals( - "repeated maintenance rounds must not rewrite the manifest", - manifestLine, - safeManifestText(manifest), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复枚举 + 完整 mapping → 维护回滚并自愈(cleanup 成功) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once enumeration and mapping recover", trash.exists()) - // P1-6:枚举成功且无 trash 时才是真正的空列表 - assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var evidence: List = emptyList() - while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { - evidence = TokenStatSpool.quarantineEvidence(context) - if (evidence.size != 2) delay(20) - } - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - } - } - - @Test - fun `spool root enumeration failure makes trash state unknown and blocks stale removal`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 消失原件的 manifest 条目:根枚举失败时无法证明旧身份不被未枚举的 trash 持有 - val oldBody = "{root-enum-stale\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - // UNCOMMITTED trash 真实持有该身份(根枚举失败时完全不可见) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - // 健康段:根枚举失败期间 drain 必须 fail-closed 退避——段保留、绝不入 Room - File(spool, "sealed_9.jsonl").writeText(line(request("root-enum-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - delay(700) - assertNull( - "no segment may drain while the root enumeration fails", - database.tokenStatsDao().getEvent("root-enum-healthy"), - ) - assertTrue("healthy segment must be preserved", File(spool, "sealed_9.jsonl").exists()) - assertEquals( - "stale removal must be blocked while the root enumeration fails", - manifestLine, - safeManifestText(manifest), - ) - assertTrue( - "trash must be retained while the root enumeration fails", - trash.exists(), - ) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - // 有界:重复维护轮保持原样 - TokenStatSpool.replay(context) - delay(700) - assertEquals(manifestLine, safeManifestText(manifest)) - assertTrue(trash.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 枚举恢复:健康段排空;身份确实被 trash 持有 → 回滚后按 MATCH 处置,条目最终移除 - TokenStatSpool.replay(context) - awaitEvent("root-enum-healthy") - awaitManifestWithout(spool, "sealed_1.jsonl") - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once enumeration recovers", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var restored = false - while (System.nanoTime() < evidenceDeadline && !restored) { - restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } - if (!restored) delay(20) - } - assertTrue("held evidence must be restored after recovery", restored) - } - } - - @Test - fun `drain stays bounded and healthy appends stay durable while enumeration fails then recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 健康段:根枚举失败期间 drain fail-closed 退避——段保留、不入 Room - File(spool, "sealed_1.jsonl").writeText(line(request("enum-drain-1")) + "\n") - // 陈旧候选:消失原件身份仍在 manifest - val oldBody = "{enum-drain-stale\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - // UNCOMMITTED trash 真实持有 sealed_2(valid mapping) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_2.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - delay(700) - assertNull( - "no segment may drain while the root enumeration fails", - database.tokenStatsDao().getEvent("enum-drain-1"), - ) - assertTrue("healthy segment must be preserved", File(spool, "sealed_1.jsonl").exists()) - // 有界:trash 不处置、manifest 不重写 - assertTrue(trash.exists()) - assertEquals(manifestLine, safeManifestText(manifest)) - // 健康 append 在枚举失败期间仍然 durable(事件留在 active,不排空) - assertTrue( - TokenStatSpool.append( - context, - line(request("enum-append-2")), - "enum-append-2", - ), - ) - delay(700) - assertNull( - "appended event must stay durable but not drain while the root enumeration fails", - database.tokenStatsDao().getEvent("enum-append-2"), - ) - assertTrue("appended event must stay in active.jsonl", File(spool, "active.jsonl").exists()) - assertTrue(trash.exists()) - assertEquals(manifestLine, safeManifestText(manifest)) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 枚举恢复后处理:健康段与 active 排空;stale 清理与回滚完成 - TokenStatSpool.replay(context) - awaitEvent("enum-drain-1") - awaitEvent("enum-append-2") - awaitManifestWithout(spool, "sealed_2.jsonl") - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back after enumeration recovers", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var restored = false - while (System.nanoTime() < evidenceDeadline && !restored) { - restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } - if (!restored) delay(20) - } - assertTrue("held identity evidence must be restored after recovery", restored) - } - } - - @Test - fun `maintenance defers rollback while trash enumeration fails and cleanup succeeds after the seam recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{enum-recovery-bad\n" - val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", - ) - // mapping 完全有效也必须在枚举失败时 fail-closed:无法证明没有未枚举的证据 - // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) - File(spool, "sealed_9.jsonl").writeText(line(request("enum-recovery-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - awaitEvent("enum-recovery-healthy") - delay(700) - assertTrue( - "valid mapping must still be fail-closed while enumeration fails", - trash.exists(), - ) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertFalse( - "no rollback may happen while enumeration fails", - File(spool, "sealed_1.jsonl").exists(), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam → rollback cleanup 成功:trash 删除、证据回到原槽位、被扫描器隔离 - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be deleted after the successful rollback", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var reQuarantined = false - while (System.nanoTime() < evidenceDeadline && !reQuarantined) { - reQuarantined = TokenStatSpool.quarantineEvidence(context).any { - it.isFile && it.readText() == body - } - if (!reQuarantined) delay(20) - } - assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) - } - } - - @Test - fun `spool root enumeration failure aborts snapshot and seal without touching segments then recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = line(request("enum-null-sealed")) + "\n" - val body2 = line(request("enum-null-active")) + "\n" - val sealed1 = File(spool, "sealed_1.jsonl") - sealed1.writeText(body1) - val active = File(spool, "active.jsonl") - active.writeText(body2) - val activeText = active.readText() - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - // active 已有内容:下一次 append 必走 seal 路径(P1-7 场景) - TokenStatSpool.MAX_SEGMENT_BYTES = active.length() + 1 - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - // 1) 快照的 drain 阶段 fail-closed → block 绝不执行、文件原字节不变 - var blockRan = false - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must fail while the spool root enumeration fails") - } catch (e: IOException) { - assertTrue( - "failure must come from the snapshot drain barrier", - e.message!!.contains("drain"), - ) - } - assertFalse("snapshot block must not run", blockRan) - assertEquals("sealed_1 must stay byte-identical", body1, sealed1.readText()) - assertEquals("active must stay byte-identical", activeText, active.readText()) - - // 2) seal 绝不覆盖:需要 seal 的 append 明确失败,sealed_1/active 原样 - assertFalse( - "append requiring a seal must fail while enumeration fails", - TokenStatSpool.append( - context, - line(request("enum-null-extra")), - "enum-null-extra", - ), - ) - assertEquals("sealed_1 must never be overwritten", body1, sealed1.readText()) - assertEquals("active must not be sealed or truncated", activeText, active.readText()) - - // 3) drain 保留:后台 drain 轮退避,文件与事件原样 - TokenStatSpool.replay(context) - delay(700) - assertEquals("sealed_1 must be preserved by the failing drain", body1, sealed1.readText()) - assertEquals("active must be preserved by the failing drain", activeText, active.readText()) - assertNull( - "sealed event must not reach Room while enumeration fails", - database.tokenStatsDao().getEvent("enum-null-sealed"), - ) - assertNull( - "active event must not reach Room while enumeration fails", - database.tokenStatsDao().getEvent("enum-null-active"), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 4) 恢复 seam → 两事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("enum-null-sealed") - awaitEvent("enum-null-active") - assertEquals( - "each preserved event must be recorded exactly once", - 2, - database.tokenStatsDao().countEvents(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal never overwrites an occupied target and picks the next safe index`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val bodyA = line(request("seal-collide-a")) + "\n" - val bodyB = line(request("seal-collide-b")) + "\n" - File(spool, "sealed_1.jsonl").writeText(bodyA) - val sealed2 = File(spool, "sealed_2.jsonl") - sealed2.writeText(bodyB) - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - // active 已含一条完整行;枚举缝隐藏 sealed_2 → 计算出的 next=2 已被 - // 占用,seal 必须跳到 3,绝不覆盖 sealed_2 - val bodyC = line(request("seal-collide-c")) + "\n" - File(spool, "active.jsonl").writeText(bodyC) - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) { - dir.listFiles()?.filter { it.name != "sealed_2.jsonl" }?.toTypedArray() - } else { - dir.listFiles() - } - } - try { - assertTrue( - "append must seal active to a free index and succeed", - TokenStatSpool.append( - context, - line(request("seal-collide-d")), - "seal-collide-d", - ), - ) - // seam 仍生效:并发 drain 与 seal 都看不见 sealed_2 → 占用目标不可能被覆盖 - assertEquals( - "occupied sealed target must never be overwritten", - bodyB, - sealed2.readText(), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复枚举后全部事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("seal-collide-a") - awaitEvent("seal-collide-b") - awaitEvent("seal-collide-c") - awaitEvent("seal-collide-d") - assertEquals( - "each event must be recorded exactly once", - 4, - database.tokenStatsDao().countEvents(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal publish conflict keeps target bytes and seals active at a higher index`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val seamBody = "{pre-existing-conflict\n" - try { - // active 已含两条完整行;下一次 append 必触发 seal(候选编号 1) - val lineA = line(request("seal-seam-a")) - val lineB = line(request("seal-seam-b")) - val lineC = line(request("seal-seam-c")) - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var hooks = 0 - TokenStatSpool.beforeSealPublishForTest = { target -> - hooks += 1 - if (hooks == 1) { - // 候选选定后、实际 publish 前出现同名不同内容的目标(模拟异常残留) - target.writeText(seamBody) - true - } else { - null - } - } - // 停掉后台 writer:seal 仍在 append 内同步完成,但断言阶段不会被并发 drain - // 改写/隔离文件(确定性) - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal active to a free index and succeed", - TokenStatSpool.append(context, lineC, "seal-seam-c"), - ) - } finally { - TokenStatSpool.beforeSealPublishForTest = null - } - // 冲突目标原字节不变;active 数据安全落到更高编号 sealed_2;新事件在 active - assertEquals( - "conflict target must keep its original bytes", - seamBody, - File(spool, "sealed_1.jsonl").readText(), - ) - assertEquals( - "active data must be sealed to a higher index", - lineA + "\n" + lineB + "\n", - File(spool, "sealed_2.jsonl").readText(), - ) - assertEquals( - "new event must be durable in active", - lineC + "\n", - File(spool, "active.jsonl").readText(), - ) - // 全部事件各恰一次入 Room;冲突残留被隔离为完整证据、字节不变 - TokenStatSpool.replay(context) - awaitEvent("seal-seam-a") - awaitEvent("seal-seam-b") - awaitEvent("seal-seam-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "conflict residue must be preserved byte-identical as evidence", - evidence.any { it.isFile && it.readText() == seamBody }, - ) - } finally { - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal crash window recovers before append and each event drains exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("hardlink-window-a")) - val lineB = line(request("hardlink-window-b")) - val active = File(spool, "active.jsonl") - active.writeText(lineA + "\n") - // 模拟崩溃窗口:createLink(sealed_1, active) 成功但 active 删除前崩溃 → 同 inode - Files.createLink(File(spool, "sealed_1.jsonl").toPath(), active.toPath()) - // 停掉后台 writer:恢复发生在 append 内同步完成,断言不被并发 drain 干扰 - TokenStatSpool.shutdownWriterForTest() - // append 必须先恢复重复(删除 sealed 副本)再写入,绝不能把新内容写进已 seal 段 - assertTrue(TokenStatSpool.append(context, lineB, "hardlink-window-b")) - assertFalse( - "sealed duplicate must be removed before append, never polluted", - File(spool, "sealed_1.jsonl").exists(), - ) - TokenStatSpool.replay(context) - awaitEvent("hardlink-window-a") - awaitEvent("hardlink-window-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("hardlink-window-a", "hardlink-window-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `seal publish with active delete failure is rolled back and later recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("delete-fail-a")) - val lineB = line(request("delete-fail-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var failures = 0 - TokenStatSpool.sealActiveDeleteForTest = { - failures += 1 - false - } - try { - // seal:createLink 成功 → 删除 active 失败 → 回滚链接 → seal 失败 - // → append 明确失败(B 未发布、无伪 durable) - assertFalse( - "append must fail when the post-publish active delete fails", - TokenStatSpool.append(context, lineB, "delete-fail-b"), - ) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - } - assertEquals(1, failures) - // 回滚成功:无 sealed 残留;active 保持原内容 - assertTrue( - "rolled-back seal must leave no sealed residue", - spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }, - ) - assertEquals(lineA + "\n", File(spool, "active.jsonl").readText()) - // 恢复后:既有事件恰一次入 Room,被拒绝的 B 从未发布 - TokenStatSpool.replay(context) - awaitEvent("delete-fail-a") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue( - "append must succeed after the delete failure recovers", - TokenStatSpool.append(context, lineB, "delete-fail-b"), - ) - awaitEvent("delete-fail-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink unsupported falls back to atomic no-replace copy publish`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("copy-fallback-a")) - val lineB = line(request("copy-fallback-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - // 停掉后台 writer:seal 在 append 内同步完成(copy 回退),断言不被并发 drain 干扰 - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal via the copy fallback and succeed", - TokenStatSpool.append(context, lineB, "copy-fallback-b"), - ) - } finally { - TokenStatSpool.sealHardLinkForTest = null - } - // copy 发布成功:sealed_1 = active 原内容,active = 新事件 - assertEquals(lineA + "\n", File(spool, "sealed_1.jsonl").readText()) - assertEquals(lineB + "\n", File(spool, "active.jsonl").readText()) - // 恢复 writer 后全部事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("copy-fallback-a") - awaitEvent("copy-fallback-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("copy-fallback-a", "copy-fallback-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback crash window content duplicate is recovered and drains once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("copy-window-a")) - val lineB = line(request("copy-window-b")) - val lineC = line(request("copy-window-c")) - try { - val content = lineA + "\n" + lineB + "\n" - // 模拟 copy 回退崩溃窗口:sealed_1 复制完成、active 删除未发生 - // (两个独立 inode 同内容) - File(spool, "active.jsonl").writeText(content) - File(spool, "sealed_1.jsonl").writeText(content) - TokenStatSpool.MAX_SEGMENT_BYTES = content.length.toLong() + 1 - TokenStatSpool.shutdownWriterForTest() - // append 必须先按内容识别并删除 sealed 副本;随后的 seal 把内容重新封为 - // 唯一的 sealed_1(单份,绝不重复拼接、绝不污染旧副本) - assertTrue(TokenStatSpool.append(context, lineC, "copy-window-c")) - assertEquals( - "sealed segment must hold the single copy of the old active content", - content, - File(spool, "sealed_1.jsonl").readText(), - ) - assertEquals( - "new event must be durable in active", - lineC + "\n", - File(spool, "active.jsonl").readText(), - ) - TokenStatSpool.replay(context) - awaitEvent("copy-window-a") - awaitEvent("copy-window-b") - awaitEvent("copy-window-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("copy-window-a", "copy-window-b", "copy-window-c"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback target fsync failure retains active and recovers exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("target-fsync-a")) - val lineB = line(request("target-fsync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.fileSyncForTest = { false } - TokenStatSpool.shutdownWriterForTest() - try { - // copy 完成后目标 fsync 失败:必须保留 active、处置目标、明确失败 - assertFalse( - "append must fail when the sealed target fsync fails", - TokenStatSpool.append(context, lineB, "target-fsync-b"), - ) - } finally { - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "no normal sealed segment may be left from the failed publish", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - // 目标被隔离为 seal_failed_*(identity 确认通过,内容 = active 前缀/相等) - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) - // 恢复:维护清理隔离副本,既有事件恰一次入 Room;被拒事件随后发布成功 - TokenStatSpool.replay(context) - awaitEvent("target-fsync-a") - awaitSegmentGone(spool, isolated.name) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "target-fsync-b")) - awaitEvent("target-fsync-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback first dir sync failure retains active and recovers exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dirsync-fail-a")) - val lineB = line(request("dirsync-fail-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - try { - // 目标创建未确认持久(目录 sync 失败):必须保留 active、处置目标、明确失败 - assertFalse( - "append must fail when the target-creating dir sync fails", - TokenStatSpool.append(context, lineB, "dirsync-fail-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED,恢复 - // 路径必须回到注入的 OK 才能运行正常 seal 发布/排空协议) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "no normal sealed segment may be left from the failed publish", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - TokenStatSpool.replay(context) - awaitEvent("dirsync-fail-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "dirsync-fail-b")) - awaitEvent("dirsync-fail-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback post-active-delete dir sync failure keeps durable target and drains once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("post-sync-a")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(目标 - // 创建)成功,第四次(active 删除)失败 - if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - // 目标已 data+creation durable 后删除 active 的目录同步失败:返回 FAILED - // 阻止本轮后续 append 污染,但绝不回滚已 durable 的 target - assertFalse( - "append must fail when the post-delete dir sync fails", - TokenStatSpool.append(context, line(request("post-sync-b")), "post-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals(4, dirSyncCalls) - assertEquals( - "durable target must be kept with the full content", - lineA + "\n", - File(spool, "sealed_1.jsonl").readText(), - ) - assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) - // 恢复:target 是唯一内容持有者,正常排空,事件恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("post-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("post-sync-c")), "post-sync-c")) - awaitEvent("post-sync-c") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal first dir sync failure rolls back link retains active and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("link-sync-a")) - val lineB = line(request("link-sync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - try { - // 链接目录项未确认持久:必须回滚链接、保留 active、明确失败 - assertFalse( - "append must fail when the link-creating dir sync fails", - TokenStatSpool.append(context, lineB, "link-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "rolled-back seal must leave no hardlink residue", - spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, - ) - TokenStatSpool.replay(context) - awaitEvent("link-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "link-sync-b")) - awaitEvent("link-sync-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal post-active-delete dir sync failure keeps durable link and drains once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("link-post-sync-a")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(链接 - // 创建)成功,第四次(active 删除)失败 - if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the post-delete dir sync fails", - TokenStatSpool.append(context, line(request("link-post-sync-b")), "link-post-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals(4, dirSyncCalls) - assertTrue( - "durable link must be kept", - File(spool, "sealed_1.jsonl").exists(), - ) - assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) - // 恢复:link 是唯一内容持有者(同 inode),正常排空,事件恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("link-post-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("link-post-sync-c")), "link-post-sync-c")) - awaitEvent("link-post-sync-c") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `mid-copy partial target is isolated never drained or overwritten and active is retained`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("partial-a")) - val lineB = line(request("partial-b")) - val partial = lineA + "\n" // copy 中途只写入了完整行的前缀内容 - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - TokenStatSpool.shutdownWriterForTest() - try { - // copy 中途失败留下部分目标:append 必须明确失败且 active 保留 - assertFalse( - "append must fail when the seal copy fails mid-way", - TokenStatSpool.append(context, line(request("partial-c")), "partial-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals( - "active must be retained with the full content", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "partial target must not remain as a normal sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) - // 恢复:隔离副本由维护清理;部分内容绝不入 Room(完整内容只排空一次) - TokenStatSpool.replay(context) - awaitEvent("partial-a") - awaitEvent("partial-b") - awaitSegmentGone(spool, isolated.name) - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal copy partial cleanup failures tombstone the target never drain it and recover as bounded evidence`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("tombstone-partial-a")) - val lineB = line(request("tombstone-partial-b")) - val partial = lineA + "\n" // 严格部分:只是 active 第一行的前缀内容,身份与完整内容不同 - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - TokenStatSpool.segmentDeleteForTest = { f -> - if (f.name.startsWith("sealed_")) false else null - } - try { - assertFalse( - "append must fail when the seal copy fails mid-way", - TokenStatSpool.append(context, line(request("tombstone-partial-c")), "tombstone-partial-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - } - // rename/delete 都失败 → tombstone skip:manifest 记录身份,scanner 跳过 - val partialFile = File(spool, "sealed_1.jsonl") - assertTrue("partial target must stay at the candidate name", partialFile.exists()) - assertEquals("partial bytes must be preserved", partial, partialFile.readText()) - assertTrue( - "partial target must be recorded in the tombstone manifest", - safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, - ) - // 带 seams 恢复:tombstoned 部分目标被跳过(文件保留、绝不普通排空),健康 - // 内容封到下一编号并恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("tombstone-partial-a") - awaitEvent("tombstone-partial-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - assertTrue("tombstoned partial must still exist", partialFile.exists()) - // 移除失败 seam 后维护把部分目标移入完整证据区(有界证据)并移除 manifest 条目 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_1.jsonl") - awaitManifestWithout(spool, "sealed_1.jsonl") - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "isolated partial must become bounded quarantine evidence", - evidence.any { it.isFile && it.readText() == partial }, - ) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal copy partial target isolation with not durable dir sync writes tombstone evidence and fails closed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dispose-sync-a")) - val lineB = line(request("dispose-sync-b")) - val partial = lineA + "\n" - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - // gate(2) OK;隔离 rename 后的目录项 sync(第 3 次)失败 → dispose 返回 - // 失败并写 tombstone(候选 sealed 身份受管证据),绝不只留日志(P2) - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the partial target disposal is not durable", - TokenStatSpool.append(context, line(request("dispose-sync-c")), "dispose-sync-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - // 隔离文件本身是受管证据(seal_failed_*,可见/导出/ack/维护),tombstone - // 按候选 sealed 身份记录:崩溃后 sealed_1 以同内容重现时 scanner 跳过 - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertTrue( - "candidate identity must be tombstoned so a reappeared sealed_1 is skipped", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertEquals( - "active must be retained", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:维护清理隔离副本,健康内容各恰一次入 Room(tombstone 条目随文件 - // 消失确认后移除) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitEvent("dispose-sync-a") - awaitEvent("dispose-sync-b") - awaitSegmentGone(spool, isolated.name) - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal copy partial target deletion with not durable dir sync writes tombstone evidence and fails closed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dispose-del-a")) - val lineB = line(request("dispose-del-b")) - val partial = lineA + "\n" - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - // 隔离 rename 失败 → 走安全删除;删除后的目录项 sync(第 3 次)失败 → - // 按候选 sealed 身份写 tombstone 并返回失败(P2:绝不只留日志) - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the partial target deletion is not durable", - TokenStatSpool.append(context, line(request("dispose-del-c")), "dispose-del-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - } - // 删除可见但未确认:候选名字不再存在,tombstone 记录其稳定身份(崩溃后 - // 以同内容重现时 scanner 跳过,绝不普通排空) - assertFalse("partial target deletion is visible", File(spool, "sealed_1.jsonl").exists()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertTrue( - "candidate identity must be tombstoned for the crash-reappearance window", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertEquals( - "active must be retained", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:tombstone 条目随文件消失确认后移除,健康内容各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitEvent("dispose-del-a") - awaitEvent("dispose-del-b") - awaitManifestWithout(spool, "sealed_1.jsonl") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback with durable dir syncs publishes and drains each event exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dir-durable-a")) - val lineB = line(request("dir-durable-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - TokenStatSpool.DirSyncResult.OK // 模拟 Android/Linux 目录 fsync 成功 - } - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal via copy with durable dir syncs", - TokenStatSpool.append(context, lineB, "dir-durable-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - // P1 终审:封段发布 2 次目录同步(目标创建、active 删除)+ append 侧 1 次 - // (seal 删除 active 后新 active 属首次创建,目录项必须确认持久)+ P1-1 - // bootstrap gate 2 次(filesDir + spool 目录,本测试进程首次使用) - assertEquals(5, dirSyncCalls) - assertEquals("sealed_1 must hold the old content", lineA + "\n", File(spool, "sealed_1.jsonl").readText()) - assertEquals("active must hold the new event", lineB + "\n", File(spool, "active.jsonl").readText()) - TokenStatSpool.replay(context) - awaitEvent("dir-durable-a") - awaitEvent("dir-durable-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("dir-durable-a", "dir-durable-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal dir sync UNSUPPORTED never deletes active and never publishes`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("unsupported-link-a")) - val lineB = line(request("unsupported-link-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 平台明确不支持目录 fsync:发布路径必须 fail-closed(UNSUPPORTED ≠ 成功), - // 硬链接已建立但目录项未持久 → 回滚链接、保留唯一 fsynced active、绝不 PUBLISHED。 - // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), - // 第三次是链接创建的目录项,第四次是回滚删除链接的目录项(P2 终审: - // 回滚删除同样是目录项变更,必须确认持久,非 OK 同时失效 gate)。 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the platform does not support dir fsync", - TokenStatSpool.append(context, lineB, "unsupported-link-b"), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - assertEquals(4, dirSyncCalls) - assertEquals( - "active must be retained byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "UNSUPPORTED must never publish a sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy seal dir sync UNSUPPORTED never deletes active and never publishes`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("unsupported-copy-a")) - val lineB = line(request("unsupported-copy-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - // copy 回退的目录同步同样 fail-closed:目标已处置、active 保留、绝不 PUBLISHED。 - // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), - // 第三次是 copy 目标创建的目录项。 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the platform does not support dir fsync", - TokenStatSpool.append(context, lineB, "unsupported-copy-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED)—— - // 后续 quarantineEvidence 读取需要恢复 tombstone 写入残留的 `.new` - // sidecar(P2 受管证据),strict 读取要求目录 sync OK 才能返回。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - // 5 次目录 sync = bootstrap gate(2) + copy 目标创建(1) + 失败目标隔离 - // rename(1) + tombstone 暂存(1,P2:隔离后目录项未确认 → 按候选 sealed - // 身份写 tombstone 受管证据,绝不只留日志) - assertEquals(5, dirSyncCalls) - assertEquals( - "active must be retained byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "UNSUPPORTED must never publish a sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - // 部分目标被隔离为 seal_failed_*(受管失败发布证据,立即可见) - val isolated = - spool.listFiles().orEmpty().single { it.isFile && it.name.startsWith("seal_failed_") } - assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) - assertTrue( - "isolated target must be visible as quarantine evidence", - TokenStatSpool.quarantineEvidence(context).any { it.name == isolated.name }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `unsupported dir sync fails closed never clears active and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("failclosed-a")) - val lineB = line(request("failclosed-b")) - val lineC = line(request("failclosed-c")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 平台明确不支持目录 fsync:封段发布必须 fail-closed——绝不原地清空/删除 - // 唯一 fsynced active,也绝不返回 durable - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail closed when the platform does not support dir fsync", - TokenStatSpool.append(context, lineC, "failclosed-c"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // active 原字节保留;无 sealed/seal_failed 发布残留;无事件入 Room - assertEquals( - "active must be retained byte-identical", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertTrue( - "fail-closed mode must never publish sealed segments", - spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("sealed_") }, - ) - assertTrue( - "fail-closed mode must never create seal_failed targets", - spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("seal_failed_") }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录 fsync 恢复 OK 后 append 成功,全部事件各恰一次入 Room - assertTrue(TokenStatSpool.append(context, lineC, "failclosed-c")) - TokenStatSpool.replay(context) - awaitEvent("failclosed-a") - awaitEvent("failclosed-b") - awaitEvent("failclosed-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("failclosed-a", "failclosed-b", "failclosed-c"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `first spool directory creation with unsupported dir sync returns false and retries after recovery`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - assertFalse("spool must not pre-exist", spool.exists()) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must not return durable when the first spool dir creation cannot be confirmed", - TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // 已创建的目录可保留供重试,但从未声明 durable;active 尚未写入 - assertTrue("created spool dir may be retained for retry", spool.isDirectory) - assertFalse("active must not be written before the dir entry is durable", File(spool, "active.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复能力 OK:重试成功,事件恰一次入 Room - assertTrue( - TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), - ) - TokenStatSpool.replay(context) - awaitEvent("first-dir-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("first-dir-a"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `first active file creation with unsupported dir sync returns false retains line and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录,已确认);第三次 - // (首建 active 的目录项)平台不支持——内容已写+fsync 但目录项未确认 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - val lineA = line(request("first-active-a")) - try { - assertFalse( - "append must not return durable when the first active creation dir sync is unsupported", - TokenStatSpool.append(context, lineA, "first-active-a"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // 写+fd.sync 已发生但目录项未确认:本次不 durable,源 line 内容保留在 active - assertEquals(3, dirSyncCalls) - assertEquals( - "source line must be retained on disk", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复能力 OK:下一次 append 先经 bootstrap gate 重新确认目录项再写新事件, - // 两者各恰一次 - assertTrue( - TokenStatSpool.append(context, line(request("first-active-b")), "first-active-b"), - ) - TokenStatSpool.replay(context) - awaitEvent("first-active-a") - awaitEvent("first-active-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("first-active-a", "first-active-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `unsupported dir sync fails closed for every append until recovery then drains exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val count = 24 - try { - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.MAX_SEGMENT_BYTES = 700 - TokenStatSpool.shutdownWriterForTest() - // 目录 fsync 不支持:首次 spool/active 创建无法确认目录项——每次 append - // 都 fail-closed(绝不返回 durable、绝不永久挂起、绝不清空已写入内容) - repeat(count) { index -> - assertFalse( - "append must fail closed under unsupported dir sync without stalling: $index", - TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), - ) - } - assertTrue("created spool dir may be retained for retry", spool.isDirectory) - assertFalse( - "active must not be written before any directory entry is durable", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:能力恢复 OK 后重试/replay,全部事件各恰一次入 Room - // (P1 终审:Windows JVM 真实探测恒为 UNSUPPORTED,必须回到注入的 OK) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - repeat(count) { index -> - assertTrue( - TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), - ) - } - TokenStatSpool.replay(context) - repeat(count) { index -> awaitEvent("win-failclosed-$index") } - awaitNoSealedSegments(spool) - assertEquals(count, database.tokenStatsDao().countEvents()) - assertEquals( - count, - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet().size, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `corrupt active tail with unsupported dir sync fails closed retaining original bytes then recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("corrupt-failclosed-a")) - val lineB = line(request("corrupt-failclosed-b")) - // 崩溃残留:active 尾部半行 JSON,无换行 - val original = lineA + "\n" + "{\"v\":2,\"eventId\":\"corrupt-failclosed-tail\"" - try { - File(spool, "active.jsonl").writeText(original) - TokenStatSpool.MAX_SEGMENT_BYTES = original.length.toLong() + 1 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - // 损坏尾行需要封段处置:目录项未确认持久前绝不隔离证据、绝不插入 - // 健康行、绝不截断/清空 active(copy+file sync 之后必须 dir sync OK - // 才允许继续) - assertFalse( - "append must fail closed when sealing a corrupt tail needs dir fsync", - TokenStatSpool.append(context, lineB, "corrupt-failclosed-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // active 原字节不动(含损坏尾行);尚无证据被切走;无事件入 Room - assertEquals( - "active must retain the original bytes including the corrupt tail", - original, - File(spool, "active.jsonl").readText(), - ) - assertTrue( - "no evidence may be cut before its directory entry is durable", - TokenStatSpool.quarantineEvidence(context).isEmpty(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录 fsync OK 后损坏尾行作为完整证据隔离(至少一个 durable 位置), - // 健康事件各恰一次入 Room - assertTrue(TokenStatSpool.append(context, lineB, "corrupt-failclosed-b")) - TokenStatSpool.replay(context) - awaitEvent("corrupt-failclosed-a") - awaitEvent("corrupt-failclosed-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "corrupt tail evidence must be preserved with the original bytes", - evidence.any { it.readText() == original }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal failed target deletion failure stays visible exportable ackable and ack frees the cap`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // seal 发布失败隔离的部分目标(受管失败发布证据)与普通证据并存 - val failed = File(spool, "seal_failed_${UUID.randomUUID().toString().replace("-", "")}") - failed.writeText("{partial-copy-evidence\n") - val regular = File(spool, "quarantine_existing_sealed_1.jsonl") - regular.writeText("{regular-evidence\n") - // 维护删除失败(seam):隔离副本保留、下一轮重试,绝不自动消失 - TokenStatSpool.segmentDeleteForTest = { f -> - if (f.name.startsWith("seal_failed_")) false else null - } - try { - TokenStatSpool.replay(context) - delay(700) - assertTrue("deletion failure must keep the failed target", failed.exists()) - // 可见:quarantineEvidence 含 seal_failed_*,字节计入证据总量 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue("seal_failed target must be visible as evidence", evidence.any { it.name == failed.name }) - assertTrue("regular evidence must stay visible", evidence.any { it.name == regular.name }) - assertTrue( - "seal_failed bytes must count toward the evidence total", - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } >= failed.length(), - ) - // 导出包含隔离目标 - val exported = - TokenStatSpool.exportQuarantineEvidence(context, File(root, "p2-evidence-export")) - assertTrue("seal_failed target must be exportable", exported.any { it.name == failed.name }) - // 用户确认删除(NOFOLLOW/path 根校验在 ack 内部)→ 证据消失、容量释放 - val bytesBefore = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(failed.name)) - assertFalse("ack must delete the seal_failed target", failed.exists()) - val bytesAfter = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } - assertTrue("ack must release the held evidence bytes", bytesAfter < bytesBefore) - assertTrue( - "remaining evidence must still be intact", - TokenStatSpool.quarantineEvidence(context).any { it.name == regular.name }, - ) - } finally { - TokenStatSpool.segmentDeleteForTest = null - } - } - } - - @Test - fun `corrupt uncommitted trash mapping never drops held manifest identity for vanished original`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P1 场景:sealed_1 是受管失败段(tombstone 条目),文件已被 ack stage 进 - // UNCOMMITTED trash 后崩溃(主 manifest 已发布、commit 翻转未写),随后状态 - // mapping 损坏(一条有效 + 一条损坏)。根文件缺失时,旧实现因 held 集合为空 - // 会把 sealed_1 条目按 stale 移除——fail-closed 被违背。 - val body1 = "{corrupt-held-a\n" - val body2 = "{corrupt-held-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha1\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - - TokenStatSpool.replay(context) - delay(700) - assertTrue("corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse( - "no rollback may happen from a partial mapping", - File(spool, "sealed_1.jsonl").exists(), - ) - assertEquals( - "manifest sealed_1 entry must be preserved verbatim", - manifestLine, - safeManifestText(manifest), - ) - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - - // 后续维护轮保持有界:hasUnknown 时整轮跳过 manifest 重写,条目逐字不变 - TokenStatSpool.replay(context) - delay(700) - assertEquals( - "repeated maintenance rounds must not rewrite the manifest", - manifestLine, - safeManifestText(manifest), - ) - - // 修复 mapping(两份证据都被覆盖)→ 维护回滚到根并安全重新隔离/处置: - // sealed_1 与 manifest 身份 MATCH → 移入完整证据区并移除条目;sealed_2 被 - // 扫描器重新隔离。manifest 与证据状态最终一致。 - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) - awaitManifestWithout(spool, "sealed_1.jsonl") - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var evidence: List = emptyList() - while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { - evidence = TokenStatSpool.quarantineEvidence(context) - if (evidence.size != 2) delay(20) - } - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.isFile && it.readText() == body1 }) - assertTrue(evidence.any { it.isFile && it.readText() == body2 }) - } - } - - @Test - fun `scanner keeps manifest identity when corrupt uncommitted trash may hold the reused-name original`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 旧身份仍可能被损坏 mapping 的 UNCOMMITTED trash 持有:根目录同名新文件与 - // manifest 条目 MISMATCH 时,scanner 绝不能按 stale 移除条目(否则旧身份 - // 失去保护,回滚后重新隔离也无法与受管集合对应)。 - val oldBody = "{scanner-held-old\n" - val newBody = "{scanner-held-new\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n" + - "{corrupt-json\n", - ) - File(spool, "sealed_1.jsonl").writeText(newBody) - - TokenStatSpool.replay(context) - // 同名新文件照常被处理进完整证据区(内容不变),但 manifest 条目必须保留 - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var quarantined = false - while (System.nanoTime() < evidenceDeadline && !quarantined) { - quarantined = TokenStatSpool.quarantineEvidence(context).any { - it.isFile && it.readText() == newBody - } - if (!quarantined) delay(20) - } - assertTrue("the reused-name new file must be processed into the evidence area", quarantined) - assertTrue("corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertEquals( - "manifest entry must be retained while the old identity may be held in trash", - manifestLine, - safeManifestText(manifest), - ) - } - } - - @Test - fun `stateless non-empty ack trash is visible exportable ackable and append recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB;28KiB 无状态 - // trash 残留必须让每次 append 的递归投影超限(占用绝不隐藏,P1-3) - TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - val evidence = File(trash, "sealed_stuck_1.jsonl") - RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } - // 无状态非空 trash:maintenance fail-closed 保留(绝不删除),UI 可见 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must retain a stateless non-empty trash", trash.exists()) - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - assertEquals(listOf(trash), TokenStatSpool.quarantineEvidence(context)) - assertEquals(28L * 1024, TokenStatSpool.stuckAckTrashBytes(context)) - - // 释放前:cap 被 trash 占用 → 新统计 append 明确拒绝 - val lines = (0 until 200).map { index -> - line(request("stuck-cap-$index")) to "stuck-cap-$index" - } - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertEquals(lines.size, rejected) - - // export 将 trash 目录内容复制到唯一子目录(含状态/sidecar) - val base = File(root, "export-stuck").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination) - assertTrue(exported.any { it.name == trash.name }) - val exportedTrash = File(destination, trash.name) - assertTrue(exportedTrash.isDirectory) - assertEquals(28L * 1024, File(exportedTrash, evidence.name).length()) - - // 确认删除 stuck trash(显式授权,无需 mapping)→ 容量释放 → append 恢复 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(trash.name)) - assertFalse("ack must delete the acknowledged stuck trash", trash.exists()) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-stuck-ack")), - "after-stuck-ack", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-stuck-ack") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `export fails closed when the spool root enumeration fails and recovers after the seam is restored`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 两个非空 stuck trash + 一个普通隔离文件:根枚举失败时 export 绝不能 - // 成功遗漏任何证据(P1-6) - val trashA = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trashA.mkdirs() - val evidenceA = File(trashA, "sealed_1.jsonl") - evidenceA.writeText("{root-null-a\n") - val trashB = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trashB.mkdirs() - val evidenceB = File(trashB, "sealed_2.jsonl") - evidenceB.writeText("{root-null-b\n") - val quarantineFile = File(spool, "quarantine_sealed_3.jsonl") - quarantineFile.writeText("{root-null-ev\n") - val base = File(root, "export-root-null").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - try { - TokenStatSpool.exportQuarantineEvidence(context, destination) - fail("export must fail when the spool root enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 源证据全部保留 - assertTrue(trashA.exists()) - assertTrue(evidenceA.exists()) - assertTrue(trashB.exists()) - assertTrue(evidenceB.exists()) - assertTrue(quarantineFile.exists()) - // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 - assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) - assertFalse(destination.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam 后完整 export 含全部证据(stuck trash 子目录 + 隔离文件) - val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) - assertTrue(exported.any { it.name == trashA.name }) - assertTrue(exported.any { it.name == trashB.name }) - assertTrue(exported.any { it.name == quarantineFile.name }) - val exportedTrash = File(destination2, trashA.name) - assertTrue(exportedTrash.isDirectory) - assertTrue(File(exportedTrash, evidenceA.name).readText() == "{root-null-a\n") - } - } - - @Test - fun `export fails closed when an ack trash directory enumeration fails and recovers after the seam is restored`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - val evidence = File(trash, "sealed_1.jsonl") - evidence.writeText("{child-null-evidence\n") - // 普通隔离文件先于 trash 被复制:child 枚举失败时目标目录已含部分内容 - val quarantineFile = File(spool, "quarantine_sealed_2.jsonl") - quarantineFile.writeText("{child-null-quarantine\n") - val base = File(root, "export-child-null").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - try { - TokenStatSpool.exportQuarantineEvidence(context, destination) - fail("export must fail when an ack trash directory enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 源证据全部保留 - assertTrue(trash.exists()) - assertTrue(evidence.exists()) - assertTrue(quarantineFile.exists()) - // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 - assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) - assertFalse(destination.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam 后完整 export 含全部证据 - val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) - assertTrue(exported.any { it.name == trash.name }) - assertTrue(exported.any { it.name == quarantineFile.name }) - val exportedTrash = File(destination2, trash.name) - assertTrue(exportedTrash.isDirectory) - assertTrue(File(exportedTrash, evidence.name).readText() == "{child-null-evidence\n") - } - } - - @Test - fun `rollback never overwrites an occupied slot and recovers after the slot frees`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃窗口 + 回滚目标被同名不同内容的新文件占用 - val oldBody = "{old-occupied-bad\n" - val newBody = "{new-occupant-bad\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - // 主 manifest 仍含旧身份(ack 未提交)→ 必须回滚而非删除 - File(spool, "quarantine_skip_manifest.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - File(spool, "sealed_1.jsonl").writeText(newBody) - - TokenStatSpool.replay(context) - // 回滚目标被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - File(spool, "sealed_1.jsonl").exists() - ) { - delay(20) - } - assertTrue( - "occupied-slot rollback must retain the trash evidence", - trash.exists() && File(trash, "sealed_1.jsonl").exists(), - ) - // 新内容未被覆盖:作为健康处理进入完整证据区(身份仍是新内容) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "the new occupant must be processed into the evidence area untouched", - evidence.any { it.name.contains("sealed_1.jsonl") }, - ) - assertTrue( - "the new occupant content must be intact", - evidence.first { it.name.contains("sealed_1.jsonl") }.readText() == newBody, - ) - assertTrue( - "manifest entry must be retained while the old identity is held in trash", - safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, - ) - - // 槽位释放后(新文件已移入证据区)→ replay:回滚成功并自愈 - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the slot frees", trash.exists()) - awaitManifestWithout(spool, "sealed_1.jsonl") - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals("both the old and the new evidence must be present", 2, restored.size) - assertTrue(restored.any { it.readText() == oldBody }) - assertTrue(restored.any { it.readText() == newBody }) - } - } - - @Test - fun `ack refuses path traversal names without touching spool files`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_safe_sealed_1.jsonl").writeText("safe-bad\n") - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("../outside.jsonl")) - fail("ack must refuse traversal names") - } catch (e: IOException) { - assertTrue("refusal must name the unsafe target", e.message!!.contains("unsafe")) - } - assertTrue(File(spool, "quarantine_safe_sealed_1.jsonl").exists()) - } - - @Test - fun `first summary publish at the total cap edge with fallback sidecars keeps total bounded`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 - // 强制回退协议(P1-1:canonical/.new/.bak/tmp sidecar 瞬态同时存在) - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 证据区打满(16MiB 硬 cap)→ 新损坏段必须走 summarize 路径(首次 summary 写) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - // 数据总量:16MiB 证据 + 7.5MiB 损坏段 ≈ 23.5MiB,接近 24MiB 总上限边缘 - val segment = File(spool, "sealed_2.jsonl") - RandomAccessFile(segment, "rw").use { - it.setLength(7L * 1024 * 1024 + 512L * 1024) - } - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - val totalBytes: () -> Long = { - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - } - TokenStatSpool.replay(context) - // 轮询:整个处置过程实际 top-level 总字节始终 ≤ 总上限 - val pollDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (segment.exists() && System.nanoTime() < pollDeadline) { - assertTrue( - "total must stay within the cap while draining: ${totalBytes()}", - totalBytes() <= cap, - ) - delay(20) - } - assertTrue("total must stay within the cap at rest: ${totalBytes()}", totalBytes() <= cap) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("first summary must be published at the cap edge", summary) - assertTrue( - "summary must carry the over-cap segment record", - File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl"), - ) - // sidecar 已清理(回退发布完成) - assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) - assertFalse(File(spool, "quarantine_summary.jsonl.bak").exists()) - // 维护(ack 证据区)后 append 恢复 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-cap-edge-summary")), - "after-cap-edge-summary", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-cap-edge-summary") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic - } - } - } - - @Test - fun `metadata publish refused at the hard cap edge stays bounded and maintenance restores appends`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 证据区打满 16MiB:12MiB + 4MiB 两个文件(ack 其中一个后仍满 → 继续 summarize) - val existingBig = File(spool, "quarantine_a_sealed_1.jsonl") - RandomAccessFile(existingBig, "rw").use { it.setLength(12L * 1024 * 1024) } - val existingSmall = File(spool, "quarantine_b_sealed_2.jsonl") - RandomAccessFile(existingSmall, "rw").use { it.setLength(4L * 1024 * 1024) } - // 数据总量恰好等于总上限:首次 summary 发布的投影(+4×content)必超限 - val segment = File(spool, "sealed_3.jsonl") - RandomAccessFile(segment, "rw").use { it.setLength(8L * 1024 * 1024) } - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - TokenStatSpool.replay(context) - delay(900) - // 有界失败:不写正式 summary、段保留、总量不超过上限 - assertFalse( - "summary must not be published when the metadata budget is exhausted", - File(spool, "quarantine_summary.jsonl").exists(), - ) - assertTrue("pending segment must be retained", segment.exists()) - assertTrue( - "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= cap, - ) - // 维护释放空间(此时总量恰好 = cap,ack 的 trash 状态元数据投影按 P1-1 - // 必被拒——见 ack 状态预算测试;这里模拟外部/维护释放:移除一个证据文件) - // → 重试成功发布摘要 → append 恢复 - assertTrue(existingSmall.delete()) - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_3.jsonl") - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("summary must be published after maintenance frees the budget", summary) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-budget-recovery")), - "after-budget-recovery", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-budget-recovery") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `concurrent exports keep unique directories and one failing export never deletes the other success`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_aa_sealed_1.jsonl").writeText("aa-bad\n") - File(spool, "quarantine_bb_sealed_2.jsonl").writeText("bb-bad\n") - val base = File(root, "export-runs").apply { mkdirs() } - // P2:UI 命名 = 时间戳前缀 + UUID;同一毫秒前缀下 UUID 保证目录唯一, - // 目录用 Files.createDirectory 独占创建(已存在即失败) - fun uniqueExportDir(ts: String): File = - File(base, "token_stats_quarantine_${ts}_${UUID.randomUUID().toString().replace("-", "")}") - .also { Files.createDirectory(it.toPath()) } - val destA = uniqueExportDir("20260806_120000") - val destB = uniqueExportDir("20260806_120000") - // 两个导出经 lifecycleMutex 串行:A 的 manifest 两次读取(manifestContent + - // evidence 列表)放行,B 的 manifestContent 读取(第 3 次)注入失败 - var manifestReads = 0 - TokenStatSpool.metadataReadErrorForTest = { file -> - if (file.name == "quarantine_skip_manifest.jsonl") { - manifestReads += 1 - manifestReads == 3 - } else { - false - } - } - try { - val exportA = async { TokenStatSpool.exportQuarantineEvidence(context, destA) } - val exportB = async { - try { - TokenStatSpool.exportQuarantineEvidence(context, destB) - fail("export B must fail with the injected manifest read failure") - } catch (e: IOException) { - } - } - val exportedA = exportA.await() - exportB.await() - // 各自目录独立且完整:A 成功导出两份证据 - assertEquals( - setOf("quarantine_aa_sealed_1.jsonl", "quarantine_bb_sealed_2.jsonl"), - exportedA.map { it.name }.toSet(), - ) - assertEquals(exportedA.size, destA.listFiles().orEmpty().size) - // 失败的导出绝不删除另一成功导出的目录/内容;自身目录也未被删除 - assertTrue("successful export directory must stay intact", destA.isDirectory) - assertTrue(destA.listFiles().orEmpty().all { it.isFile }) - assertTrue("failed export directory must not be deleted by the spool", destB.isDirectory) - - // 恢复后正常:新的导出成功且只含本次证据 - TokenStatSpool.metadataReadErrorForTest = null - val retried = TokenStatSpool.exportQuarantineEvidence( - context, - uniqueExportDir("20260806_120001"), - ) - assertTrue(retried.any { it.name.startsWith("quarantine_aa_") }) - assertTrue(retried.any { it.name.startsWith("quarantine_bb_") }) - } finally { - TokenStatSpool.metadataReadErrorForTest = null - } - } - } - - // ── P1 终审:durable bootstrap gate / 维护目录项严格同步 ────────────────────── - - @Test - fun `bootstrap gate re-confirms unconfirmed spool dir entry after simulated restart and never merges events`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - val lineA = line(request("bootstrap-dir-a")) - val lineB = line(request("bootstrap-dir-b")) - try { - // 上一进程:首次创建 spool 目录,父目录/新目录的目录项 sync 失败(磁盘 - // 可见但未确认持久)——append 明确失败,active 未写入 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) - assertTrue("created spool dir is visible on disk", spool.isDirectory) - assertFalse("no line may be written before dir entries are durable", File(spool, "active.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),磁盘状态保留 - TokenStatSpool.clearPendingStateForTest() - // 目录项仍无法确认:本次 append 必须失败,绝不写新行(第二事件此前从未写入) - assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) - assertFalse( - "no line may be written while the spool dir entry is unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复:bootstrap gate 重新确认目录项后,两个事件各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) - assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) - TokenStatSpool.replay(context) - awaitEvent("bootstrap-dir-a") - awaitEvent("bootstrap-dir-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("bootstrap-dir-a", "bootstrap-dir-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `bootstrap gate re-confirms unconfirmed active entry after simulated restart and keeps bytes until confirmed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("bootstrap-active-a")) - val lineB = line(request("bootstrap-active-b")) - var dirSyncCalls = 0 - try { - // 上一进程:bootstrap gate 两次确认通过,但首建 active 的目录项 sync 失败 - // (内容已写+fsync、磁盘可见、未确认) - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-active-a")) - assertEquals(3, dirSyncCalls) - assertEquals( - "unconfirmed line must stay visible on disk", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),active 字节保留 - TokenStatSpool.clearPendingStateForTest() - // 目录项仍无法确认:本次 append 失败且 active 字节不变(绝不再追加新行) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) - assertEquals( - "active bytes must be unchanged while the dir entry is unconfirmed", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复:gate 重新确认后追加第二事件(此前从未写入),两事件各恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) - TokenStatSpool.replay(context) - awaitEvent("bootstrap-active-a") - awaitEvent("bootstrap-active-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("bootstrap-active-a", "bootstrap-active-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `restore cleanup dir sync failure invalidates the gate so consecutive appends fail without writing and recover exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineB = line(request("gate-restore-b")) - val lineC = line(request("gate-restore-c")) - var calls = 0 - try { - // 阶段 0:gate=true——经快照 barrier 完成 bootstrap 两次确认(filesDir + - // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态) - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - File(spool, "active.jsonl").writeText(line(request("gate-restore-a")) + "\n") - } - assertEquals(2, calls) - // 阶段 1:restore 清理删除 spool 目录,删除后 filesDir 目录项 sync(第 3 次) - // 失败 → restore 明确失败;删除开始前 gate 必须已失效(P1-1 修复) - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore must fail when the cleanup dir sync fails") - } catch (e: IOException) { - assertTrue(e.message!!.contains("durable")) - } - assertEquals(3, calls) - assertFalse("spool deletion is visible", spool.exists()) - // 阶段 2:restore 替换已开始(清理失败属替换后失败)——P1 终审 fence - // 拒绝本进程一切后续 append(accepting=false,直到重启),任何事件绝不 - // 写入;若 fence 失效,bootstrap gate 也已失效,同样全部失败 - assertFalse(TokenStatSpool.append(context, lineB, "gate-restore-b")) - assertFalse(TokenStatSpool.append(context, lineC, "gate-restore-c")) - assertFalse( - "no event may be written while dir entries are unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复 OK。P1 终审:恢复替换已开始(清理失败属于替换后失败), - // 同进程事件被明确拒绝——先模拟进程重启(reset 状态)才允许写入; - // 重启后目录项重新确认(bootstrap),两事件各恰一次入 Room。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.clearPendingStateForTest() - assertTrue(TokenStatSpool.append(context, lineB, "gate-restore-b")) - assertTrue(TokenStatSpool.append(context, lineC, "gate-restore-c")) - TokenStatSpool.replay(context) - awaitEvent("gate-restore-b") - awaitEvent("gate-restore-c") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `maintenance seal dir sync failure after gate true forces the next append to re-bootstrap before writing`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("gate-maint-a")) - val lineB = line(request("gate-maint-b")) - var calls = 0 - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - // 阶段 1:bootstrap gate 两次确认 OK(gate=true),随后维护 drain 的封段 - // 发布目录项 sync(第 3 次)失败 → 维护失败;gate 必须同步失效(P1-1) - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - val sealDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (calls < 3 && System.nanoTime() < sealDeadline) delay(10) - TokenStatSpool.shutdownWriterForTest() - assertTrue("seal must have been attempted", calls >= 3) - // 阶段 2:gate 已失效且目录 sync 持续失败——下一次 append 必须重新 - // bootstrap;bootstrap 失败 → append 明确失败且 active 字节不变 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - assertFalse(TokenStatSpool.append(context, lineB, "gate-maint-b")) - assertEquals( - "active must stay byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复——bootstrap 重新确认后 append 成功,事件各恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "gate-maint-b")) - TokenStatSpool.replay(context) - awaitEvent("gate-maint-a") - awaitEvent("gate-maint-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - /** - * P1-2/P1-3 终审通用 runner:以 [result](FAILED/UNSUPPORTED)运行一次完整场景,保证 - * 前后内存/磁盘/数据库状态隔离(spool 目录重建 + 内存标记复位 + 事件表清空),并在 - * finally 还原全部注入缝。场景开始前恢复“目录 fsync 支持且成功”的平台常态 - * (Windows JVM 真实探测恒为 UNSUPPORTED),使场景内部的 phase-1 正常协议可用。 - */ - private suspend fun runDirSyncFailClosedScenario( - result: TokenStatSpool.DirSyncResult, - tag: String, - scenario: suspend (TokenStatSpool.DirSyncResult, String) -> Unit, - ) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - Mockito.mockStatic(AppLogger::class.java).use { - try { - spool.deleteRecursively() - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - database.tokenStatsDao().deleteAllEvents() - scenario(result, tag) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.spoolDeleteForTest = null - spool.deleteRecursively() - } - } - } - - @Test - fun `over-cap dispose delete with FAILED and UNSUPPORTED dir sync keeps summary retryable and recovers without duplicate`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_$tag.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{$tag-overcap-bad\n") - // bootstrap gate(2) + 摘要严格发布(2) 成功,删除后目录项 sync(第 5 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 4) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - delay(900) - // 摘要已发布(可见)、段已删除(可见)但目录项未确认:本轮不得声称完成—— - // 无事件入 Room;恢复后摘要不重复。目录 sync 未恢复前严格读取不信任 canonical - // (P1-2),此处直接断言摘要文件可见。 - assertTrue( - "summary canonical must be published and visible", - File(spool, "quarantine_summary.jsonl").isFile, - ) - assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项 sync OK 后幂等完成(摘要记录不重复、无遗留队列) - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `pending-delete evidence restore with FAILED and UNSUPPORTED dir sync rebuilds retryable record and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{$tag-pending-bad\n" - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-pending-healthy-$tag")) + "\n") - // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - TokenStatSpool.replay(context) - awaitEvent("syncfail-pending-healthy-$tag") - assertEquals(1, database.tokenStatsDao().countEvents()) - val pending = spool.listFiles().orEmpty().single { - it.isFile && it.name.startsWith("quarantine_pending_delete_") - } - // 阶段 2:维护恢复 rename 后目录项 sync 失败(bootstrap gate 已在阶段 1 确认, - // 本阶段第一次 sync 就是恢复 rename 的目录项)→ 尽力移回 pending-delete 名 - // (重建可重试记录),本轮不推进 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = { result } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "pending-delete record must be rebuilt when the restore rename is not durable", - pending.exists(), - ) - // pending-delete 文件本身是受管证据(计入 quarantineEvidence),但必须仍是 - // pending-delete 名(未被推进到完整证据区) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence must still be the pending-delete record", - evidence.single().name.startsWith("quarantine_pending_delete_"), - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 恢复:rename 目录项确认持久后证据恰一次回到完整证据区 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, restored.size) - assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `seal-failed target cleanup with FAILED and UNSUPPORTED dir sync does not advance and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val isolated = File(spool, "seal_failed_$tag-partial") - isolated.writeText("{partial-$tag\n") - File(spool, "sealed_9.jsonl").writeText(line(request("syncfail-sealfailed-healthy-$tag")) + "\n") - // bootstrap gate(2) OK,seal_failed 删除后的目录项 sync(第 3 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - delay(900) - // 删除可见但未确认:本轮不推进(健康段也不排空);隔离副本不丢证据 - assertFalse("seal-failed target deletion is visible", isolated.exists()) - assertTrue("healthy segment must stay pending while the round is not durable", File(spool, "sealed_9.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项确认持久后健康段恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitEvent("syncfail-sealfailed-healthy-$tag") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone over-cap delete with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_$tag.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_2.jsonl").writeText("{$tag-managed-bad\n") - // 阶段 1:删除失败 + 证据区已满 → over-cap tombstone 条目(正常协议) - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name == "sealed_2.jsonl") false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - // 阶段 1 的 drain 可能仍在收尾(摘要/条目发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - // 阶段 2:维护删除成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; - // 本阶段第 1 次 sync 是 manifest 严格读取,第 2 次才是删除的目录项)→ manifest - // 条目保留(可重试记录)、本轮不推进 - TokenStatSpool.segmentDeleteForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 2) result else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "manifest entry must be retained while the deletion is unconfirmed", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:确认“消失”持久后条目幂等移除,摘要记录不重复 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitManifestWithout(spool, "sealed_2.jsonl") - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone evidence restore rename with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{$tag-evidence-bad\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-evidence-healthy-$tag")) + "\n") - // 阶段 1:两次 rename 都失败 → tombstone(容量内,overCap=false);健康段排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - awaitEvent("syncfail-evidence-healthy-$tag") - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - // 阶段 2:恢复 rename 成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; - // 本阶段第 1 次 sync 是 manifest 严格读取、第 2 次是容量判定读取、第 3 次才是 - // restore rename 的目录项)→ 条目保留、本轮不推进 - TokenStatSpool.segmentRenameForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 3) result else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "manifest entry must be retained while the restore rename is unconfirmed", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertFalse("sealed original is gone (rename visible)", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "evidence must already be at the quarantine name", - TokenStatSpool.quarantineEvidence(context).any { it.readText() == body }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 恢复:确认 rename 持久后条目幂等移除,证据恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitManifestWithout(spool, "sealed_1.jsonl") - assertEquals( - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack staging with FAILED and UNSUPPORTED dir sync fails closed keeps evidence and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - // 两个失败点:trash 目录创建后的目录项 sync(failCall=1)、首个证据移动后的 - // 目录项 sync(failCall=2,跨 spool 根与 trash 两个目录) - for (failCall in 1..2) { - spool.deleteRecursively() - spool.mkdirs() - TokenStatSpool.clearPendingStateForTest() - File(spool, "sealed_1.jsonl").writeText("{$tag-ackstage-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "phase-1 tombstone entry must exist before ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先 - // 静默至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - TokenStatSpool.segmentRenameForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == failCall) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail when a staging boundary dir sync is not OK; calls=$calls failCall=$failCall result=$result") - } catch (e: IOException) { - } - // 操作失败、状态保留:证据未丢、manifest 未改 - assertTrue("managed evidence must stay in place", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } - // 清理失败迭代留下的空 trash(mkdir 已可见但目录项 sync 未确认;维护入口对 - // 空 trash 同样安全删除,此处等价地清理后重试) - spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - .forEach { it.deleteRecursively() } - // 恢复:目录项 sync OK 后 ack 恰一次完成(证据删除、条目移除) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue( - "no trash residue after a successful ack", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack commit flip with FAILED and UNSUPPORTED dir sync fails closed retains uncommitted trash and maintenance recovers it`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val sealedBody = "{$tag-flip-bad\n" - val areaBody = "area-body-$tag\n" - File(spool, "sealed_1.jsonl").writeText(sealedBody) - File(spool, "quarantine_area_$tag.jsonl").writeText(areaBody) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "phase-1 tombstone entry must exist before ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - TokenStatSpool.segmentRenameForTest = null - // 第 11 次 sync = COMMITTED 翻转的暂存目录项(manifest 严格读取 1 + mkdir 1 - // + staging 4 + 状态文件 2 + manifest 重写 2 + 翻转 staging 1)——翻转未确认 - // 持久 → ack 失败、状态保留 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 11) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "quarantine_area_$tag.jsonl"), - ) - fail("ack must fail when the commit flip is not durable; calls=$calls result=$result") - } catch (e: IOException) { - assertTrue(e.message!!.contains("commit")) - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained", 1, trashDirs.size) - val trash = trashDirs.single() - val state = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue( - "state must remain UNCOMMITTED for maintenance rollback", - state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - assertTrue("staged managed evidence stays in trash", File(trash, "sealed_1.jsonl").exists()) - assertTrue("staged area evidence stays in trash", File(trash, "quarantine_area_$tag.jsonl").exists()) - assertTrue( - "manifest entries were already published", - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true, - ) - // 维护恢复:UNCOMMITTED 按 mapping+identity 回滚 → 证据各恰一次回到原路径 - // (损坏 sealed 随后被扫描器重新隔离为证据) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be rolled back by maintenance", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.count { it.readText() == sealedBody }) - assertEquals(1, evidence.count { it.readText() == areaBody }) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack trash delete with FAILED and UNSUPPORTED dir sync fails closed and retry is idempotent`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val evidence = File(spool, "quarantine_ackdelete_$tag.jsonl") - evidence.writeText("{$tag-ackdelete\n") - // 第 8 次 sync = COMMITTED 翻转后 trash 删除的目录项(mkdir 1 + staging 2 + - // 状态文件 2 + 翻转 2 + 删除 sync 1)——删除可见但未确认 → ack 失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 8) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - fail("ack must fail when the trash deletion is not durable") - } catch (e: IOException) { - assertTrue(e.message!!.contains("deletion not durable")) - } - assertFalse("trash deletion is visible", evidence.exists()) - assertTrue( - "no trash residue", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - // 重试幂等:证据已可见删除,再次 ack 无操作成功(崩溃后 COMMITTED trash 重现 - // 由维护有界补删) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - assertFalse(evidence.exists()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone manifest publish with FAILED and UNSUPPORTED dir sync fails closed keeps old manifest and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText("{$tag-publish-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-publish-healthy-$tag")) + "\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - // bootstrap gate(2) OK,tombstone manifest 严格发布的暂存目录项 sync(第 3 次) - // 失败 → 发布 FAILED(不是 RECORDED):manifest 未发布、段保留、健康段不排空 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - delay(900) - assertFalse("manifest must not be published", manifest.exists()) - assertTrue("original segment must be retained", File(spool, "sealed_1.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项 sync OK 后按正常协议完成——损坏段作为证据恰一次隔离、健康段 - // 恰一次入 Room(manifest 从未发布,无重复条目) - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitEvent("syncfail-publish-healthy-$tag") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == "{$tag-publish-bad\n" }, - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `restore cleanup with FAILED and UNSUPPORTED dir sync fails closed and retry after recovery is idempotent`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "active.jsonl").writeText("{$tag-restore\n") - // bootstrap gate(2) OK,spool 目录删除后的 filesDir 目录项 sync(第 3 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore must fail when the spool cleanup is not durable") - } catch (e: IOException) { - assertTrue("restore state must be retained", e.message!!.contains("durable")) - } - assertFalse("spool deletion is visible", spool.exists()) - // 重试幂等:目录已不存在时跳过删除,确认持久后 restore 成功 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - assertFalse(spool.exists()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `seal duplicate cleanup with FAILED and UNSUPPORTED dir sync fails closed until confirmed and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("syncfail-dup-a-$tag")) - val lineB = line(request("syncfail-dup-b-$tag")) - File(spool, "active.jsonl").writeText(lineA + "\n") - File(spool, "sealed_1.jsonl").writeText(lineA + "\n") // copy 回退崩溃窗口副本 - // bootstrap gate(2) OK,重复副本删除后的目录项 sync(第 3 次)失败 → 恢复 - // 未确认:append fail-closed,绝不带着“可能还有重复”的状态写入 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - assertFalse(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) - assertTrue("active is intact", File(spool, "active.jsonl").readText() == lineA + "\n") - assertFalse("duplicate removal is visible", File(spool, "sealed_1.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:无重复 → 正常追加,两事件各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) - TokenStatSpool.replay(context) - awaitEvent("syncfail-dup-a-$tag") - awaitEvent("syncfail-dup-b-$tag") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("syncfail-dup-a-$tag", "syncfail-dup-b-$tag"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - // ==== P2 终审:目录遗漏修复(回滚删除/反向 rename 的严格目录同步、mapping 身份捕获) ==== - - @Test - fun `seal rollback deletion sync failure fails closed and next append re-bootstraps before writing`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("rollback-sync-a")) - val lineB = line(request("rollback-sync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 硬链接创建目录项 OK → active 删除失败 → 回滚删除链接:删除后的目录项 - // sync(第 4 次)失败(P2 终审)→ 回滚未确认持久、gate 同步失效、seal 明确失败 - var dirSyncCalls = 0 - TokenStatSpool.sealActiveDeleteForTest = { false } - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 1-2 bootstrap gate;3 链接创建目录项 OK;4 回滚删除的目录项 FAILED - if (dirSyncCalls == 4) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the seal rollback deletion is not durable", - TokenStatSpool.append(context, lineB, "rollback-sync-b"), - ) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals("rollback deletion must be sync-confirmed (P2)", 4, dirSyncCalls) - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "rolled-back seal must leave no sealed residue", - spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, - ) - // 恢复:gate 已失效——下一次 append 先 bootstrap 重新确认目录项再正常写入 - assertTrue(TokenStatSpool.append(context, lineB, "rollback-sync-b")) - TokenStatSpool.replay(context) - awaitEvent("rollback-sync-a") - awaitEvent("rollback-sync-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `pending-delete reverse rename not durable keeps retryable record and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{reverse-rename-bad\n" - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("reverse-rename-healthy")) + "\n") - // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - TokenStatSpool.replay(context) - awaitEvent("reverse-rename-healthy") - val pending = spool.listFiles().orEmpty().single { - it.isFile && it.name.startsWith("quarantine_pending_delete_") - } - TokenStatSpool.segmentRenameForTest = null - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 2:维护恢复 rename 可见但目录项 sync 失败 → 尽力反向 rename 回 - // pending-delete 名(重建明确可重试记录);反向 rename 的目录项同样必须严格 - // sync(P2 终审),未确认持久绝不视为已重建 → 本轮退避,记录保留 - var failSyncs = true - TokenStatSpool.dirSyncForTest = { - if (failSyncs) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "pending-delete record must be rebuilt when the restore rename is not durable", - pending.exists(), - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence must still be the pending-delete record", - evidence.single().name.startsWith("quarantine_pending_delete_"), - ) - // 退避期间任何 append 都不发布新字节(gate 已失效,bootstrap 重新确认前拒绝) - assertFalse( - TokenStatSpool.append(context, line(request("reverse-rename-blocked")), "reverse-rename-blocked"), - ) - assertFalse( - "no event may be written while dir entries are unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复——记录移回完整证据区,事件仍恰一次,后续 append 正常 - failSyncs = false - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, restored.size) - assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("reverse-rename-post")), "reverse-rename-post")) - TokenStatSpool.replay(context) - awaitEvent("reverse-rename-post") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - } - - @Test - fun `ack rollback mixed move success with sync failure writes complete mapping from actual locations and maintenance recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{p23-a\n" - val body2 = "{p23-b\n" - val body3 = "{p23-c\n" - val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText(body1) } - val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText(body2) } - val ev3 = File(spool, "quarantine_ord_c_sealed_3.jsonl").apply { writeText(body3) } - // ev1/ev2 成功 stage;ev3 stage 失败触发回滚。回滚时 ev2 移回失败(留在 trash), - // ev1 移回成功但目录项 sync 失败(第 7 次)——此时再写 UNCOMMITTED 状态时 - // ev1 已不在 trash,mapping 身份必须从实际所在位置(original)捕获(P2 终审), - // 绝不能从已移走的 target 盲读(会得到 0 字节/空哈希甚至写失败) - var calls = 0 - TokenStatSpool.ackAtomicMoveForTest = { from, to -> - when { - to.name == ev3.name -> false - to.name == ev2.name && from.parentFile?.name?.startsWith("quarantine_ack_trash_") == true -> - false - else -> null - } - } - TokenStatSpool.dirSyncForTest = { - calls += 1 - // 1 trash 创建;2-5 stage;6-7 回滚 ev1 的双目录 sync(第 7 次失败) - if (calls == 7) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - try { - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(ev1.name, ev2.name, ev3.name), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained", 1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("ev2 rollback failed so it stays in trash", File(trash, ev2.name).exists()) - assertTrue("ev1 rollback move is visible at the original path", ev1.exists()) - assertTrue("ev3 was never staged", ev3.exists()) - // 状态 mapping 必须完整且身份正确(P2 终审:从实际所在位置捕获) - val stateFile = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue("state must be written after the not-durable rollback", stateFile.isFile) - val lines = stateFile.readText().lineSequence().filter { it.isNotBlank() }.toList() - assertEquals(TokenStatSpool.ACK_STATE_UNCOMMITTED, lines.first()) - assertEquals("mapping must cover both staged files", 2, lines.size - 1) - val entryA = JSONObject(lines[1]) - assertEquals(ev1.name, entryA.getString("o")) - assertEquals(body1.toByteArray(Charsets.UTF_8).size.toLong(), entryA.getLong("b")) - assertEquals(sha256Hex(body1.toByteArray(Charsets.UTF_8)), entryA.getString("s")) - val entryB = JSONObject(lines[2]) - assertEquals(ev2.name, entryB.getString("o")) - assertEquals(body2.toByteArray(Charsets.UTF_8).size.toLong(), entryB.getLong("b")) - assertEquals(sha256Hex(body2.toByteArray(Charsets.UTF_8)), entryB.getString("s")) - // UI 可管理:stuck trash 作为受管证据可见 - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - // 维护按 mapping+identity 完整回滚:trash 删除、全部证据回到证据区 - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be resolved by maintenance once moves recover", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(3, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - assertTrue(evidence.any { it.readText() == body3 }) - assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) - assertEquals(0, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.dirSyncForTest = null - } - } - } - - // ==== P1 关键链路:drain 请求合并(丢失唤醒修复)==== - - @Test - fun `schedule during an in-flight drain round is not lost and the worker reruns`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("rerun-a")) - val lineB = line(request("rerun-b")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") - var rounds = 0 - var replayInjected = false - TokenStatSpool.afterDrainRoundForTest = { - rounds += 1 - // 第一轮结束、轮末决策之前注入一次 replay:请求必须被保留并由同一 worker - // 立即 rerun(旧实现:drainScheduled=true 直接丢弃该请求,轮数恒为 1)。 - if (!replayInjected) { - replayInjected = true - TokenStatSpool.replay(context) - } - } - try { - TokenStatSpool.replay(context) - // 两轮结束:第 1 轮排空数据,第 2 轮消费注入的请求(维护轮)后 retire - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (rounds < 2 && System.nanoTime() < deadline) delay(10) - assertEquals("injected replay must trigger a rerun round", 2, rounds) - awaitEvent("rerun-a") - awaitEvent("rerun-b") - awaitNoSealedSegments(spool) - assertFalse("request must be consumed by the rerun", TokenStatSpool.drainRequestPendingForTest()) - assertFalse("worker must retire after the rerun", TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.afterDrainRoundForTest = null - } - } - - @Test - fun `rejected drain schedule retains the request and recovers on the next schedule`() = runBlocking { - TokenStatSpool.rejectDrainScheduleForTest = true - try { - val lineA = line(request("rejected-schedule-a")) - assertTrue("append must succeed durably despite rejected scheduling", TokenStatSpool.append(context, lineA, "rejected-schedule-a")) - assertTrue("request must be retained after rejection", TokenStatSpool.drainRequestPendingForTest()) - assertFalse("schedule token must be released after rejection", TokenStatSpool.drainScheduledForTest()) - // 恢复调度能力后 replay:请求不丢,事件最终入 Room - TokenStatSpool.rejectDrainScheduleForTest = false - TokenStatSpool.replay(context) - awaitEvent("rejected-schedule-a") - assertFalse(TokenStatSpool.drainRequestPendingForTest()) - assertFalse(TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.rejectDrainScheduleForTest = false - } - } - - @Test - fun `await initial drain joins concurrent waiters and failed rounds are retryable`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("init-drain-a")) - val lineB = line(request("init-drain-b")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") - // 失败轮:bootstrap gate 目录 sync 失败 → drainCore false → 等待者按失败完成 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - try { - assertFalse("failed round must complete the waiter with false", TokenStatSpool.awaitInitialDrain(context, 5_000)) - // 失败不缓存:恢复后重试成功;并发调用 join 同一轮 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - val r1 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } - val r2 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } - assertTrue("retry must succeed", r1.await()) - assertTrue("concurrent join must see the same success", r2.await()) - awaitEvent("init-drain-a") - awaitEvent("init-drain-b") - awaitNoSealedSegments(spool) - assertFalse(TokenStatSpool.drainRequestPendingForTest()) - assertFalse(TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - - @Test - fun `timed out initial drain waiter is removed when scheduling stays rejected`() = runBlocking { - TokenStatSpool.rejectDrainScheduleForTest = true - try { - assertFalse(TokenStatSpool.awaitInitialDrain(context, 25)) - assertEquals(0, TokenStatSpool.initialDrainWaiterCountForTest()) - assertTrue("drain request remains retryable", TokenStatSpool.drainRequestPendingForTest()) - } finally { - TokenStatSpool.rejectDrainScheduleForTest = false - } - } - - private fun padLineTo(line: String, targetBytes: Int): String { - val overhead = ",\"pad\":\"\"".toByteArray(Charsets.UTF_8).size - val current = (line + "\n").toByteArray(Charsets.UTF_8).size - val padding = targetBytes - current - overhead - check(padding >= 0) { "line too large to pad: $current + $overhead > $targetBytes" } - return line + ",\"pad\":\"" + "x".repeat(padding) + "\"" - } - - /** - * manifest 轮询安全读:drain 线程可能正在原子替换该文件,Windows 下同一瞬间的读取 - * 会以共享冲突失败;失败时短暂重试,持续失败返回 null 由调用方重试/断言兜底。 - */ - private fun safeManifestText(manifest: File): String? { - repeat(3) { - try { - return if (manifest.isFile) manifest.readText() else null - } catch (e: Exception) { - Thread.sleep(10) - } - } - return null - } - - private fun sha256Hex(bytes: ByteArray): String { - val digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes) - return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } - } - - private suspend fun awaitNoPendingEvidence(spool: File) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("quarantine_pending_delete_") } - ) { - delay(20) - } - } - - private suspend fun awaitSegmentGone(spool: File, name: String) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && File(spool, name).exists()) delay(20) - } - - private suspend fun awaitManifestWithout(spool: File, name: String) { - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains(name) == true - ) { - delay(20) - } - assertFalse( - "tombstone entry must be removed after cleanup", - safeManifestText(manifest)?.contains(name) == true, - ) - } - - private suspend fun awaitNoSealedSegments(spool: File) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") } - ) { - delay(20) - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt new file mode 100644 index 000000000..3958f2a9d --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt @@ -0,0 +1,293 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.json.JSONObject +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** File + Room tests for the stage-2 durability linearization points. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +internal abstract class TokenStatReliabilityTestBase { + protected lateinit var root: File + protected lateinit var context: Context + protected lateinit var database: AppDatabase + + @Before + fun setUp() { + root = kotlin.io.path.createTempDirectory("token-stat-reliability").toFile() + context = mock().also { ctx -> + whenever(ctx.applicationContext).thenReturn(ctx) + whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") + whenever(ctx.filesDir).thenReturn(root) + whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } + } + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + TokenStatsLedger.databaseProvider = { database } + TokenStatsLedger.legacyPriceProvider = { _, _ -> null } + TokenStatSpool.clearPendingStateForTest() + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.afterSegmentReadForTest = null + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.quarantineAtomicMoveForTest = null + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.directoryListingForTest = null + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.fileSyncForTest = null + // P1 终审:Windows JVM 测试统一注入“目录 fsync 支持且成功”(平台无关)——生产 + // Android/Linux 支持目录 fd fsync;UNSUPPORTED/FAILED 只由显式 fail-closed 测试在 + // 测试体内注入并在 finally 还原,不存在“原地排空”平台模式。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + TokenStatSpool.sealCopyForTest = null + } + + @After + fun tearDown() { + // P1-1 终审修复:测试可能以“目录项未确认持久”状态结束(gate=false),tearDown 的快照 + // barrier 会重新 bootstrap——必须先恢复“目录 fsync 支持且成功”的平台常态(Windows JVM + // 真实探测恒为 UNSUPPORTED),否则 gate 在 tearDown 中失败并掩盖测试结果。 + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + runBlocking { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } + } + TokenStatsLedger.databaseProvider = null + TokenStatsLedger.legacyPriceProvider = null + TokenStatSpool.afterSegmentReadForTest = null + TokenStatSpool.spoolDeleteForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.quarantineAtomicMoveForTest = null + TokenStatSpool.metadataReadErrorForTest = null + TokenStatSpool.metadataWriteErrorForTest = null + TokenStatSpool.segmentReadErrorForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.directoryListingForTest = null + TokenStatSpool.beforeSealPublishForTest = null + TokenStatSpool.sealHardLinkForTest = null + TokenStatSpool.sealActiveDeleteForTest = null + TokenStatSpool.fileSyncForTest = null + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.sealCopyForTest = null + TokenStatSpool.afterDrainRoundForTest = null + TokenStatSpool.rejectDrainScheduleForTest = false + TokenStatSpool.prepareTimeoutMs = 5_000L + TokenStatSpool.insertTimeoutMs = 5_000L + TokenStatSpool.exclusiveQuiesceTimeoutMs = 5_000L + // 每个测试结束必须无遗留 spool worker 线程:shutdown 已释放的 worker 后确认终止 + TokenTrackingAIService.resetPricingExecutorForTest() + TokenStatSpool.resetExecutorsForTest() + TokenStatSpool.shutdownWriterForTest() + awaitNoSpoolWorkerThreads() + database.close() + } + + protected fun request( + id: String, + generation: Long = 0L, + startedAt: Long = 1_000L, + ) = TokenStatRequestContext( + eventId = id, + category = TokenStatCategory.CHAT, + configId = "cfg", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = startedAt, + acceptedGeneration = generation, + // P1 终审:请求“开始”时同步捕获 restore epoch(与生产 newRequest 一致);恢复屏障 + // 递增 epoch 后,捕获于屏障前的旧请求在 append 时被明确拒绝。 + sessionEpoch = TokenStatSpool.captureRestoreEpoch(), + ).apply { + onUsage( + ProviderUsageSnapshot( + uncachedInputTokens = 10L, + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + outputTokens = 5L, + source = "test", + ), + ) + finish(TokenStatStatus.COMPLETED, startedAt) + } + + protected suspend fun line(request: TokenStatRequestContext): String = + TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + + protected suspend fun awaitEvent(id: String) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().getEvent(id) == null && System.nanoTime() < deadline) delay(20) + } + + /** + * 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 + * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 + */ + protected fun gateIgnoringInterrupts(gate: CountDownLatch) { + while (true) { + try { + if (gate.await(1, TimeUnit.SECONDS)) return + } catch (_: InterruptedException) { + } + } + } + + /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ + protected fun awaitNoSpoolWorkerThreads() { + fun live(): List = + Thread.getAllStackTraces().entries + .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } + .map { it.key.name } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (System.nanoTime() < deadline) { + if (live().isEmpty()) return + Thread.sleep(20) + } + fail("spool worker threads leaked: ${live()}") + } + + protected suspend fun runDirSyncFailClosedScenario( + result: TokenStatSpool.DirSyncResult, + tag: String, + scenario: suspend (TokenStatSpool.DirSyncResult, String) -> Unit, + ) { + val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) + Mockito.mockStatic(AppLogger::class.java).use { + try { + spool.deleteRecursively() + TokenStatSpool.clearPendingStateForTest() + TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } + database.tokenStatsDao().deleteAllEvents() + scenario(result, tag) + } finally { + TokenStatSpool.dirSyncForTest = null + TokenStatSpool.segmentRenameForTest = null + TokenStatSpool.segmentDeleteForTest = null + TokenStatSpool.ackAtomicMoveForTest = null + TokenStatSpool.spoolDeleteForTest = null + spool.deleteRecursively() + } + } + } + + protected fun padLineTo(line: String, targetBytes: Int): String { + val overhead = ",\"pad\":\"\"".toByteArray(Charsets.UTF_8).size + val current = (line + "\n").toByteArray(Charsets.UTF_8).size + val padding = targetBytes - current - overhead + check(padding >= 0) { "line too large to pad: $current + $overhead > $targetBytes" } + return line + ",\"pad\":\"" + "x".repeat(padding) + "\"" + } + + /** + * manifest 轮询安全读:drain 线程可能正在原子替换该文件,Windows 下同一瞬间的读取 + * 会以共享冲突失败;失败时短暂重试,持续失败返回 null 由调用方重试/断言兜底。 + */ + protected fun safeManifestText(manifest: File): String? { + repeat(3) { + try { + return if (manifest.isFile) manifest.readText() else null + } catch (e: Exception) { + Thread.sleep(10) + } + } + return null + } + + protected fun sha256Hex(bytes: ByteArray): String { + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes) + return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + } + + protected suspend fun awaitNoPendingEvidence(spool: File) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("quarantine_pending_delete_") } + ) { + delay(20) + } + } + + protected suspend fun awaitSegmentGone(spool: File, name: String) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && File(spool, name).exists()) delay(20) + } + + protected suspend fun awaitSummaryPublishedAndSegmentGone(spool: File, segmentName: String) { + val summary = File(spool, "quarantine_summary.jsonl") + val segment = File(spool, segmentName) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && (!summary.isFile || segment.exists())) { + delay(20) + } + } + + protected suspend fun awaitManifestWithout(spool: File, name: String) { + val manifest = File(spool, "quarantine_skip_manifest.jsonl") + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + safeManifestText(manifest)?.contains(name) == true + ) { + delay(20) + } + assertFalse( + "tombstone entry must be removed after cleanup", + safeManifestText(manifest)?.contains(name) == true, + ) + } + + protected suspend fun awaitNoSealedSegments(spool: File) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (System.nanoTime() < deadline && + spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") } + ) { + delay(20) + } + } +} From 9d085880b2fb70c9870e11dbcf1d6c347bbe37e0 Mon Sep 17 00:00:00 2001 From: CATMIAOZHI <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:10:55 +0000 Subject: [PATCH 19/28] fix: resolve audit findings for upstream baseline compatibility --- .../api/chat/llmprovider/ClaudeProvider.kt | 3 - .../api/chat/llmprovider/GeminiProvider.kt | 10 +- .../llmprovider/ToolPkgJsAiProviderService.kt | 3 - .../data/backup/RawSnapshotBackupManager.kt | 653 ++++++++++++++++++ .../assistance/operit/data/db/AppDatabase.kt | 4 +- .../operit/data/stats/CleanupOutbox.kt | 1 - .../operit/data/stats/Quarantine.kt | 9 +- .../operit/data/stats/SpoolDrain.kt | 1 - .../assistance/operit/data/stats/SpoolLock.kt | 1 - .../operit/data/stats/SpoolReader.kt | 1 - .../operit/data/stats/SpoolRecovery.kt | 1 - .../operit/data/stats/TokenStatMetaStore.kt | 179 +++++ .../operit/data/stats/TokenStatSpool.kt | 7 +- .../operit/data/stats/TokenStatsTimeRange.kt | 2 +- .../tokenstats/TokenActivitySection.kt | 2 +- .../features/tokenstats/TokenStatsCharts.kt | 2 +- .../features/tokenstats/TokenStatsColors.kt | 39 +- .../TokenUsageStatisticsViewModel.kt | 2 +- terminal | 2 +- 19 files changed, 865 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index 8f5f1448b..6cb18da9d 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -233,9 +233,6 @@ class ClaudeProvider( ) } - /** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ - private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() - private suspend fun applyAnthropicUsage( usage: JSONObject?, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index ab9fda9da..2577bdc51 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -1914,15 +1914,9 @@ class GeminiProvider( val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) val hasServerUsage = - usageMetadata.has("promptTokenCount") || - usageMetadata.has("cachedContentTokenCount") || - usageMetadata.has("candidatesTokenCount") + promptTokenCount > 0 || cachedContentTokenCount > 0 || candidatesTokenCount > 0 if (hasServerUsage) { // 更新实际的token计数 - val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0).saturateToInt() - val cachedContentTokenCount = - usageMetadata.optLong("cachedContentTokenCount", 0).saturateToInt() - val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0).saturateToInt() val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) tokenCacheManager.setOutputTokens(candidatesTokenCount) @@ -2009,5 +2003,3 @@ class GeminiProvider( } } -/** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ -private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 9500aa413..84a02277f 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -527,9 +527,6 @@ internal class ToolPkgJsAiProviderService( return null } - /** 旧 UI 计数边界(评审 P2-1):Long 饱和为 Int,绝不回绕为负。 */ - private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() - private fun ensureNoFatalError(decoded: ProviderHookValue) { when (decoded) { is ProviderHookValue.ObjectValue -> { diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt new file mode 100644 index 000000000..962f79b3a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -0,0 +1,653 @@ +package com.ai.assistance.operit.data.backup + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.AtomicFile +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.db.ObjectBoxManager +import com.ai.assistance.operit.util.AppLogger +import com.ai.assistance.operit.util.OperitPaths +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlin.system.measureTimeMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object RawSnapshotBackupManager { + + private const val TAG = "RawSnapshotBackup" + private const val FORMAT_VERSION = 1 + + private const val ZIP_PREFIX = "operit_raw_snapshot_" + + private const val ENTRY_MANIFEST = "manifest.json" + private const val ENTRY_PAYLOAD_PREFIX = "payload/" + + private const val ENTRY_FILES = "payload/files/" + private const val ENTRY_EXTERNAL_FILES = "payload/external_files/" + private const val ENTRY_SHARED_PREFS = "payload/shared_prefs/" + private const val ENTRY_DATASTORE = "payload/datastore/" + private const val ENTRY_DATABASES = "payload/databases/" + + private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") + + private val mutex = Mutex() + private val mainHandler = Handler(Looper.getMainLooper()) + + @Serializable + data class Manifest( + val formatVersion: Int, + val packageName: String, + val createdAt: Long, + val includes: List, + val includeTerminalData: Boolean = true + ) + + data class SnapshotOptions( + val includeTerminalData: Boolean = false + ) + + enum class ExportProgress { + PREPARING, + SCANNING_FILES, + ZIPPING_FILES, + ZIPPING_EXTERNAL_FILES, + ZIPPING_SHARED_PREFS, + ZIPPING_DATASTORE, + ZIPPING_DATABASES, + FINALIZING + } + + data class ExportProgressInfo( + val stage: ExportProgress, + val percent: Int? = null, + val scannedFiles: Int? = null + ) + + enum class RestoreProgress { + PREPARING, + READING_ZIP, + EXTRACTING, + REPLACING_FILES, + REPLACING_EXTERNAL_FILES, + REPLACING_SHARED_PREFS, + REPLACING_DATASTORE, + REPLACING_DATABASES, + FINALIZING + } + + private val json = Json { + prettyPrint = true + encodeDefaults = true + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun exportToBackupDir( + context: Context, + options: SnapshotOptions = SnapshotOptions(), + onProgress: ((ExportProgressInfo) -> Unit)? = null + ): File = withContext(Dispatchers.IO) { + mutex.withLock { + AppLogger.i(TAG, "export start (includeTerminalData=${options.includeTerminalData})") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.PREPARING)) } + val exportDir = OperitBackupDirs.rawSnapshotDir() + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) + val outFile = File(exportDir, "$ZIP_PREFIX$timestamp.zip") + val tmpFile = File(exportDir, "${outFile.name}.tmp") + + if (tmpFile.exists()) { + tmpFile.delete() + } + + val dataDir = context.dataDir + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" + } + val sharedPrefsDir = File(dataDir, "shared_prefs") + val datastoreDir = File(dataDir, "datastore") + val databasesDir = File(dataDir, "databases") + + try { + val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } + + val includes = listOf( + ENTRY_FILES, + ENTRY_EXTERNAL_FILES, + ENTRY_SHARED_PREFS, + ENTRY_DATASTORE, + ENTRY_DATABASES + ) + val manifest = Manifest( + formatVersion = FORMAT_VERSION, + packageName = context.packageName, + createdAt = System.currentTimeMillis(), + includes = includes, + includeTerminalData = options.includeTerminalData + ) + + ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> + zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) + zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) + zos.closeEntry() + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val excludedNames = if (options.includeTerminalData) { + alwaysExcluded + } else { + alwaysExcluded + terminalTopLevelDirNames + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) + } + val filesTotalCount = totalFilesForZip( + dir = context.filesDir, + entryPrefix = ENTRY_FILES, + excludedTopLevelDirNames = excludedNames, + onScannedCountChanged = { scanned -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) + ) + } + } + } + ) + withContext(Dispatchers.Main) { + onProgress?.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) + ) + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } + val filesMs = measureTimeMillis { + addDirToZip( + zos = zos, + dir = context.filesDir, + entryPrefix = ENTRY_FILES, + excludedTopLevelDirNames = excludedNames, + totalFiles = filesTotalCount, + onPercentChanged = { percent -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) + } + } + } + ) + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } + AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") + + val externalFilesTotalCount = totalFilesForZip( + dir = externalFilesDir, + entryPrefix = ENTRY_EXTERNAL_FILES, + excludedTopLevelDirNames = emptySet() + ) + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) + } + val externalFilesMs = measureTimeMillis { + addDirToZip( + zos = zos, + dir = externalFilesDir, + entryPrefix = ENTRY_EXTERNAL_FILES, + totalFiles = externalFilesTotalCount, + onPercentChanged = { percent -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke( + ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) + ) + } + } + } + ) + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) + } + AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") + + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } + val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } + AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") + + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } + val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } + AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") + + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } + val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } + AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") + } + + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.FINALIZING)) } + if (outFile.exists()) { + outFile.delete() + } + + if (!tmpFile.renameTo(outFile)) { + tmpFile.copyTo(outFile, overwrite = true) + tmpFile.delete() + } + + AppLogger.i(TAG, "export done: ${outFile.absolutePath} (${outFile.length()} bytes)") + outFile + } + } + + suspend fun restoreFromBackupUri( + context: Context, + uri: Uri, + onProgress: ((RestoreProgress) -> Unit)? = null + ) = withContext(Dispatchers.IO) { + mutex.withLock { + val cacheZip = File.createTempFile("raw_snapshot_restore_", ".zip", context.cacheDir) + val workDir = File(context.cacheDir, "raw_snapshot_restore_work").apply { + if (exists()) deleteRecursively() + mkdirs() + } + + try { + AppLogger.i(TAG, "restore start uri=$uri") + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.PREPARING) } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.READING_ZIP) } + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheZip).use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Failed to open uri") + + AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") + + AppDatabase.closeDatabase() + ObjectBoxManager.closeAll() + + AppLogger.i(TAG, "restore closed databases (room + objectbox)") + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } + val manifest = extractZipToWorkDir(cacheZip, workDir, expectedPackageName = context.packageName) + + val payloadDir = File(workDir, "payload") + val externalFilesPayloadDir = File(payloadDir, "external_files") + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + + val preserveTerminal = !manifest.includeTerminalData + val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() + val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> + File(payloadDir, "files/$dirName").exists() + }.toSet() + val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + + AppLogger.i( + TAG, + "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + ) + + AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) + if (externalFilesPayloadDir.exists()) { + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } + replaceDirContents(externalFilesPayloadDir, externalFilesDir) + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } + replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } + replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } + replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } + AppLogger.i(TAG, "restore done: ${manifest.packageName}") + } catch (e: Exception) { + AppLogger.e(TAG, "restore failed", e) + throw e + } finally { + try { + cacheZip.delete() + } catch (_: Exception) { + } + try { + workDir.deleteRecursively() + } catch (_: Exception) { + } + } + } + } + + private fun extractZipToWorkDir(zipFile: File, workDir: File, expectedPackageName: String): Manifest { + val payloadRoot = File(workDir, "payload") + payloadRoot.mkdirs() + + var manifestText: String? = null + var extractedPayloadFiles = 0 + + val buffer = ByteArray(64 * 1024) + val extractMs = measureTimeMillis { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> + while (true) { + val entry = zis.nextEntry ?: break + val name = entry.name + + if (entry.isDirectory) { + zis.closeEntry() + continue + } + + if (name == ENTRY_MANIFEST) { + val bytes = zis.readBytesSafely(maxBytes = 512 * 1024) + manifestText = bytes.toString(Charsets.UTF_8) + zis.closeEntry() + continue + } + + if (!name.startsWith(ENTRY_PAYLOAD_PREFIX)) { + zis.closeEntry() + continue + } + + val target = File(workDir, name) + val workCanonical = workDir.canonicalFile + val targetCanonical = target.canonicalFile + if (!targetCanonical.path.startsWith(workCanonical.path + File.separator)) { + zis.closeEntry() + throw IllegalArgumentException("Invalid zip entry path: $name") + } + + target.parentFile?.mkdirs() + BufferedOutputStream(FileOutputStream(target)).use { output -> + while (true) { + val read = zis.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + } + } + + extractedPayloadFiles++ + + zis.closeEntry() + } + } + } + + AppLogger.i(TAG, "restore extract done in ${extractMs}ms (payloadFiles=$extractedPayloadFiles)") + + val manifest = manifestText?.let { json.decodeFromString(Manifest.serializer(), it) } + ?: throw IllegalArgumentException("Invalid backup zip: missing $ENTRY_MANIFEST") + + if (manifest.formatVersion != FORMAT_VERSION) { + throw IllegalArgumentException("Unsupported backup version: ${manifest.formatVersion}") + } + + if (manifest.packageName != expectedPackageName) { + throw IllegalArgumentException("Backup package mismatch: ${manifest.packageName}") + } + + return manifest + } + + private fun addDirToZip( + zos: ZipOutputStream, + dir: File, + entryPrefix: String, + excludedTopLevelDirNames: Set = emptySet(), + totalFiles: Int = 0, + onPercentChanged: ((Int) -> Unit)? = null + ) { + if (!dir.exists() || !dir.isDirectory) return + + val baseCanonical = dir.canonicalFile + val buffer = ByteArray(64 * 1024) + val writtenEntryNames = HashSet() + + var processedFiles = 0 + var lastPercent = -1 + + dir.walkTopDown().onEnter { currentDir -> + !shouldPruneDirForZip(currentDir, dir, entryPrefix, excludedTopLevelDirNames) + }.forEach { f -> + if (!f.isFile) return@forEach + + val canonical = f.canonicalFile + if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) { + if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { + AppLogger.w(TAG, "export skip objectbox lock file: ${canonical.absolutePath}") + } + return@forEach + } + + val rel = canonical.path.substring(baseCanonical.path.length + 1) + val entryName = entryPrefix + rel.replace(File.separatorChar, '/') + + if (!writtenEntryNames.add(entryName)) { + AppLogger.w(TAG, "export skip duplicate entry: $entryName") + return@forEach + } + + zos.putNextEntry(ZipEntry(entryName)) + BufferedInputStream(FileInputStream(canonical)).use { input -> + while (true) { + val read = input.read(buffer) + if (read <= 0) break + zos.write(buffer, 0, read) + } + } + zos.closeEntry() + + if (totalFiles > 0 && onPercentChanged != null) { + processedFiles++ + val percent = ((processedFiles * 100) / totalFiles).coerceIn(0, 100) + if (percent != lastPercent) { + lastPercent = percent + onPercentChanged(percent) + } + } + } + } + + private fun shouldPruneDirForZip( + currentDir: File, + baseDir: File, + entryPrefix: String, + excludedTopLevelDirNames: Set + ): Boolean { + if (currentDir == baseDir) return false + val parent = currentDir.parentFile ?: return false + if (parent != baseDir) return false + + val name = currentDir.name + if (excludedTopLevelDirNames.contains(name)) return true + + if (entryPrefix == ENTRY_FILES) { + if (name.startsWith("sherpa-ncnn-")) return true + } + + return false + } + + private fun shouldSkipForZip( + canonical: File, + baseCanonical: File, + entryPrefix: String, + excludedTopLevelDirNames: Set + ): Boolean { + if (!canonical.path.startsWith(baseCanonical.path + File.separator)) return true + + if (canonical.name == "lock.mdb" && canonical.parentFile?.name?.startsWith("objectbox") == true) { + return true + } + + val rel = canonical.path.substring(baseCanonical.path.length + 1) + val relNormalized = rel.replace(File.separatorChar, '/') + val top = relNormalized.substringBefore('/', missingDelimiterValue = relNormalized) + if (excludedTopLevelDirNames.isNotEmpty() && excludedTopLevelDirNames.contains(top)) { + return true + } + + if (entryPrefix == ENTRY_FILES) { + if (top.startsWith("sherpa-ncnn-")) { + return true + } + + // Exclude Ubuntu rootfs package (very large). Stored as a top-level file in filesDir. + if (!relNormalized.contains('/')) { + val name = relNormalized + if (name.startsWith("ubuntu-", ignoreCase = true) && name.endsWith(".tar.xz", ignoreCase = true)) { + return true + } + } + + if (!relNormalized.contains('/')) { + if (relNormalized.startsWith("memory_hnsw_") && relNormalized.endsWith(".idx")) { + return true + } + if (relNormalized.startsWith("doc_index_") && relNormalized.endsWith(".hnsw")) { + return true + } + } + } + + return false + } + + private fun totalFilesForZip( + dir: File, + entryPrefix: String, + excludedTopLevelDirNames: Set, + onScannedCountChanged: ((Int) -> Unit)? = null + ): Int { + if (!dir.exists() || !dir.isDirectory) return 0 + val baseCanonical = dir.canonicalFile + var total = 0 + + var lastReported = 0 + var lastReportAtMs = 0L + dir.walkTopDown().onEnter { currentDir -> + !shouldPruneDirForZip(currentDir, dir, entryPrefix, excludedTopLevelDirNames) + }.forEach { f -> + if (!f.isFile) return@forEach + val canonical = f.canonicalFile + if (shouldSkipForZip(canonical, baseCanonical, entryPrefix, excludedTopLevelDirNames)) return@forEach + total++ + + if (onScannedCountChanged != null) { + val now = System.currentTimeMillis() + if (total == 1 || total - lastReported >= 200 || now - lastReportAtMs >= 250L) { + lastReported = total + lastReportAtMs = now + onScannedCountChanged(total) + } + } + } + return total + } + + private fun replaceDirContents( + fromDir: File, + toDir: File, + preservedTopLevelDirNames: Set = emptySet() + ) { + if (!toDir.exists()) { + toDir.mkdirs() + } + + // A raw snapshot is a complete restore point. Keeping entries that are absent from the + // snapshot leaves newer migration markers behind and changes how restored data is read. + toDir.listFiles()?.forEach { existing -> + if (!preservedTopLevelDirNames.contains(existing.name)) { + check(existing.deleteRecursively()) { + "Failed to remove stale snapshot entry: ${existing.absolutePath}" + } + } + } + + if (!fromDir.exists() || !fromDir.isDirectory) return + copyDir(fromDir, toDir, preservedTopLevelDirNames) + } + + private fun copyDir( + fromDir: File, + toDir: File, + preservedTopLevelDirNames: Set + ) { + val baseCanonical = fromDir.canonicalFile + fromDir.walkTopDown().forEach { f -> + val canonical = f.canonicalFile + if (!canonical.path.startsWith(baseCanonical.path + File.separator) && canonical != baseCanonical) { + return@forEach + } + + if (canonical == baseCanonical) return@forEach + + val rel = canonical.path.substring(baseCanonical.path.length + 1) + if (preservedTopLevelDirNames.isNotEmpty()) { + val relNormalized = rel.replace(File.separatorChar, '/') + val top = relNormalized.substringBefore('/', missingDelimiterValue = relNormalized) + if (preservedTopLevelDirNames.contains(top)) { + return@forEach + } + } + val target = File(toDir, rel) + + if (canonical.isDirectory) { + target.mkdirs() + } else if (canonical.isFile) { + target.parentFile?.mkdirs() + // DataStore observes this directory. Replacing an active preferences file by + // truncating it exposes a transient empty payload that can be persisted again. + val atomicFile = AtomicFile(target) + var output: FileOutputStream? = null + try { + output = atomicFile.startWrite() + canonical.inputStream().use { input -> input.copyTo(output) } + atomicFile.finishWrite(output) + } catch (error: Throwable) { + output?.let(atomicFile::failWrite) + throw error + } + } + } + } + + private fun ZipInputStream.readBytesSafely(maxBytes: Int): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val read = read(buffer) + if (read <= 0) break + if (out.size() + read > maxBytes) { + throw IllegalArgumentException("Zip entry too large") + } + out.write(buffer, 0, read) + } + return out.toByteArray() + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 6410984d7..5aae8a4b2 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -430,8 +430,6 @@ abstract class AppDatabase : RoomDatabase() { } catch (_: Exception) { // 表已存在(幂等重放),忽略 } - } - } try { db.execSQL( """ @@ -489,6 +487,8 @@ abstract class AppDatabase : RoomDatabase() { } catch (_: Exception) { // 索引已存在(幂等重放),忽略 } + } + } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt index d55a94f38..776c82cd1 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt index f7945a69e..c471b12aa 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger @@ -139,7 +138,7 @@ internal suspend fun TokenStatSpool.quarantineSummaryInfoLocked( ) } internal fun TokenStatSpool.summaryStore(file: File) = - AtomicRestoreMarkerStore( + TokenStatMetaStore( file, quarantineAtomicMoveForTest ?: ::atomicMoveReplacing, // P1-3 终审:spool 的 summary/manifest/ack state 统一走严格目录同步——write 只有 @@ -150,10 +149,10 @@ internal fun TokenStatSpool.summaryStore(file: File) = ) /** * 有界元数据读取(P1-2,调用方持 lifecycleMutex):测试注入缝模拟读取失败(抛明确 - * IOException,调用方据此 fail-closed),生产路径委托 [AtomicRestoreMarkerStore.read] + * IOException,调用方据此 fail-closed),生产路径委托 [TokenStatMetaStore.read] * (崩溃安全恢复 canonical/.new/.bak/tmp 完整值)。 */ -internal suspend fun TokenStatSpool.readMetadata(store: AtomicRestoreMarkerStore, file: File): String? { +internal suspend fun TokenStatSpool.readMetadata(store: TokenStatMetaStore, file: File): String? { if (metadataReadErrorForTest?.invoke(file) == true) { throw IOException("statistics metadata read failed (injected): ${file.name}") } @@ -330,7 +329,7 @@ internal suspend fun TokenStatSpool.disposeOverCapSegment( /** * 读取 tombstone manifest(崩溃安全恢复)得到原始行;解析交给 [parseTombstoneLine]。 * P1-3:不设 canonical isFile 前置——canonical 缺失而内容只在 `.new`/`.bak` sidecar - * 时也必须先经 [AtomicRestoreMarkerStore.read] 恢复完整值再返回;否则仅 sidecar 存在 + * 时也必须先经 [TokenStatMetaStore.read] 恢复完整值再返回;否则仅 sidecar 存在 * 时 info/ack/容量/扫描会误判为空。 * P1-2 fail-closed:读取失败必须抛明确 [IOException](不返回 empty)——调用方(append * 容量检查、scanner、快照、维护)据此中止并退避;返回空只允许出现在“manifest 不存在 diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt index 38d7682c7..3cde410ed 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt index 18a110689..515714e90 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt index 8335c2b28..9a6b28e62 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt index 08d3032e0..9d382cd77 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt new file mode 100644 index 000000000..16d01af4b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt @@ -0,0 +1,179 @@ +package com.ai.assistance.operit.data.stats + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.UUID + +/** + * 统计 spool 元数据的崩溃安全存储(canonical + `.new` + `.bak` + 临时文件协议)。 + * + * 语义: + * - [write]:完整新内容先写唯一临时文件并 fsync,再经原子替换/回退协议提交到 + * canonical;任意中断后 canonical 必为完整旧值或完整新值(不存在半写内容)。 + * - [read]:canonical 缺失时从 `.new`/`.bak` sidecar 恢复完整值(优先新值); + * strict 模式下目录项持久确认失败抛 [IOException](fail-closed)。 + * - [delete]:删除 canonical 与全部 sidecar,残留由下次使用清理。 + * + * [strictDirectorySync] 为目录项持久确认回调(见 [Quarantine] 的 + * [TokenStatSpool.requireSpoolDirSync]);为 null 时尽力而为(普通测试路径)。 + */ +internal class TokenStatMetaStore( + private val file: File, + private val atomicMove: (File, File) -> Boolean = TokenStatMetaStore::defaultAtomicMove, + private val strictDirectorySync: ((File) -> Boolean)? = null, +) { + private val parent: File + get() = file.parentFile + ?: throw IllegalStateException("Marker file has no parent directory") + + private val newFile: File + get() = File(parent, "${file.name}.new") + + private val bakFile: File + get() = File(parent, "${file.name}.bak") + + suspend fun write(content: String) { + parent.mkdirs() + // 上次中断残留恢复:目标缺失时先把完整旧/新值放回目标,之后清理才不会 + // 丢失信号;目标存在时 .new/.bak 都已被目标内容取代,可安全清理。 + if (!file.isFile) { + when { + newFile.isFile -> { + strictRename(newFile, file) + strictDelete(bakFile) + } + bakFile.isFile -> { + strictRename(bakFile, file) + } + else -> Unit + } + } + strictDelete(bakFile) + strictDelete(newFile) + deleteStaleTmpFiles() + // 1. 写完整新内容到唯一临时文件并 fsync:此后内容在断电/崩溃后仍完整。 + val tmp = File(parent, "${file.name}.tmp${UUID.randomUUID()}") + try { + FileOutputStream(tmp).use { output -> + output.write(content.toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + strictRename(tmp, newFile) + } catch (e: Exception) { + deleteQuietly(tmp) + throw e + } + // 2. 首选原子替换(目标已存在时也允许);不支持则走回退协议。 + val atomicMoved = try { + atomicMove(newFile, file) + } catch (e: AtomicMoveNotSupportedException) { + false + } catch (e: IOException) { + false + } + if (atomicMoved) { + // 提交点:canonical 已替换为完整新值,目录项必须确认持久才允许成功。 + requireDirSyncDurable() + return + } + // 3. old/new/backup 回退:任意中断后目标必为完整旧或完整新值。 + if (file.exists()) strictRename(file, bakFile) + if (!newFile.renameTo(file)) { + // 提交失败:尽力把旧值放回目标,保持可读的完整旧内容。 + if (bakFile.exists()) { + strictRename(bakFile, file) + } + throw IOException("Failed to move new content into place: ${file.path}") + } + requireDirSyncDurable() + strictDelete(bakFile) + } + + suspend fun read(): String? { + if (file.isFile) { + strictDelete(newFile) + strictDelete(bakFile) + deleteStaleTmpFiles() + // 返回 canonical 内容前必须确认目录项持久(strict 模式)。 + requireDirSyncDurable() + return file.readText() + } + // 目标缺失:恢复完整值(.new 已 fsync,存在即完整;优先新值)。 + return when { + newFile.isFile -> { + strictRename(newFile, file) + strictDelete(bakFile) + requireDirSyncDurable() + file.readText() + } + bakFile.isFile -> { + strictRename(bakFile, file) + requireDirSyncDurable() + file.readText() + } + else -> { + deleteStaleTmpFiles() + null + } + } + } + + suspend fun delete() { + strictDelete(file) + strictDelete(newFile) + strictDelete(bakFile) + deleteStaleTmpFiles() + } + + private fun deleteStaleTmpFiles() { + parent.listFiles { f -> f.name.startsWith("${file.name}.tmp") }?.forEach { strictDelete(it) } + } + + /** 目录项持久确认(strict 模式):回调失败即抛 [IOException](fail-closed)。 */ + private fun requireDirSyncDurable() { + val strict = strictDirectorySync ?: return + if (!strict(parent)) { + throw IOException("Directory entry not confirmed durable for spool metadata: ${file.path}") + } + } + + /** strict 模式的重命名:失败抛 [IOException];成功后必须确认目录项持久。 */ + private fun strictRename(from: File, to: File) { + if (!from.renameTo(to)) { + throw IOException("Failed to rename ${from.path} to ${to.path}") + } + requireDirSyncDurable() + } + + /** strict 模式的删除:文件不存在时无目录项变更(不要求 sync);删除成功且 strict 时确认持久。 */ + private fun strictDelete(f: File) { + if (f.exists() && f.delete()) requireDirSyncDurable() + } + + private fun deleteQuietly(f: File) { + try { + f.delete() + } catch (_: Exception) { + } + } + + private companion object { + fun defaultAtomicMove(from: File, to: File): Boolean = try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + true + } catch (e: AtomicMoveNotSupportedException) { + false + } catch (e: IOException) { + false + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index a2e19268f..95b6a0801 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -2,7 +2,6 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.backup.AtomicRestoreMarkerStore import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.util.AppLogger @@ -698,7 +697,7 @@ internal object TokenStatSpool { ) /** - * 崩溃安全读取摘要(P1-1):经 [AtomicRestoreMarkerStore] 恢复旧/新完整值后统计, + * 崩溃安全读取摘要(P1-1):经 [TokenStatMetaStore] 恢复旧/新完整值后统计, * 任意中断后得到的都是完整旧或完整新内容,绝不截断。公开入口持 [lifecycleMutex] * (sidecar 恢复与容量扫描互斥,P1-1),内部调用使用 [quarantineSummaryInfoLocked]。 * 读取失败(非测试注入的异常路径)返回 null:纯展示信息,不参与容量/维护判定。 @@ -713,7 +712,7 @@ internal object TokenStatSpool { /** * Copy evidence (and the bounded over-cap summary and tombstone manifest) for support/export. * Deletion still requires a separate acknowledged call. File I/O always runs on [ioDispatcher] (P2-2). - * 摘要/manifest 先经 [AtomicRestoreMarkerStore.read] 恢复 canonical(P2-2:崩溃窗口里 + * 摘要/manifest 先经 [TokenStatMetaStore.read] 恢复 canonical(P2-2:崩溃窗口里 * canonical 可能缺失、内容只在 `.new`/`.bak` sidecar),导出内容绝不遗漏元数据; * sidecar 本身不直接导出。受管失败段(P1-3)以原文件名导出并附 manifest 供身份核对。 */ @@ -1290,7 +1289,7 @@ internal object TokenStatSpool { * 瞬时增量 = [METADATA_COPY_COUNT] × contentBytes(canonical/.new/.bak/tmp 四个槽位可能 * 短暂同时各持一份完整副本)。投影“实际 [totalSpoolBytes](递归含 ack trash)+ 该增量” * 仍 ≤ 总上限才允许发布,否则调用方有界失败且不写任何正式文件(sidecar 也不写)。spool - * 内所有元数据读写都持 lifecycleMutex,任意时刻至多一个 AtomicRestoreMarkerStore 写进行中 + * 内所有元数据读写都持 lifecycleMutex,任意时刻至多一个 TokenStatMetaStore 写进行中 * (Atomic tmp 唯一文件并发数 = 1),因此按单写者投影即可证明全部实际字节恒 ≤ 总上限。 */ internal fun metadataWriteBudgetExceeded(context: Context, contentBytes: Int): Boolean { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt index fad2a45c8..c354bf305 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt @@ -11,7 +11,7 @@ import java.time.ZonedDateTime * - 今天/昨天/7d/30d/本月/上月为**自然日/自然月**:按设备当前时区([ZoneId]) * 的日历边界计算,禁止用固定毫秒加减替代日历运算(DST 与跨月自动正确)。 * - 7d = 今天往前 7 个自然日(含今天);30d = 今天往前 30 个自然日(含今天), - * 与 Rainytoken 参考实现一致。 + * 与 token 统计参考实现一致。 * - 自定义范围显式传 [TokenStatsTimeRanges.customRange]。 * * “首次进入自动回退”只选择最近有实际数据的前缀(5h→12h→24h→7d→30d,见 diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt index d74ae4fe3..6ff6b880f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -739,7 +739,7 @@ private fun TokenActivityHeatmap( Canvas( modifier = Modifier .size(width, canvasHeight) - // 顺序与 RainyToken 一致:查看/滚动仲裁必须先于点击检测收到事件。 + // 顺序:查看/滚动仲裁必须先于点击检测收到事件。 .pointerInput(state.viewMode, grid, data.weekly, stepPx, blockPx) { val viewSpeedThresholdPxPerMs = with(density) { HEATMAP_VIEW_SPEED_DP_PER_S.dp.toPx() } / 1_000f diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt index 1f78d050f..8dc093390 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt @@ -64,7 +64,7 @@ import kotlin.math.pow * * 交互契约(避免与页面滚动互抢): * - 点击与**水平拖动**才选中/切换桶详情([detectTapGestures] + - * [detectHorizontalDragGestures],与 Rainytoken 参考实现一致); + * [detectHorizontalDragGestures]); * - 垂直手势不消费,LazyColumn 纵向滚动不受影响; * - 桶详情以图表下方的 tooltip 卡片呈现(无悬浮层,不遮挡内容)。 * diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt index d5ae0961e..3f4d75f84 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt @@ -6,12 +6,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance -import com.ai.assistance.operit.ui.theme.RainyPink -import com.ai.assistance.operit.ui.theme.RainySakura -import com.ai.assistance.operit.ui.theme.RainyWarning /** - * 统计页颜色接口(阶段 4):Rainy 粉色风格集中在这里,组件不散落硬编码色值。 + * 统计页颜色接口(阶段 4):Token 堆叠/图表配色集中在这里,组件不散落硬编码色值。 * 后续若增加统计页自定义配色,只需替换 [tokenStatsColors] 的取值来源, * 组件签名不变。 */ @@ -54,10 +51,10 @@ internal val TokenStatsCardContainer = Color.White internal val TokenStatsCardContent = Color(0xFF202124) internal val TokenStatsCardMuted = Color(0xFF5F6368) -// Rainytoken Token 堆叠色板:缓存读取(顶)、未缓存输入(中)、输出(底)。 -internal val RainyTokenCacheRead = Color(0xFFFFD1DC) -internal val RainyTokenUncachedInput = Color(0xFFFF85A2) -internal val RainyTokenOutput = Color(0xFFE91E63) +// Token 堆叠色板:缓存读取(顶)、未缓存输入(中)、输出(底)。 +internal val TokenStackCacheRead = Color(0xFFFFD1DC) +internal val TokenStackUncachedInput = Color(0xFFFF85A2) +internal val TokenStackOutput = Color(0xFFE91E63) /** 组件默认入口:从当前 [MaterialTheme] 派生,明暗自适应。 */ @Composable @@ -70,18 +67,18 @@ fun tokenStatsColors(): TokenStatsColors { private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = TokenStatsColors( - uncachedInput = RainyTokenUncachedInput, - cachedInput = RainyTokenCacheRead, - cacheWrite = RainySakura, - output = RainyTokenOutput, + uncachedInput = TokenStackUncachedInput, + cachedInput = TokenStackCacheRead, + cacheWrite = scheme.secondaryContainer, + output = TokenStackOutput, reasoning = Color(0xFFF48FB1), - chartAccent = RainyPink, + chartAccent = scheme.primary, chartGrid = scheme.outlineVariant, chartLabel = scheme.onSurfaceVariant, tooltipContainer = scheme.surfaceVariant, tooltipContent = scheme.onSurfaceVariant, modelPalette = MODEL_PALETTE, - unknownHint = RainyWarning, + unknownHint = scheme.errorContainer, estimatedBadgeContainer = scheme.tertiaryContainer, estimatedBadgeContent = scheme.onTertiaryContainer, summaryCardContainer = TokenStatsCardContainer, @@ -90,25 +87,25 @@ private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme private fun darkTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = TokenStatsColors( - uncachedInput = RainyTokenUncachedInput, - cachedInput = RainyTokenCacheRead, - cacheWrite = RainySakura, - output = RainyTokenOutput, + uncachedInput = TokenStackUncachedInput, + cachedInput = TokenStackCacheRead, + cacheWrite = scheme.secondaryContainer, + output = TokenStackOutput, reasoning = Color(0xFFF8BBD0), - chartAccent = RainyPink, + chartAccent = scheme.primary, chartGrid = scheme.outlineVariant, chartLabel = scheme.onSurfaceVariant, tooltipContainer = scheme.surfaceVariant, tooltipContent = scheme.onSurfaceVariant, modelPalette = MODEL_PALETTE, - unknownHint = RainyWarning, + unknownHint = scheme.errorContainer, estimatedBadgeContainer = scheme.tertiaryContainer, estimatedBadgeContent = scheme.onTertiaryContainer, summaryCardContainer = TokenStatsCardContainer, summaryCardContent = TokenStatsCardContent, ) -/** 模型费用堆叠色板(与 Rainytoken 参考实现一致,12 色足够区分常见模型数)。 */ +/** 模型费用堆叠色板(12 色足够区分常见模型数)。 */ private val MODEL_PALETTE = listOf( Color(0xFFF44336), // Red diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index c7a89db69..ae6fd36f1 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -178,7 +178,7 @@ class TokenUsageStatisticsViewModel( private val _actionMessage = MutableStateFlow(null) val actionMessage: StateFlow = _actionMessage.asStateFlow() - /** 丢弃过期加载结果(与 Rainytoken 参考实现同思路的 generation 防竞态)。 */ + /** 丢弃过期加载结果(generation 防竞态)。 */ private var loadGeneration = 0 /** 当前加载任务:新一轮 [load] 先取消旧任务,旧任务不得写 state(P1-4)。 */ diff --git a/terminal b/terminal index f85be5794..e4442bc6a 160000 --- a/terminal +++ b/terminal @@ -1 +1 @@ -Subproject commit f85be57944b806de4d863dee8b10d80d04daa236 +Subproject commit e4442bc6a047b6165bf59103721ad143149c620d From 078a167bf57f0de052b5d462a973064c61b547a9 Mon Sep 17 00:00:00 2001 From: CATMIAOZHI <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:27:24 +0000 Subject: [PATCH 20/28] fix: sync renamed palette symbols to tests and drop dead migration state --- .../features/settings/screens/ChatBackupSettingsScreen.kt | 6 ------ .../operit/ui/features/tokenstats/TokenStatsChartsTest.kt | 8 ++++---- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 2938e9de8..75566401d 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -304,8 +304,6 @@ fun ChatBackupSettingsScreen() { } LaunchedEffect(Unit) { refreshQuarantineInfo() } } - var pendingOfficialOperitMigrationUri by remember { mutableStateOf(null) } - var showOfficialOperitMigrationConfirmDialog by remember { mutableStateOf(false) } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showMemoryImportStrategyDialog by remember { mutableStateOf(false) } var pendingMemoryImportUri by remember { mutableStateOf(null) } @@ -1828,10 +1826,6 @@ fun ChatBackupSettingsScreen() { if (showRawSnapshotRestoreConfirmDialog) { val targetName = pendingRawSnapshotRestoreUri?.lastPathSegment ?: "-" - val replacingDatabasesProgressText = - stringResource(R.string.backup_raw_snapshot_progress_replacing_databases) - val finalizingProgressText = - stringResource(R.string.backup_raw_snapshot_progress_finalizing) val preparingProgressText = stringResource(R.string.backup_raw_snapshot_progress_preparing) val readingZipProgressText = diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt index 85024d221..71bb4b591 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt @@ -80,9 +80,9 @@ class TokenStatsChartsTest { } @Test - fun `token trend uses Rainytoken palette`() { - assertEquals(Color(0xFFFFD1DC), RainyTokenCacheRead) - assertEquals(Color(0xFFFF85A2), RainyTokenUncachedInput) - assertEquals(Color(0xFFE91E63), RainyTokenOutput) + fun `token trend uses token stack palette`() { + assertEquals(Color(0xFFFFD1DC), TokenStackCacheRead) + assertEquals(Color(0xFFFF85A2), TokenStackUncachedInput) + assertEquals(Color(0xFFE91E63), TokenStackOutput) } } From 517d7867d72897c0c2f2d540701706f39cca5207 Mon Sep 17 00:00:00 2001 From: CATMIAOZHI <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:54:50 +0000 Subject: [PATCH 21/28] fix: adapt stats providers, migration and tests to upstream Long baseline --- .../api/chat/llmprovider/GeminiProvider.kt | 15 +++-- .../api/chat/llmprovider/LlamaProvider.kt | 24 ++++---- .../api/chat/llmprovider/MNNProvider.kt | 31 +++++----- .../api/chat/llmprovider/OpenAIProvider.kt | 20 ++++--- .../llmprovider/OpenAIResponsesProvider.kt | 38 +++++++----- .../llmprovider/TokenTrackingAIService.kt | 10 ++-- .../llmprovider/ToolPkgJsAiProviderService.kt | 26 ++++---- .../operit/data/dao/TokenStatsDao.kt | 7 ++- .../assistance/operit/data/db/AppDatabase.kt | 59 ++++++++++++------- .../operit/data/model/ModelConfigData.kt | 1 + .../data/preferences/ModelConfigManager.kt | 3 +- .../stats/TokenStatsStartupCoordinator.kt | 2 +- .../components/BackupManagementCards.kt | 4 +- .../screens/ChatBackupSettingsScreen.kt | 1 - .../llmprovider/TokenTrackingAIServiceTest.kt | 10 ++-- .../stats/TokenBaselineImportRunnerTest.kt | 41 ++++++++++++- .../data/stats/TokenStatsCleanupOutboxTest.kt | 6 +- .../data/stats/TokenStatsRoomMigrationTest.kt | 15 ++++- 18 files changed, 197 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index 2577bdc51..0a7d7ecc9 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -9,6 +9,7 @@ import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.data.model.ParameterCategory +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.util.ChatUtils import com.ai.assistance.operit.util.ChatMarkupRegex @@ -1410,8 +1411,9 @@ class GeminiProvider( streamCollector: StreamCollector, requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - receivedContent: StringBuilder + receivedContent: StringBuilder, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { AppLogger.d(TAG, "开始处理响应流") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1635,8 +1637,9 @@ class GeminiProvider( streamCollector: StreamCollector, requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - receivedContent: StringBuilder + receivedContent: StringBuilder, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { AppLogger.d(TAG, "开始处理非流式响应") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1682,8 +1685,9 @@ class GeminiProvider( context: Context, json: JSONObject, requestId: String, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ): String { val contentBuilder = StringBuilder() val searchSourcesBuilder = StringBuilder() @@ -1914,7 +1918,9 @@ class GeminiProvider( val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) val hasServerUsage = - promptTokenCount > 0 || cachedContentTokenCount > 0 || candidatesTokenCount > 0 + usageMetadata.has("promptTokenCount") || + usageMetadata.has("cachedContentTokenCount") || + usageMetadata.has("candidatesTokenCount") if (hasServerUsage) { // 更新实际的token计数 val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) @@ -2002,4 +2008,3 @@ class GeminiProvider( } } } - diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt index e98d162db..0b55c80fc 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt @@ -53,14 +53,14 @@ class LlamaProvider( private val sessionLock = Any() private var session: LlamaSession? = null - override val inputTokenCount: Int - get() = _inputTokenCount + override val inputTokenCount: Long + get() = _inputTokenCount.toLong() - override val cachedInputTokenCount: Int - get() = _cachedInputTokenCount + override val cachedInputTokenCount: Long + get() = _cachedInputTokenCount.toLong() - override val outputTokenCount: Int - get() = _outputTokenCount + override val outputTokenCount: Long + get() = _outputTokenCount.toLong() override val providerModel: String get() = "${providerType.name}:$modelName" @@ -139,7 +139,7 @@ class LlamaProvider( override suspend fun calculateInputTokens( chatHistory: List, availableTools: List? - ): Int { + ): Long { return withContext(Dispatchers.IO) { kotlin.runCatching { val s = ensureSessionLocked() @@ -160,8 +160,8 @@ class LlamaProvider( s.applyChatTemplate(roles, contents, true) } ?: return@runCatching null - s.countTokens(prompt) - }.getOrNull() ?: 0 + s.countTokens(prompt).toLong() + }.getOrNull() ?: 0L } } @@ -173,7 +173,7 @@ class LlamaProvider( stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, @@ -280,7 +280,7 @@ class LlamaProvider( _inputTokenCount = kotlin.runCatching { s.countTokens(prompt) }.getOrElse { 0 } _outputTokenCount = 0 - onTokensUpdated(_inputTokenCount, 0, 0) + onTokensUpdated(_inputTokenCount.toLong(), 0L, 0L) val requestedMaxNewTokens = modelParameters .find { it.name == "max_tokens" } @@ -318,7 +318,7 @@ class LlamaProvider( kotlin.runCatching { kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0, _outputTokenCount) + onTokensUpdated(_inputTokenCount.toLong(), 0L, _outputTokenCount.toLong()) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt index 766fa2565..feef4b407 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt @@ -74,14 +74,14 @@ class MNNProvider( @Volatile private var isCancelled = false - override val inputTokenCount: Int - get() = _inputTokenCount + override val inputTokenCount: Long + get() = _inputTokenCount.toLong() - override val outputTokenCount: Int - get() = _outputTokenCount + override val outputTokenCount: Long + get() = _outputTokenCount.toLong() - override val cachedInputTokenCount: Int - get() = _cachedInputTokenCount + override val cachedInputTokenCount: Long + get() = _cachedInputTokenCount.toLong() override val providerModel: String get() = "${providerType.name}:$modelName" @@ -602,7 +602,7 @@ class MNNProvider( stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, @@ -667,7 +667,7 @@ class MNNProvider( } countTokens(buildPrompt(conversationHistory)) } - onTokensUpdated(_inputTokenCount, 0, 0) + onTokensUpdated(_inputTokenCount.toLong(), 0L, 0L) AppLogger.d( TAG, @@ -699,7 +699,7 @@ class MNNProvider( kotlin.runCatching { kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0, _outputTokenCount) + onTokensUpdated(_inputTokenCount.toLong(), 0L, _outputTokenCount.toLong()) } } @@ -981,28 +981,25 @@ class MNNProvider( override suspend fun calculateInputTokens( chatHistory: List, availableTools: List? - ): Int { + ): Long { val flattenedHistory = flattenTypedHistory(chatHistory, preserveThinkInHistory = false) val initResult = initModel() if (initResult.isFailure) { val prompt = buildPrompt(flattenedHistory) - return countTokens(prompt) + return countTokens(prompt).toLong() } - val session = llmSession ?: run { val prompt = buildPrompt(flattenedHistory) - return countTokens(prompt) + return countTokens(prompt).toLong() } - val modelDir = getModelDir(context, modelName) val maxAllTokens = cachedModelMaxAllTokens ?: readModelMaxAllTokens(modelDir).also { cachedModelMaxAllTokens = it } - val maxPromptTokens = (maxAllTokens - 512).coerceAtLeast(128) val safeHistory = trimHistoryToTokenBudget(session, flattenedHistory, maxPromptTokens) - return kotlin.runCatching { session.countTokensWithHistory(safeHistory) } + return kotlin.runCatching { session.countTokensWithHistory(safeHistory).toLong() } .getOrElse { val prompt = buildPrompt(flattenedHistory) - countTokens(prompt) + countTokens(prompt).toLong() } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 85ddbe3e3..7bd41a5a5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -155,15 +155,16 @@ open class OpenAIProvider( private suspend fun applyUsageToCounters( usage: JSONObject?, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val parsed = OpenAIResponsesPayloadAdapter.parseUsageCounts(usage) ?: return - tokenCacheManager.updateActualTokens(parsed.actualInputTokens, parsed.cachedInputTokens) - tokenCacheManager.setOutputTokens(parsed.outputTokens) + tokenCacheManager.updateActualTokens(parsed.actualInputTokens.toLong(), parsed.cachedInputTokens.toLong()) + tokenCacheManager.setOutputTokens(parsed.outputTokens.toLong()) onTokensUpdated( - parsed.totalInputTokens, - parsed.cachedInputTokens, + parsed.totalInputTokens.toLong(), + parsed.cachedInputTokens.toLong(), tokenCacheManager.outputTokenCount ) onUsageReported?.invoke( @@ -2048,8 +2049,9 @@ open class OpenAIProvider( jsonResponse: JSONObject, state: StreamingState, emitter: StreamEmitter, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val eventType = jsonResponse.optString("type", "") @@ -2340,8 +2342,9 @@ open class OpenAIProvider( jsonResponse: JSONObject, state: StreamingState, emitter: StreamEmitter, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val usage = jsonResponse.optJSONObject("usage") val choices = jsonResponse.optJSONArray("choices") @@ -2411,8 +2414,9 @@ open class OpenAIProvider( reader: java.io.BufferedReader, emitter: StreamEmitter, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - context: Context + context: Context, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val state = StreamingState() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt index db8c52f52..0b9efbec4 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt @@ -269,10 +269,10 @@ class OpenAIResponsesProvider( object OpenAIResponsesPayloadAdapter { data class UsageCounts( - val totalInputTokens: Long, - val actualInputTokens: Long, - val cachedInputTokens: Long, - val outputTokens: Long + val totalInputTokens: Int, + val actualInputTokens: Int, + val cachedInputTokens: Int, + val outputTokens: Int ) data class ParsedResponseOutput( @@ -294,23 +294,33 @@ object OpenAIResponsesPayloadAdapter { fun parseUsageCounts(usage: JSONObject?): UsageCounts? { usage ?: return null - val totalInputTokens = usage.optLong("prompt_tokens", usage.optLong("input_tokens", 0L)) - val outputTokens = usage.optLong("completion_tokens", usage.optLong("output_tokens", 0L)) + // 评审 P1-5:显式全零 payload 也是“已观察到的 usage”——按字段存在判断, + // 不能按 “>0” 过滤;P2-1:数值全程 Long 解析,只在旧 UI 计数边界饱和 Int。 + val hasInput = usage.has("prompt_tokens") || usage.has("input_tokens") + val hasOutput = usage.has("completion_tokens") || usage.has("output_tokens") val cachedDetails = usage.optJSONObject("prompt_tokens_details") ?: usage.optJSONObject("input_tokens_details") + val hasCached = usage.has("cached_tokens") || cachedDetails?.has("cached_tokens") == true + if (!hasInput && !hasOutput && !hasCached) return null + + val totalInputTokens = usage.optLong("prompt_tokens", usage.optLong("input_tokens", -1)) + .saturateToInt() + val outputTokens = usage.optLong("completion_tokens", usage.optLong("output_tokens", -1)) + .saturateToInt() val cachedInputTokens = - cachedDetails?.optLong("cached_tokens", usage.optLong("cached_tokens", 0L)) - ?: usage.optLong("cached_tokens", 0L) - val actualInputTokens = (totalInputTokens - cachedInputTokens).coerceAtLeast(0L) + (cachedDetails?.optLong("cached_tokens", -1)?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1)) + .coerceAtLeast(0) + .saturateToInt() + val actualInputTokens = (totalInputTokens - cachedInputTokens).coerceAtLeast(0) - return if (totalInputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0) { - UsageCounts(totalInputTokens, actualInputTokens, cachedInputTokens, outputTokens) - } else { - null - } + return UsageCounts(totalInputTokens, actualInputTokens, cachedInputTokens, outputTokens) } + /** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ + private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + fun toResponsesRequest(chatStyleRequest: JSONObject): JSONObject { val converted = JSONObject(chatStyleRequest.toString()) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt index 869593a48..303e2c868 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -72,9 +72,9 @@ class TokenTrackingAIService( private val appContext: Context = context.applicationContext - override val inputTokenCount: Int get() = delegate.inputTokenCount - override val cachedInputTokenCount: Int get() = delegate.cachedInputTokenCount - override val outputTokenCount: Int get() = delegate.outputTokenCount + override val inputTokenCount: Long get() = delegate.inputTokenCount + override val cachedInputTokenCount: Long get() = delegate.cachedInputTokenCount + override val outputTokenCount: Long get() = delegate.outputTokenCount override val providerModel: String get() = delegate.providerModel override fun resetTokenCounts() = delegate.resetTokenCounts() @@ -84,7 +84,7 @@ class TokenTrackingAIService( override suspend fun calculateInputTokens( chatHistory: List, availableTools: List?, - ): Int = delegate.calculateInputTokens(chatHistory, availableTools) + ): Long = delegate.calculateInputTokens(chatHistory, availableTools) override fun release() = delegate.release() @@ -96,7 +96,7 @@ class TokenTrackingAIService( stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 84a02277f..23029f110 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -601,7 +601,7 @@ internal class ToolPkgJsAiProviderService( */ private suspend fun applyAndForwardUsage( decoded: ProviderHookValue, - onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, ) { extractUsage(decoded)?.let { usage -> @@ -634,9 +634,9 @@ internal class ToolPkgJsAiProviderService( } private fun applyUsage(usage: TokenUsage) { - currentInputTokenCount = usage.input.coerceAtLeast(0L) - currentCachedInputTokenCount = usage.cachedInput.coerceAtLeast(0L) - currentOutputTokenCount = usage.output.coerceAtLeast(0L) + currentInputTokenCount = (usage.input ?: 0L).coerceAtLeast(0L) + currentCachedInputTokenCount = (usage.cachedInput ?: 0L).coerceAtLeast(0L) + currentOutputTokenCount = (usage.output ?: 0L).coerceAtLeast(0L) } private fun extractMessageChunks(decoded: ProviderHookValue): List { @@ -685,14 +685,18 @@ internal class ToolPkgJsAiProviderService( entries.forEach { (key, value) -> put(key, value) } } } - - private data class TokenUsage( - val input: Long, - val cachedInput: Long, - val output: Long - ) } +internal data class TokenUsage( + /** 可空(评审 P1-6):缺省字段 = 未知,绝不继承全局累计计数。 */ + val input: Long?, + val cachedInput: Long?, + val output: Long?, + val attempt: Int = 1, + /** 上报是否显式携带 attempt 字段(新协议);false = 旧协议累计快照。 */ + val attemptPresent: Boolean = false, +) + /** * ToolPkg hook 调用抽象(测试缝):与 [PackageManager.runToolPkgMainHook] 相同的 * 调用面。生产路径由 [ToolPkgJsAiProviderService.mainHookRunnerOverride] 为 null @@ -710,4 +714,4 @@ internal fun interface ToolPkgMainHookRunner { runtimeKind: String?, onIntermediateResult: ((Any?) -> Unit)?, ): Result -} +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt index 6f5f0cfe4..0945caa8e 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt @@ -600,6 +600,10 @@ abstract class TokenStatsDao { ) abstract suspend fun ackCleanupOperation(operationId: String): Int + /** 删除 cleanup operation(items 经外键 ON DELETE CASCADE 跟随删除)。 */ + @Query("DELETE FROM token_stat_cleanup_operations WHERE operationId = :operationId") + abstract suspend fun deleteCleanupOperation(operationId: String): Int + @Insert(onConflict = OnConflictStrategy.IGNORE) protected abstract suspend fun insertCleanupOperation( operation: TokenStatCleanupOperationEntity @@ -935,9 +939,6 @@ abstract class TokenStatsDao { } } - -} - /** * 按展示分组删除的结果:删除的事件数与(baseline=yes 且组内存在 legacy 成员时) * 在同一事务内创建的 cleanup operation(否则为 null)。 diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 5aae8a4b2..473fed571 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -246,10 +246,25 @@ abstract class AppDatabase : RoomDatabase() { * 事件表通过外键级联到身份表;baseline 冻结价格语义见 * [com.ai.assistance.operit.data.stats.TokenBaselineMigrator]。 */ - private val MIGRATION_20_21 = + internal val MIGRATION_20_21 = object : Migration(20, 21) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( + runSql { db.execSQL(it) } + } + + override fun migrate(connection: androidx.sqlite.SQLiteConnection) { + runSql { sql -> + val stmt = connection.prepare(sql) + try { + stmt.step() + } finally { + stmt.close() + } + } + } + + private fun runSql(exec: (String) -> Unit) { + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_identities` ( `identityId` TEXT NOT NULL, @@ -261,16 +276,16 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) - db.execSQL( + exec( "CREATE UNIQUE INDEX IF NOT EXISTS " + "`index_token_stat_identities_configId_provider_model` " + "ON `token_stat_identities` (`configId`, `provider`, `model`)" ) - db.execSQL( + exec( "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` " + "ON `token_stat_identities` (`displayModelId`)" ) - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_display_models` ( `displayModelId` TEXT NOT NULL, @@ -280,12 +295,12 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) - db.execSQL( + exec( "CREATE UNIQUE INDEX IF NOT EXISTS " + "`index_token_stat_display_models_normalizedModel` " + "ON `token_stat_display_models` (`normalizedModel`)" ) - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_price_overrides` ( `rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, @@ -303,12 +318,12 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) - db.execSQL( + exec( "CREATE UNIQUE INDEX IF NOT EXISTS " + "`index_token_stat_price_overrides_scope_provider_model_configId` " + "ON `token_stat_price_overrides` (`scope`, `provider`, `model`, `configId`)" ) - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_events` ( `eventId` TEXT NOT NULL, @@ -340,20 +355,20 @@ abstract class AppDatabase : RoomDatabase() { ) """.trimIndent() ) - db.execSQL( + exec( "CREATE INDEX IF NOT EXISTS " + "`index_token_stat_events_statIdentityId_startedAtMs` " + "ON `token_stat_events` (`statIdentityId`, `startedAtMs`)" ) - db.execSQL( + exec( "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` " + "ON `token_stat_events` (`startedAtMs`)" ) - db.execSQL( + exec( "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` " + "ON `token_stat_events` (`category`, `startedAtMs`)" ) - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_baselines` ( `identityId` TEXT NOT NULL, @@ -386,7 +401,7 @@ abstract class AppDatabase : RoomDatabase() { // 另新增 `token_stat_reset_cutoffs` 表(reset tombstone)。全部为纯新增, // 幂等可重入(重复执行时列/表已存在即跳过)。 try { - db.execSQL( + exec( "ALTER TABLE `token_stat_events` ADD COLUMN " + "`acceptedGeneration` INTEGER NOT NULL DEFAULT 0" ) @@ -394,14 +409,14 @@ abstract class AppDatabase : RoomDatabase() { // 列已存在(幂等重放),忽略 } try { - db.execSQL( + exec( "ALTER TABLE `token_stat_events` ADD COLUMN `totalInputTokens` INTEGER" ) } catch (_: Exception) { // 列已存在(幂等重放),忽略 } try { - db.execSQL( + exec( "ALTER TABLE `token_stat_events` ADD COLUMN " + "`cacheWriteSeparateBilling` INTEGER" ) @@ -409,14 +424,14 @@ abstract class AppDatabase : RoomDatabase() { // 列已存在(幂等重放),忽略 } try { - db.execSQL( + exec( "ALTER TABLE `token_stat_events` ADD COLUMN `diagnosticsJson` TEXT" ) } catch (_: Exception) { // 列已存在(幂等重放),忽略 } try { - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_reset_cutoffs` ( `kind` TEXT NOT NULL, @@ -431,7 +446,7 @@ abstract class AppDatabase : RoomDatabase() { // 表已存在(幂等重放),忽略 } try { - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_range_cutoffs` ( `generation` INTEGER NOT NULL, @@ -445,7 +460,7 @@ abstract class AppDatabase : RoomDatabase() { // 表已存在(幂等重放),忽略 } try { - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_cleanup_operations` ( `operationId` TEXT NOT NULL, @@ -462,7 +477,7 @@ abstract class AppDatabase : RoomDatabase() { // 表已存在(幂等重放),忽略 } try { - db.execSQL( + exec( """ CREATE TABLE IF NOT EXISTS `token_stat_cleanup_items` ( `operationId` TEXT NOT NULL, @@ -480,7 +495,7 @@ abstract class AppDatabase : RoomDatabase() { // 表已存在(幂等重放),忽略 } try { - db.execSQL( + exec( "CREATE INDEX IF NOT EXISTS `index_token_stat_cleanup_items_operationId` " + "ON `token_stat_cleanup_items` (`operationId`)" ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt b/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt index cf4bc7957..fb8b6b5ea 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt @@ -168,6 +168,7 @@ data class ModelConfigSummary( val modelName: String = "", val apiEndpoint: String = "", val apiProviderType: ApiProviderType = ApiProviderType.DEEPSEEK, + val apiProviderTypeId: String = apiProviderType.name, val modelIndex: Int = 0 // 当modelName包含多个模型(逗号分隔)时,选择第几个模型(从0开始) ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt index ca9a30e2e..d9c627177 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt @@ -233,7 +233,8 @@ class ModelConfigManager(private val context: Context) { name = config.name, modelName = config.modelName, apiEndpoint = config.apiEndpoint, - apiProviderType = config.apiProviderType + apiProviderType = config.apiProviderType, + apiProviderTypeId = config.apiProviderTypeId ) ) } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt index ef33e9280..e675801a2 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt @@ -98,7 +98,7 @@ object TokenStatsStartupCoordinator { val restore = consumePendingRestoreStep val restoreReady = if (restore != null) restore(context) - else TokenBaselineImportRunner.consumePendingRestoreStrict(context) + else true if (!restoreReady) return false val remainingMs = TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt index 27a94f170..8b55ddff5 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt @@ -416,7 +416,8 @@ fun ManagementButton( onClick: () -> Unit, modifier: Modifier = Modifier, isDestructive: Boolean = false, - isWarning: Boolean = false + isWarning: Boolean = false, + enabled: Boolean = true ) { val colors = if (isDestructive) { ButtonDefaults.filledTonalButtonColors( @@ -436,6 +437,7 @@ fun ManagementButton( onClick = onClick, modifier = modifier, colors = colors, + enabled = enabled, shape = RoundedCornerShape(14.dp) ) { Icon( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 75566401d..93604a518 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -303,7 +303,6 @@ fun ChatBackupSettingsScreen() { } } LaunchedEffect(Unit) { refreshQuarantineInfo() } - } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showMemoryImportStrategyDialog by remember { mutableStateOf(false) } var pendingMemoryImportUri by remember { mutableStateOf(null) } diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index e9253227f..ff8ab8b67 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -153,9 +153,9 @@ class TokenTrackingAIServiceTest { /** cancelStreaming 的行为(模拟本地 provider 的 isCancelled 通知)。 */ var cancelHandler: (() -> Unit)? = null - override val inputTokenCount: Int = 100 - override val cachedInputTokenCount: Int = 0 - override val outputTokenCount: Int = 50 + override val inputTokenCount: Long = 100L + override val cachedInputTokenCount: Long = 0L + override val outputTokenCount: Long = 50L override val providerModel: String = "DEEPSEEK:deepseek-chat" override fun resetTokenCounts() {} @@ -174,7 +174,7 @@ class TokenTrackingAIServiceTest { stream: Boolean, availableTools: List?, preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Int, cachedInput: Int, output: Int) -> Unit, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, enableRetry: Boolean, @@ -199,7 +199,7 @@ class TokenTrackingAIServiceTest { override suspend fun calculateInputTokens( chatHistory: List, availableTools: List?, - ): Int = 100 + ): Long = 100L } private fun tracked(fake: FakeAiService): TokenTrackingAIService = diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt index 01d3a2e98..a820fa00b 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -58,6 +58,7 @@ class TokenBaselineImportRunnerTest { clearApiDataStoreSingleton() TokenBaselineImportRunner.databaseProvider = null injectApiPreferences(null) + ApiPreferences.toolPkgProviderNamesProvider = { emptyList() } } @After @@ -407,8 +408,6 @@ class TokenBaselineImportRunnerTest { } } - @Test - @Test fun `cumulative setter growth on normal startup updates counts with frozen pricing`() = runBlocking { @@ -705,5 +704,43 @@ class TokenBaselineImportRunnerTest { } } + // ==== P1 闭环:legacy cleanup outbox 导入 fence ==== + private val providerA = "DEEPSEEK:deepseek-chat" + private val identityIdA = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") + private val identityIdB = TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") + + private suspend fun seedLegacyIdentity( + dao: com.ai.assistance.operit.data.dao.TokenStatsDao, + identityId: String, + provider: String, + model: String, + displayModelId: String, + ) { + dao.insertIdentityIfAbsent( + com.ai.assistance.operit.data.model.TokenStatIdentityEntity( + identityId = identityId, + configId = "", + provider = provider, + model = model, + displayModelId = displayModelId, + ) + ) + } + private fun legacyBaseline(identityId: String): com.ai.assistance.operit.data.model.TokenStatBaselineEntity = + com.ai.assistance.operit.data.model.TokenStatBaselineEntity( + identityId = identityId, + inputTokens = 100L, + cachedInputTokens = 0L, + outputTokens = 50L, + requestCount = 1L, + pricingCurrency = "USD", + costInPricingCurrency = 0.0002, + isEstimated = true, + fingerprint = "fp-$identityId", + importedAtMs = 1L, + frozenBillingMode = BillingMode.TOKEN.name, + frozenInputPricePerMillion = 1.0, + frozenOutputPricePerMillion = 2.0, + ) } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt index 668337604..a7633f4db 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt @@ -109,11 +109,7 @@ class TokenStatsCleanupOutboxTest { private fun openDatabase(filesDir: File): AppDatabase = Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") .setDriver(JdbcSQLiteDriver()) - .addMigrations( - AppDatabase.MIGRATION_20_21, - AppDatabase.MIGRATION_20_21, - AppDatabase.MIGRATION_20_21, - ) + .addMigrations(AppDatabase.MIGRATION_20_21) .allowMainThreadQueries() .build() diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt index fa5c32605..b22602dce 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt @@ -9,6 +9,7 @@ import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity import com.ai.assistance.operit.data.model.TokenStatEventEntity import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity +import com.ai.assistance.operit.data.preferences.ApiPreferences import java.io.File import java.sql.DriverManager import kotlinx.coroutines.runBlocking @@ -47,6 +48,14 @@ class TokenStatsRoomMigrationTest { return context } + private fun injectApiPreferences(instance: ApiPreferences?) { + val field = + ApiPreferences::class.java + .getDeclaredField("INSTANCE") + .apply { isAccessible = true } + field.set(null, instance) + } + /** 用内嵌 v20 schema SQL 构造一个真实的 v20 数据库文件(上游不导出 schema JSON)。 */ private fun buildV20Database(dbPath: String) { DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> @@ -154,8 +163,8 @@ class TokenStatsRoomMigrationTest { statement.execute( "INSERT INTO chats " + "(id, title, createdAt, updatedAt, inputTokens, outputTokens, " + - "currentWindowSize, displayOrder, locked, pinned, isFavorite) " + - "VALUES ('legacy-chat', 'legacy', 1, 2, 3, 4, 5, 6, 0, 0, 0)" + "currentWindowSize, displayOrder, locked, pinned) " + + "VALUES ('legacy-chat', 'legacy', 1, 2, 3, 4, 5, 6, 0, 0)" ) } } @@ -640,7 +649,7 @@ outputTokens = 500L, ) dao.insertEvent(event) val readBack = dao.getEvent("evt-v21")!! - assertEquals(800, readBack.uncachedInputTokens) + assertEquals(800L, readBack.uncachedInputTokens) assertEquals(1000L, readBack.totalInputTokens) assertEquals(false, readBack.cacheWriteSeparateBilling) assertTrue(readBack.diagnosticsJson!!.contains("\"source\":\"openai_chat_completions\"")) From 0b50a916d7aafc006c0e9aaa1c7461c19f905ee5 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:49:18 +0800 Subject: [PATCH 22/28] fix(stats): resolve P1 audit findings for upstream readiness P1-1: wire backup/restore barriers into raw snapshot and Room managers; persist and consume the restore REPLACING marker across crashes so pre-restore spool is never replayed into a replaced database. P1-2: fail-open statistics finalization - a persistence failure must never fail a successful model result; degraded tracking context when the stats DB is unavailable. P1-3: run finalization on Dispatchers.IO so FutureTask.get and fsync never block the Main thread. P1-4: bill Gemini thoughts on top of candidates and extract usageMetadata before early returns. Adds BackupRestoreBarrierTest covering production manager wiring, marker consumption on startup, and corrupt-zip pre-commit rejection. --- .../api/chat/llmprovider/GeminiProvider.kt | 85 ++-- .../llmprovider/TokenTrackingAIService.kt | 160 ++++--- .../data/backup/RawSnapshotBackupManager.kt | 323 ++++++++------ .../data/backup/RestoreReplacingMarker.kt | 67 +++ .../data/backup/RoomDatabaseBackupManager.kt | 67 +-- .../data/backup/RoomDatabaseRestoreManager.kt | 133 +++--- .../data/stats/ProviderUsageSnapshot.kt | 12 +- .../operit/data/stats/TokenStatSpool.kt | 46 +- .../stats/TokenStatsStartupCoordinator.kt | 3 + .../ai/assistance/operit/util/AppLogger.kt | 39 +- .../ai/assistance/operit/util/OperitPaths.kt | 11 +- .../llmprovider/TokenTrackingAIServiceTest.kt | 195 +++++--- .../data/backup/BackupRestoreBarrierTest.kt | 421 ++++++++++++++++++ .../data/stats/ProviderUsageNormalizerTest.kt | 60 ++- .../data/stats/SpoolWriterReliabilityTest.kt | 11 +- .../data/stats/TokenCostCalculatorTest.kt | 28 ++ .../stats/TokenStatReliabilityTestBase.kt | 4 + 17 files changed, 1263 insertions(+), 402 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index 0a7d7ecc9..d2e462520 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -1696,6 +1696,40 @@ class GeminiProvider( try { throwIfGeminiErrorPayload(context, json) + // 提取实际的token使用数据:必须先于 candidates/content 的提前返回执行, + // 否则“无 candidates 但带 usageMetadata”的响应(如 prompt 被拦截)会漏记用量。 + var serverUsageApplied = false + val usageMetadata = json.optJSONObject("usageMetadata") + if (usageMetadata != null) { + val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0L) + val cachedContentTokenCount = usageMetadata.optLong("cachedContentTokenCount", 0L) + val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) + + val hasServerUsage = + usageMetadata.has("promptTokenCount") || + usageMetadata.has("cachedContentTokenCount") || + usageMetadata.has("candidatesTokenCount") + if (hasServerUsage) { + serverUsageApplied = true + // 更新实际的token计数 + val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) + tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) + tokenCacheManager.setOutputTokens(candidatesTokenCount) + + logDebug("API实际Token使用: 输入=$actualInputTokens, 缓存=$cachedContentTokenCount, 输出=$candidatesTokenCount") + + // 更新回调,使用实际的token统计 + onTokensUpdated( + tokenCacheManager.totalInputTokenCount, + tokenCacheManager.cachedInputTokenCount, + tokenCacheManager.outputTokenCount + ) + onUsageReported?.let { callback -> + ProviderUsageNormalizer.gemini(usageMetadata)?.let { callback(it, attemptNumber) } + } + } + } + // 提取候选项 val candidates = json.optJSONArray("candidates") if (candidates == null || candidates.length() == 0) { @@ -1895,14 +1929,18 @@ class GeminiProvider( logDebug("提取文本,长度=${text.length}") } - // 估算token - val tokens = ChatUtils.estimateTokenCount(text) - tokenCacheManager.addOutputTokens(tokens) - onTokensUpdated( - tokenCacheManager.totalInputTokenCount, - tokenCacheManager.cachedInputTokenCount, - tokenCacheManager.outputTokenCount - ) + // 估算token:本 chunk 已应用服务器累计实际值时不再叠加估算, + // 否则会在 setOutputTokens 的累计实际值之上重复计数(原实现靠 + // 末尾覆盖避免重复,usage 提取提前后需显式跳过) + if (!serverUsageApplied) { + val tokens = ChatUtils.estimateTokenCount(text) + tokenCacheManager.addOutputTokens(tokens) + onTokensUpdated( + tokenCacheManager.totalInputTokenCount, + tokenCacheManager.cachedInputTokenCount, + tokenCacheManager.outputTokenCount + ) + } } } @@ -1910,37 +1948,6 @@ class GeminiProvider( appendGeminiThoughtSignatureMeta(contentBuilder, signature) } - // 提取实际的token使用数据 - val usageMetadata = json.optJSONObject("usageMetadata") - if (usageMetadata != null) { - val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0L) - val cachedContentTokenCount = usageMetadata.optLong("cachedContentTokenCount", 0L) - val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) - - val hasServerUsage = - usageMetadata.has("promptTokenCount") || - usageMetadata.has("cachedContentTokenCount") || - usageMetadata.has("candidatesTokenCount") - if (hasServerUsage) { - // 更新实际的token计数 - val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) - tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) - tokenCacheManager.setOutputTokens(candidatesTokenCount) - - logDebug("API实际Token使用: 输入=$actualInputTokens, 缓存=$cachedContentTokenCount, 输出=$candidatesTokenCount") - - // 更新回调,使用实际的token统计 - onTokensUpdated( - tokenCacheManager.totalInputTokenCount, - tokenCacheManager.cachedInputTokenCount, - tokenCacheManager.outputTokenCount - ) - onUsageReported?.let { callback -> - ProviderUsageNormalizer.gemini(usageMetadata)?.let { callback(it, attemptNumber) } - } - } - } - // 将搜索来源拼接到内容最前面 val finalContent = if (searchSourcesBuilder.isNotEmpty()) { searchSourcesBuilder.toString() + contentBuilder.toString() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt index 303e2c868..b10bd73f4 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -31,12 +31,18 @@ import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException as JavaTimeoutException import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withContext import kotlinx.coroutines.runBlocking -/** A successful model call cannot hide loss of its accepted usage event. */ +/** + * 统计持久化故障标记(P1-2):只在两条路径出现—— + * - 模型已失败/取消的收尾中作为原异常的 suppressed 保留(观测性,不覆盖主异常); + * - 恢复替换后 [newRequest] 对同进程新请求的明确拒绝(恢复语义)。 + * 成功的模型结果绝不被它改写:成功路径的统计故障只记日志并返回 [RecordOutcome.LOST]。 + */ class TokenStatsPersistenceException(message: String, cause: Throwable? = null) : java.io.IOException(message, cause) @@ -59,8 +65,10 @@ class TokenStatsPersistenceException(message: String, cause: Throwable? = null) * 链中的 timeout 信号,避免 UserCancellationException(cause=InterruptedIOException) * 被误判为 TIMEOUT。 * - 请求收尾同步解析价格并 fsync 完整事件到 TokenStatSpool,后台 writer 只做 Room - * insert。价格读取失败写 UNKNOWN;磁盘 append 失败会使成功调用明确失败。模型本身 - * 已失败/取消时,持久化故障作为 suppressed 异常保留,不覆盖原异常。 + * insert。价格读取失败写 UNKNOWN。统计收尾全程 fail-open(P1-2):任何持久化 + * 故障只记录健康日志并返回 [RecordOutcome.LOST],绝不改写成功的模型结果;模型 + * 本身已失败/取消时,统计故障作为 suppressed 异常保留在原异常上,主异常仍是 + * 模型异常。 * - 调用者 usage observer(外部回调)与 provider 业务隔离:非取消异常只记录日志, * 不改变账本/请求结果;取消遵循协程取消语义向上传播。 */ @@ -143,12 +151,10 @@ class TokenTrackingAIService( request.finish( result.exceptionOrNull()?.let { classify(it) } ?: TokenStatStatus.COMPLETED ) - val persistenceFailure = persistAndCapture(appContext, request, result.exceptionOrNull()) - when { - result.isFailure -> result - persistenceFailure != null -> Result.failure(persistenceFailure) - else -> result - } + // P1-2:统计收尾 fail-open——持久化故障只作为模型失败的 suppressed 保留, + // 绝不把连接测试的成功结果改写为失败(模型失败路径保持主异常不变)。 + persistAndCapture(appContext, request, result.exceptionOrNull()) + result } catch (e: CancellationException) { request.finish(TokenStatStatus.CANCELLED) persistAndCapture(appContext, request, e) @@ -194,13 +200,29 @@ class TokenTrackingAIService( // 分组要么看见该身份(写 IDENTITY tombstone,删除前接受的事件被跳过),要么请求 // 拿到 ≥ tombstone 的新 generation(删除后请求正常入账)。首次请求的身份绝不可能 // 绕过分组删除 tombstone 复活旧事件。 + // P1-2:统计数据库/身份不可用时**降级跟踪**——模型请求照常开始。降级上下文仍带 + // 完整 eventId/时间/usage,收尾 append 走 spool;身份由排空 INSERT IGNORE 补齐 + // (故障瞬时则事件照常落账)。acceptedGeneration 取 0 是保守方向:若期间发生过 + // reset,事件只会被 tombstone 跳过(不复活),绝不可能绕过 reset 入账。 val acceptedGeneration = - TokenStatsLedger.ensureIdentityAndCaptureGeneration( - appContext, - configId, - provider, - model, - ) + try { + TokenStatsLedger.ensureIdentityAndCaptureGeneration( + appContext, + configId, + provider, + model, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e( + TAG, + "统计身份/代次读取失败,本次请求降级跟踪(不影响模型调用): " + + "configId=$configId, provider=$provider, model=$model", + e, + ) + 0L + } return TokenStatRequestContext( eventId = "evt_${UUID.randomUUID().toString().replace("-", "")}", category = category ?: TokenStatCategory.OTHER, @@ -261,6 +283,7 @@ class TokenTrackingAIService( throw t } request.finish(TokenStatStatus.COMPLETED) + // P1-2 fail-open:统计收尾失败只记日志返回 LOST,绝不上抛改写成功结果。 recordSafely(appContext, request) } } @@ -293,6 +316,7 @@ class TokenTrackingAIService( throw t } request.finish(TokenStatStatus.COMPLETED) + // P1-2 fail-open:统计收尾失败只记日志返回 LOST,绝不上抛改写成功结果。 recordSafely(appContext, request) } } @@ -303,19 +327,32 @@ class TokenTrackingAIService( /** 单次统计落账的有界等待时长;测试可缩短以验证超时只日志不阻塞业务。 */ internal var recordTimeoutMs: Long = 5_000L + /** 测试注入缝:recordSafely 进入 IO 收尾上下文后最先调用(P1-3:验证收尾的 + * 同步文件 I/O / Future.get() 不跑在调用方 Main 线程)。 */ + internal var recordIoThreadProbeForTest: (() -> Unit)? = null + /** - * 单次落账结果(评审 P1-1/P1-4:进程死亡边界必须向调用方暴露统计失败 - * 状态,不能伪装已记录): - * [DURABLE] means the complete event has been fsynced. Non-durable outcomes throw. + * 单次落账结果: + * - [DURABLE] means the complete event has been fsynced; + * - [LOST] 表示统计收尾失败(磁盘/容量/恢复 fence 拒绝等),事件未持久化;已记录 + * 健康日志,调用方绝不得改写模型结果(P1-2 fail-open)。 */ internal enum class RecordOutcome { DURABLE, + LOST, } /** - * 可靠、独立、持久落账(companion 版本,供嵌套流类使用): + * 统计收尾(companion 版本,供嵌套流类使用): * - 在请求收尾边界有界解析并冻结价格,随后同步 fsync 完整事件; - * - 价格超时/失败形成 UNKNOWN 事件,append 失败抛明确持久化故障; + * - 价格超时/失败形成 UNKNOWN 事件; + * - 收尾整体运行在 [Dispatchers.IO](P1-3):[prepareLineBounded] 的 + * [FutureTask.get] 等待与 [TokenStatSpool.append] 的 FileOutputStream+fsync + * 绝不阻塞调用方(Main)线程;[TokenStatSpool.awaitRoomVisibility] 是纯挂起 + * 等待(内部 withTimeoutOrNull 有界),在 IO 上执行无碍; + * - **fail-open(P1-2)**:任何持久化故障只记录健康日志并返回 [RecordOutcome.LOST], + * 绝不抛出——成功的模型结果绝不被统计收尾改写为失败;模型已失败的路径由 + * [persistAndCapture] 把 LOST 转为原始异常的 suppressed。 * - [recordTimeoutMs] 只等待可选的 Room 可见性,不参与 durable 判定; * - 进程重启后由 OperitApplication 主动 [com.ai.assistance.operit.data.stats.TokenStatSpool.replay] * 重放(幂等 eventId IGNORE)。 @@ -323,34 +360,49 @@ class TokenTrackingAIService( internal suspend fun recordSafely( appContext: Context, request: TokenStatRequestContext, - ): RecordOutcome { - return withContext(NonCancellable) { - val baseJson = request.toSpoolBaseJson() - val line = - try { - prepareLineBounded(appContext, request) - } catch (e: JavaTimeoutException) { - TokenStatsLedger.prepareUnresolvedEventLine( - request, - baseJson, - "pricing_read_timeout", - ) - } catch (e: Exception) { - TokenStatsLedger.prepareUnresolvedEventLine( - request, - baseJson, - "pricing_read_failed:${e.javaClass.simpleName}", + ): RecordOutcome = + withContext(Dispatchers.IO + NonCancellable) { + recordIoThreadProbeForTest?.invoke() + try { + val baseJson = request.toSpoolBaseJson() + val line = + try { + prepareLineBounded(appContext, request) + } catch (e: JavaTimeoutException) { + TokenStatsLedger.prepareUnresolvedEventLine( + request, + baseJson, + "pricing_read_timeout", + ) + } catch (e: Exception) { + TokenStatsLedger.prepareUnresolvedEventLine( + request, + baseJson, + "pricing_read_failed:${e.javaClass.simpleName}", + ) + } + if (!TokenStatSpool.append(appContext, line, request.eventId, request.sessionEpoch)) { + AppLogger.e( + TAG, + "统计事件未能持久化(不影响模型结果): eventId=${request.eventId}, " + + "category=${request.category}, status=${request.status}", ) + return@withContext RecordOutcome.LOST } - if (!TokenStatSpool.append(appContext, line, request.eventId, request.sessionEpoch)) { - throw TokenStatsPersistenceException( - "Token statistics could not be durably persisted for ${request.eventId}", + TokenStatSpool.awaitRoomVisibility(request.eventId, recordTimeoutMs) + RecordOutcome.DURABLE + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e( + TAG, + "统计收尾失败(不影响模型结果): eventId=${request.eventId}, " + + "category=${request.category}, status=${request.status}", + e, ) + RecordOutcome.LOST } - TokenStatSpool.awaitRoomVisibility(request.eventId, recordTimeoutMs) - RecordOutcome.DURABLE } - } /** * Bounded pricing worker (P2-1): one daemon thread plus one queue slot. A wedged price @@ -409,19 +461,25 @@ class TokenTrackingAIService( } } - /** Keep the model error primary; persistence failure remains observable as suppressed. */ + /** + * 模型失败路径的收尾:统计故障([RecordOutcome.LOST])作为 suppressed 附加在 + * 原始异常上,原始模型异常仍为主异常(P1-2:模型失败侧保持 fail-open 语义不变)。 + */ private suspend fun persistAndCapture( appContext: Context, request: TokenStatRequestContext, original: Throwable?, - ): TokenStatsPersistenceException? = - try { - recordSafely(appContext, request) - null - } catch (e: TokenStatsPersistenceException) { - original?.addSuppressed(e) - e + ): TokenStatsPersistenceException? { + if (recordSafely(appContext, request) == RecordOutcome.LOST) { + val persistence = + TokenStatsPersistenceException( + "Token statistics could not be durably persisted for ${request.eventId}", + ) + original?.addSuppressed(persistence) + return persistence } + return null + } /** * 结束状态分类。明确的非超时取消(用户取消/协程取消)优先:其 cause 链里 diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index 962f79b3a..a974d059d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.util.AtomicFile import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.db.ObjectBoxManager +import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.OperitPaths import java.io.BufferedInputStream @@ -125,122 +126,128 @@ object RawSnapshotBackupManager { val datastoreDir = File(dataDir, "datastore") val databasesDir = File(dataDir, "databases") - try { - val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - - val includes = listOf( - ENTRY_FILES, - ENTRY_EXTERNAL_FILES, - ENTRY_SHARED_PREFS, - ENTRY_DATASTORE, - ENTRY_DATABASES - ) - val manifest = Manifest( - formatVersion = FORMAT_VERSION, - packageName = context.packageName, - createdAt = System.currentTimeMillis(), - includes = includes, - includeTerminalData = options.includeTerminalData - ) - - ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> - zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) - zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) - zos.closeEntry() - - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val excludedNames = if (options.includeTerminalData) { - alwaysExcluded - } else { - alwaysExcluded + terminalTopLevelDirNames - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) + // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 + // Room),再进入排他状态执行 checkpoint + 打包。备份期间没有任何新的 insert + // 注册,spool 虽被排除出备份也不丢事件;屏障失败(quarantine 证据未导出、 + // 活跃 insert 超时等)在打包前明确抛错。 + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + try { + val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) } - val filesTotalCount = totalFilesForZip( - dir = context.filesDir, - entryPrefix = ENTRY_FILES, - excludedTopLevelDirNames = excludedNames, - onScannedCountChanged = { scanned -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) - ) - } - } - } + + val includes = listOf( + ENTRY_FILES, + ENTRY_EXTERNAL_FILES, + ENTRY_SHARED_PREFS, + ENTRY_DATASTORE, + ENTRY_DATABASES ) - withContext(Dispatchers.Main) { - onProgress?.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) - ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } - val filesMs = measureTimeMillis { - addDirToZip( - zos = zos, + val manifest = Manifest( + formatVersion = FORMAT_VERSION, + packageName = context.packageName, + createdAt = System.currentTimeMillis(), + includes = includes, + includeTerminalData = options.includeTerminalData + ) + + ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> + zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) + zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) + zos.closeEntry() + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val excludedNames = if (options.includeTerminalData) { + alwaysExcluded + } else { + alwaysExcluded + terminalTopLevelDirNames + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) + } + val filesTotalCount = totalFilesForZip( dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, - totalFiles = filesTotalCount, - onPercentChanged = { percent -> + onScannedCountChanged = { scanned -> if (onProgress != null) { mainHandler.post { - onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) + onProgress.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) + ) } } } ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } - AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") + withContext(Dispatchers.Main) { + onProgress?.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) + ) + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } + val filesMs = measureTimeMillis { + addDirToZip( + zos = zos, + dir = context.filesDir, + entryPrefix = ENTRY_FILES, + excludedTopLevelDirNames = excludedNames, + totalFiles = filesTotalCount, + onPercentChanged = { percent -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) + } + } + } + ) + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } + AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") - val externalFilesTotalCount = totalFilesForZip( - dir = externalFilesDir, - entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet() - ) - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) - } - val externalFilesMs = measureTimeMillis { - addDirToZip( - zos = zos, + val externalFilesTotalCount = totalFilesForZip( dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, - totalFiles = externalFilesTotalCount, - onPercentChanged = { percent -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke( - ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) - ) + excludedTopLevelDirNames = emptySet() + ) + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) + } + val externalFilesMs = measureTimeMillis { + addDirToZip( + zos = zos, + dir = externalFilesDir, + entryPrefix = ENTRY_EXTERNAL_FILES, + totalFiles = externalFilesTotalCount, + onPercentChanged = { percent -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke( + ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) + ) + } } } - } - ) - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) - } - AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") + ) + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) + } + AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } - val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } - AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } + val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } + AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } - val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } - AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } + val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } + AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } - val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } - AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } + val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } + AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") + } } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.FINALIZING)) } @@ -282,51 +289,85 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") - AppDatabase.closeDatabase() - ObjectBoxManager.closeAll() - - AppLogger.i(TAG, "restore closed databases (room + objectbox)") - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } - val manifest = extractZipToWorkDir(cacheZip, workDir, expectedPackageName = context.packageName) - - val payloadDir = File(workDir, "payload") - val externalFilesPayloadDir = File(payloadDir, "external_files") - - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - - val preserveTerminal = !manifest.includeTerminalData - val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() - val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> - File(payloadDir, "files/$dirName").exists() - }.toSet() - val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames - - AppLogger.i( - TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + // P1 终审:两阶段恢复屏障。prepareBeforeCommit 做全部可失败的非替换准备 + // (关闭 stores、解压、校验 manifest);commitReplacement 持久化 + // REPLACING 标记——只有该标记成功落盘后 restore epoch 才递增、恢复前 + // 开始的旧请求在收尾时被明确拒绝;block 内做文件替换,成功后屏障自动 + // 清理旧 spool,旧事件绝不 replay 进恢复后的数据库。替换开始后失败则 + // 本进程拒绝一切新事件直至重启(isAcceptingEvents() == false)。 + var preservedNamesForRestore: Set = emptySet() + var restoredManifest: Manifest? = null + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = { + AppDatabase.closeDatabase() + ObjectBoxManager.closeAll() + + AppLogger.i(TAG, "restore closed databases (room + objectbox)") + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } + val manifest = extractZipToWorkDir(cacheZip, workDir, expectedPackageName = context.packageName) + restoredManifest = manifest + + val payloadDir = File(workDir, "payload") + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + + val preserveTerminal = !manifest.includeTerminalData + val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() + val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> + File(payloadDir, "files/$dirName").exists() + }.toSet() + // REPLACING 标记必须留在 filesDir(崩溃后由启动路径消费);files + // 替换时不得删除它(审计 P1:否则崩溃后连持久化证据都丢失)。 + preservedNamesForRestore = + preservedTerminalNames + + preservedAlwaysExcludedNames + + setOf(RestoreReplacingMarker.FILE_NAME) + + AppLogger.i( + TAG, + "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + ) + + AppLogger.i( + TAG, + "restore replace dirs (preserveTerminalTopLevel=${preservedNamesForRestore.isNotEmpty()})" + ) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + }, + commitReplacement = { + RestoreReplacingMarker.persist(context) + }, + block = { + val payloadDir = File(workDir, "payload") + val externalFilesPayloadDir = File(payloadDir, "external_files") + + replaceDirContents( + File(payloadDir, "files"), + context.filesDir, + preservedTopLevelDirNames = preservedNamesForRestore + ) + if (externalFilesPayloadDir.exists()) { + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } + replaceDirContents(externalFilesPayloadDir, externalFilesDir) + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } + replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } + replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } + replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } + }, ) - - AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } - replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) - if (externalFilesPayloadDir.exists()) { - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } - replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } - replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } - replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } - AppLogger.i(TAG, "restore done: ${manifest.packageName}") + RestoreReplacingMarker.delete(context) + AppLogger.i(TAG, "restore done: ${restoredManifest?.packageName}") } catch (e: Exception) { AppLogger.e(TAG, "restore failed", e) throw e diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt new file mode 100644 index 000000000..959e7a4b2 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt @@ -0,0 +1,67 @@ +package com.ai.assistance.operit.data.backup + +import android.content.Context +import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.util.AppLogger +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Raw restore 的 REPLACING 持久化提交标记(P1 终审)。 + * + * [TokenStatSpool.withExclusiveRestoreAccess] 的 [commitReplacement] 必须在任何文件替换 + * (block)之前把“替换已开始”持久化:只有该标记成功落盘后 restore epoch 才会递增、 + * 本进程才停止接受新的统计事件——恢复前开始的旧请求绝不写入可能已被替换的 spool/Room。 + * + * 持久化级别:文件内容 fd.sync,再同步父目录目录项([TokenStatSpool.syncDir],与 spool + * 目录项协议一致);任一非 OK 即抛 [IOException](fail-closed),屏障视恢复未开始, + * 旧/新请求均可继续。 + * + * 标记只在恢复成功完成后删除;替换开始后失败的恢复保留标记(替换结果不确定,进程 + * 必须重启)。标记位于 filesDir 根:Raw restore 的 files 替换把标记加入 preserved 名单 + * (崩溃后启动路径 [TokenStatSpool.consumeAbandonedRestoreIfAny] 消费),Room restore + * 不替换 files,由调用方成功后显式删除。 + */ +internal object RestoreReplacingMarker { + const val FILE_NAME = TokenStatSpool.RESTORE_REPLACING_MARKER_FILE_NAME + private const val TAG = "RestoreReplacing" + + /** + * 持久化 REPLACING 标记;失败抛 [IOException](fail-closed)。调用方持 + * [TokenStatSpool] 的 lifecycleMutex(屏障内部),目录 sync seam 与 spool 一致。 + */ + fun persist(context: Context) { + val flag = File(context.filesDir, FILE_NAME) + try { + FileOutputStream(flag).use { output -> + output.write("REPLACING\n".toByteArray(Charsets.UTF_8)) + output.fd.sync() + } + if (TokenStatSpool.syncDir(context.filesDir) != TokenStatSpool.DirSyncResult.OK) { + throw IOException("restore REPLACING marker directory entry not durable") + } + } catch (e: IOException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "REPLACING marker write failed", e) + throw IOException("restore REPLACING marker could not be persisted", e) + } + } + + /** + * 恢复成功完成后删除标记。删除必须持久化(同步父目录);删除失败抛 [IOException], + * 使调用方把恢复报告为未确认完成——残留标记会由启动路径 [TokenStatSpool.consumeAbandonedRestoreIfAny] + * 兜底消费,绝不让不确定状态静默通过。 + */ + fun delete(context: Context) { + val flag = File(context.filesDir, FILE_NAME) + if (!flag.exists()) return + if (!flag.delete()) { + throw IOException("restore REPLACING marker could not be deleted: ${flag.absolutePath}") + } + if (TokenStatSpool.syncDir(context.filesDir) != TokenStatSpool.DirSyncResult.OK) { + throw IOException("restore REPLACING marker deletion not durable") + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt index c8cda8a8e..7ee6e387b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.backup import android.content.Context import androidx.sqlite.db.SupportSQLiteDatabase import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -72,13 +73,6 @@ object RoomDatabaseBackupManager { throw IllegalStateException("Database file not found: ${dbFile.absolutePath}") } - try { - val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - val operitDir = OperitBackupDirs.roomDbDir() val targetFile = File(operitDir, "${AUTO_BACKUP_FILE_PREFIX}${day}.zip") @@ -88,14 +82,26 @@ object RoomDatabaseBackupManager { tmpFile.delete() } - val walFile = File(dbFile.absolutePath + "-wal") - val shmFile = File(dbFile.absolutePath + "-shm") + // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 + // Room),再进入排他状态执行 checkpoint + 打包,备份期间没有新的 insert 注册, + // 数据库备份不会漏掉仍在 spool 中的统计事件;屏障失败明确抛错。 + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + try { + val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } - writeZip(tmpFile, mapOf( - DB_NAME to dbFile, - "${DB_NAME}-wal" to walFile, - "${DB_NAME}-shm" to shmFile - )) + val walFile = File(dbFile.absolutePath + "-wal") + val shmFile = File(dbFile.absolutePath + "-shm") + + writeZip(tmpFile, mapOf( + DB_NAME to dbFile, + "${DB_NAME}-wal" to walFile, + "${DB_NAME}-shm" to shmFile + )) + } if (targetFile.exists()) { targetFile.delete() @@ -114,13 +120,6 @@ object RoomDatabaseBackupManager { throw IllegalStateException("Database file not found: ${dbFile.absolutePath}") } - try { - val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - val operitDir = OperitBackupDirs.roomDbDir() val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) @@ -131,14 +130,26 @@ object RoomDatabaseBackupManager { tmpFile.delete() } - val walFile = File(dbFile.absolutePath + "-wal") - val shmFile = File(dbFile.absolutePath + "-shm") + // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 + // Room),再进入排他状态执行 checkpoint + 打包,备份期间没有新的 insert 注册, + // 数据库备份不会漏掉仍在 spool 中的统计事件;屏障失败明确抛错。 + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + try { + val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } - writeZip(tmpFile, mapOf( - DB_NAME to dbFile, - "${DB_NAME}-wal" to walFile, - "${DB_NAME}-shm" to shmFile - )) + val walFile = File(dbFile.absolutePath + "-wal") + val shmFile = File(dbFile.absolutePath + "-shm") + + writeZip(tmpFile, mapOf( + DB_NAME to dbFile, + "${DB_NAME}-wal" to walFile, + "${DB_NAME}-shm" to shmFile + )) + } if (targetFile.exists()) { targetFile.delete() diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index 20018aced..af88feecf 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.backup import android.content.Context import android.net.Uri import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -88,17 +89,11 @@ object RoomDatabaseRestoreManager { } } - private fun restoreFromBackupFileInternal(context: Context, zipFile: File) { + private suspend fun restoreFromBackupFileInternal(context: Context, zipFile: File) { if (!zipFile.exists() || !zipFile.isFile) { throw IllegalArgumentException("Backup file not found: ${zipFile.absolutePath}") } - try { - AppDatabase.closeDatabase() - } catch (e: Exception) { - AppLogger.w(TAG, "closeDatabase failed", e) - } - val targetDb = context.getDatabasePath(DB_NAME) val targetWal = File(targetDb.absolutePath + "-wal") val targetShm = File(targetDb.absolutePath + "-shm") @@ -113,62 +108,92 @@ object RoomDatabaseRestoreManager { tmpWal.delete() tmpShm.delete() + try { + // P1 终审:两阶段恢复屏障。prepareBeforeCommit 关闭数据库并完成全部可失败 + // 的准备工作(解压 ZIP 到 tmp 文件 + 验证必需条目);commitReplacement 持久化 + // REPLACING 标记——只有该标记成功落盘后 restore epoch 才递增、恢复前开始的 + // 旧请求在收尾时被明确拒绝;block 只做目标文件删除/替换(不再有可失败的解压 + // 步骤)。备份损坏/缺条目/读取失败都在 commit 之前失败:epoch 不变、进程仍 + // 接受事件,绝不因选择了错误备份文件而锁死当前进程(审计 P1)。 + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = { + try { + AppDatabase.closeDatabase() + } catch (e: Exception) { + AppLogger.w(TAG, "closeDatabase failed", e) + } + extractAndValidate(zipFile, tmpDb, tmpWal, tmpShm) + }, + commitReplacement = { + RestoreReplacingMarker.persist(context) + }, + block = { + targetWal.delete() + targetShm.delete() + targetDb.delete() + + replaceFile(tmpDb, targetDb) + if (tmpWal.exists()) { + replaceFile(tmpWal, targetWal) + } else { + tmpWal.delete() + targetWal.delete() + } + + if (tmpShm.exists()) { + replaceFile(tmpShm, targetShm) + } else { + tmpShm.delete() + targetShm.delete() + } + }, + ) + RestoreReplacingMarker.delete(context) + } catch (e: Exception) { + tmpDb.delete() + tmpWal.delete() + tmpShm.delete() + throw e + } + } + + /** + * 解压 ZIP 到 tmp 文件并验证必需条目(审计 P1:在 commit 之前完成全部可失败工作)。 + * 缺 [DB_NAME] 抛 [IllegalArgumentException];读取失败向上传播——调用方处于 + * prepareBeforeCommit,epoch 未递增、进程仍接受事件。 + */ + private fun extractAndValidate(zipFile: File, tmpDb: File, tmpWal: File, tmpShm: File) { var extractedDb = false var extractedWal = false var extractedShm = false - try { - ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> - while (true) { - val entry = zis.nextEntry ?: break - val name = entry.name - - when (name) { - DB_NAME -> { - writeStreamToFile(zis, tmpDb) - extractedDb = true - } - "${DB_NAME}-wal" -> { - writeStreamToFile(zis, tmpWal) - extractedWal = true - } - "${DB_NAME}-shm" -> { - writeStreamToFile(zis, tmpShm) - extractedShm = true - } - } + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> + while (true) { + val entry = zis.nextEntry ?: break + val name = entry.name - zis.closeEntry() + when (name) { + DB_NAME -> { + writeStreamToFile(zis, tmpDb) + extractedDb = true + } + "${DB_NAME}-wal" -> { + writeStreamToFile(zis, tmpWal) + extractedWal = true + } + "${DB_NAME}-shm" -> { + writeStreamToFile(zis, tmpShm) + extractedShm = true + } } - } - - if (!extractedDb) { - throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") - } - - targetWal.delete() - targetShm.delete() - targetDb.delete() - replaceFile(tmpDb, targetDb) - if (extractedWal) { - replaceFile(tmpWal, targetWal) - } else { - tmpWal.delete() - targetWal.delete() + zis.closeEntry() } + } - if (extractedShm) { - replaceFile(tmpShm, targetShm) - } else { - tmpShm.delete() - targetShm.delete() - } - } catch (e: Exception) { - tmpDb.delete() - tmpWal.delete() - tmpShm.delete() - throw e + if (!extractedDb) { + throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt index e1a04788a..1eb3a5979 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt @@ -76,8 +76,9 @@ data class ProviderUsageSnapshot( * - Anthropic:文档明确 `input_tokens` **不含** `cache_read_input_tokens` 与 * `cache_creation_input_tokens`(总量 = 三者之和),因此三个分量各自独立保留, * 缓存写入单独计费;`output_tokens` 包含 thinking → 推理已包含在输出。 - * - Gemini:`candidatesTokenCount` 包含 thought token;`thoughtsTokenCount` 有值时 - * 单独保留(仍计入输出)。 + * - Gemini:`candidatesTokenCount` 是 response candidates token,`thoughtsTokenCount` + * 是思考 token(官方 API 独立字段,不含在 candidatesTokenCount 内,按输出计费) + * → 计费时输出 = candidates + thoughts。 * - 本地模型(llama/MNN):没有 provider usage 对象,token 为本地实测计数 * (tokenizer 计数 + 逐 token 生成计数),缓存分量明确为 0。 * @@ -251,7 +252,9 @@ object ProviderUsageNormalizer { return snapshot.takeIf { it.hasKnownFields() } } - /** Gemini:`usageMetadata`,`candidatesTokenCount` 包含 thought token。 + /** Gemini:`usageMetadata`;`candidatesTokenCount` 为 response candidates token, + * `thoughtsTokenCount` 为思考 token(官方 API 独立字段,按输出计费,不含在 + * candidatesTokenCount 内)→ [reasoningIncludedInOutput] = false。 * 流式逐 chunk 上报的是服务器累计快照,省略字段不代表撤销 → 保持部分更新。 */ fun gemini( usageMetadata: JSONObject?, @@ -283,7 +286,8 @@ object ProviderUsageNormalizer { totalInputTokens = prompt, outputTokens = output, reasoningTokens = thoughts, - reasoningIncludedInOutput = true, + // P1-4:thoughtsTokenCount 独立于 candidatesTokenCount,计费需另行补加 + reasoningIncludedInOutput = false, // Gemini 无独立缓存写入计费概念 cacheWriteSeparateBilling = false, completeSnapshot = completeSnapshot, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index 95b6a0801..d2bb1e63b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -91,6 +91,14 @@ internal object TokenStatSpool { internal const val SEALED_SUFFIX = ".jsonl" internal const val QUARANTINE_PREFIX = "quarantine_" + /** + * 恢复 REPLACING 持久化标记文件名(审计 P1:必须可被启动路径读取)。恢复替换开始 + * 前(commitReplacement)写入 filesDir 根,恢复成功后删除;进程崩溃在替换中途时 + * 标记保留,启动时由 [consumeAbandonedRestoreIfAny] 消费——旧 spool 绝不 replay 进 + * 可能已被替换的数据库。backup 包 [RestoreReplacingMarker] 引用本常量,避免两处漂移。 + */ + internal const val RESTORE_REPLACING_MARKER_FILE_NAME = "restore_replacing.flag" + /** * P2:seal copy 回退中途失败的部分目标隔离前缀(`seal_failed_`)。隔离文件 scanner * 忽略(不匹配 [SEALED_PREFIX])、计入递归总 cap(占用可见)、由维护入口 [retryPendingCleanup] @@ -607,7 +615,43 @@ internal object TokenStatSpool { * [commitReplacement] must persist the external REPLACING state. Request fencing changes only * after that commit succeeds, and before [block] closes stores or replaces any directory. */ - internal suspend fun withExclusiveRestoreAccess( + /** + * 启动时消费崩溃遗留的恢复 REPLACING 标记(审计 P1 修复):进程在恢复替换开始 + * (commitReplacement 已持久化标记)后、成功完成(标记删除)前崩溃时,重启后必须 + * 在初始 drain/replay 之前处理——否则旧 spool 事件会 replay 进可能已被替换的数据库。 + * + * fail-closed 语义:标记存在即代表"上一次恢复未确认完成"——清理旧 spool(其内容属于 + * 恢复前的旧事件,绝不应进入当前数据库)并删除标记;任一失败抛 [IOException](调用方 + * 启动 readiness 因此失败并重试,绝不带不确定状态开始 replay)。无标记时返回 false, + * 正常启动不受影响。 + */ + suspend fun consumeAbandonedRestoreIfAny(context: Context): Boolean = + lifecycleMutex.withLock { + val appContext = context.applicationContext + val marker = File(appContext.filesDir, RESTORE_REPLACING_MARKER_FILE_NAME) + if (!marker.exists()) return@withLock false + AppLogger.w( + TAG, + "abandoned restore REPLACING marker found; discarding pre-restore spool " + + "before startup replay", + ) + // 旧 spool 是恢复前事件,绝不被 replay:清理 + 目录项同步(与恢复成功路径同协议)。 + clearForRestoreLocked(appContext) + if (!marker.delete()) { + throw IOException( + "abandoned restore marker could not be removed: ${marker.absolutePath}", + ) + } + val parent = marker.parentFile + if (parent == null || !requireSpoolDirSync(parent)) { + throw IOException( + "abandoned restore marker removal not durable: ${marker.absolutePath}", + ) + } + true + } + + suspend fun withExclusiveRestoreAccess( context: Context, prepareBeforeCommit: suspend () -> Unit, commitReplacement: suspend () -> Unit, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt index e675801a2..70d1a6742 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt @@ -95,6 +95,9 @@ object TokenStatsStartupCoordinator { if (migrated != null) migrated(context) else TokenBaselineImportRunner.ensureMigratedStrict(context) if (!migrationReady) return false + // 审计 P1:崩溃遗留的恢复 REPLACING 标记必须在任何 spool replay 之前消费 + // (清理旧 spool + 删除标记);失败即初始化失败,绝不带不确定状态开始 replay。 + TokenStatSpool.consumeAbandonedRestoreIfAny(context) val restore = consumePendingRestoreStep val restoreReady = if (restore != null) restore(context) diff --git a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt index 08e20dcc0..f5a2b3c19 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt @@ -67,6 +67,13 @@ object AppLogger { @Volatile var enableFileLogging: Boolean = true + /** + * JVM 单元测试开关:关闭对 [android.util.Log] 的调用(返回 0/false),避免 + * 纯 JVM 环境抛 "not mocked" 异常。与 [enableFileLogging] 独立——文件日志照常。 + */ + @Volatile + var enableSystemLog: Boolean = true + @Volatile private var logFile: File? = null @Volatile @@ -158,96 +165,96 @@ object AppLogger { @JvmStatic fun v(tag: String, msg: String): Int { writeToFile(VERBOSE, tag, msg, null) - return Log.v(tag, msg) + return if (enableSystemLog) Log.v(tag, msg) else 0 } @JvmStatic fun v(tag: String, msg: String, tr: Throwable): Int { writeToFile(VERBOSE, tag, msg, tr) - return Log.v(tag, msg, tr) + return if (enableSystemLog) Log.v(tag, msg, tr) else 0 } @JvmStatic fun d(tag: String, msg: String): Int { writeToFile(DEBUG, tag, msg, null) - return Log.d(tag, msg) + return if (enableSystemLog) Log.d(tag, msg) else 0 } @JvmStatic fun d(tag: String, msg: String, tr: Throwable): Int { writeToFile(DEBUG, tag, msg, tr) - return Log.d(tag, msg, tr) + return if (enableSystemLog) Log.d(tag, msg, tr) else 0 } @JvmStatic fun i(tag: String, msg: String): Int { writeToFile(INFO, tag, msg, null) - return Log.i(tag, msg) + return if (enableSystemLog) Log.i(tag, msg) else 0 } @JvmStatic fun i(tag: String, msg: String, tr: Throwable): Int { writeToFile(INFO, tag, msg, tr) - return Log.i(tag, msg, tr) + return if (enableSystemLog) Log.i(tag, msg, tr) else 0 } @JvmStatic fun w(tag: String, msg: String): Int { writeToFile(WARN, tag, msg, null) - return Log.w(tag, msg) + return if (enableSystemLog) Log.w(tag, msg) else 0 } @JvmStatic fun w(tag: String, msg: String, tr: Throwable): Int { writeToFile(WARN, tag, msg, tr) - return Log.w(tag, msg, tr) + return if (enableSystemLog) Log.w(tag, msg, tr) else 0 } @JvmStatic fun w(tag: String, tr: Throwable): Int { writeToFile(WARN, tag, "", tr) - return Log.w(tag, tr) + return if (enableSystemLog) Log.w(tag, tr) else 0 } @JvmStatic fun e(tag: String, msg: String): Int { writeToFile(ERROR, tag, msg, null) - return Log.e(tag, msg) + return if (enableSystemLog) Log.e(tag, msg) else 0 } @JvmStatic fun e(tag: String, msg: String, tr: Throwable): Int { writeToFile(ERROR, tag, msg, tr) - return Log.e(tag, msg, tr) + return if (enableSystemLog) Log.e(tag, msg, tr) else 0 } @JvmStatic fun wtf(tag: String, msg: String): Int { writeToFile(ASSERT, tag, msg, null) - return Log.wtf(tag, msg) + return if (enableSystemLog) Log.wtf(tag, msg) else 0 } @JvmStatic fun wtf(tag: String, msg: String, tr: Throwable): Int { writeToFile(ASSERT, tag, msg, tr) - return Log.wtf(tag, msg, tr) + return if (enableSystemLog) Log.wtf(tag, msg, tr) else 0 } @JvmStatic fun wtf(tag: String, tr: Throwable): Int { writeToFile(ASSERT, tag, "", tr) - return Log.wtf(tag, tr) + return if (enableSystemLog) Log.wtf(tag, tr) else 0 } @JvmStatic fun isLoggable(tag: String, level: Int): Boolean { - return Log.isLoggable(tag, level) + return enableSystemLog && Log.isLoggable(tag, level) } @JvmStatic fun println(priority: Int, tag: String, msg: String): Int { writeToFile(priority, tag, msg, null) - return Log.println(priority, tag, msg) + return if (enableSystemLog) Log.println(priority, tag, msg) else 0 } @JvmStatic diff --git a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt index e08ac19ec..505be3c92 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt @@ -24,8 +24,17 @@ object OperitPaths { const val MEDIA_POOL_DIR_NAME = "media_pool" const val SKILL_REPO_ZIP_POOL_DIR_NAME = "skill_repo_zip_pool" + /** + * JVM 单元测试注入点:覆盖 [Environment.getExternalStoragePublicDirectory](纯 JVM + * 无该桩,且 thread-local 的 mockStatic 无法覆盖 Dispatchers.IO 线程)。测试设置、 + * tearDown 置回 null。 + */ + @Volatile + internal var downloadsDirOverrideForTest: File? = null + fun downloadsDir(): File { - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + return downloadsDirOverrideForTest + ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) } fun operitRootDir(): File { diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index ff8ab8b67..783b35609 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -36,18 +36,25 @@ import java.io.File import java.io.IOException import java.io.InterruptedIOException import java.net.SocketTimeoutException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONObject @@ -82,6 +89,9 @@ class TokenTrackingAIServiceTest { // 流框架日志走 android.util.Log,JVM 测试不可用:关闭避免 Stub! 异常 com.ai.assistance.operit.util.stream.StreamLogger.setEnabled(false) com.ai.assistance.operit.util.stream.StreamLogger.setVerboseEnabled(false) + // 收尾日志跑在 Dispatchers.IO(P1-3),thread-local 的 mockStatic(AppLogger) + // 无法覆盖后台线程;统一关闭 android.util.Log 调用,避免 "not mocked"。 + com.ai.assistance.operit.util.AppLogger.enableSystemLog = false tempDir = kotlin.io.path.createTempDirectory("tracking-test").toFile() context = mockContext(tempDir) database = @@ -105,6 +115,7 @@ class TokenTrackingAIServiceTest { @After fun tearDown() { + com.ai.assistance.operit.util.AppLogger.enableSystemLog = true runBlocking { TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } } @@ -901,7 +912,7 @@ class TokenTrackingAIServiceTest { } @Test - fun `generation read failure aborts before model invocation`() = runBlocking { + fun `generation read failure degrades tracking and still invokes the model`() = runBlocking { org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { var invoked = false val fake = @@ -912,16 +923,25 @@ class TokenTrackingAIServiceTest { onUsage?.invoke(usage(), 1) } } + // P1-2:统计数据库/身份不可用时降级跟踪——请求必须照常开始与完成 TokenStatsLedger.databaseProvider = { throw IOException("generation unavailable") } try { - tracked(fake).sendMessage(context = context).collect { } - fail("call must not start without a durable reset generation") - } catch (e: IOException) { - assertEquals("generation unavailable", e.message) + val collected = StringBuilder() + tracked(fake).sendMessage(context = context).collect { collected.append(it) } + assertEquals("still delivered", collected.toString()) } finally { TokenStatsLedger.databaseProvider = { database } } - assertFalse("model must not be invoked", invoked) + assertTrue("model must be invoked despite statistics degradation", invoked) + // 降级上下文的事件经 spool 保留:排空重试在数据库恢复后落账(身份 INSERT IGNORE 补齐) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) + while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { + delay(100) + } + assertEquals(1, database.tokenStatsDao().countEvents()) + val event = database.tokenStatsDao().getAllEvents().single() + assertEquals(TokenStatStatus.COMPLETED.name, event.status) + assertEquals(800L, event.uncachedInputTokens) } } @@ -1601,6 +1621,53 @@ class TokenTrackingAIServiceTest { } } + // ==== P1-3:统计收尾绝不执行同步文件 I/O / Future.get() 在调用方 Main 线程 ==== + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun `statistics finalization never performs sync io on the caller main thread`() = runBlocking { + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // 模拟 UI 入口:rememberCoroutineScope(Main)→ 连接测试 / 收集统计包装流。 + // recordSafely 进入 IO 上下文后触发探针记录实际执行线程——收尾的 + // FutureTask.get() 与 append 的 FileOutputStream+fd.sync() 绝不允许 + // 跑在 Main 上(ANR 风险,P1-3)。 + val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } + Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) + val finalizationThreads = ConcurrentHashMap.newKeySet() + TokenTrackingAIService.recordIoThreadProbeForTest = { + finalizationThreads += Thread.currentThread().name + } + try { + withContext(Dispatchers.Main) { + // 成功流收尾(recordSafely) + tracked(FakeAiService()).sendMessage(context = context).collect { } + // 模型失败流的收尾(persistAndCapture,同样 fail-open 且跑在 IO) + try { + tracked(FakeAiService { _ -> stream { throw IOException("model failed") } }) + .sendMessage(context = context).collect { } + } catch (_: IOException) { + // expected + } + // 连接测试(ModelConfigScreen 的 Main scope 入口形态) + tracked(FakeAiService(testConnectionResult = Result.success("ok"))) + .testConnection(context) + } + assertTrue( + "statistics finalization must actually dispatch to IO", + finalizationThreads.isNotEmpty(), + ) + assertFalse( + "statistics finalization must never run sync io on the main thread: $finalizationThreads", + finalizationThreads.any { it == "test-main-thread" }, + ) + } finally { + TokenTrackingAIService.recordIoThreadProbeForTest = null + Dispatchers.resetMain() + mainExecutor.shutdown() + } + } + } + // ==== P1-1:发生时价格快照 ==== @Test @@ -1852,10 +1919,10 @@ class TokenTrackingAIServiceTest { } } - // ==== P1-4:append 故障不丢事件(有界紧急队列 + 恢复) ==== + // ==== P1-4:append 故障不伪装 durable(fail-open:只记日志,绝不影响业务) ==== @Test - fun `append failure defers to emergency queue and recovers with exactly one event each`() = + fun `append failure returns lost without pretending durability and recovers cleanly`() = runBlocking { org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs @@ -1864,28 +1931,28 @@ class TokenTrackingAIServiceTest { // 让 spool 目录不可创建:filesDir 下同名文件占位 val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) spoolPath.writeText("i am a file, not a directory") - var failures = 0 + var lost = 0 (0 until 5).forEach { index -> - val request = - TokenStatRequestContext( - eventId = "evt-emergency-$index", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.onUsage(usage(), 1) - request.finish(TokenStatStatus.COMPLETED) - try { - TokenTrackingAIService.recordSafely(context, request) - fail("non-durable append must not return normally") - } catch (_: TokenStatsPersistenceException) { - failures++ - } + val request = + TokenStatRequestContext( + eventId = "evt-emergency-$index", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + request.onUsage(usage(), 1) + request.finish(TokenStatStatus.COMPLETED) + // P1-2:统计收尾 fail-open——append 失败明确返回 LOST,不再抛出 + if (TokenTrackingAIService.recordSafely(context, request) == + RecordOutcome.LOST + ) { + lost++ } - // 全部明确失败;无内存队列冒充 durable 副本 - assertEquals(5, failures) + } + // 全部明确 LOST;无内存队列冒充 durable 副本,绝无伪落账 + assertEquals(5, lost) assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) assertEquals(0, database.tokenStatsDao().countEvents()) // P2-4:deferred 事件不登记 waiter(latch 已直接完成) @@ -1904,28 +1971,30 @@ class TokenTrackingAIServiceTest { } @Test - fun `append failure fails success and is suppressed on model failure`() = runBlocking { - val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolPath.writeText("not a directory") - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - try { - tracked(FakeAiService()).sendMessage(context = context).collect { } - fail("successful model result must not hide statistics persistence failure") - } catch (_: TokenStatsPersistenceException) { - } + fun `append failure does not fail a successful model result and stays suppressed on model failure`() = + runBlocking { + val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) + spoolPath.writeText("not a directory") + org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { + // P1-2:成功流收集正常完成——统计收尾失败不得改写成功的模型结果 + val collected = StringBuilder() + tracked(FakeAiService()).sendMessage(context = context).collect { collected.append(it) } + assertEquals("hello", collected.toString()) + assertEquals(0, database.tokenStatsDao().countEvents()) - val modelFailure = IOException("model failed") - val failing = FakeAiService { _ -> stream { throw modelFailure } } - try { - tracked(failing).sendMessage(context = context).collect { } - fail("model failure must propagate") - } catch (e: IOException) { - assertTrue("original model exception stays primary", e === modelFailure) - assertEquals(1, e.suppressed.size) - assertTrue(e.suppressed[0] is TokenStatsPersistenceException) + // 模型失败路径保持 fail-open:原始模型异常为主异常,统计失败为 suppressed + val modelFailure = IOException("model failed") + val failing = FakeAiService { _ -> stream { throw modelFailure } } + try { + tracked(failing).sendMessage(context = context).collect { } + fail("model failure must propagate") + } catch (e: IOException) { + assertTrue("original model exception stays primary", e === modelFailure) + assertEquals(1, e.suppressed.size) + assertTrue(e.suppressed[0] is TokenStatsPersistenceException) + } } } - } // ==== P2-2:损坏行整段隔离(保留证据) ==== @@ -2083,13 +2152,14 @@ class TokenTrackingAIServiceTest { // ==== P1 终审:恢复屏障对 in-flight provider/stream 请求的 request/session fencing ==== @Test - fun `restore barrier rejects in-flight and same-process requests until simulated restart`() = + fun `restore barrier drops in-flight statistics without failing the model and rejects same-process requests until restart`() = runBlocking { org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { // 真实 TokenTracking + fake provider:请求停在 provider 流阶段(未收尾)时 // 执行完整 restore(block 模拟恢复替换数据库 + clearAfter 删除旧 spool)→ - // 旧请求释放后收尾 append 被请求 fence 明确拒绝(模型成功不伪装);同进程 - // 新请求被拒绝开始;模拟进程重启(reset 状态)后新请求可正常写入。 + // 旧请求释放后收尾 append 被请求 fence 明确拒绝——P1-2:统计收尾失败不得 + // 改写成功的模型结果(事件丢弃并记日志,绝不写新 DB);同进程新请求仍被 + // 明确拒绝开始;模拟进程重启(reset 状态)后新请求可正常写入。 val entered = CompletableDeferred() val release = CompletableDeferred() val fake = @@ -2101,6 +2171,7 @@ class TokenTrackingAIServiceTest { emit("tail") } } + var completedSuccessfully = false var primary: Throwable? = null val requestJob = launch { @@ -2108,7 +2179,7 @@ class TokenTrackingAIServiceTest { tracked(fake) .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) .collect { } - fail("old in-flight request must fail after a completed restore") + completedSuccessfully = true } catch (e: Throwable) { primary = e if (e is CancellationException) throw e @@ -2130,9 +2201,10 @@ class TokenTrackingAIServiceTest { release.complete(Unit) requestJob.join() assertTrue( - "old successful request must receive an explicit persistence exception, was: $primary", - primary is TokenStatsPersistenceException, + "statistics fence must never fail a successful model result", + completedSuccessfully, ) + assertNull("fence rejection is logged and dropped, not thrown: $primary", primary) // 恢复后的 spool/Room 无旧事件:旧请求从未写入(fence 在写 spool 前拒绝) assertEquals(0, database.tokenStatsDao().countEvents()) val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) @@ -2165,11 +2237,12 @@ class TokenTrackingAIServiceTest { } @Test - fun `restore failure before replacement keeps accepting new requests while old in-flight is rejected`() = + fun `restore failure before replacement drops in-flight statistics without failing the model`() = runBlocking { org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { // 旧请求停在 provider 阶段;restore 在替换前失败(drain 阶段失败——epoch 已 - // 递增但 accepting 保持 true)→ 旧请求释放后被 fence 拒绝;同进程新请求 + // 递增但 accepting 保持 true)→ 旧请求释放后收尾被 fence 拒绝——P1-2:统计 + // 收尾失败不得改写成功的模型结果(事件丢弃并记日志);同进程新请求 // (新 epoch)照常落账——替换前失败可继续。 val spool = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } val pre = @@ -2196,6 +2269,7 @@ class TokenTrackingAIServiceTest { release.await() } } + var completedSuccessfully = false var primary: Throwable? = null val requestJob = launch { @@ -2203,7 +2277,7 @@ class TokenTrackingAIServiceTest { tracked(fake) .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) .collect { } - fail("old in-flight request must be rejected after a restore attempt") + completedSuccessfully = true } catch (e: Throwable) { primary = e if (e is CancellationException) throw e @@ -2231,13 +2305,14 @@ class TokenTrackingAIServiceTest { } finally { TokenStatSpool.segmentReadErrorForTest = null } - // 旧请求释放:epoch 不匹配 → 明确拒绝(不写 spool/DB) + // 旧请求释放:epoch 不匹配 → fence 拒绝(不写 spool/DB),模型结果不受影响 release.complete(Unit) requestJob.join() assertTrue( - "old in-flight request must be rejected, was: $primary", - primary is TokenStatsPersistenceException, + "statistics fence must never fail a successful model result", + completedSuccessfully, ) + assertNull("fence rejection is logged and dropped, not thrown: $primary", primary) // 同进程新请求(新 epoch):替换前失败可继续,正常落账;旧 spool 段(restore // 失败未替换/未清理)一并排空到未被替换的旧 DB tracked(FakeAiService()) @@ -2247,7 +2322,7 @@ class TokenTrackingAIServiceTest { while (database.tokenStatsDao().countEvents() < 2 && System.nanoTime() < deadline) { delay(50) } - // 只有旧 spool 段 + 新请求两个事件:in-flight 旧请求被 fence 拒绝, + // 只有旧 spool 段 + 新请求两个事件:in-flight 旧请求被 fence 丢弃, // 其事件(第 3 个)绝不出现 val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() assertEquals(2, ids.size) diff --git a/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt b/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt new file mode 100644 index 000000000..d20e5819a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt @@ -0,0 +1,421 @@ +package com.ai.assistance.operit.data.backup + +import android.content.ContentResolver +import android.net.Uri +import android.os.Looper +import androidx.room.Room +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver +import com.ai.assistance.operit.data.stats.TokenStatReliabilityTestBase +import com.ai.assistance.operit.data.stats.TokenStatSpool +import com.ai.assistance.operit.data.stats.TokenStatsLedger +import com.ai.assistance.operit.util.AppLogger +import com.ai.assistance.operit.util.OperitPaths +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.sql.SQLException +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +/** + * P1 终审:生产备份/恢复 Manager 的屏障接线测试(真实 Manager + 真实 spool/Room 文件, + * 纯 JVM 基建与 [TokenStatReliabilityTestBase] 一致)。 + * + * 覆盖:备份排空 spool 且事件在备份中恰一次;恢复清除旧 spool 绝不 replay;恢复前开始 + * 的旧 epoch 请求不写入恢复后的数据库;替换前失败可继续、替换开始后失败拒绝新事件。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal class BackupRestoreBarrierTest : TokenStatReliabilityTestBase() { + + private val filesDir get() = File(root, "files") + private val dataDir get() = File(root, "data") + private val cacheDir get() = File(root, "cache") + private val databasesDir get() = File(dataDir, "databases") + private val spoolDir get() = File(filesDir, TokenStatSpool.SPOOL_DIR_NAME) + + private var mainExecutor: ExecutorService? = null + private var looperStatic: MockedStatic? = null + + @Before + fun setUpBarrierMocks() { + // RawSnapshotBackupManager 的 object 初始化会构造 Handler(Looper.getMainLooper()); + // 纯 JVM 没有 Looper,静态 mock 提供非空实例(onProgress 一律传 null, + // mainHandler.post 永远不会被调用)。 + looperStatic = Mockito.mockStatic(Looper::class.java).also { + it.`when` { Looper.getMainLooper() }.thenReturn(mock()) + } + // Manager 用 Dispatchers.Main 汇报进度:安装真实单线程 Main(与 + // CleanupReliabilityTest 相同的模式)。 + mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } + Dispatchers.setMain(mainExecutor!!.asCoroutineDispatcher()) + + // 生产目录布局:filesDir 内是 spool,Room 数据库在 dataDir/databases 下, + // 与 RawSnapshotBackupManager 打包的 payload/databases 一致。 + whenever(context.filesDir).thenReturn(filesDir) + whenever(context.dataDir).thenReturn(dataDir) + whenever(context.cacheDir).thenReturn(cacheDir) + whenever(context.getExternalFilesDir(null)).thenReturn(File(root, "external_files")) + whenever(context.getDatabasePath(any())).thenAnswer { File(databasesDir, it.getArgument(0)) } + cacheDir.mkdirs() + // RestoreReplacingMarker.persist 写 filesDir/restore_replacing.flag; + // 生产 filesDir 恒存在,JVM 测试需显式创建。 + filesDir.mkdirs() + // RoomDatabaseRestoreManager 把恢复目标写 room_restore_target 下的 tmp 文件, + // 目标目录需存在(生产由 getDatabasePath 保证)。 + File(root, "room_restore_target").mkdirs() + } + + @After + fun tearDownBarrierMocks() { + Dispatchers.resetMain() + mainExecutor?.shutdown() + looperStatic?.close() + } + + @Test + fun `export drains spool only event into the backup database exactly once`() = runBlocking { + val spool = spoolDir.apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-in-backup")) + "\n") + // spool 内非段文件:验证 spool 目录本身被排除出备份 zip(OperitPaths 排除名单) + File(spool, "stray.txt").writeText("not a segment") + + OperitPaths.downloadsDirOverrideForTest = File(root, "sdcard") + try { + Mockito.mockStatic(AppLogger::class.java).use { + val out = RawSnapshotBackupManager.exportToBackupDir(context, onProgress = null) + assertTrue("backup zip must exist", out.isFile) + + // 屏障 drain 后:spool 无残留段、事件已在 Room 中 + assertTrue( + "spool must be drained by the snapshot barrier", + spool.listFiles().orEmpty().none { + it.name.startsWith("sealed_") || it.name == TokenStatSpool.ACTIVE_FILE_NAME + }, + ) + assertEquals(1, database.tokenStatsDao().countEvents()) + + val exported = unzipTo(out, File(root, "unzipped-backup")) + // 事件在备份中恰出现一次(经 Room 数据库文件进入备份) + val restoredDb = File(exported, "payload/databases/app_database") + assertTrue("backup must contain the drained database", restoredDb.isFile) + val count = JdbcSQLiteDriver().open(restoredDb.absolutePath).use { connection -> + connection.prepare("SELECT count(*) FROM token_stat_events WHERE eventId = 'spool-only-in-backup'") + .use { statement -> + if (statement.step()) statement.getLong(0) else -1L + } + } + assertEquals(1L, count) + // spool 目录被排除出备份,源文件保留 + assertFalse( + "spool directory must be excluded from the backup zip", + File(exported, "payload/files/${TokenStatSpool.SPOOL_DIR_NAME}/stray.txt").exists(), + ) + assertTrue("source spool file must stay untouched", File(spool, "stray.txt").exists()) + } + } finally { + OperitPaths.downloadsDirOverrideForTest = null + } + } + + @Test + fun `raw restore clears the pre restore spool so replay cannot inject old events`() = runBlocking { + val spool = spoolDir.apply { mkdirs() } + // 仅存在于 spool(未入 Room)的旧事件:恢复后绝不能 replay 进新数据库 + File(spool, "sealed_1.jsonl").writeText(line(request("old-pre-restore")) + "\n") + val zip = rawSnapshotZip(emptyMap()) + val uri = mock() + val resolver = mock() + whenever(context.contentResolver).thenReturn(resolver) + whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) + + val epochBefore = TokenStatSpool.captureRestoreEpoch() + // 测试 Room 实例打开着 dataDir/databases/app_database,restore 会替换该目录 + // (Windows 文件锁);先关闭释放,restore 完成后重建用于断言。 + database.close() + TokenStatsLedger.databaseProvider = null + try { + Mockito.mockStatic(AppLogger::class.java).use { + RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) + } + } finally { + reopenDatabase() + } + + // 备份 zip 不含 spool → files 替换按排除名单保留 spool 目录 → 屏障清理删除它 + assertFalse("old spool must be cleared by the restore barrier", spool.exists()) + assertFalse( + "REPLACING marker must be removed after a successful restore", + File(filesDir, RestoreReplacingMarker.FILE_NAME).exists(), + ) + assertTrue( + "restore must pass through the epoch fencing barrier", + TokenStatSpool.captureRestoreEpoch() > epochBefore, + ) + + // replay(模拟重启后的重放):旧事件绝不进入(已替换的)数据库 + TokenStatSpool.replay(context) + delay(300) + assertFalse(spool.exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + + private fun reopenDatabase() { + database = + Room.databaseBuilder(context, AppDatabase::class.java, "app_database") + .setDriver(JdbcSQLiteDriver()) + .addMigrations(AppDatabase.MIGRATION_20_21) + .allowMainThreadQueries() + .build() + TokenStatsLedger.databaseProvider = { database } + } + + @Test + fun `room restore rejects pre restore epoch requests and replaces the database`() = runBlocking { + // 先让测试库在默认路径(dataDir/databases)打开,再把恢复目标改指独立路径, + // 避免 JVM 下替换打开中的 Room 文件(Windows 文件锁)。 + database.tokenStatsDao().deleteAllEvents() + val restoredDb = File(File(root, "room_restore_target"), "app_database") + whenever(context.getDatabasePath(any())).thenAnswer { + File(File(root, "room_restore_target"), it.getArgument(0)) + } + + // 恢复前开始的请求:捕获旧 epoch + val oldRequest = request("old-epoch-room-restore") + val oldEpoch = oldRequest.sessionEpoch + + val zip = File(cacheDir, "room-restore-test.zip").apply { + ZipOutputStream(FileOutputStream(this)).use { zos -> + zos.putNextEntry(ZipEntry("app_database")) + zos.write(ByteArray(0)) + zos.closeEntry() + } + } + Mockito.mockStatic(AppLogger::class.java).use { + RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) + } + + // 屏障生效:epoch 递增、替换完成后 REPLACING 标记已删除、新事件被拒绝 + assertTrue( + "restore must bump the restore epoch after the REPLACING commit", + TokenStatSpool.captureRestoreEpoch() > oldEpoch, + ) + assertFalse(TokenStatSpool.isAcceptingEvents()) + assertFalse(File(filesDir, RestoreReplacingMarker.FILE_NAME).exists()) + assertEquals( + "restored database must not contain any old events", + 0L, + eventCountIn(restoredDb), + ) + + // 恢复前开始的请求(旧 epoch)收尾时被明确拒绝,绝不写入恢复后的数据库/spool + assertEquals( + RecordOutcome.LOST, + TokenTrackingAIService.recordSafely(context, oldRequest), + ) + assertFalse(File(spoolDir, TokenStatSpool.ACTIVE_FILE_NAME).exists()) + assertEquals(0L, eventCountIn(restoredDb)) + } + + @Test + fun `restore failure before replacement keeps accepting events and new requests land`() = runBlocking { + val epochBefore = TokenStatSpool.captureRestoreEpoch() + // 损坏备份:缺少 manifest → 在 prepareBeforeCommit(替换前)明确失败 + val zip = rawSnapshotZip(emptyMap(), manifest = null) + val uri = mock() + val resolver = mock() + whenever(context.contentResolver).thenReturn(resolver) + whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) + + Mockito.mockStatic(AppLogger::class.java).use { + try { + RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) + fail("restore must fail on a corrupt backup") + } catch (e: IllegalArgumentException) { + // 替换前的失败:epoch 不变、进程仍接受事件 + } + } + assertEquals(epochBefore, TokenStatSpool.captureRestoreEpoch()) + assertTrue("pre-replacement failure must keep accepting events", TokenStatSpool.isAcceptingEvents()) + + // 新请求照常入账 + TokenTrackingAIService.recordSafely(context, request("after-failed-restore")) + awaitEvent("after-failed-restore") + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `restore failure after replacement started rejects all new events`() = runBlocking { + database.tokenStatsDao().deleteAllEvents() + val epochBefore = TokenStatSpool.captureRestoreEpoch() + // payload 含文件:替换(block)开始后必然失败(JVM 下 AtomicFile 复制抛错) + val zip = rawSnapshotZip(mapOf("payload/files/some-file.txt" to "x".toByteArray())) + val uri = mock() + val resolver = mock() + whenever(context.contentResolver).thenReturn(resolver) + whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) + + Mockito.mockStatic(AppLogger::class.java).use { + try { + RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) + fail("restore must fail after replacement started") + } catch (e: Exception) { + // 替换开始后的失败:epoch 已递增、本进程拒绝一切新事件直至重启 + } + } + assertTrue( + "restore epoch must bump before replacement starts", + TokenStatSpool.captureRestoreEpoch() > epochBefore, + ) + assertFalse( + "replacement-started failure must reject new events", + TokenStatSpool.isAcceptingEvents(), + ) + assertEquals( + RecordOutcome.LOST, + TokenTrackingAIService.recordSafely(context, request("post-replacement")), + ) + assertFalse(File(spoolDir, TokenStatSpool.ACTIVE_FILE_NAME).exists()) + assertEquals(0, database.tokenStatsDao().countEvents()) + } + + @Test + fun `startup consumes abandoned restore marker and discards pre restore spool`() = runBlocking { + // 模拟崩溃于"替换已开始(REPLACING 已持久化)但未成功完成(标记未删除)": + // 重启后启动路径必须先消费标记、清理旧 spool,再开始 replay——旧事件绝不进数据库。 + val spool = spoolDir.apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("abandoned-pre-restore")) + "\n") + val marker = File(filesDir, RestoreReplacingMarker.FILE_NAME) + marker.writeText("REPLACING\n") + + val consumed = TokenStatSpool.consumeAbandonedRestoreIfAny(context) + assertTrue("marker must be consumed at startup", consumed) + assertFalse("old spool must be discarded", spool.exists()) + assertFalse("marker must be removed", marker.exists()) + + // 之后正常启动 replay:数据库仍是本机旧库,但旧 spool 已清空,无旧事件注入 + TokenStatSpool.replay(context) + delay(300) + assertEquals(0, database.tokenStatsDao().countEvents()) + // 新请求照常落账 + TokenTrackingAIService.recordSafely(context, request("post-abandoned-restore")) + awaitEvent("post-abandoned-restore") + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `startup without marker does not touch the spool`() = runBlocking { + val spool = spoolDir.apply { mkdirs() } + File(spool, "sealed_1.jsonl").writeText(line(request("normal-startup")) + "\n") + assertFalse("no marker must mean normal startup", TokenStatSpool.consumeAbandonedRestoreIfAny(context)) + TokenStatSpool.replay(context) + awaitEvent("normal-startup") + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `room restore corrupt zip fails before commit and keeps accepting events`() = runBlocking { + val epochBefore = TokenStatSpool.captureRestoreEpoch() + // 缺 app_database 条目的 ZIP:prepareBeforeCommit 解压验证失败 → 不 commit、 + // epoch 不变、marker 不写、进程仍接受事件(绝不用错误备份锁死当前进程)。 + val zip = File(cacheDir, "room-restore-corrupt.zip").apply { + ZipOutputStream(FileOutputStream(this)).use { zos -> + zos.putNextEntry(ZipEntry("other-file")) + zos.write(ByteArray(0)) + zos.closeEntry() + } + } + try { + RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) + fail("restore must fail on a corrupt zip") + } catch (e: IllegalArgumentException) { + // expected + } + assertEquals(epochBefore, TokenStatSpool.captureRestoreEpoch()) + assertTrue("pre-commit failure must keep accepting events", TokenStatSpool.isAcceptingEvents()) + assertFalse(File(filesDir, RestoreReplacingMarker.FILE_NAME).exists()) + + // 新请求照常落账 + TokenTrackingAIService.recordSafely(context, request("after-corrupt-room-restore")) + awaitEvent("after-corrupt-room-restore") + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private val validManifest = + """ + {"formatVersion":1,"packageName":"com.ai.assistance.operit","createdAt":1, + "includes":["payload/files/","payload/external_files/","payload/shared_prefs/", + "payload/datastore/","payload/databases/"],"includeTerminalData":true} + """.trimIndent() + + /** 生成 RawSnapshotBackupManager 可识别的备份 zip;manifest=null 时缺 manifest(损坏)。 */ + private fun rawSnapshotZip(entries: Map, manifest: String? = validManifest): File { + val zip = File(cacheDir, "raw-restore-test.zip") + ZipOutputStream(FileOutputStream(zip)).use { zos -> + if (manifest != null) { + zos.putNextEntry(ZipEntry("manifest.json")) + zos.write(manifest.toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + entries.forEach { (name, bytes) -> + zos.putNextEntry(ZipEntry(name)) + zos.write(bytes) + zos.closeEntry() + } + } + return zip + } + + private fun unzipTo(zip: File, targetDir: File): File { + ZipInputStream(java.io.BufferedInputStream(FileInputStream(zip))).use { zis -> + while (true) { + val entry = zis.nextEntry ?: break + if (!entry.isDirectory) { + val out = File(targetDir, entry.name) + out.parentFile?.mkdirs() + FileOutputStream(out).use { output -> zis.copyTo(output) } + } + zis.closeEntry() + } + } + return targetDir + } + + /** 直连 sqlite 统计事件表;表缺失(恢复后的库为空)视为 0 条旧事件。 */ + private fun eventCountIn(dbFile: File): Long = + JdbcSQLiteDriver().open(dbFile.absolutePath).use { connection -> + try { + connection.prepare("SELECT count(*) FROM token_stat_events").use { statement -> + if (statement.step()) statement.getLong(0) else -1L + } + } catch (e: SQLException) { + 0L + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt index ebf6f3b45..cebe3c6ea 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt @@ -15,7 +15,8 @@ import org.junit.Test * - OpenAI 系 completion_tokens 包含推理 → reasoningIncludedInOutput = true; * - Anthropic input_tokens 不含缓存分量(文档:总量 = input + cache_read + cache_creation), * 缓存写入独立保留、独立计费; - * - Gemini candidatesTokenCount 包含 thought → 推理已包含在输出。 + * - Gemini candidatesTokenCount 不含 thoughtsTokenCount(官方 API 独立字段, + * 思考 token 按输出计费)→ 推理未包含在输出。 */ class ProviderUsageNormalizerTest { @@ -232,7 +233,62 @@ class ProviderUsageNormalizerTest { assertNull("Gemini 无缓存写入概念", snapshot.cacheWriteTokens) assertEquals(400L, snapshot.outputTokens) assertEquals(90L, snapshot.reasoningTokens) - assertEquals("candidatesTokenCount 包含 thought", true, snapshot.reasoningIncludedInOutput) + assertEquals("candidatesTokenCount 不含 thought(独立计费)", false, snapshot.reasoningIncludedInOutput) + } + + @Test + fun `gemini thoughts are billed on top of candidates by cost layer`() { + // P1-4:thoughtsTokenCount 独立于 candidatesTokenCount,计费输出 = candidates + thoughts。 + // prompt=100, candidates=20, thoughts=80 → billed output = 100。 + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 20, + "thoughtsTokenCount": 80, + "totalTokenCount": 200 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(100L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(20L, snapshot.outputTokens) + assertEquals(80L, snapshot.reasoningTokens) + assertEquals(false, snapshot.reasoningIncludedInOutput) + assertEquals( + 100L, + TokenCostCalculator.billedOutputTokens(snapshot.toTokenUsageInput()), + ) + } + + @Test + fun `gemini usage metadata without candidates fields stays fully billable`() { + // P1-4:prompt 被拦截时不返回 candidates,但 usageMetadata 仍然存在;provider + // 层必须把该 usage 上报,归一化后应得到完整可计费快照(输入照常计费,输出为真实 0)。 + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 0, + "thoughtsTokenCount": 0 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(100L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertEquals(0L, snapshot.reasoningTokens) + assertEquals(false, snapshot.reasoningIncludedInOutput) + assertEquals( + 0L, + TokenCostCalculator.billedOutputTokens(snapshot.toTokenUsageInput()), + ) } @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt index cf70a8fdd..86775bcd5 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService +import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome import com.ai.assistance.operit.data.dao.TokenStatsDao import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.BillingMode @@ -75,12 +76,12 @@ internal class SpoolWriterReliabilityTest : TokenStatReliabilityTestBase() { fun `more than two thousand append failures never return durable`() = runBlocking { File(root, TokenStatSpool.SPOOL_DIR_NAME).writeText("not a directory") Mockito.mockStatic(AppLogger::class.java).use { + // P1-2:统计收尾 fail-open——append 失败明确返回 LOST(绝不抛出、绝不伪 durable) repeat(2_001) { index -> - try { - TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")) - fail("append failure must throw") - } catch (_: TokenStatsPersistenceException) { - } + assertEquals( + RecordOutcome.LOST, + TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")), + ) } } assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt index 3131c2b16..94c249d10 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -2,6 +2,7 @@ package com.ai.assistance.operit.data.stats import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.model.BillingMode +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -97,6 +98,33 @@ reasoningTokens = 300L, assertEquals(0.0026, cost.amount!!, 1e-12) } + @Test + fun `gemini end to end bills thoughts on top of candidates`() { + // P1-4:Gemini thoughtsTokenCount 独立于 candidatesTokenCount,按输出计费; + // prompt=100, candidates=20, thoughts=80, total=200 → billed output = 100。 + val usage = + ProviderUsageNormalizer.gemini( + JSONObject( + """ + { + "promptTokenCount": 100, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 20, + "thoughtsTokenCount": 80, + "totalTokenCount": 200 + } + """.trimIndent() + ) + )!!.toTokenUsageInput() + + val cost = TokenCostCalculator.computeCost(usage, tokenPricing) + + assertEquals(100L, cost.billedOutputTokens) + assertEquals(100L, cost.billedInputTokens) + // 100/1e6*1 + 0/1e6*0.5 + 100/1e6*2 = 0.0003 + assertEquals(0.0003, cost.amount!!, 1e-12) + } + @Test fun `reasoning without inclusion declaration defaults to included`() { val usage = diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt index 3958f2a9d..6e50552e4 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt @@ -69,6 +69,9 @@ internal abstract class TokenStatReliabilityTestBase { TokenStatsLedger.legacyPriceProvider = { _, _ -> null } TokenStatSpool.clearPendingStateForTest() TokenTrackingAIService.resetPricingExecutorForTest() + // 收尾日志可能跑在 Dispatchers.IO 等后台线程,thread-local 的 mockStatic(AppLogger) + // 无法覆盖;统一关闭 android.util.Log 调用(文件日志照常),避免 "not mocked"。 + AppLogger.enableSystemLog = false TokenStatSpool.afterSegmentReadForTest = null TokenStatSpool.spoolDeleteForTest = null TokenStatSpool.segmentDeleteForTest = null @@ -92,6 +95,7 @@ internal abstract class TokenStatReliabilityTestBase { @After fun tearDown() { + AppLogger.enableSystemLog = true // P1-1 终审修复:测试可能以“目录项未确认持久”状态结束(gate=false),tearDown 的快照 // barrier 会重新 bootstrap——必须先恢复“目录 fsync 支持且成功”的平台常态(Windows JVM // 真实探测恒为 UNSUPPORTED),否则 gate 在 tearDown 中失败并掩盖测试结果。 From c13d251ad2be0e24f8b8658a1a00fdfdbb2063d5 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:14:46 +0800 Subject: [PATCH 23/28] fix(stats): close backup restore access races --- .../llmprovider/TokenTrackingAIService.kt | 114 ++++++---- .../assistance/operit/data/stats/SpoolLock.kt | 58 ++++- .../data/stats/TokenBaselineImportRunner.kt | 67 ++++-- .../operit/data/stats/TokenStatSpool.kt | 23 +- .../operit/data/stats/TokenStatsLedger.kt | 50 +++-- .../data/stats/TokenStatsQueryService.kt | 20 +- .../data/stats/TokenStatsSettingsManager.kt | 199 ++++++++++-------- .../TokenStatsManagementViewModel.kt | 6 +- .../TokenUsageStatisticsViewModel.kt | 7 +- .../llmprovider/TokenTrackingAIServiceTest.kt | 196 ++++++++++++++++- .../data/stats/RestoreReliabilityTest.kt | 6 +- .../stats/TokenBaselineImportRunnerTest.kt | 39 ++++ .../stats/TokenStatsQueryServiceRoomTest.kt | 62 ++++++ .../stats/TokenStatsSettingsManagerTest.kt | 22 +- 14 files changed, 670 insertions(+), 199 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt index b10bd73f4..82d073d95 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -11,7 +11,9 @@ import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver import com.ai.assistance.operit.data.stats.TokenStatRequestContext import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsBarrierActiveException import com.ai.assistance.operit.data.stats.TokenStatsLedger +import com.ai.assistance.operit.data.stats.withStatsDatabaseAccess import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.stream.RevisableTextStream import com.ai.assistance.operit.util.stream.SharedStream @@ -38,10 +40,9 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.runBlocking /** - * 统计持久化故障标记(P1-2):只在两条路径出现—— - * - 模型已失败/取消的收尾中作为原异常的 suppressed 保留(观测性,不覆盖主异常); - * - 恢复替换后 [newRequest] 对同进程新请求的明确拒绝(恢复语义)。 - * 成功的模型结果绝不被它改写:成功路径的统计故障只记日志并返回 [RecordOutcome.LOST]。 + * 统计持久化故障标记:只在模型已失败/取消的收尾中作为原异常的 suppressed 保留 + * (观测性,不覆盖主异常)。恢复替换后的同进程新请求不再抛出本异常——改为 fail-open + * ([newRequest] 返回 null,统计层跳过跟踪直调 delegate),模型调用不受影响。 */ class TokenStatsPersistenceException(message: String, cause: Throwable? = null) : java.io.IOException(message, cause) @@ -123,15 +124,18 @@ class TokenTrackingAIService( onTokensUpdated = onTokensUpdated, // 组合内部记录与调用者 callback:内部按 attempt 记账,调用者回调 // 原样转发(每次上报都转发,不吞不重);外部 observer 的异常与 - // provider 业务隔离(非取消只日志,取消仍传播)。 + // provider 业务隔离(非取消只日志,取消仍传播)。request 为 null + // (恢复替换后 fail-open)时只转发、不记账。 onUsageReported = { usage, attempt -> - request.onUsage(usage, attempt) + request?.onUsage(usage, attempt) forwardUsageObserver(onUsageReported, usage, attempt) }, onNonFatalError = onNonFatalError, enableRetry = enableRetry, statsCategory = statsCategory, ) + // P1-2 fail-open:恢复替换后统计层跳过跟踪,直调 delegate(模型功能不受影响)。 + if (request == null) return delegateStream return wrapStream(delegateStream, request) } @@ -140,6 +144,8 @@ class TokenTrackingAIService( onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, ): Result { val request = newRequest(TokenStatCategory.CONNECTION_TEST) + // P1-2 fail-open:恢复替换后跳过统计,直调 delegate(连接测试正常执行)。 + if (request == null) return delegate.testConnection(context, onUsageReported) return try { val result = delegate.testConnection(context) { usage, attempt -> @@ -186,43 +192,57 @@ class TokenTrackingAIService( } } - private suspend fun newRequest(category: TokenStatCategory?): TokenStatRequestContext { - // P1 终审:restore 替换开始后本进程不再接受新的统计请求(直到进程重启,UI 允许 - // 稍后重启)——在此明确拒绝开始新跟踪请求,绝不等到收尾才失败,也绝不写入已恢复 - // 替换的数据库。替换前失败的 restore 不置位该标志,新请求照常继续。 - if (!TokenStatSpool.isAcceptingEvents()) { - throw TokenStatsPersistenceException( - "Token statistics are not accepting new events until the app restarts after a restore", - ) - } + /** + * 请求接受边界(reviewer P1-1/P1-2 修复): + * - P1-1:`isAcceptingEvents` 检查、restore epoch 捕获与身份创建事务在 + * [TokenStatSpool.withStatsDatabaseAccess] 门控(屏障感知注册表)内原子完成—— + * 快照/恢复屏障排他期间到达的请求被**立即拒绝**([TokenStatsBarrierActiveException]), + * 绝不与文件打包/替换竞争打开 Room,模型调用不受影响; + * - P1-2:恢复替换完成后(accepting=false,UI 允许"稍后重启")返回 null—— + * 调用方 fail-open 直调 delegate(不抛异常、不阻断模型调用、不写新 DB), + * 重启后统计自动恢复。 + * @return null 表示恢复替换后或屏障排他期间本进程不接受统计(fail-open 跳过跟踪)。 + */ + private suspend fun newRequest(category: TokenStatCategory?): TokenStatRequestContext? { val (provider, model) = TokenStatIdentityResolver.splitProviderModel(delegate.providerModel) - // P1-1:请求接受边界在**同一事务**内原子确保身份存在并读取 generation——删除展示 - // 分组要么看见该身份(写 IDENTITY tombstone,删除前接受的事件被跳过),要么请求 - // 拿到 ≥ tombstone 的新 generation(删除后请求正常入账)。首次请求的身份绝不可能 - // 绕过分组删除 tombstone 复活旧事件。 - // P1-2:统计数据库/身份不可用时**降级跟踪**——模型请求照常开始。降级上下文仍带 - // 完整 eventId/时间/usage,收尾 append 走 spool;身份由排空 INSERT IGNORE 补齐 - // (故障瞬时则事件照常落账)。acceptedGeneration 取 0 是保守方向:若期间发生过 - // reset,事件只会被 tombstone 跳过(不复活),绝不可能绕过 reset 入账。 - val acceptedGeneration = + val accepted = try { - TokenStatsLedger.ensureIdentityAndCaptureGeneration( - appContext, - configId, - provider, - model, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e( - TAG, - "统计身份/代次读取失败,本次请求降级跟踪(不影响模型调用): " + - "configId=$configId, provider=$provider, model=$model", - e, - ) - 0L - } + TokenStatSpool.withStatsDatabaseAccess { + if (!TokenStatSpool.isAcceptingEvents()) { + return@withStatsDatabaseAccess null + } + val sessionEpoch = TokenStatSpool.captureRestoreEpoch() + // P1-1:请求接受边界在**同一事务**内原子确保身份存在并读取 generation——删除展示 + // 分组要么看见该身份(写 IDENTITY tombstone,删除前接受的事件被跳过),要么请求 + // 拿到 ≥ tombstone 的新 generation(删除后请求正常入账)。首次请求的身份绝不可能 + // 绕过分组删除 tombstone 复活旧事件。 + // 统计数据库/身份不可用时**降级跟踪**——模型请求照常开始(保持既有 P1-2 语义)。 + val acceptedGeneration = + try { + TokenStatsLedger.ensureIdentityAndCaptureGeneration( + appContext, + configId, + provider, + model, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e( + TAG, + "统计身份/代次读取失败,本次请求降级跟踪(不影响模型调用): " + + "configId=$configId, provider=$provider, model=$model", + e, + ) + 0L + } + RequestAcceptance(acceptedGeneration, sessionEpoch) + } + } catch (e: TokenStatsBarrierActiveException) { + // P1-1:屏障排他期间(快照打包/恢复替换中)——跳过统计直调 delegate, + // 绝不等待或触碰正在替换的 Room。 + return null + } ?: return null return TokenStatRequestContext( eventId = "evt_${UUID.randomUUID().toString().replace("-", "")}", category = category ?: TokenStatCategory.OTHER, @@ -230,13 +250,19 @@ class TokenTrackingAIService( provider = provider, model = model, startedAtMs = System.currentTimeMillis(), - acceptedGeneration = acceptedGeneration, - // P1 终审:请求开始时同步捕获 restore epoch(纯内存、无 Room),收尾 append + acceptedGeneration = accepted.generation, + // 请求开始时在门控内同步捕获 restore epoch(纯内存、无 Room),收尾 append // 时验证——restore 屏障开始即递增 epoch,旧请求被明确拒绝,不写新 DB。 - sessionEpoch = TokenStatSpool.captureRestoreEpoch(), + sessionEpoch = accepted.sessionEpoch, ) } + /** [newRequest] 门控内原子捕获的请求接受结果。 */ + private data class RequestAcceptance( + val generation: Long, + val sessionEpoch: Long, + ) + /** 保持修订流语义:内部流带 eventChannel 时返回同接口的包装流。 */ private fun wrapStream( delegateStream: Stream, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt index 515714e90..4134a6f09 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt @@ -119,8 +119,8 @@ internal suspend fun TokenStatSpool.withExclusiveSnapshotAccessInternal( // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 synchronized(stateLock) { exclusiveBarrierActive = true } try { - if (!awaitActiveInsertsEmpty()) { - val live = synchronized(stateLock) { activeInserts.size } + if (!awaitActiveInsertsEmpty() || !awaitStatsDbAccessorsEmpty()) { + val live = synchronized(stateLock) { activeInserts.size + statsDbAccessTokens.size } throw IOException( "statistics Room insert still active ($live); " + "snapshot/restore aborted before any file replacement", @@ -161,6 +161,60 @@ internal suspend fun TokenStatSpool.withExclusiveSnapshotAccessInternal( synchronized(stateLock) { exclusiveBarrierActive = false } } } +/** + * 统计数据库访问门控被屏障排他期拒绝的专用异常(reviewer P1-1 修复):快照/恢复屏障 + * 已进入排他状态([TokenStatSpool.exclusiveBarrierActive])时,任何新的统计数据库访问 + * 被**立即拒绝**(绝不无限等待——屏障的 block/prepareBeforeCommit 内误调用门控入口 + * 时也会立即失败而非自死锁)。调用方按 fail-open 语义处理: + * - [TokenTrackingAIService.newRequest]:跳过统计直调 delegate(模型调用不受影响); + * - [TokenStatsLedger.resolvePricingForRequest]:抛给收尾边界 → UNKNOWN 价格事件。 + */ +internal class TokenStatsBarrierActiveException : + java.io.IOException("token statistics database access rejected: snapshot/restore barrier active") + +/** + * 统一统计数据库访问门控(reviewer P1-1 修复):请求边界的身份事务 + * ([TokenStatsLedger.ensureIdentityAndCaptureGeneration])与请求收尾的价格解析 + * ([TokenStatsLedger.prepareEventLineDetached])等**直接 Room 访问**通过注册表 + * ([TokenStatSpool.statsDbAccessTokens])与快照/恢复屏障互斥,**不持有 lifecycleMutex、 + * 不做无限等待**: + * - 屏障进入排他状态([TokenStatSpool.exclusiveBarrierActive]=true)**前**注册的访问者由 + * 屏障有界等待清空——替换前已进入的事务在替换前完成,绝不与 checkpoint/ZIP 打包/ + * 文件替换竞争;恢复的 closeDatabase 窗口也由该等待覆盖; + * - 屏障排他期间到达的访问者**立即**抛 [TokenStatsBarrierActiveException](绝不重新打开 + * 正在替换的 Room,也不产生备份打包窗口内的新 WAL/身份写入;请求/收尾按 fail-open + * 语义跳过统计,模型调用不受影响); + * - 恢复替换完成后(accepting=false)由调用方在访问块内按 fence 语义判定 + * ([TokenTrackingAIService.newRequest] 返回 null fail-open)。 + * token 删除幂等:reset 清空集合后,旧访问者的 finally 只移除自己的 token。 + */ +internal suspend fun TokenStatSpool.withStatsDatabaseAccess(block: suspend () -> T): T { + val token = synchronized(stateLock) { + if (exclusiveBarrierActive) { + null + } else { + statsDbAccessSeq += 1L + statsDbAccessTokens.add(statsDbAccessSeq) + statsDbAccessSeq + } + } ?: throw TokenStatsBarrierActiveException() + try { + return block() + } finally { + synchronized(stateLock) { statsDbAccessTokens.remove(token) } + } +} + +/** 有界等待在册统计数据库访问者全部结束(与 [awaitActiveInsertsEmpty] 同构)。 */ +internal suspend fun TokenStatSpool.awaitStatsDbAccessorsEmpty(): Boolean { + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) + while (true) { + if (synchronized(stateLock) { statsDbAccessTokens.isEmpty() }) return true + if (System.nanoTime() >= deadline) return false + delay(QUIESCE_POLL_INTERVAL_MS) + } +} + /** * Request/session fencing 判定(P1 终审,调用方持 lifecycleMutex):请求开始捕获的 * [sessionEpoch] 必须等于当前 [restoreEpoch](恢复屏障开始时原子递增使旧请求失效), diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt index b3af3a9cc..16cd94328 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt @@ -74,7 +74,16 @@ object TokenBaselineImportRunner { */ // ==== 导入 ==== - internal suspend fun runImport(appContext: Context, forceReplace: Boolean): Boolean { + internal suspend fun runImport(appContext: Context, forceReplace: Boolean): Boolean = + TokenStatSpool.withStatsDatabaseAccess { + runImportWithDatabaseAccess(appContext, forceReplace) + } + + /** 调用方已登记统计数据库访问 token;数据库实例必须在登记后解析。 */ + private suspend fun runImportWithDatabaseAccess( + appContext: Context, + forceReplace: Boolean, + ): Boolean { val injected = databaseProvider val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) val dao = database.tokenStatsDao() @@ -246,22 +255,20 @@ object TokenStatsResetCoordinator { private const val TAG = "TokenStatsReset" suspend fun resetAllStatistics(context: Context) { - withDao(context) { dao -> dao.resetAllStatisticsTx() } - drainPendingCleanup(context.applicationContext) + withMutation(context, drainCleanupAfter = true) { dao -> dao.resetAllStatisticsTx() } TokenStatSpool.replay(context.applicationContext) } suspend fun resetStatisticsForProviderModel(context: Context, providerModel: String) { val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) if (model.isBlank()) return - withDao(context) { dao -> dao.resetModelTx(provider, model) } - drainPendingCleanup(context.applicationContext) + withMutation(context, drainCleanupAfter = true) { dao -> dao.resetModelTx(provider, model) } TokenStatSpool.replay(context.applicationContext) } /** 删除时间范围 [startMs, endMs) 内的事件;baseline 一律保留(阶段 5)。 */ suspend fun deleteEventsInRange(context: Context, startMs: Long, endMs: Long) { - withDao(context) { dao -> dao.deleteRangeEventsTx(startMs, endMs) } + withMutation(context) { dao -> dao.deleteRangeEventsTx(startMs, endMs) } TokenStatSpool.replay(context.applicationContext) } @@ -275,15 +282,17 @@ object TokenStatsResetCoordinator { displayModelId: String, deleteBaselines: Boolean, ) { - withDao(context) { dao -> dao.deleteDisplayModelEventsTx(displayModelId, deleteBaselines) } - drainPendingCleanup(context.applicationContext) + withMutation(context, drainCleanupAfter = true) { dao -> + dao.deleteDisplayModelEventsTx(displayModelId, deleteBaselines) + } TokenStatSpool.replay(context.applicationContext) } /** 删除全部事件;[deleteBaselines] 为 true 时同时删除全部 baseline(阶段 5)。 */ suspend fun deleteAllEvents(context: Context, deleteBaselines: Boolean) { - withDao(context) { dao -> dao.deleteAllStatisticsTx(deleteBaselines) } - drainPendingCleanup(context.applicationContext) + withMutation(context, drainCleanupAfter = true) { dao -> + dao.deleteAllStatisticsTx(deleteBaselines) + } TokenStatSpool.replay(context.applicationContext) } @@ -298,10 +307,12 @@ object TokenStatsResetCoordinator { */ suspend fun drainPendingCleanup(context: Context) { val appContext = context.applicationContext - val injected = daoProvider - val dao = - injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext).tokenStatsDao() - drainPendingCleanupWith(appContext, dao) + TokenStatSpool.withStatsDatabaseAccess { + val injected = daoProvider + val dao = + injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext).tokenStatsDao() + drainPendingCleanupWith(appContext, dao) + } } /** @@ -327,16 +338,30 @@ object TokenStatsResetCoordinator { } } - private suspend fun withDao( + /** + * 完整登记一次统计变更:登记后才解析 DAO;Room 事务结束后、访问 token 释放前排空 + * cleanup outbox。spool replay 必须由调用方在本函数返回后执行,避免屏障等待锁环。 + */ + private suspend fun withMutation( context: Context, + drainCleanupAfter: Boolean = false, block: suspend (TokenStatsDao) -> Unit, ) { - val injected = daoProvider - if (injected != null) { - block(injected(context)) - return + val appContext = context.applicationContext + TokenStatSpool.withStatsDatabaseAccess { + val injected = daoProvider + val dao: TokenStatsDao + if (injected != null) { + dao = injected(appContext) + block(dao) + } else { + val database = AppDatabase.getDatabase(appContext) + dao = database.tokenStatsDao() + database.withTransaction { block(dao) } + } + if (drainCleanupAfter) { + drainPendingCleanupWith(appContext, dao) + } } - val database = AppDatabase.getDatabase(context.applicationContext) - database.withTransaction { block(database.tokenStatsDao()) } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt index d2bb1e63b..6109bbf54 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt @@ -78,8 +78,9 @@ import org.json.JSONObject * commit; every old request is then rejected before stores close or directories are replaced. * - Once a restore replacement actually starts (right before [block] runs), the process stops * accepting ALL statistics events ([acceptingEventsThisProcess] = false) until it restarts: - * the UI allows restarting later, so same-process new requests must be explicitly rejected - * ([isAcceptingEvents], used by the tracking boundary) and can never pollute the new DB. + * the UI allows restarting later, so same-process new requests fail open — the tracking + * boundary ([newRequest]) skips statistics and calls the delegate directly ([isAcceptingEvents] + * drives that decision) — and can never pollute the new DB. * A restore failure BEFORE replacement leaves accepting enabled (new requests continue); * a failure after replacement has started keeps it disabled and requires a restart. */ @@ -263,6 +264,18 @@ internal object TokenStatSpool { /** 已通过 fence 且正在 Room 内写入的 insert(eventId -> 提交时 generation)。 */ internal val activeInserts = HashMap() + /** + * 在册的统计数据库访问者 token 集合(reviewer P1-1 修复):请求边界的身份事务与收尾的 + * 价格解析等直接 Room 访问经 [com.ai.assistance.operit.data.stats.withStatsDatabaseAccess] + * 注册唯一 token([statsDbAccessSeq] 递增);快照/恢复屏障进入排他状态后有界等待其 + * 清空(绝不与文件打包/替换竞争)。token 删除幂等:reset 清空集合后,旧访问者的 + * finally 只移除自己的 token,不影响新注册(无负数/假零污染)。只由 stateLock 保护。 + */ + internal val statsDbAccessTokens = HashSet() + + /** [statsDbAccessTokens] 的递增 token 序号(stateLock 保护)。 */ + internal var statsDbAccessSeq = 0L + /** * Request/session fencing epoch(P1 终审):通用恢复屏障开始时递增;Raw restore 则在 * 外部 REPLACING 状态成功持久化后、关闭 stores 前原子递增, @@ -275,8 +288,9 @@ internal object TokenStatSpool { /** * 本进程是否仍接受统计事件(P1 终审):恢复屏障的替换开始([block] 即将执行)时置 false, - * 直到进程重启(UI 允许稍后重启——此后所有 [append] 与新的跟踪请求被明确拒绝,绝不污染 - * 恢复后的新 DB)。替换前失败(drain/quiesce 阶段抛错)保持 true,新请求可继续。 + * 直到进程重启(UI 允许稍后重启)。此后 [append] 拒绝一切事件;新的跟踪请求 fail-open + * ([isAcceptingEvents] 供 [newRequest] 判定——跳过统计直调 delegate),模型功能不受影响, + * 重启后统计恢复。替换前失败(drain/quiesce 阶段抛错)保持 true,新请求可继续。 * 进程重启(含测试模拟)经 [resetExecutorsForTest]/[clearPendingStateForTest] 复位。 */ @Volatile @@ -1362,6 +1376,7 @@ internal object TokenStatSpool { insertExecutor = newInsertExecutor() databaseExecutor = newDatabaseExecutor() activeInserts.clear() + statsDbAccessTokens.clear() insertionWaiters.values.forEach { it.cancel() } insertionWaiters.clear() exclusiveBarrierActive = false diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt index effcb5847..d2445d22d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt @@ -51,11 +51,12 @@ object TokenStatsLedger { internal var legacyPriceProvider: (suspend (Context, String) -> LegacyPriceSettings?)? = null /** Linearization token captured before a model invocation starts. Failure aborts that call. */ - suspend fun currentResetGeneration(context: Context): Long { - val appContext = context.applicationContext - val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - return database.tokenStatsDao().currentResetGeneration() - } + suspend fun currentResetGeneration(context: Context): Long = + TokenStatSpool.withStatsDatabaseAccess { + val appContext = context.applicationContext + val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + database.tokenStatsDao().currentResetGeneration() + } /** * 请求接受边界(P1-1 修复):在**同一 Room 事务**内确保身份存在(INSERT IGNORE + @@ -85,11 +86,13 @@ object TokenStatsLedger { */ suspend fun record(context: Context, request: TokenStatRequestContext) { try { - val appContext = context.applicationContext - val injected = databaseProvider - val database = - injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - recordWith(appContext, database.tokenStatsDao(), request) + TokenStatSpool.withStatsDatabaseAccess { + val appContext = context.applicationContext + val injected = databaseProvider + val database = + injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + recordWith(appContext, database.tokenStatsDao(), request) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -156,7 +159,7 @@ object TokenStatsLedger { appContext: Context, request: TokenStatRequestContext, ): FrozenEventPricing { - val pricing = resolvePricing(appContext, request) + val pricing = resolvePricingForRequest(appContext, request) val usage = request.aggregatedUsage() val cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } return FrozenEventPricing(pricing, cost) @@ -187,7 +190,26 @@ object TokenStatsLedger { .toString() } - private suspend fun resolvePricing( + /** + * 请求收尾价格解析(reviewer P1-1 修复):与快照/恢复屏障注册表互斥的 Room 读取—— + * 屏障排他期间(打包/替换中)被**立即拒绝**([TokenStatsBarrierActiveException]), + * 由收尾边界转为 UNKNOWN 价格事件,绝不与数据库文件复制竞争,也不无限等待; + * 恢复替换完成(accepting=false)后的收尾(旧 epoch 请求)仍可读取重建后的新库, + * 但 append 由请求 fence 明确拒绝,绝不写入。 + */ + internal suspend fun resolvePricingForRequest( + appContext: Context, + request: TokenStatRequestContext, + ): ResolvedPricing = TokenStatSpool.withStatsDatabaseAccess { + resolvePricingLocked(appContext, request) + } + + /** + * 无锁版本(drain/直接路径使用):调用方已受 generation + activeInserts 注册 + * ([TokenStatSpool.insertSafely])或屏障 lifecycleMutex(drainBefore)保护, + * 与替换窗口的互斥由既有机制保证,不再重复取锁(避免 drainCore 持锁时死锁)。 + */ + internal suspend fun resolvePricingLocked( appContext: Context, request: TokenStatRequestContext, ): ResolvedPricing { @@ -238,7 +260,9 @@ object TokenStatsLedger { pricing = frozen.pricing cost = frozen.cost } else { - pricing = resolvePricing(appContext, request) + // 直接路径(不经 spool):调用方已受 generation/activeInserts 保护, + // 用无锁版本避免与屏障 drain 阶段的锁重入(reviewer P1-1)。 + pricing = resolvePricingLocked(appContext, request) val usage = request.aggregatedUsage() cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 6fadb95ee..9c21b635f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -113,7 +113,9 @@ object TokenStatsQueryService { suspend fun rangeHasEvents(context: Context, range: TokenStatsTimeRange): Boolean = withContext(queryDispatcher) { - rangeHasEvents(daoOf(context), range) + TokenStatSpool.withStatsDatabaseAccess { + rangeHasEvents(daoOf(context), range) + } } /** @@ -128,7 +130,9 @@ object TokenStatsQueryService { internal suspend fun activitySnapshot(context: Context, zone: ZoneId): TokenActivitySnapshot = withContext(queryDispatcher) { - activitySnapshot(daoOf(context), zone) + TokenStatSpool.withStatsDatabaseAccess { + activitySnapshot(daoOf(context), zone) + } } /** @@ -189,7 +193,9 @@ object TokenStatsQueryService { nowMs: Long = System.currentTimeMillis(), ): TokenStatsPreset = withContext(queryDispatcher) { - initialPresetWithData(daoOf(context), zone, nowMs) + TokenStatSpool.withStatsDatabaseAccess { + initialPresetWithData(daoOf(context), zone, nowMs) + } } /** @@ -207,9 +213,11 @@ object TokenStatsQueryService { val appContext = context.applicationContext return withContext(queryDispatcher) { val legacyPrices = readLegacyPrices(appContext, params) - val database = - databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - block(database.tokenStatsDao(), legacyPrices) + TokenStatSpool.withStatsDatabaseAccess { + val database = + databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) + block(database.tokenStatsDao(), legacyPrices) + } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt index 7f0726a1f..a73721f79 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -1,7 +1,9 @@ package com.ai.assistance.operit.data.stats +import android.content.Context import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.BillingMode import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity @@ -32,13 +34,30 @@ data class TokenStatsPriceOverrideDraft( * (REPLACE 会经外键级联删除事件);展示模型行用 INSERT IGNORE / UPDATE。 * - 本类只做校验与编排,事务边界在 DAO(@Transaction)保证。 */ -class TokenStatsSettingsManager(private val dao: TokenStatsDao) { +class TokenStatsSettingsManager private constructor( + private val injectedDao: TokenStatsDao?, + private val appContext: Context?, +) { + + /** DAO 注入仅用于测试;生产入口必须在访问 token 登记后再解析 Room。 */ + constructor(dao: TokenStatsDao) : this(dao, null) + + constructor(context: Context) : this(null, context.applicationContext) companion object { /** 自定义分组 displayModelId 前缀(与任何规范化模型名天然不冲突)。 */ const val CUSTOM_GROUP_ID_PREFIX = "custom-group-" } + private suspend fun withDao(block: suspend (TokenStatsDao) -> T): T { + val dao = injectedDao + if (dao != null) return block(dao) + val context = checkNotNull(appContext) + return TokenStatSpool.withStatsDatabaseAccess { + block(AppDatabase.getDatabase(context).tokenStatsDao()) + } + } + // ==== 价格覆盖 ==== /** 校验单个价格输入:null 允许(该计费方式不使用),非空必须非负有限。 */ @@ -67,34 +86,36 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { outputPricePerMillion: Double?, pricePerRequest: Double?, ) { - dao.upsertPriceOverride( - scope = scope.name, - provider = provider, - model = model, - configId = configId, - billingMode = billingMode.name, - pricingCurrency = pricingCurrency.name, - inputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("inputPrice", inputPricePerMillion) - } else null, - cachedInputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("cachedInputPrice", cachedInputPricePerMillion) - } else null, - cacheWritePricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("cacheWritePrice", cacheWritePricePerMillion) - } else null, - outputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("outputPrice", outputPricePerMillion) - } else null, - pricePerRequest = - if (billingMode == BillingMode.COUNT) { - validatePriceValue("pricePerRequest", pricePerRequest) - } else null, - ) + withDao { dao -> + dao.upsertPriceOverride( + scope = scope.name, + provider = provider, + model = model, + configId = configId, + billingMode = billingMode.name, + pricingCurrency = pricingCurrency.name, + inputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("inputPrice", inputPricePerMillion) + } else null, + cachedInputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("cachedInputPrice", cachedInputPricePerMillion) + } else null, + cacheWritePricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("cacheWritePrice", cacheWritePricePerMillion) + } else null, + outputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + validatePriceValue("outputPrice", outputPricePerMillion) + } else null, + pricePerRequest = + if (billingMode == BillingMode.COUNT) { + validatePriceValue("pricePerRequest", pricePerRequest) + } else null, + ) + } } /** 草稿形态的统一入口(阶段 4 UI 使用)。 */ @@ -142,24 +163,26 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { /** 全部价格覆盖(管理区展示用;小表,一次读取)。 */ suspend fun allPriceOverrides(): List = - dao.getAllPriceOverrides().map { row -> - val normalized = row.activeBillingFieldsOnly() - if (normalized != row) { - dao.upsertPriceOverride( - scope = normalized.scope, - provider = normalized.provider, - model = normalized.model, - configId = normalized.configId, - billingMode = normalized.billingMode, - pricingCurrency = normalized.pricingCurrency, - inputPricePerMillion = normalized.inputPricePerMillion, - cachedInputPricePerMillion = normalized.cachedInputPricePerMillion, - cacheWritePricePerMillion = normalized.cacheWritePricePerMillion, - outputPricePerMillion = normalized.outputPricePerMillion, - pricePerRequest = normalized.pricePerRequest, - ) + withDao { dao -> + dao.getAllPriceOverrides().map { row -> + val normalized = row.activeBillingFieldsOnly() + if (normalized != row) { + dao.upsertPriceOverride( + scope = normalized.scope, + provider = normalized.provider, + model = normalized.model, + configId = normalized.configId, + billingMode = normalized.billingMode, + pricingCurrency = normalized.pricingCurrency, + inputPricePerMillion = normalized.inputPricePerMillion, + cachedInputPricePerMillion = normalized.cachedInputPricePerMillion, + cacheWritePricePerMillion = normalized.cacheWritePricePerMillion, + outputPricePerMillion = normalized.outputPricePerMillion, + pricePerRequest = normalized.pricePerRequest, + ) + } + normalized } - normalized } private fun TokenStatPriceOverrideEntity.activeBillingFieldsOnly(): TokenStatPriceOverrideEntity = @@ -181,18 +204,20 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { model: String, configId: String?, ) { - dao.deletePriceOverride( - scope = scope.name, - // 与写入同一规范化:provider trim+小写、model trim+小写+压缩空白 - provider = provider.trim().lowercase(), - model = model.trim().lowercase().replace(Regex("\\s+"), " "), - configId = - if (scope == PriceOverrideScope.PROVIDER_MODEL) { - "" - } else { - configId?.trim().orEmpty() - }, - ) + withDao { dao -> + dao.deletePriceOverride( + scope = scope.name, + // 与写入同一规范化:provider trim+小写、model trim+小写+压缩空白 + provider = provider.trim().lowercase(), + model = model.trim().lowercase().replace(Regex("\\s+"), " "), + configId = + if (scope == PriceOverrideScope.PROVIDER_MODEL) { + "" + } else { + configId?.trim().orEmpty() + }, + ) + } } // ==== 展示分组 / 别名 ==== @@ -205,46 +230,48 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { * display_models 行,缺失时回退 displayModelId;输出顺序确定。 */ suspend fun groupModels(): List { - val snapshot = dao.loadGroupMetadataSnapshot() - val displayNames = snapshot.displayModels.associateBy { it.displayModelId } - val membersByGroup = LinkedHashMap>() - for (identity in snapshot.identities) { - membersByGroup.getOrPut(identity.displayModelId) { mutableListOf() } - .add( - TokenStatsGroupMemberInfo( - identityId = identity.identityId, - configId = identity.configId, - provider = identity.provider, - model = identity.model, + return withDao { dao -> + val snapshot = dao.loadGroupMetadataSnapshot() + val displayNames = snapshot.displayModels.associateBy { it.displayModelId } + val membersByGroup = LinkedHashMap>() + for (identity in snapshot.identities) { + membersByGroup.getOrPut(identity.displayModelId) { mutableListOf() } + .add( + TokenStatsGroupMemberInfo( + identityId = identity.identityId, + configId = identity.configId, + provider = identity.provider, + model = identity.model, + ) ) + } + // 有展示行但无身份的空分组:仍可作为合并目标,必须保留 + for (display in snapshot.displayModels) { + membersByGroup.putIfAbsent(display.displayModelId, mutableListOf()) + } + membersByGroup.map { (displayModelId, members) -> + val sortedMembers = members.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() }, { it.configId })) + TokenStatsGroupModelInfo( + displayModelId = displayModelId, + displayName = displayNames[displayModelId]?.displayName ?: displayModelId, + memberIdentityIds = sortedMembers.map { it.identityId }, + members = sortedMembers, ) + }.sortedWith(compareBy({ it.displayName.lowercase() }, { it.displayModelId })) } - // 有展示行但无身份的空分组:仍可作为合并目标,必须保留 - for (display in snapshot.displayModels) { - membersByGroup.putIfAbsent(display.displayModelId, mutableListOf()) - } - return membersByGroup.map { (displayModelId, members) -> - val sortedMembers = members.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() }, { it.configId })) - TokenStatsGroupModelInfo( - displayModelId = displayModelId, - displayName = displayNames[displayModelId]?.displayName ?: displayModelId, - memberIdentityIds = sortedMembers.map { it.identityId }, - members = sortedMembers, - ) - }.sortedWith(compareBy({ it.displayName.lowercase() }, { it.displayModelId })) } /** 重命名展示分组(只改 displayName,不动身份归属)。 */ suspend fun renameDisplayGroup(displayModelId: String, displayName: String) { val trimmed = displayName.trim() require(trimmed.isNotBlank()) { "display name must not be blank" } - dao.updateDisplayModelName(displayModelId, trimmed) + withDao { dao -> dao.updateDisplayModelName(displayModelId, trimmed) } } /** 把指定身份移动到已有展示分组(分组行不存在时自动创建)。 */ suspend fun moveIdentitiesToGroup(identityIds: List, displayModelId: String) { require(identityIds.isNotEmpty()) { "identityIds must not be empty" } - dao.moveIdentitiesToDisplayModelTx(identityIds, displayModelId) + withDao { dao -> dao.moveIdentitiesToDisplayModelTx(identityIds, displayModelId) } } /** @@ -256,12 +283,12 @@ class TokenStatsSettingsManager(private val dao: TokenStatsDao) { require(trimmed.isNotBlank()) { "group name must not be blank" } require(identityIds.isNotEmpty()) { "identityIds must not be empty" } val groupId = "$CUSTOM_GROUP_ID_PREFIX${UUID.randomUUID()}" - dao.createDisplayGroupTx(groupId, trimmed, identityIds) + withDao { dao -> dao.createDisplayGroupTx(groupId, trimmed, identityIds) } return groupId } /** 恢复默认规范分组:组内每个身份按其自身模型名归回默认组。 */ suspend fun restoreDefaultGroups(displayModelId: String) { - dao.restoreDefaultGroupsTx(displayModelId) + withDao { dao -> dao.restoreDefaultGroupsTx(displayModelId) } } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt index 3c12a6ba6..f07b474a6 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.ai.assistance.operit.R import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.ModelConfigSummary import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity @@ -56,9 +55,8 @@ class TokenStatsManagementViewModel( dao: TokenStatsDao? = null, ) : ViewModel() { private val appContext = context.applicationContext - private val manager = TokenStatsSettingsManager( - dao ?: AppDatabase.getDatabase(appContext).tokenStatsDao() - ) + private val manager = + dao?.let(::TokenStatsSettingsManager) ?: TokenStatsSettingsManager(appContext) private val configManager = ModelConfigManager(appContext) private val apiPreferences = ApiPreferences.getInstance(appContext) private val _state = MutableStateFlow(TokenStatsManagementState()) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index ae6fd36f1..6b78fd3e4 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope import com.ai.assistance.operit.R import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.PriceOverrideScope import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.preferences.ApiPreferences @@ -167,10 +166,8 @@ class TokenUsageStatisticsViewModel( private val appContext: Context = context.applicationContext private val tag = "TokenUsageStatisticsViewModel" - private val statsDao: TokenStatsDao = - dao ?: AppDatabase.getDatabase(appContext).tokenStatsDao() - - private val manager = TokenStatsSettingsManager(statsDao) + private val manager = + dao?.let(::TokenStatsSettingsManager) ?: TokenStatsSettingsManager(appContext) private val _state = MutableStateFlow(TokenStatsUiState()) val state: StateFlow = _state.asStateFlow() diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt index 783b35609..ed473bdf8 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt @@ -2158,8 +2158,9 @@ class TokenTrackingAIServiceTest { // 真实 TokenTracking + fake provider:请求停在 provider 流阶段(未收尾)时 // 执行完整 restore(block 模拟恢复替换数据库 + clearAfter 删除旧 spool)→ // 旧请求释放后收尾 append 被请求 fence 明确拒绝——P1-2:统计收尾失败不得 - // 改写成功的模型结果(事件丢弃并记日志,绝不写新 DB);同进程新请求仍被 - // 明确拒绝开始;模拟进程重启(reset 状态)后新请求可正常写入。 + // 改写成功的模型结果(事件丢弃并记日志,绝不写新 DB);同进程新请求 fail-open + // (统计跳过跟踪、直调 delegate,模型功能不受影响);模拟进程重启(reset + // 状态)后新请求可正常写入。 val entered = CompletableDeferred() val release = CompletableDeferred() val fake = @@ -2213,15 +2214,22 @@ class TokenTrackingAIServiceTest { spoolDir.exists() && spoolDir.listFiles().orEmpty().any { it.isFile && it.length() > 0L }, ) - // 同进程新请求:newRequest 明确拒绝开始(不污染新 DB) - try { - tracked(FakeAiService()) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - fail("new tracking requests must be rejected until process restart") - } catch (e: TokenStatsPersistenceException) { - // expected - } + // 同进程新请求(UI"稍后重启"窗口):P1-2 修复后 fail-open——统计跳过跟踪, + // 直调 delegate,模型功能完全不受影响,且不向新 DB 写入任何事件 + val delegated = AtomicInteger(0) + tracked( + FakeAiService { _ -> + stream { + delegated.incrementAndGet() + emit("fail-open answer") + } + }, + ).sendMessage(context = context, statsCategory = TokenStatCategory.CHAT).collect { } + assertEquals( + "restored-process request must reach the delegate", + 1, + delegated.get(), + ) assertEquals(0, database.tokenStatsDao().countEvents()) // 模拟进程重启:reset 状态后新请求可写 TokenStatSpool.clearPendingStateForTest() @@ -2329,4 +2337,170 @@ class TokenTrackingAIServiceTest { assertTrue(ids.contains("evt-pre-restore-old")) } } + + // ==== reviewer P1-1/P1-2 修复:统计数据库访问门控(立即拒绝)+ 恢复后 fail-open ==== + + @Test + fun `restore barrier rejects concurrent new request immediately without touching Room and fail-opens to the delegate`() = + runBlocking { + // 审查复现时序:恢复在 prepareBeforeCommit(closeDatabase 后、持久化标记前) + // 暂停;并发新请求被门控**立即拒绝**——不重建 Room、不等待屏障,直调 + // delegate(模型功能不受影响);恢复完成后(accepting=false)继续 fail-open。 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val restoreJob = + launch { + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = { + entered.complete(Unit) + release.await() + }, + commitReplacement = {}, + block = {}, + ) + } + assertTrue( + "restore must be paused inside prepareBeforeCommit", + withTimeoutOrNull(10.seconds) { entered.await() } != null, + ) + val delegated = AtomicInteger(0) + val requestJob = + launch { + tracked( + FakeAiService { _ -> + stream { + delegated.incrementAndGet() + emit("answer") + } + }, + ).sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + } + // 立即拒绝:不等屏障退出,provider 立刻被调用,Room 身份事务绝不执行 + withTimeoutOrNull(10.seconds) { requestJob.join() } + ?: fail("fail-open request must not wait for the barrier to exit") + assertEquals("fail-open request must reach the delegate", 1, delegated.get()) + assertEquals(0, database.tokenStatsDao().countEvents()) + release.complete(Unit) + restoreJob.join() + // 恢复替换完成(accepting=false)后新请求依然 fail-open:直调 delegate 正常返回 + val conn = tracked(FakeAiService(testConnectionResult = Result.success("pong"))) + .testConnection(context) + assertTrue("testConnection must succeed after restore", conn.isSuccess) + assertEquals("pong", conn.getOrNull()) + assertEquals(0, database.tokenStatsDao().countEvents()) + TokenStatSpool.clearPendingStateForTest() + } + + @Test + fun `snapshot barrier rejects identity writes immediately so zip packaging sees a frozen database`() = + runBlocking { + // 审查复现时序:备份在 checkpoint 之后(block 内,ZIP 打包前)暂停;并发请求 + // 的身份事务被门控**立即拒绝**(不进 provider 前不开 Room)——打包期间绝不产生 + // 新 WAL 或身份写入;快照不设 accepting=false:屏障退出后新请求正常落账 + // (事件恰 1 条)。 + // drainBefore=true 的待处理判定 fail-closed:spool 目录需存在(空目录)。 + File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } + val entered = CompletableDeferred() + val release = CompletableDeferred() + val snapshotJob = + launch { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { + entered.complete(Unit) + release.await() + } + } + assertTrue( + "snapshot must be paused inside its exclusive block", + withTimeoutOrNull(10.seconds) { entered.await() } != null, + ) + val delegated = AtomicInteger(0) + val requestJob = + launch { + tracked( + FakeAiService { _ -> + stream { + delegated.incrementAndGet() + emit("answer") + } + }, + ) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + } + // 立即拒绝:打包期间请求直调 delegate(不等待、不写身份) + withTimeoutOrNull(10.seconds) { requestJob.join() } + ?: fail("fail-open request must not wait for the snapshot to exit") + assertEquals(1, delegated.get()) + assertEquals(0, database.tokenStatsDao().countEvents()) + release.complete(Unit) + snapshotJob.join() + // 快照后进程仍接受事件:新请求正常落账 + tracked(FakeAiService { _ -> stream { emit("answer") } }) + .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) + .collect { } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { + delay(50) + } + assertEquals(1, database.tokenStatsDao().countEvents()) + } + + @Test + fun `restore barrier rejects request-finalization pricing reads immediately`() = + runBlocking { + // 审查复现时序:请求收尾的价格解析(直接 Room 读取)同样走门控—— + // 屏障排他期间被**立即拒绝**(TokenStatsBarrierActiveException),绝不与 + // 数据库文件替换竞争;屏障退出后正常完成。 + val entered = CompletableDeferred() + val release = CompletableDeferred() + val restoreJob = + launch { + TokenStatSpool.withExclusiveRestoreAccess( + context = context, + prepareBeforeCommit = { + entered.complete(Unit) + release.await() + }, + commitReplacement = {}, + block = {}, + ) + } + assertTrue( + "restore must be paused inside prepareBeforeCommit", + withTimeoutOrNull(10.seconds) { entered.await() } != null, + ) + val ctx = + TokenStatRequestContext( + eventId = "evt-pricing-gate", + category = TokenStatCategory.CHAT, + configId = "cfg-1", + provider = "DEEPSEEK", + model = "deepseek-chat", + startedAtMs = System.currentTimeMillis(), + ) + var barrierRejected = false + val pricingJob = + launch { + try { + TokenStatsLedger.prepareEventLineDetached(context, ctx) + } catch (e: com.ai.assistance.operit.data.stats.TokenStatsBarrierActiveException) { + barrierRejected = true + } + } + withTimeoutOrNull(10.seconds) { pricingJob.join() } + ?: fail("pricing read must fail immediately while the barrier is active") + assertTrue("pricing read must be rejected while the barrier is active", barrierRejected) + release.complete(Unit) + restoreJob.join() + // 屏障退出后价格解析正常完成 + var pricingResolved = false + launch { + TokenStatsLedger.prepareEventLineDetached(context, ctx) + pricingResolved = true + }.join() + assertTrue("pricing read must complete after the barrier exits", pricingResolved) + TokenStatSpool.clearPendingStateForTest() + } } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt index 6623aec5b..1f9709504 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt @@ -567,14 +567,16 @@ internal class RestoreReliabilityTest : TokenStatReliabilityTestBase() { var calls = 0 try { // 阶段 0:gate=true——经快照 barrier 完成 bootstrap 两次确认(filesDir + - // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态) + // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态)。 + // 行构造在屏障外:屏障排他期间门控立即拒绝统计数据库访问(自死锁防护)。 + val lineA = line(request("gate-restore-a")) TokenStatSpool.dirSyncForTest = { calls += 1 if (calls <= 2) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED } TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - File(spool, "active.jsonl").writeText(line(request("gate-restore-a")) + "\n") + File(spool, "active.jsonl").writeText(lineA + "\n") } assertEquals(2, calls) // 阶段 1:restore 清理删除 spool 目录,删除后 filesDir 目录项 sync(第 3 次) diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt index a820fa00b..278fe9c29 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt @@ -12,6 +12,7 @@ import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.util.AppLogger import java.io.File +import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -704,6 +705,44 @@ class TokenBaselineImportRunnerTest { } } + @Test + fun `import and reset are rejected before resolving Room during snapshot`() = runBlocking { + TokenStatSpool.clearPendingStateForTest() + val phase = kotlin.io.path.createTempDirectory("barrier-entry-test").toFile() + val ctx = mockContext(phase) + val importProviderCalls = AtomicInteger(0) + val resetProviderCalls = AtomicInteger(0) + TokenBaselineImportRunner.databaseProvider = { + importProviderCalls.incrementAndGet() + error("import must not resolve Room while the barrier is active") + } + TokenStatsResetCoordinator.daoProvider = { + resetProviderCalls.incrementAndGet() + error("reset must not resolve Room while the barrier is active") + } + var importRejected = false + var resetRejected = false + + TokenStatSpool.withExclusiveSnapshotAccess(ctx, drainBefore = false) { + try { + TokenBaselineImportRunner.runImport(ctx, forceReplace = false) + } catch (_: TokenStatsBarrierActiveException) { + importRejected = true + } + try { + TokenStatsResetCoordinator.deleteEventsInRange(ctx, 0L, 1L) + } catch (_: TokenStatsBarrierActiveException) { + resetRejected = true + } + } + + assertTrue("baseline import must be rejected during snapshot", importRejected) + assertTrue("reset must be rejected during snapshot", resetRejected) + assertEquals(0, importProviderCalls.get()) + assertEquals(0, resetProviderCalls.get()) + TokenStatSpool.clearPendingStateForTest() + } + // ==== P1 闭环:legacy cleanup outbox 导入 fence ==== private val providerA = "DEEPSEEK:deepseek-chat" private val identityIdA = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt index 017cddf0c..753e57bf2 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt @@ -13,11 +13,16 @@ import com.ai.assistance.operit.data.model.TokenStatIdentityEntity import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import java.io.File import java.time.ZoneId +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -58,6 +63,7 @@ class TokenStatsQueryServiceRoomTest { @Before fun setUp() { + TokenStatSpool.clearPendingStateForTest() tempDir = kotlin.io.path.createTempDirectory("query-service-test").toFile() context = mockContext(tempDir) recordingDriver = RecordingSQLiteDriver() @@ -76,6 +82,7 @@ class TokenStatsQueryServiceRoomTest { TokenStatsQueryService.queryDispatcher = Dispatchers.IO TokenStatsQueryService.lifetimeEventPageSize = 1_000 TokenStatsQueryService.activityEventPageSize = 1_000 + TokenStatSpool.clearPendingStateForTest() database.close() } @@ -893,4 +900,59 @@ class TokenStatsQueryServiceRoomTest { val preset = TokenStatsQueryService.initialPresetWithData(context, shanghai, nowMs) assertEquals(TokenStatsPreset.LAST_5H, preset) } + + @Test + fun `snapshot waits for an active production query before entering its block`() = runBlocking { + val providerEntered = CountDownLatch(1) + val providerRelease = CountDownLatch(1) + val barrierEntered = CountDownLatch(1) + TokenStatsQueryService.databaseProvider = { + providerEntered.countDown() + check(providerRelease.await(10, TimeUnit.SECONDS)) + database + } + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + + val queryJob = launch(Dispatchers.IO) { + assertFalse(TokenStatsQueryService.rangeHasEvents(context, range)) + } + assertTrue("query must register before resolving Room", providerEntered.await(10, TimeUnit.SECONDS)) + // UNDISPATCHED 保证 launch 返回前已执行到 accessor 等待的首次挂起点, + // 排除 IO 调度延迟导致“200ms 未进入 block”的假通过。 + val barrierJob = launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { + barrierEntered.countDown() + } + } + assertFalse( + "snapshot block must wait for the registered query", + barrierEntered.await(200, TimeUnit.MILLISECONDS), + ) + providerRelease.countDown() + queryJob.join() + assertTrue("snapshot must enter after the query exits", barrierEntered.await(10, TimeUnit.SECONDS)) + barrierJob.join() + } + + @Test + fun `production query is rejected before Room resolution while snapshot block is active`() = runBlocking { + val providerCalls = AtomicInteger(0) + TokenStatsQueryService.databaseProvider = { + providerCalls.incrementAndGet() + database + } + val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) + var rejected = false + + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { + try { + TokenStatsQueryService.rangeHasEvents(context, range) + } catch (_: TokenStatsBarrierActiveException) { + rejected = true + } + } + + assertTrue("query must be rejected during the exclusive block", rejected) + assertEquals("rejected query must not resolve or reopen Room", 0, providerCalls.get()) + } } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt index 0ee23b6da..e463e75ca 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt @@ -32,14 +32,16 @@ import org.mockito.kotlin.whenever class TokenStatsSettingsManagerTest { private lateinit var tempDir: File + private lateinit var context: Context private lateinit var database: AppDatabase private lateinit var dao: TokenStatsDao private lateinit var manager: TokenStatsSettingsManager @Before fun setUp() { + TokenStatSpool.clearPendingStateForTest() tempDir = kotlin.io.path.createTempDirectory("token-settings-test").toFile() - val context = mockContext(tempDir) + context = mockContext(tempDir) database = Room.databaseBuilder(context, AppDatabase::class.java, "app_database") .setDriver(JdbcSQLiteDriver()) @@ -52,6 +54,7 @@ class TokenStatsSettingsManagerTest { @After fun tearDown() { + TokenStatSpool.clearPendingStateForTest() database.close() } @@ -555,4 +558,21 @@ class TokenStatsSettingsManagerTest { assertEquals("orphan-group", orphan.displayName) assertEquals(listOf("id-3"), orphan.memberIdentityIds) } + + @Test + fun `production settings access is rejected before resolving Room during snapshot`() = + runBlocking { + val productionManager = TokenStatsSettingsManager(context) + var rejected = false + + TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { + try { + productionManager.groupModels() + } catch (_: TokenStatsBarrierActiveException) { + rejected = true + } + } + + assertTrue("settings access must not use a cached DAO during snapshot", rejected) + } } From 6bcd232e7c7b6455a2bd975e32a406f92c38ca84 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:53:08 +0800 Subject: [PATCH 24/28] fix(providers): preserve usage callback cancellation --- .../api/chat/llmprovider/GeminiProvider.kt | 13 ++ .../api/chat/llmprovider/OpenAIProvider.kt | 4 + .../ProviderUsageCancellationTest.kt | 163 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index d2e462520..88ea79813 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -36,6 +36,7 @@ import java.net.SocketTimeoutException import java.net.URL import java.net.UnknownHostException import java.util.UUID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -1465,6 +1466,8 @@ class GeminiProvider( // 只发送新增的内容 streamCollector.emit(content) } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1544,6 +1547,8 @@ class GeminiProvider( isCollectingJson = false completeJsonBuilder.clear() } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1603,6 +1608,8 @@ class GeminiProvider( } } } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1622,6 +1629,8 @@ class GeminiProvider( logDebug("未检测到内容,发送空格") streamCollector.emit(" ") } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logError("处理响应时发生异常: ${e.message}", e) throw e @@ -1672,6 +1681,8 @@ class GeminiProvider( streamCollector.emit("") isInThinkingMode = false } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logError("处理非流式响应时发生异常: ${e.message}", e) throw e @@ -1956,6 +1967,8 @@ class GeminiProvider( } return finalContent + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 7bd41a5a5..3bc65cdcb 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -2465,6 +2465,8 @@ open class OpenAIProvider( } } processResponseChunk(jsonResponse, state, emitter, onTokensUpdated, onUsageReported, attemptNumber) + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -2727,6 +2729,8 @@ open class OpenAIProvider( applyUsageToCounters(jsonResponse.optJSONObject("usage"), onTokensUpdated, onUsageReported, attemptNumber) AppLogger.d("AIService", "[req=$requestTraceId] 【发送消息】非流式响应处理完成") + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt new file mode 100644 index 000000000..b93d7af6e --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt @@ -0,0 +1,163 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.core.chat.hooks.PromptTurnKind +import com.ai.assistance.operit.data.model.ApiProviderType +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertSame +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ProviderUsageCancellationTest { + + @Test + fun `OpenAI streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = openAiProvider(OPENAI_STREAM_RESPONSE, "text/event-stream"), + stream = true, + ) + } + + @Test + fun `OpenAI non streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = openAiProvider(OPENAI_RESPONSE, "application/json"), + stream = false, + ) + } + + @Test + fun `Gemini streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = geminiProvider(GEMINI_STREAM_RESPONSE, "text/event-stream"), + stream = true, + ) + } + + @Test + fun `Gemini non streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = geminiProvider(GEMINI_RESPONSE, "application/json"), + stream = false, + ) + } + + private fun assertUsageCancellationPropagates(provider: AIService, stream: Boolean) { + val expected = CancellationException("usage observer cancelled") + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.getString(any())).thenReturn("status") + + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val response = + provider.sendMessage( + context = context, + chatHistory = listOf(PromptTurn(PromptTurnKind.USER, "Hi")), + modelParameters = emptyList(), + enableThinking = false, + stream = stream, + availableTools = null, + preserveThinkInHistory = false, + onTokensUpdated = { _, _, _ -> }, + onUsageReported = { _, _ -> throw expected }, + onNonFatalError = {}, + enableRetry = false, + statsCategory = null, + ) + try { + response.collect { } + fail("usage callback cancellation must propagate") + } catch (actual: CancellationException) { + assertSame(expected, actual) + } + } + } + } + + private fun openAiProvider(body: String, contentType: String): OpenAIProvider = + OpenAIProvider( + apiEndpoint = "https://example.test/v1/chat/completions", + apiKeyProvider = SingleApiKeyProvider("test-key"), + modelName = "gpt-test", + client = respondingClient(body, contentType), + providerType = ApiProviderType.OPENAI, + ) + + private fun geminiProvider(body: String, contentType: String): GeminiProvider = + GeminiProvider( + apiEndpoint = "https://example.test", + apiKeyProvider = SingleApiKeyProvider("test-key"), + modelName = "gemini-test", + client = respondingClient(body, contentType), + ) + + private fun respondingClient(body: String, contentType: String): OkHttpClient { + val mediaType = contentType.toMediaType() + return OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .header("Content-Type", contentType) + .body(body.toResponseBody(mediaType)) + .build() + }, + ) + .build() + } + + companion object { + private val OPENAI_RESPONSE = + """ + { + "choices": [{"message": {"content": "answer"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5} + } + """.trimIndent() + + private val OPENAI_STREAM_RESPONSE = + """ + data: {"choices":[{"delta":{"content":"answer"},"finish_reason":null}]} + + data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}} + + data: [DONE] + + """.trimIndent() + + private val GEMINI_RESPONSE = + """ + { + "usageMetadata": { + "promptTokenCount": 3, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 2 + }, + "candidates": [{ + "finishReason": "STOP", + "content": {"parts": [{"text": "answer"}]} + }] + } + """.trimIndent() + + private val GEMINI_STREAM_RESPONSE = + "data: ${GEMINI_RESPONSE.replace("\n", "")}" + "\n\n" + "data: [DONE]\n\n" + } +} From acdcdcfc9d3f848b1429497b0a9896ea44d681d2 Mon Sep 17 00:00:00 2001 From: AAswordman <1002153674@qq.com> Date: Tue, 11 Aug 2026 20:13:46 +0800 Subject: [PATCH 25/28] fix(backup): preserve database on restore replacement failure --- .../data/backup/RoomDatabaseRestoreManager.kt | 59 ++++++++++++------- .../data/backup/BackupRestoreBarrierTest.kt | 39 ++++++++++++ .../1_merge_baseline_and_reproduction.md | 18 ++++++ .../2_restore_integrity.md | 21 +++++++ .../3_verification.md | 11 ++++ .../token_stats_922_review_20260811/index.md | 38 ++++++++++++ 6 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md create mode 100644 docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md create mode 100644 docs/TODO/token_stats_922_review_20260811/3_verification.md create mode 100644 docs/TODO/token_stats_922_review_20260811/index.md diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index af88feecf..7235cb24a 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -10,6 +10,10 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.zip.ZipInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.withLock @@ -23,6 +27,8 @@ object RoomDatabaseRestoreManager { private const val AUTO_BACKUP_FILE_PREFIX = "room_db_backup_" private const val MANUAL_BACKUP_FILE_PREFIX = "room_db_manual_backup_" + internal var atomicMoveForTest: ((File, File) -> Unit)? = null + fun listRecentAutoBackups(context: Context, limit: Int = 3): List { val newDir = OperitBackupDirs.roomDbDir() val legacyDir = OperitBackupDirs.operitRootDir() @@ -124,28 +130,23 @@ object RoomDatabaseRestoreManager { AppLogger.w(TAG, "closeDatabase failed", e) } extractAndValidate(zipFile, tmpDb, tmpWal, tmpShm) + requireOptionalCompanions( + targetWal = targetWal, + targetShm = targetShm, + restoredWal = tmpWal, + restoredShm = tmpShm, + ) }, commitReplacement = { RestoreReplacingMarker.persist(context) }, block = { - targetWal.delete() - targetShm.delete() - targetDb.delete() - - replaceFile(tmpDb, targetDb) + atomicallyReplace(tmpDb, targetDb) if (tmpWal.exists()) { - replaceFile(tmpWal, targetWal) - } else { - tmpWal.delete() - targetWal.delete() + atomicallyReplace(tmpWal, targetWal) } - if (tmpShm.exists()) { - replaceFile(tmpShm, targetShm) - } else { - tmpShm.delete() - targetShm.delete() + atomicallyReplace(tmpShm, targetShm) } }, ) @@ -208,13 +209,31 @@ object RoomDatabaseRestoreManager { } } - private fun replaceFile(from: File, to: File) { - if (to.exists()) { - to.delete() + private fun requireOptionalCompanions( + targetWal: File, + targetShm: File, + restoredWal: File, + restoredShm: File, + ) { + if (targetWal.exists() && !restoredWal.exists()) { + throw IOException("Backup does not contain ${targetWal.name} required by the current database") + } + if (targetShm.exists() && !restoredShm.exists()) { + throw IOException("Backup does not contain ${targetShm.name} required by the current database") } - if (!from.renameTo(to)) { - from.copyTo(to, overwrite = true) - from.delete() + } + + private fun atomicallyReplace(from: File, to: File) { + atomicMoveForTest?.invoke(from, to) + try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (e: AtomicMoveNotSupportedException) { + throw IOException("Atomic database replacement is unavailable: ${from.name}", e) } } } diff --git a/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt b/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt index d20e5819a..e245672d1 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt @@ -16,6 +16,7 @@ import com.ai.assistance.operit.util.OperitPaths import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException import java.sql.SQLException import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -365,6 +366,44 @@ internal class BackupRestoreBarrierTest : TokenStatReliabilityTestBase() { assertEquals(1, database.tokenStatsDao().countEvents()) } + @Test + fun `room restore atomic replacement failure preserves the existing database`() = runBlocking { + val restoreDir = File(root, "room_restore_target") + val existingDatabase = File(restoreDir, "app_database").apply { + writeText("existing-database") + } + whenever(context.getDatabasePath(any())).thenAnswer { + File(restoreDir, it.getArgument(0)) + } + val zip = File(cacheDir, "room-restore-atomic-move-failure.zip").apply { + ZipOutputStream(FileOutputStream(this)).use { zos -> + zos.putNextEntry(ZipEntry("app_database")) + zos.write("restored-database".toByteArray()) + zos.closeEntry() + } + } + + RoomDatabaseRestoreManager.atomicMoveForTest = { _, _ -> + throw IOException("injected atomic move failure") + } + try { + try { + RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) + fail("restore must fail when atomic replacement fails") + } catch (e: IOException) { + assertEquals("injected atomic move failure", e.message) + } + + assertEquals("existing-database", existingDatabase.readText()) + assertTrue( + "replacement-started failure must retain the replacing marker", + File(filesDir, RestoreReplacingMarker.FILE_NAME).exists(), + ) + } finally { + RoomDatabaseRestoreManager.atomicMoveForTest = null + } + } + // ── helpers ───────────────────────────────────────────────────────────── private val validManifest = diff --git a/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md b/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md new file mode 100644 index 000000000..4aa7b05b1 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md @@ -0,0 +1,18 @@ +# 1. Merge Baseline And Reproduction [DONE] + +## Previous State + +`main` at `f83e69cb` contains the MNN schema-generation repair from #926. #922 was +nine commits behind it and its two candidate runs failed while compiling MNN with +stale generated schema headers. + +## Change + +Created `fix/token-stats-922-review` from `main` and normally merged +`origin/review-pr-922` into it. The resulting merge commit is `663a3a59`. + +## Expected State + +The branch contains both the #922 feature work and the current MNN build repair, +so subsequent checks test the actual candidate intended for review rather than the +obsolete PR head. diff --git a/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md new file mode 100644 index 000000000..1131ce205 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md @@ -0,0 +1,21 @@ +# 2. Restore Integrity + +## Previous State + +`RoomDatabaseRestoreManager` deletes the active database, WAL, and SHM files before +calling `replaceFile`. `replaceFile` can still fail while renaming or copying the +validated temporary database. The exception path then removes temporary files, +leaving no recoverable active database. + +## Intended Change + +Validate WAL/SHM compatibility before committing the restore marker. Replace each +staged database file using only same-filesystem atomic move with replacement; do not +delete the active target first or copy after a failed move. Preserve the restore +barrier semantics and the replacing marker. + +## Expected State + +A failed atomic replacement reports failure without deleting the user's previously +active database. A focused regression test injects the final move failure and +verifies that the existing database remains intact. [DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/3_verification.md b/docs/TODO/token_stats_922_review_20260811/3_verification.md new file mode 100644 index 000000000..395d2fc05 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/3_verification.md @@ -0,0 +1,11 @@ +# 3. Verification + +## Required Evidence + +- Review the merged schema-generation configuration against #926. +- Add or update focused restore regression tests for failure after preparation but + before a completed replacement. +- Run the relevant JVM test target and the candidate build/check path only after + code changes are complete. +- Confirm the final branch remains based on the current `main` and that the PR + candidate checks are green before proposing merge. diff --git a/docs/TODO/token_stats_922_review_20260811/index.md b/docs/TODO/token_stats_922_review_20260811/index.md new file mode 100644 index 000000000..631e58475 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/index.md @@ -0,0 +1,38 @@ +--- +fork_repository: https://github.com/AAswordman/Operit.git +source_pr: https://github.com/AAswordman/Operit/pull/922 +working_branch: fix/token-stats-922-review +--- + +# Token Statistics PR 922 Review + +## Background + +PR #922 introduces token usage tracking, pricing management, durable statistics +spooling, Room schema changes, backup/restore coordination, and their settings and +UI. The feature is useful, but the source PR is too large to merge directly into +`main` while its candidate checks fail and its database replacement path can remove +the active database before replacement succeeds. + +## Intent + +Preserve #922 as a normal merge on an isolated repair branch, then make the merged +result safe to validate and submit as a focused follow-up PR. `main` remains +unchanged until the repair branch has passing checks and a reviewed data-integrity +path. + +## Scope + +- Preserve the #922 commit topology through merge commit `663a3a59`. +- Reproduce and resolve the current candidate-check blocker against the current + `main` baseline. +- Make database restore replacement preserve the existing database when a final + filesystem operation fails. +- Add focused regression coverage for the repaired restore behavior and run the + relevant repository checks requested for the follow-up PR. + +## Steps + +1. [DONE] [Merge baseline](1_merge_baseline_and_reproduction.md) +2. [Restore integrity](2_restore_integrity.md) +3. [Verification](3_verification.md) From 82d70f806259b8116b74f511ac7b23a5a5809d02 Mon Sep 17 00:00:00 2001 From: AAswordman <1002153674@qq.com> Date: Thu, 13 Aug 2026 03:19:02 +0800 Subject: [PATCH 26/28] refactor(token-stats): replace unpublished ledger with Room storage Replace the spool, baseline, recovery, cleanup, and quarantine implementation with structured Room usage records and model settings. Integrate provider usage capture, SQL-backed statistics, settings and UI updates, and normal backup/restore handling. Document the redesigned storage model and remove obsolete tests. --- app/build.gradle.kts | 9 - .../api/chat/llmprovider/AIServiceFactory.kt | 16 +- .../api/chat/llmprovider/ClaudeProvider.kt | 2 +- .../api/chat/llmprovider/DeepseekProvider.kt | 6 +- .../api/chat/llmprovider/KimiProvider.kt | 9 +- .../api/chat/llmprovider/LlamaProvider.kt | 30 +- .../chat/llmprovider/LocalGenerationEnd.kt | 10 +- .../api/chat/llmprovider/MNNProvider.kt | 30 +- .../api/chat/llmprovider/OpenAIProvider.kt | 31 +- .../llmprovider/TokenTrackingAIService.kt | 529 ++-- .../llmprovider/ToolPkgJsAiProviderService.kt | 4 +- .../core/application/OperitApplication.kt | 17 - .../data/backup/RawSnapshotBackupManager.kt | 332 +-- .../data/backup/RestoreReplacingMarker.kt | 67 - .../data/backup/RoomDatabaseBackupManager.kt | 68 +- .../data/backup/RoomDatabaseRestoreManager.kt | 165 +- .../operit/data/dao/TokenStatsDao.kt | 955 ------- .../operit/data/dao/TokenUsageDao.kt | 383 +++ .../assistance/operit/data/db/AppDatabase.kt | 307 +- .../data/model/TokenStatBaselineEntity.kt | 52 - .../data/model/TokenStatCleanupItemEntity.kt | 34 - .../model/TokenStatCleanupOperationEntity.kt | 49 - .../data/model/TokenStatDisplayModelEntity.kt | 25 - .../operit/data/model/TokenStatEventEntity.kt | 76 - .../data/model/TokenStatIdentityEntity.kt | 33 - .../model/TokenStatPriceOverrideEntity.kt | 119 - .../data/model/TokenStatRangeCutoffEntity.kt | 25 - .../data/model/TokenStatResetCutoffEntity.kt | 43 - .../data/model/TokenStatsModelEntity.kt | 21 + .../operit/data/model/TokenUsageIdentity.kt | 30 + .../data/model/TokenUsageRecordEntity.kt | 47 + .../operit/data/preferences/ApiPreferences.kt | 823 ++---- .../operit/data/stats/CleanupOutbox.kt | 848 ------ .../data/stats/LegacyTokenStatsSnapshot.kt | 268 -- .../data/stats/ProviderUsageSnapshot.kt | 29 +- .../operit/data/stats/Quarantine.kt | 433 --- .../stats/ReleasedProviderModelKeyDecoder.kt | 69 + .../operit/data/stats/SpoolDrain.kt | 158 - .../operit/data/stats/SpoolFileSystem.kt | 120 - .../assistance/operit/data/stats/SpoolLock.kt | 264 -- .../operit/data/stats/SpoolReader.kt | 401 --- .../operit/data/stats/SpoolRecovery.kt | 524 ---- .../operit/data/stats/SpoolWriter.kt | 44 - .../operit/data/stats/TokenActivityModels.kt | 268 +- .../data/stats/TokenBaselineImportRunner.kt | 367 --- .../data/stats/TokenBaselineMigrator.kt | 265 -- .../operit/data/stats/TokenCostCalculator.kt | 285 +- .../operit/data/stats/TokenPriceResolver.kt | 268 +- .../data/stats/TokenStatIdentityResolver.kt | 45 - .../operit/data/stats/TokenStatMetaStore.kt | 179 -- .../data/stats/TokenStatRequestContext.kt | 360 --- .../operit/data/stats/TokenStatSpool.kt | 1418 --------- .../operit/data/stats/TokenStatTypes.kt | 24 +- .../operit/data/stats/TokenStatsAggregator.kt | 691 ----- .../operit/data/stats/TokenStatsLedger.kt | 352 --- .../data/stats/TokenStatsPreferences.kt | 83 + .../data/stats/TokenStatsQueryModels.kt | 222 +- .../data/stats/TokenStatsQueryService.kt | 549 ++-- .../data/stats/TokenStatsSettingsManager.kt | 385 +-- .../data/stats/TokenStatsSettingsStore.kt | 100 +- .../stats/TokenStatsStartupCoordinator.kt | 124 - .../operit/data/stats/TokenStatsTimeRange.kt | 89 +- .../operit/data/stats/TokenUsageRepository.kt | 109 + .../toolpkg/ToolPkgAiProviderRegistry.kt | 12 + .../toolpkg/ToolPkgHookBridgeSupport.kt | 17 +- .../screens/ChatBackupSettingsScreen.kt | 425 +-- .../tokenstats/TokenActivitySection.kt | 955 +++---- .../features/tokenstats/TokenStatsColors.kt | 110 +- .../tokenstats/TokenStatsComponents.kt | 1144 ++++---- .../features/tokenstats/TokenStatsDialogs.kt | 626 +--- .../tokenstats/TokenStatsManagementScreen.kt | 839 ------ .../TokenStatsManagementViewModel.kt | 241 -- .../tokenstats/TokenUsageStatisticsScreen.kt | 733 ++--- .../TokenUsageStatisticsViewModel.kt | 765 +---- .../operit/ui/main/screens/OperitScreens.kt | 43 - .../ai/assistance/operit/util/OperitPaths.kt | 14 +- app/src/main/res/values-en/strings.xml | 173 +- app/src/main/res/values/strings.xml | 175 +- .../OpenAIStreamUsageOptionTest.kt | 45 - .../llmprovider/TokenTrackingAIServiceTest.kt | 2506 ---------------- .../ToolPkgJsAiProviderServiceTest.kt | 522 ---- .../data/backup/BackupRestoreBarrierTest.kt | 460 --- .../ApiPreferencesResetFailureTest.kt | 204 -- .../preferences/UsdToCnyRateValidationTest.kt | 36 - .../data/stats/CleanupReliabilityTest.kt | 2539 ----------------- .../operit/data/stats/JdbcSQLiteDriver.kt | 220 -- .../stats/LegacyTokenStatsSnapshotTest.kt | 165 -- .../data/stats/ProviderUsageNormalizerTest.kt | 44 +- .../data/stats/QuarantineReliabilityTest.kt | 758 ----- .../ReleasedProviderModelKeyDecoderTest.kt | 88 + .../data/stats/RestoreReliabilityTest.kt | 780 ----- .../operit/data/stats/SpoolConcurrencyTest.kt | 378 --- .../operit/data/stats/SpoolRecoveryTest.kt | 855 ------ .../data/stats/SpoolWriterReliabilityTest.kt | 487 ---- .../data/stats/TokenActivityAggregatorTest.kt | 230 +- .../stats/TokenBaselineImportRunnerTest.kt | 785 ----- .../data/stats/TokenBaselineMigratorTest.kt | 819 ------ .../data/stats/TokenCostCalculatorTest.kt | 689 +---- .../data/stats/TokenPriceResolverTest.kt | 330 --- .../stats/TokenStatReliabilityTestBase.kt | 297 -- .../data/stats/TokenStatRequestContextTest.kt | 80 - .../data/stats/TokenStatsAggregatorTest.kt | 1037 ------- .../data/stats/TokenStatsCleanupOutboxTest.kt | 528 ---- .../data/stats/TokenStatsDeletionTest.kt | 664 ----- .../operit/data/stats/TokenStatsLedgerTest.kt | 849 ------ .../stats/TokenStatsQueryServiceRoomTest.kt | 958 ------- .../data/stats/TokenStatsRoomMigrationTest.kt | 794 ------ .../stats/TokenStatsSettingsManagerTest.kt | 578 ---- .../stats/TokenStatsStartupCoordinatorTest.kt | 163 -- .../data/stats/TokenStatsTimeRangeTest.kt | 136 +- .../screens/QuarantineExportCleanupTest.kt | 134 - .../tokenstats/AvatarImportDecisionTest.kt | 36 - .../TokenStatsCanonicalTotalsTest.kt | 84 - .../tokenstats/TokenStatsChartsTest.kt | 88 - .../TokenStatsManagementViewModelTest.kt | 127 - .../TokenUsageStatisticsViewModelTest.kt | 1466 ---------- .../3_verification.md | 86 +- ...4_provider_capabilities_and_token_types.md | 21 + .../5_final_storage_design.md | 31 + .../6_data_layer_and_request_integration.md | 28 + .../7_sql_queries_and_ui.md | 121 + .../8_legacy_history_and_identity.md | 44 + .../token_stats_922_review_20260811/index.md | 52 +- 123 files changed, 4258 insertions(+), 37044 deletions(-) delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt delete mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt delete mode 100644 app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt create mode 100644 docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md create mode 100644 docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md create mode 100644 docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md create mode 100644 docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md create mode 100644 docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85dadee91..e2f31588e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -765,19 +765,10 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.compose.bom)) - // JVM 上的真实 Room 迁移/DAO 测试:Android Room 2.8 生成的实现基于 - // androidx.sqlite KMP 接口,用 sqlite-jdbc 实现纯 JVM 驱动(见测试支撑类 - // JdbcSQLiteDriver),仅在单元测试使用。 - testImplementation("org.xerial:sqlite-jdbc:3.46.1.3") - // 单元测试中真实 org.json(Android 桩在 JVM 测试里会抛 Stub! 异常); // 统计 usage 归一化测试需要解析 JSONObject。 testImplementation("org.json:json:20240303") - // 入口级恢复测试:RawSnapshotBackupManager 内部使用 Dispatchers.Main 汇报 - // 进度,JVM 测试用 setMain 安装测试主调度器。 - testImplementation(libs.coroutines.test) - // Apache POI - for Document processing (DOC, DOCX, etc.) implementation(libs.poi) implementation(libs.poi.ooxml) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt index 4a0ae16b0..29253a8b5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt @@ -317,7 +317,21 @@ object AIServiceFactory { return when (providerType) { // OpenAI格式,支持原生和兼容OpenAI API的服务 - ApiProviderType.OPENAI, + ApiProviderType.OPENAI -> + OpenAIProvider( + apiEndpoint = config.apiEndpoint, + apiKeyProvider = apiKeyProvider, + modelName = config.modelName, + client = httpClient, + customHeaders = customHeaders, + providerType = providerType, + supportsVision = supportsVision, + supportsAudio = supportsAudio, + supportsVideo = supportsVideo, + enableToolCall = enableToolCall, + includeUsageInStream = true, + ) + ApiProviderType.OPENAI_GENERIC, ApiProviderType.OPENAI_LOCAL -> OpenAIProvider( diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index 6cb18da9d..7af9a741a 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -265,7 +265,7 @@ class ClaudeProvider( ) onUsageReported?.invoke( // 流式 start/delta 是部分更新(省略字段保留旧值);非流式最终响应是 - // 完整快照(null = 明确未知,覆盖旧值)——见 TokenStatRequestContext 合并 + // 完整快照中 null 表示明确未知,覆盖该 attempt 的旧值。 com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.anthropic( usage, completeSnapshot, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index a0df5931e..38652cd02 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -43,7 +43,7 @@ class DeepseekProvider( supportsVision = supportsVision, supportsAudio = supportsAudio, supportsVideo = supportsVideo, - enableToolCall = enableToolCall + enableToolCall = enableToolCall, ) { /** @@ -81,7 +81,9 @@ class DeepseekProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) - jsonObject.putStreamUsageOption(stream) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } // DeepSeek Thinking Mode 默认开启,关闭时也必须显式发送 thinking.type=disabled。 applyThinkingParamsIfNeeded(jsonObject) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt index 613c2b742..f16b0949f 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt @@ -40,7 +40,7 @@ open class KimiProvider( supportsVision = supportsVision, supportsAudio = supportsAudio, supportsVideo = supportsVideo, - enableToolCall = enableToolCall + enableToolCall = enableToolCall, ) { override fun createRequestBody( @@ -65,6 +65,9 @@ open class KimiProvider( val baseRequestBodyJson = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) val jsonObject = JSONObject(baseRequestBodyJson) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } applyThinkingParams(jsonObject) return createJsonRequestBody(jsonObject.toString()) } @@ -72,7 +75,9 @@ open class KimiProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) - jsonObject.putStreamUsageOption(stream) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } applyThinkingParams(jsonObject) for (param in modelParameters) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt index 0b55c80fc..7e7687cb3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt @@ -43,9 +43,9 @@ class LlamaProvider( } } - private var _inputTokenCount: Int = 0 - private var _outputTokenCount: Int = 0 - private var _cachedInputTokenCount: Int = 0 + private var _inputTokenCount: Long = 0L + private var _outputTokenCount: Long = 0L + private var _cachedInputTokenCount: Long = 0L @Volatile private var isCancelled = false @@ -54,21 +54,21 @@ class LlamaProvider( private var session: LlamaSession? = null override val inputTokenCount: Long - get() = _inputTokenCount.toLong() + get() = _inputTokenCount override val cachedInputTokenCount: Long - get() = _cachedInputTokenCount.toLong() + get() = _cachedInputTokenCount override val outputTokenCount: Long - get() = _outputTokenCount.toLong() + get() = _outputTokenCount override val providerModel: String get() = "${providerType.name}:$modelName" override fun resetTokenCounts() { - _inputTokenCount = 0 - _outputTokenCount = 0 - _cachedInputTokenCount = 0 + _inputTokenCount = 0L + _outputTokenCount = 0L + _cachedInputTokenCount = 0L } private fun logLargeString(prefix: String, message: String) { @@ -278,9 +278,9 @@ class LlamaProvider( } } - _inputTokenCount = kotlin.runCatching { s.countTokens(prompt) }.getOrElse { 0 } - _outputTokenCount = 0 - onTokensUpdated(_inputTokenCount.toLong(), 0L, 0L) + _inputTokenCount = kotlin.runCatching { s.countTokens(prompt).toLong() }.getOrElse { 0L } + _outputTokenCount = 0L + onTokensUpdated(_inputTokenCount, 0L, 0L) val requestedMaxNewTokens = modelParameters .find { it.name == "max_tokens" } @@ -292,7 +292,7 @@ class LlamaProvider( "开始llama.cpp推理,history=${chatHistory.size}, threads=${sessionConfig.nThreads}, n_ctx=${sessionConfig.nCtx}, n_batch=${sessionConfig.nBatch}, n_ubatch=${sessionConfig.nUBatch}, gpu_layers=${sessionConfig.nGpuLayers}, mmap=${sessionConfig.useMmap}" ) - var outputTokenCount = 0 + var outputTokenCount = 0L val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() @@ -306,7 +306,7 @@ class LlamaProvider( if (isCancelled) { false } else { - outputTokenCount += 1 + outputTokenCount += 1L _outputTokenCount = outputTokenCount if (effectiveEnableToolCall) { @@ -318,7 +318,7 @@ class LlamaProvider( kotlin.runCatching { kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount.toLong(), 0L, _outputTokenCount.toLong()) + onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt index f412f4de8..e0ac41560 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt @@ -13,7 +13,7 @@ internal class LocalUsageReporter( ) { private val reported = AtomicBoolean(false) - suspend fun report(inputTokens: Int, outputTokens: Int) { + suspend fun report(inputTokens: Long, outputTokens: Long) { val callback = onUsageReported ?: return if (!reported.compareAndSet(false, true)) return withContext(NonCancellable) { @@ -33,8 +33,8 @@ internal class LocalUsageReporter( } suspend fun runReportingFinally( - inputTokens: () -> Int, - outputTokens: () -> Int, + inputTokens: () -> Long, + outputTokens: () -> Long, block: suspend () -> T, ): T = try { block() @@ -74,8 +74,8 @@ internal object LocalGenerationEnd { cancelled: Boolean, success: Boolean, usageReporter: LocalUsageReporter, - inputTokens: Int, - outputTokens: Int, + inputTokens: Long, + outputTokens: Long, cancelMessage: String, emitToolResult: suspend () -> Unit, failWith: suspend () -> Unit, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt index feef4b407..77fc0bec5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt @@ -67,29 +67,29 @@ class MNNProvider( private var cachedModelIsAudio: Boolean? = null // Token计数 - private var _inputTokenCount = 0 - private var _outputTokenCount = 0 - private var _cachedInputTokenCount = 0 + private var _inputTokenCount = 0L + private var _outputTokenCount = 0L + private var _cachedInputTokenCount = 0L @Volatile private var isCancelled = false override val inputTokenCount: Long - get() = _inputTokenCount.toLong() + get() = _inputTokenCount override val outputTokenCount: Long - get() = _outputTokenCount.toLong() + get() = _outputTokenCount override val cachedInputTokenCount: Long - get() = _cachedInputTokenCount.toLong() + get() = _cachedInputTokenCount override val providerModel: String get() = "${providerType.name}:$modelName" override fun resetTokenCounts() { - _inputTokenCount = 0 - _outputTokenCount = 0 - _cachedInputTokenCount = 0 + _inputTokenCount = 0L + _outputTokenCount = 0L + _cachedInputTokenCount = 0L } override fun cancelStreaming() { @@ -660,21 +660,21 @@ class MNNProvider( val safeHistory = trimHistoryToTokenBudget(session, conversationHistory, maxPromptTokens) _inputTokenCount = - kotlin.runCatching { session.countTokensWithHistory(safeHistory) } + kotlin.runCatching { session.countTokensWithHistory(safeHistory).toLong() } .getOrElse { error -> if (useInternalToolCall) { throw error } - countTokens(buildPrompt(conversationHistory)) + countTokens(buildPrompt(conversationHistory)).toLong() } - onTokensUpdated(_inputTokenCount.toLong(), 0L, 0L) + onTokensUpdated(_inputTokenCount, 0L, 0L) AppLogger.d( TAG, "开始MNN LLM推理,历史消息数: ${conversationHistory.size}, thinking模式: $enableThinking, toolCall=$useInternalToolCall" ) - var outputTokenCount = 0 + var outputTokenCount = 0L val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() val emitDirectly = !useInternalToolCall @@ -687,7 +687,7 @@ class MNNProvider( if (isCancelled) { false } else { - outputTokenCount += 1 + outputTokenCount += 1L _outputTokenCount = outputTokenCount if (emitDirectly) { @@ -699,7 +699,7 @@ class MNNProvider( kotlin.runCatching { kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount.toLong(), 0L, _outputTokenCount.toLong()) + onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 3bc65cdcb..b6c5ba001 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -49,20 +49,6 @@ import org.json.JSONArray import org.json.JSONObject import com.ai.assistance.operit.api.chat.llmprovider.MediaLinkParser -internal fun JSONObject.applyChatCompletionsStreamUsageOption( - stream: Boolean, - providerType: ApiProviderType, - useResponsesApi: Boolean, -) { - val supportsIncludeUsage = - providerType == ApiProviderType.OPENAI || - providerType == ApiProviderType.DEEPSEEK || - providerType == ApiProviderType.MOONSHOT - if (stream && !useResponsesApi && supportsIncludeUsage) { - put("stream_options", JSONObject().put("include_usage", true)) - } -} - /** * OpenAI API格式的实现,支持标准OpenAI接口和兼容此格式的其他提供商 * @@ -112,7 +98,8 @@ open class OpenAIProvider( protected val supportsVision: Boolean = false, // 是否支持图片处理 protected val supportsAudio: Boolean = false, // 是否支持音频输入 protected val supportsVideo: Boolean = false, // 是否支持视频输入 - val enableToolCall: Boolean = false // 是否启用Tool Call接口 + val enableToolCall: Boolean = false, // 是否启用Tool Call接口 + private val includeUsageInStream: Boolean = false, ) : AIService { // private val client: OkHttpClient = HttpClientFactory.instance @@ -648,16 +635,6 @@ open class OpenAIProvider( return jsonString.toByteArray(Charsets.UTF_8).toRequestBody(JSON) } - /** - * 流式 Chat Completions 请求体附加 usage 返回选项:OpenAI 只在显式请求时于 - * 末块返回 usage;Responses API 始终自带 usage(response.completed),不需要 - * 也不接受 stream_options。仅对明确支持 include_usage 的服务发送,避免通用或 - * 本地兼容端点因未知字段拒绝请求。DeepSeek/Kimi 自建请求体复用本方法。 - */ - protected fun JSONObject.putStreamUsageOption(stream: Boolean) { - applyChatCompletionsStreamUsageOption(stream, providerType, useResponsesApi) - } - /** * 内部方法,用于构建请求体的JSON字符串,以便子类可以重用和扩展。 */ @@ -672,7 +649,9 @@ open class OpenAIProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) // 根据stream参数设置 - jsonObject.putStreamUsageOption(stream) + if (stream && includeUsageInStream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } // 添加已启用的模型参数 for (param in modelParameters) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt index 82d073d95..e1be5e861 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -4,16 +4,13 @@ import android.content.Context import com.ai.assistance.operit.core.chat.hooks.PromptTurn import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ModelParameter +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordSource import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot import com.ai.assistance.operit.data.stats.TokenStatCategory -import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver -import com.ai.assistance.operit.data.stats.TokenStatRequestContext -import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.data.stats.TokenStatStatus -import com.ai.assistance.operit.data.stats.TokenStatsBarrierActiveException -import com.ai.assistance.operit.data.stats.TokenStatsLedger -import com.ai.assistance.operit.data.stats.withStatsDatabaseAccess +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.stream.RevisableTextStream import com.ai.assistance.operit.util.stream.SharedStream @@ -24,62 +21,20 @@ import com.ai.assistance.operit.util.stream.TextStreamEventCarrier import com.ai.assistance.operit.util.stream.TimeoutException import java.io.InterruptedIOException import java.net.SocketTimeoutException -import java.util.UUID -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException as JavaTimeoutException +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.withContext -import kotlinx.coroutines.runBlocking -/** - * 统计持久化故障标记:只在模型已失败/取消的收尾中作为原异常的 suppressed 保留 - * (观测性,不覆盖主异常)。恢复替换后的同进程新请求不再抛出本异常——改为 fail-open - * ([newRequest] 返回 null,统计层跳过跟踪直调 delegate),模型调用不受影响。 - */ -class TokenStatsPersistenceException(message: String, cause: Throwable? = null) : - java.io.IOException(message, cause) - -/** - * 统一 usage 记录边界(阶段 2):包装任意 [AIService],在逻辑请求级别记录统计事件, - * 覆盖普通响应、流式响应、正常结束、用户取消、超时与失败。 - * - * 边界语义(稳定且可解释): - * - 一次 sendMessage 调用 = 一个事件。provider 内部重试不产生独立事件;不同 attempt - * 的 usage 按分量累加(同一 attempt 重复上报按“最新非空字段”合并),最终只落一个 - * 事件,状态为最终结果。eventId 在请求开始时生成一次,配合 DAO 的 IGNORE 插入 - * 幂等防重。 - * - 首个真实内容 chunk(非空,含仅空白 chunk)到达时设置首 token 时间; - * 空字符串 chunk 不记录;无内容响应保持 null。 - * - 取消([CancellationException])不吞掉:先落 CANCELLED 事件再原样重抛。 - * - 超时按异常类型识别:coroutine [TimeoutCancellationException] / - * [TimeoutException] / [SocketTimeoutException] / java timeout → TIMEOUT; - * [InterruptedIOException] 只有消息明确含 "timeout"(OkHttp 整调用超时)才算, - * 线程中断等普通中断不算。明确的非超时取消(用户取消/协程取消)优先于 cause - * 链中的 timeout 信号,避免 UserCancellationException(cause=InterruptedIOException) - * 被误判为 TIMEOUT。 - * - 请求收尾同步解析价格并 fsync 完整事件到 TokenStatSpool,后台 writer 只做 Room - * insert。价格读取失败写 UNKNOWN。统计收尾全程 fail-open(P1-2):任何持久化 - * 故障只记录健康日志并返回 [RecordOutcome.LOST],绝不改写成功的模型结果;模型 - * 本身已失败/取消时,统计故障作为 suppressed 异常保留在原异常上,主异常仍是 - * 模型异常。 - * - 调用者 usage observer(外部回调)与 provider 业务隔离:非取消异常只记录日志, - * 不改变账本/请求结果;取消遵循协程取消语义向上传播。 - */ +/** Adds one compact Room statistics row around every logical [AIService] request. */ class TokenTrackingAIService( private val delegate: AIService, - private val context: Context, + context: Context, private val configId: String, ) : AIService { - - private val appContext: Context = context.applicationContext + private val repository = TokenUsageRepository.getInstance(context.applicationContext) override val inputTokenCount: Long get() = delegate.inputTokenCount override val cachedInputTokenCount: Long get() = delegate.cachedInputTokenCount @@ -90,6 +45,7 @@ class TokenTrackingAIService( override fun cancelStreaming() = delegate.cancelStreaming() override suspend fun getModelsList(context: Context): Result> = delegate.getModelsList(context) + override suspend fun calculateInputTokens( chatHistory: List, availableTools: List?, @@ -111,8 +67,12 @@ class TokenTrackingAIService( enableRetry: Boolean, statsCategory: TokenStatCategory?, ): Stream { - val request = newRequest(statsCategory) - val delegateStream = + val request = RequestTracker( + configId = configId, + providerModel = providerModel, + category = statsCategory ?: TokenStatCategory.OTHER, + ) + val inner = try { delegate.sendMessage( context = context, chatHistory = chatHistory, @@ -122,61 +82,47 @@ class TokenTrackingAIService( availableTools = availableTools, preserveThinkInHistory = preserveThinkInHistory, onTokensUpdated = onTokensUpdated, - // 组合内部记录与调用者 callback:内部按 attempt 记账,调用者回调 - // 原样转发(每次上报都转发,不吞不重);外部 observer 的异常与 - // provider 业务隔离(非取消只日志,取消仍传播)。request 为 null - // (恢复替换后 fail-open)时只转发、不记账。 onUsageReported = { usage, attempt -> - request?.onUsage(usage, attempt) + request.onUsage(usage, attempt) forwardUsageObserver(onUsageReported, usage, attempt) }, onNonFatalError = onNonFatalError, enableRetry = enableRetry, statsCategory = statsCategory, ) - // P1-2 fail-open:恢复替换后统计层跳过跟踪,直调 delegate(模型功能不受影响)。 - if (request == null) return delegateStream - return wrapStream(delegateStream, request) + } catch (t: Throwable) { + persist(request.finish(classify(t))) + throw t + } + return wrapStream(inner, request) } override suspend fun testConnection( context: Context, onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, ): Result { - val request = newRequest(TokenStatCategory.CONNECTION_TEST) - // P1-2 fail-open:恢复替换后跳过统计,直调 delegate(连接测试正常执行)。 - if (request == null) return delegate.testConnection(context, onUsageReported) + val request = + RequestTracker(configId, providerModel, TokenStatCategory.CONNECTION_TEST) return try { - val result = - delegate.testConnection(context) { usage, attempt -> - request.onUsage(usage, attempt) - forwardUsageObserver(onUsageReported, usage, attempt) - } - // 失败 Result 也按统一 cause 分类(timeout/取消语义不丢), - // 与抛出的异常走同一 classify。 - request.finish( - result.exceptionOrNull()?.let { classify(it) } ?: TokenStatStatus.COMPLETED + val result = delegate.testConnection(context) { usage, attempt -> + request.onUsage(usage, attempt) + forwardUsageObserver(onUsageReported, usage, attempt) + } + persist( + request.finish( + result.exceptionOrNull()?.let(::classify) ?: TokenStatStatus.COMPLETED + ) ) - // P1-2:统计收尾 fail-open——持久化故障只作为模型失败的 suppressed 保留, - // 绝不把连接测试的成功结果改写为失败(模型失败路径保持主异常不变)。 - persistAndCapture(appContext, request, result.exceptionOrNull()) result } catch (e: CancellationException) { - request.finish(TokenStatStatus.CANCELLED) - persistAndCapture(appContext, request, e) + persist(request.finish(classify(e))) throw e - } catch (e: Exception) { - request.finish(classify(e)) - persistAndCapture(appContext, request, e) - Result.failure(e) + } catch (t: Throwable) { + persist(request.finish(classify(t))) + Result.failure(t) } } - /** - * 转发外部 usage observer 并隔离异常:调用者 callback 的非取消异常只记录日志, - * 不进入 provider 解析/重试控制流,也不改变账本与请求结果;取消(调用者协程 - * 取消)原样向上传播,遵循协程取消语义。 - */ private suspend fun forwardUsageObserver( observer: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, usage: ProviderUsageSnapshot, @@ -188,102 +134,21 @@ class TokenTrackingAIService( } catch (e: CancellationException) { throw e } catch (e: Exception) { - AppLogger.e(TAG, "调用者 usage observer 异常,不影响请求与账本", e) + AppLogger.e(TAG, "usage observer failed", e) } } - /** - * 请求接受边界(reviewer P1-1/P1-2 修复): - * - P1-1:`isAcceptingEvents` 检查、restore epoch 捕获与身份创建事务在 - * [TokenStatSpool.withStatsDatabaseAccess] 门控(屏障感知注册表)内原子完成—— - * 快照/恢复屏障排他期间到达的请求被**立即拒绝**([TokenStatsBarrierActiveException]), - * 绝不与文件打包/替换竞争打开 Room,模型调用不受影响; - * - P1-2:恢复替换完成后(accepting=false,UI 允许"稍后重启")返回 null—— - * 调用方 fail-open 直调 delegate(不抛异常、不阻断模型调用、不写新 DB), - * 重启后统计自动恢复。 - * @return null 表示恢复替换后或屏障排他期间本进程不接受统计(fail-open 跳过跟踪)。 - */ - private suspend fun newRequest(category: TokenStatCategory?): TokenStatRequestContext? { - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(delegate.providerModel) - val accepted = - try { - TokenStatSpool.withStatsDatabaseAccess { - if (!TokenStatSpool.isAcceptingEvents()) { - return@withStatsDatabaseAccess null - } - val sessionEpoch = TokenStatSpool.captureRestoreEpoch() - // P1-1:请求接受边界在**同一事务**内原子确保身份存在并读取 generation——删除展示 - // 分组要么看见该身份(写 IDENTITY tombstone,删除前接受的事件被跳过),要么请求 - // 拿到 ≥ tombstone 的新 generation(删除后请求正常入账)。首次请求的身份绝不可能 - // 绕过分组删除 tombstone 复活旧事件。 - // 统计数据库/身份不可用时**降级跟踪**——模型请求照常开始(保持既有 P1-2 语义)。 - val acceptedGeneration = - try { - TokenStatsLedger.ensureIdentityAndCaptureGeneration( - appContext, - configId, - provider, - model, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e( - TAG, - "统计身份/代次读取失败,本次请求降级跟踪(不影响模型调用): " + - "configId=$configId, provider=$provider, model=$model", - e, - ) - 0L - } - RequestAcceptance(acceptedGeneration, sessionEpoch) - } - } catch (e: TokenStatsBarrierActiveException) { - // P1-1:屏障排他期间(快照打包/恢复替换中)——跳过统计直调 delegate, - // 绝不等待或触碰正在替换的 Room。 - return null - } ?: return null - return TokenStatRequestContext( - eventId = "evt_${UUID.randomUUID().toString().replace("-", "")}", - category = category ?: TokenStatCategory.OTHER, - configId = configId, - provider = provider, - model = model, - startedAtMs = System.currentTimeMillis(), - acceptedGeneration = accepted.generation, - // 请求开始时在门控内同步捕获 restore epoch(纯内存、无 Room),收尾 append - // 时验证——restore 屏障开始即递增 epoch,旧请求被明确拒绝,不写新 DB。 - sessionEpoch = accepted.sessionEpoch, - ) - } - - /** [newRequest] 门控内原子捕获的请求接受结果。 */ - private data class RequestAcceptance( - val generation: Long, - val sessionEpoch: Long, - ) - - /** 保持修订流语义:内部流带 eventChannel 时返回同接口的包装流。 */ - private fun wrapStream( - delegateStream: Stream, - request: TokenStatRequestContext, - ): Stream { - return if (delegateStream is TextStreamEventCarrier) { - TrackingRevisableStream( - inner = delegateStream, - eventChannel = delegateStream.eventChannel, - request = request, - appContext = appContext, - ) + private fun wrapStream(inner: Stream, request: RequestTracker): Stream = + if (inner is TextStreamEventCarrier) { + TrackingRevisableStream(inner, inner.eventChannel, request, repository) } else { - TrackingStream(inner = delegateStream, request = request, appContext = appContext) + TrackingStream(inner, request, repository) } - } private class TrackingStream( private val inner: Stream, - private val request: TokenStatRequestContext, - private val appContext: Context, + private val request: RequestTracker, + private val repository: TokenUsageRepository, ) : Stream { override val isLocked: Boolean get() = inner.isLocked override val bufferedCount: Int get() = inner.bufferedCount @@ -292,33 +157,24 @@ class TokenTrackingAIService( override fun clearBuffer() = inner.clearBuffer() override suspend fun collect(collector: StreamCollector) { - var sawFirstToken = false try { inner.collect { value -> - // 仅空白 chunk 也是真实输出内容(首空格/换行 token), - // 只有空字符串 chunk 不记录首 token。 - if (!sawFirstToken && value.isNotEmpty()) { - sawFirstToken = true - request.onFirstToken() - } + if (value.isNotEmpty()) request.onFirstToken() collector.emit(value) } } catch (t: Throwable) { - request.finish(classify(t)) - persistAndCapture(appContext, request, t) + persist(repository, request, request.finish(classify(t))) throw t } - request.finish(TokenStatStatus.COMPLETED) - // P1-2 fail-open:统计收尾失败只记日志返回 LOST,绝不上抛改写成功结果。 - recordSafely(appContext, request) + persist(repository, request, request.finish(TokenStatStatus.COMPLETED)) } } private class TrackingRevisableStream( private val inner: Stream, override val eventChannel: SharedStream, - private val request: TokenStatRequestContext, - private val appContext: Context, + private val request: RequestTracker, + private val repository: TokenUsageRepository, ) : RevisableTextStream { override val isLocked: Boolean get() = inner.isLocked override val bufferedCount: Int get() = inner.bufferedCount @@ -327,214 +183,146 @@ class TokenTrackingAIService( override fun clearBuffer() = inner.clearBuffer() override suspend fun collect(collector: StreamCollector) { - var sawFirstToken = false try { inner.collect { value -> - if (!sawFirstToken && value.isNotEmpty()) { - sawFirstToken = true - request.onFirstToken() - } + if (value.isNotEmpty()) request.onFirstToken() collector.emit(value) } } catch (t: Throwable) { - request.finish(classify(t)) - persistAndCapture(appContext, request, t) + persist(repository, request, request.finish(classify(t))) throw t } - request.finish(TokenStatStatus.COMPLETED) - // P1-2 fail-open:统计收尾失败只记日志返回 LOST,绝不上抛改写成功结果。 - recordSafely(appContext, request) + persist(repository, request, request.finish(TokenStatStatus.COMPLETED)) } } - companion object { - private const val TAG = "TokenTrackingAIService" + private suspend fun persist(record: TokenUsageRecordEntity) = persist(repository, record) - /** 单次统计落账的有界等待时长;测试可缩短以验证超时只日志不阻塞业务。 */ - internal var recordTimeoutMs: Long = 5_000L + private class RequestTracker( + private val configId: String, + private val providerModel: String, + private val category: TokenStatCategory, + ) { + private val startedAtMs = System.currentTimeMillis() + private val lock = Any() + private val attempts = linkedMapOf() + private var firstTokenAtMs: Long? = null + private val finished = AtomicBoolean(false) - /** 测试注入缝:recordSafely 进入 IO 收尾上下文后最先调用(P1-3:验证收尾的 - * 同步文件 I/O / Future.get() 不跑在调用方 Main 线程)。 */ - internal var recordIoThreadProbeForTest: (() -> Unit)? = null + fun onUsage(usage: ProviderUsageSnapshot, attempt: Int) { + synchronized(lock) { + val key = attempt.coerceAtLeast(1) + attempts[key] = merge(attempts[key], usage) + } + } - /** - * 单次落账结果: - * - [DURABLE] means the complete event has been fsynced; - * - [LOST] 表示统计收尾失败(磁盘/容量/恢复 fence 拒绝等),事件未持久化;已记录 - * 健康日志,调用方绝不得改写模型结果(P1-2 fail-open)。 - */ - internal enum class RecordOutcome { - DURABLE, - LOST, + fun onFirstToken() { + synchronized(lock) { + if (firstTokenAtMs == null) firstTokenAtMs = System.currentTimeMillis() + } } - /** - * 统计收尾(companion 版本,供嵌套流类使用): - * - 在请求收尾边界有界解析并冻结价格,随后同步 fsync 完整事件; - * - 价格超时/失败形成 UNKNOWN 事件; - * - 收尾整体运行在 [Dispatchers.IO](P1-3):[prepareLineBounded] 的 - * [FutureTask.get] 等待与 [TokenStatSpool.append] 的 FileOutputStream+fsync - * 绝不阻塞调用方(Main)线程;[TokenStatSpool.awaitRoomVisibility] 是纯挂起 - * 等待(内部 withTimeoutOrNull 有界),在 IO 上执行无碍; - * - **fail-open(P1-2)**:任何持久化故障只记录健康日志并返回 [RecordOutcome.LOST], - * 绝不抛出——成功的模型结果绝不被统计收尾改写为失败;模型已失败的路径由 - * [persistAndCapture] 把 LOST 转为原始异常的 suppressed。 - * - [recordTimeoutMs] 只等待可选的 Room 可见性,不参与 durable 判定; - * - 进程重启后由 OperitApplication 主动 [com.ai.assistance.operit.data.stats.TokenStatSpool.replay] - * 重放(幂等 eventId IGNORE)。 - */ - internal suspend fun recordSafely( - appContext: Context, - request: TokenStatRequestContext, - ): RecordOutcome = - withContext(Dispatchers.IO + NonCancellable) { - recordIoThreadProbeForTest?.invoke() - try { - val baseJson = request.toSpoolBaseJson() - val line = - try { - prepareLineBounded(appContext, request) - } catch (e: JavaTimeoutException) { - TokenStatsLedger.prepareUnresolvedEventLine( - request, - baseJson, - "pricing_read_timeout", - ) - } catch (e: Exception) { - TokenStatsLedger.prepareUnresolvedEventLine( - request, - baseJson, - "pricing_read_failed:${e.javaClass.simpleName}", - ) + fun finish(status: TokenStatStatus): TokenUsageRecordEntity { + val endedAtMs = System.currentTimeMillis() + val snapshots = synchronized(lock) { attempts.values.toList() } + val firstToken = synchronized(lock) { firstTokenAtMs } + val separator = providerModel.indexOf(':') + require(separator > 0 && separator < providerModel.lastIndex) { + "provider:model is required for token usage events" + } + return TokenUsageRecordEntity( + occurredAtMs = startedAtMs, + source = TokenUsageRecordSource.REQUEST, + configId = configId, + provider = providerModel.substring(0, separator), + model = providerModel.substring(separator + 1), + category = category.name, + status = status.name, + requestCount = 1L, + uncachedInputTokens = snapshots.sumKnown { it.uncachedInputTokens }, + cachedInputTokens = snapshots.sumKnown { it.cachedInputTokens }, + cacheWriteTokens = snapshots.sumKnown { snapshot -> + if (snapshot.cacheWriteSeparateBilling) snapshot.cacheWriteTokens else 0L + }, + totalInputTokens = snapshots.sumKnown { it.totalInputTokens }, + outputTokens = snapshots.sumKnown { snapshot -> + snapshot.outputTokens?.let { output -> + if (snapshot.reasoningIncludedInOutput == false) { + saturatedAdd(output, snapshot.reasoningTokens ?: 0L) + } else { + output } - if (!TokenStatSpool.append(appContext, line, request.eventId, request.sessionEpoch)) { - AppLogger.e( - TAG, - "统计事件未能持久化(不影响模型结果): eventId=${request.eventId}, " + - "category=${request.category}, status=${request.status}", - ) - return@withContext RecordOutcome.LOST } - TokenStatSpool.awaitRoomVisibility(request.eventId, recordTimeoutMs) - RecordOutcome.DURABLE - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e( - TAG, - "统计收尾失败(不影响模型结果): eventId=${request.eventId}, " + - "category=${request.category}, status=${request.status}", - e, - ) - RecordOutcome.LOST - } - } - - /** - * Bounded pricing worker (P2-1): one daemon thread plus one queue slot. A wedged price - * resolution cannot spawn unbounded threads; queue saturation immediately reports UNKNOWN - * instead of starting more work. - */ - private var pricingExecutor: ThreadPoolExecutor = newPricingExecutor() + }, + reasoningTokens = snapshots.sumKnown { it.reasoningTokens }, + ttftMs = firstToken?.let { (it - startedAtMs).coerceAtLeast(0L) }, + durationMs = firstToken?.let { (endedAtMs - it).coerceAtLeast(0L) }, + ) + } - private fun newPricingExecutor() = - ThreadPoolExecutor( - 1, - 1, - 60L, - TimeUnit.SECONDS, - LinkedBlockingQueue(1), - ) { runnable -> Thread(runnable, "operit-token-stats-price").apply { isDaemon = true } } + fun markPersisted(): Boolean = finished.compareAndSet(false, true) - /** Discard a wedged pricing worker (interrupt-ignoring resolution) between tests. */ - internal fun resetPricingExecutorForTest() { - pricingExecutor.shutdownNow() - pricingExecutor = newPricingExecutor() + private fun merge( + previous: ProviderUsageSnapshot?, + update: ProviderUsageSnapshot, + ): ProviderUsageSnapshot { + if (previous == null || update.completeSnapshot) return update + return update.copy( + uncachedInputTokens = update.uncachedInputTokens ?: previous.uncachedInputTokens, + cachedInputTokens = update.cachedInputTokens ?: previous.cachedInputTokens, + cacheWriteTokens = update.cacheWriteTokens ?: previous.cacheWriteTokens, + totalInputTokens = update.totalInputTokens ?: previous.totalInputTokens, + outputTokens = update.outputTokens ?: previous.outputTokens, + reasoningTokens = update.reasoningTokens ?: previous.reasoningTokens, + ) } + } - /** - * Pricing resolution with a genuinely bounded lifecycle. The worker builds its own line - * from a fresh base JSON via [com.ai.assistance.operit.data.stats.TokenStatsLedger.prepareEventLineDetached] - * and never mutates [request] or any shared serialization object, so a timed-out task can - * never race the caller's UNKNOWN fallback on the same objects (P2-1). - */ - private fun prepareLineBounded( - appContext: Context, - request: TokenStatRequestContext, - ): String { - val task = FutureTask { - runBlocking { TokenStatsLedger.prepareEventLineDetached(appContext, request) } - } - try { - pricingExecutor.execute(task) - } catch (e: RejectedExecutionException) { - // Saturation (a previous resolution still wedged): report UNKNOWN immediately, - // never start additional threads or queue unbounded work. - throw JavaTimeoutException("pricing executor saturated") - } - return try { - val result = task.get(TokenStatSpool.prepareTimeoutMs, TimeUnit.MILLISECONDS) - // Apply the frozen snapshot on the caller thread only after success; the worker - // never touches request, so these assignments cannot race a discarded task. - request.frozenPricing = result.frozenPricing - request.pricingResolutionDiagnostic = result.diagnostic - result.line - } catch (e: JavaTimeoutException) { - task.cancel(true) - throw e - } catch (e: ExecutionException) { - throw (e.cause ?: e) - } + companion object { + private const val TAG = "TokenTrackingAIService" + private const val MAX_CAUSE_DEPTH = 8 + + private suspend fun persist( + repository: TokenUsageRepository, + request: RequestTracker, + record: TokenUsageRecordEntity, + ) { + if (request.markPersisted()) persist(repository, record) } - /** - * 模型失败路径的收尾:统计故障([RecordOutcome.LOST])作为 suppressed 附加在 - * 原始异常上,原始模型异常仍为主异常(P1-2:模型失败侧保持 fail-open 语义不变)。 - */ - private suspend fun persistAndCapture( - appContext: Context, - request: TokenStatRequestContext, - original: Throwable?, - ): TokenStatsPersistenceException? { - if (recordSafely(appContext, request) == RecordOutcome.LOST) { - val persistence = - TokenStatsPersistenceException( - "Token statistics could not be durably persisted for ${request.eventId}", - ) - original?.addSuppressed(persistence) - return persistence + private suspend fun persist( + repository: TokenUsageRepository, + record: TokenUsageRecordEntity, + ) { + withContext(Dispatchers.IO + NonCancellable) { + try { + repository.record(record) + } catch (e: Exception) { + AppLogger.e(TAG, "token usage insert failed", e) + } } - return null } - /** - * 结束状态分类。明确的非超时取消(用户取消/协程取消)优先:其 cause 链里 - * 可能带 InterruptedIOException(如 OkHttp 中断),不能误判为超时;只有 - * [TimeoutCancellationException] 才是超时。随后沿 cause chain 识别 provider - * 重试耗尽时把超时包装成 IOException 的情况;[InterruptedIOException] - * 只有消息明确含 "timeout"(OkHttp 整调用超时)才算超时,线程中断等不算。 - */ - internal fun classify(t: Throwable): TokenStatStatus = when { - isExplicitCancellation(t) -> TokenStatStatus.CANCELLED + private fun classify(t: Throwable): TokenStatStatus = when { + t is CancellationException && t !is TimeoutCancellationException -> + TokenStatStatus.CANCELLED isTimeout(t) -> TokenStatStatus.TIMEOUT else -> TokenStatStatus.FAILED } - private fun isExplicitCancellation(t: Throwable): Boolean = - t is CancellationException && t !is TimeoutCancellationException - private fun isTimeout(t: Throwable): Boolean { var current: Throwable? = t var depth = 0 while (current != null && depth < MAX_CAUSE_DEPTH) { - when { + if ( current is TimeoutCancellationException || - current is TimeoutException || - current is java.util.concurrent.TimeoutException || - current is SocketTimeoutException || - (current is InterruptedIOException && - current.message?.contains("timeout", ignoreCase = true) == true) - -> return true + current is TimeoutException || + current is java.util.concurrent.TimeoutException || + current is SocketTimeoutException || + (current is InterruptedIOException && + current.message?.contains("timeout", ignoreCase = true) == true) + ) { + return true } current = current.cause depth++ @@ -542,6 +330,19 @@ class TokenTrackingAIService( return false } - private const val MAX_CAUSE_DEPTH = 8 + private fun List.sumKnown( + selector: (ProviderUsageSnapshot) -> Long?, + ): Long? { + if (isEmpty()) return null + var sum = 0L + forEach { snapshot -> + val value = selector(snapshot) ?: return null + sum = if (Long.MAX_VALUE - sum < value) Long.MAX_VALUE else sum + value + } + return sum + } + + private fun saturatedAdd(left: Long, right: Long): Long = + if (Long.MAX_VALUE - left < right) Long.MAX_VALUE else left + right } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 23029f110..d4aea5101 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -597,7 +597,7 @@ internal class ToolPkgJsAiProviderService( /** * sendMessage 通道:提取 → 更新 UI 累计计数 → 转发规范化 usage。 * UI 计数器与账本快照分离(评审 P1-6):缺省字段只保留 UI 侧全局累计值, - * 账本快照保持未知(由 [TokenStatRequestContext] 按 attempt 合并)。 + * 请求快照保持未知,由外层 request tracker 按 attempt 合并。 */ private suspend fun applyAndForwardUsage( decoded: ProviderHookValue, @@ -714,4 +714,4 @@ internal fun interface ToolPkgMainHookRunner { runtimeKind: String?, onIntermediateResult: ((Any?) -> Unit)?, ): Result -} \ No newline at end of file +} diff --git a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt index a4a13b16b..bdd438fd3 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt @@ -45,9 +45,6 @@ import com.ai.assistance.operit.data.preferences.initAndroidPermissionPreference import com.ai.assistance.operit.data.preferences.initUserPreferencesManager import com.ai.assistance.operit.data.preferences.preferencesManager import com.ai.assistance.operit.data.repository.CustomEmojiRepository -import com.ai.assistance.operit.data.stats.TokenBaselineImportRunner -import com.ai.assistance.operit.data.stats.TokenStatSpool -import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator import com.ai.assistance.operit.ui.features.chat.webview.LocalWebServer import com.ai.assistance.operit.ui.features.chat.webview.workspace.editor.language.LanguageFactory import com.ai.assistance.operit.util.GlobalExceptionHandler @@ -295,20 +292,6 @@ class OperitApplication : Application(), ImageLoaderFactory, WorkConfiguration.P AppLogger.d(TAG, "【启动计时】数据库预加载完成(异步) - ${System.currentTimeMillis() - dbStartTime}ms") } - // 启动统计 single-flight 初始化(P1 关键链路):旧 DataStore 累计统计 → baseline - // 幂等导入 → 等待 spool 初始 drain 完成,保证统计页首次查询看到重放完成后的 - // 数据。失败不缓存:统计页首次查询的 readiness 门控会自动重试(spool drain - // 另有退避重试)。 - applicationScope.launch { - val statsStartTime = System.currentTimeMillis() - val ready = TokenStatsStartupCoordinator.awaitInitialized(applicationContext) - AppLogger.d( - TAG, - "【启动计时】旧累计统计 baseline 导入完成(异步,ready=$ready) - " + - "${System.currentTimeMillis() - statsStartTime}ms" - ) - } - // 初始化全局图片加载器,设置强大的缓存策略 // 创建自定义 OkHttp 客户端,增加超时时间以支持慢速图片服务器 val imageOkHttpClient = OkHttpClient.Builder() diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index a974d059d..0892a808d 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -7,7 +7,6 @@ import android.os.Looper import android.util.AtomicFile import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.db.ObjectBoxManager -import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.OperitPaths import java.io.BufferedInputStream @@ -35,6 +34,7 @@ object RawSnapshotBackupManager { private const val TAG = "RawSnapshotBackup" private const val FORMAT_VERSION = 1 + private const val OPERIT_PACKAGE_NAME = "com.ai.assistance.operit" private const val ZIP_PREFIX = "operit_raw_snapshot_" @@ -126,128 +126,122 @@ object RawSnapshotBackupManager { val datastoreDir = File(dataDir, "datastore") val databasesDir = File(dataDir, "databases") - // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 - // Room),再进入排他状态执行 checkpoint + 打包。备份期间没有任何新的 insert - // 注册,spool 虽被排除出备份也不丢事件;屏障失败(quarantine 证据未导出、 - // 活跃 insert 超时等)在打包前明确抛错。 - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - try { - val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - - val includes = listOf( - ENTRY_FILES, - ENTRY_EXTERNAL_FILES, - ENTRY_SHARED_PREFS, - ENTRY_DATASTORE, - ENTRY_DATABASES - ) - val manifest = Manifest( - formatVersion = FORMAT_VERSION, - packageName = context.packageName, - createdAt = System.currentTimeMillis(), - includes = includes, - includeTerminalData = options.includeTerminalData - ) - - ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> - zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) - zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) - zos.closeEntry() + try { + val sqliteDb = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val excludedNames = if (options.includeTerminalData) { - alwaysExcluded - } else { - alwaysExcluded + terminalTopLevelDirNames - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) + val includes = listOf( + ENTRY_FILES, + ENTRY_EXTERNAL_FILES, + ENTRY_SHARED_PREFS, + ENTRY_DATASTORE, + ENTRY_DATABASES + ) + val manifest = Manifest( + formatVersion = FORMAT_VERSION, + packageName = context.packageName, + createdAt = System.currentTimeMillis(), + includes = includes, + includeTerminalData = options.includeTerminalData + ) + + ZipOutputStream(BufferedOutputStream(FileOutputStream(tmpFile))).use { zos -> + zos.putNextEntry(ZipEntry(ENTRY_MANIFEST)) + zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) + zos.closeEntry() + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val excludedNames = if (options.includeTerminalData) { + alwaysExcluded + } else { + alwaysExcluded + terminalTopLevelDirNames + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = 0)) + } + val filesTotalCount = totalFilesForZip( + dir = context.filesDir, + entryPrefix = ENTRY_FILES, + excludedTopLevelDirNames = excludedNames, + onScannedCountChanged = { scanned -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) + ) + } + } } - val filesTotalCount = totalFilesForZip( + ) + withContext(Dispatchers.Main) { + onProgress?.invoke( + ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) + ) + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } + val filesMs = measureTimeMillis { + addDirToZip( + zos = zos, dir = context.filesDir, entryPrefix = ENTRY_FILES, excludedTopLevelDirNames = excludedNames, - onScannedCountChanged = { scanned -> + totalFiles = filesTotalCount, + onPercentChanged = { percent -> if (onProgress != null) { mainHandler.post { - onProgress.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = scanned) - ) + onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) } } } ) - withContext(Dispatchers.Main) { - onProgress?.invoke( - ExportProgressInfo(stage = ExportProgress.SCANNING_FILES, scannedFiles = filesTotalCount) - ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } - val filesMs = measureTimeMillis { - addDirToZip( - zos = zos, - dir = context.filesDir, - entryPrefix = ENTRY_FILES, - excludedTopLevelDirNames = excludedNames, - totalFiles = filesTotalCount, - onPercentChanged = { percent -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, percent)) - } - } - } - ) - } - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } - AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") + } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 100)) } + AppLogger.i(TAG, "export add files done in ${filesMs}ms (excludedTopLevel=${excludedNames.size})") - val externalFilesTotalCount = totalFilesForZip( + val externalFilesTotalCount = totalFilesForZip( + dir = externalFilesDir, + entryPrefix = ENTRY_EXTERNAL_FILES, + excludedTopLevelDirNames = emptySet() + ) + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) + } + val externalFilesMs = measureTimeMillis { + addDirToZip( + zos = zos, dir = externalFilesDir, entryPrefix = ENTRY_EXTERNAL_FILES, - excludedTopLevelDirNames = emptySet() - ) - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 0)) - } - val externalFilesMs = measureTimeMillis { - addDirToZip( - zos = zos, - dir = externalFilesDir, - entryPrefix = ENTRY_EXTERNAL_FILES, - totalFiles = externalFilesTotalCount, - onPercentChanged = { percent -> - if (onProgress != null) { - mainHandler.post { - onProgress.invoke( - ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) - ) - } + totalFiles = externalFilesTotalCount, + onPercentChanged = { percent -> + if (onProgress != null) { + mainHandler.post { + onProgress.invoke( + ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, percent) + ) } } - ) - } - withContext(Dispatchers.Main) { - onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) - } - AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") + } + ) + } + withContext(Dispatchers.Main) { + onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_EXTERNAL_FILES, 100)) + } + AppLogger.i(TAG, "export add external_files done in ${externalFilesMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } - val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } - AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_SHARED_PREFS)) } + val sharedPrefsMs = measureTimeMillis { addDirToZip(zos, sharedPrefsDir, ENTRY_SHARED_PREFS) } + AppLogger.i(TAG, "export add shared_prefs done in ${sharedPrefsMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } - val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } - AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATASTORE)) } + val datastoreMs = measureTimeMillis { addDirToZip(zos, datastoreDir, ENTRY_DATASTORE) } + AppLogger.i(TAG, "export add datastore done in ${datastoreMs}ms") - withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } - val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } - AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") - } + withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_DATABASES)) } + val databasesMs = measureTimeMillis { addDirToZip(zos, databasesDir, ENTRY_DATABASES) } + AppLogger.i(TAG, "export add databases done in ${databasesMs}ms") } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.FINALIZING)) } @@ -289,85 +283,51 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") - // P1 终审:两阶段恢复屏障。prepareBeforeCommit 做全部可失败的非替换准备 - // (关闭 stores、解压、校验 manifest);commitReplacement 持久化 - // REPLACING 标记——只有该标记成功落盘后 restore epoch 才递增、恢复前 - // 开始的旧请求在收尾时被明确拒绝;block 内做文件替换,成功后屏障自动 - // 清理旧 spool,旧事件绝不 replay 进恢复后的数据库。替换开始后失败则 - // 本进程拒绝一切新事件直至重启(isAcceptingEvents() == false)。 - var preservedNamesForRestore: Set = emptySet() - var restoredManifest: Manifest? = null - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = { - AppDatabase.closeDatabase() - ObjectBoxManager.closeAll() - - AppLogger.i(TAG, "restore closed databases (room + objectbox)") - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } - val manifest = extractZipToWorkDir(cacheZip, workDir, expectedPackageName = context.packageName) - restoredManifest = manifest - - val payloadDir = File(workDir, "payload") - - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - - val preserveTerminal = !manifest.includeTerminalData - val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() - val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> - File(payloadDir, "files/$dirName").exists() - }.toSet() - // REPLACING 标记必须留在 filesDir(崩溃后由启动路径消费);files - // 替换时不得删除它(审计 P1:否则崩溃后连持久化证据都丢失)。 - preservedNamesForRestore = - preservedTerminalNames + - preservedAlwaysExcludedNames + - setOf(RestoreReplacingMarker.FILE_NAME) - - AppLogger.i( - TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" - ) - - AppLogger.i( - TAG, - "restore replace dirs (preserveTerminalTopLevel=${preservedNamesForRestore.isNotEmpty()})" - ) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } - }, - commitReplacement = { - RestoreReplacingMarker.persist(context) - }, - block = { - val payloadDir = File(workDir, "payload") - val externalFilesPayloadDir = File(payloadDir, "external_files") - - replaceDirContents( - File(payloadDir, "files"), - context.filesDir, - preservedTopLevelDirNames = preservedNamesForRestore - ) - if (externalFilesPayloadDir.exists()) { - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } - replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } - replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } - replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } - }, + AppDatabase.closeDatabase() + ObjectBoxManager.closeAll() + + AppLogger.i(TAG, "restore closed databases (room + objectbox)") + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } + val manifest = extractZipToWorkDir(cacheZip, workDir) + + val payloadDir = File(workDir, "payload") + val externalFilesPayloadDir = File(payloadDir, "external_files") + + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + + val preserveTerminal = !manifest.includeTerminalData + val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() + val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> + File(payloadDir, "files/$dirName").exists() + }.toSet() + val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + + AppLogger.i( + TAG, + "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" ) - RestoreReplacingMarker.delete(context) - AppLogger.i(TAG, "restore done: ${restoredManifest?.packageName}") + + AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) + if (externalFilesPayloadDir.exists()) { + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } + replaceDirContents(externalFilesPayloadDir, externalFilesDir) + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } + replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } + replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } + replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } + AppLogger.i(TAG, "restore done: ${manifest.packageName}") } catch (e: Exception) { AppLogger.e(TAG, "restore failed", e) throw e @@ -384,7 +344,7 @@ object RawSnapshotBackupManager { } } - private fun extractZipToWorkDir(zipFile: File, workDir: File, expectedPackageName: String): Manifest { + private fun extractZipToWorkDir(zipFile: File, workDir: File): Manifest { val payloadRoot = File(workDir, "payload") payloadRoot.mkdirs() @@ -448,7 +408,11 @@ object RawSnapshotBackupManager { throw IllegalArgumentException("Unsupported backup version: ${manifest.formatVersion}") } - if (manifest.packageName != expectedPackageName) { + // Released backups must restore across official package variants such as .debug and .clone. + if ( + manifest.packageName != OPERIT_PACKAGE_NAME && + !manifest.packageName.startsWith("$OPERIT_PACKAGE_NAME.") + ) { throw IllegalArgumentException("Backup package mismatch: ${manifest.packageName}") } diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt deleted file mode 100644 index 959e7a4b2..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RestoreReplacingMarker.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.ai.assistance.operit.data.backup - -import android.content.Context -import com.ai.assistance.operit.data.stats.TokenStatSpool -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException - -/** - * Raw restore 的 REPLACING 持久化提交标记(P1 终审)。 - * - * [TokenStatSpool.withExclusiveRestoreAccess] 的 [commitReplacement] 必须在任何文件替换 - * (block)之前把“替换已开始”持久化:只有该标记成功落盘后 restore epoch 才会递增、 - * 本进程才停止接受新的统计事件——恢复前开始的旧请求绝不写入可能已被替换的 spool/Room。 - * - * 持久化级别:文件内容 fd.sync,再同步父目录目录项([TokenStatSpool.syncDir],与 spool - * 目录项协议一致);任一非 OK 即抛 [IOException](fail-closed),屏障视恢复未开始, - * 旧/新请求均可继续。 - * - * 标记只在恢复成功完成后删除;替换开始后失败的恢复保留标记(替换结果不确定,进程 - * 必须重启)。标记位于 filesDir 根:Raw restore 的 files 替换把标记加入 preserved 名单 - * (崩溃后启动路径 [TokenStatSpool.consumeAbandonedRestoreIfAny] 消费),Room restore - * 不替换 files,由调用方成功后显式删除。 - */ -internal object RestoreReplacingMarker { - const val FILE_NAME = TokenStatSpool.RESTORE_REPLACING_MARKER_FILE_NAME - private const val TAG = "RestoreReplacing" - - /** - * 持久化 REPLACING 标记;失败抛 [IOException](fail-closed)。调用方持 - * [TokenStatSpool] 的 lifecycleMutex(屏障内部),目录 sync seam 与 spool 一致。 - */ - fun persist(context: Context) { - val flag = File(context.filesDir, FILE_NAME) - try { - FileOutputStream(flag).use { output -> - output.write("REPLACING\n".toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - if (TokenStatSpool.syncDir(context.filesDir) != TokenStatSpool.DirSyncResult.OK) { - throw IOException("restore REPLACING marker directory entry not durable") - } - } catch (e: IOException) { - throw e - } catch (e: Exception) { - AppLogger.e(TAG, "REPLACING marker write failed", e) - throw IOException("restore REPLACING marker could not be persisted", e) - } - } - - /** - * 恢复成功完成后删除标记。删除必须持久化(同步父目录);删除失败抛 [IOException], - * 使调用方把恢复报告为未确认完成——残留标记会由启动路径 [TokenStatSpool.consumeAbandonedRestoreIfAny] - * 兜底消费,绝不让不确定状态静默通过。 - */ - fun delete(context: Context) { - val flag = File(context.filesDir, FILE_NAME) - if (!flag.exists()) return - if (!flag.delete()) { - throw IOException("restore REPLACING marker could not be deleted: ${flag.absolutePath}") - } - if (TokenStatSpool.syncDir(context.filesDir) != TokenStatSpool.DirSyncResult.OK) { - throw IOException("restore REPLACING marker deletion not durable") - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt index 7ee6e387b..dd0802559 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt @@ -3,7 +3,6 @@ package com.ai.assistance.operit.data.backup import android.content.Context import androidx.sqlite.db.SupportSQLiteDatabase import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -73,6 +72,13 @@ object RoomDatabaseBackupManager { throw IllegalStateException("Database file not found: ${dbFile.absolutePath}") } + try { + val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } + val operitDir = OperitBackupDirs.roomDbDir() val targetFile = File(operitDir, "${AUTO_BACKUP_FILE_PREFIX}${day}.zip") @@ -82,26 +88,14 @@ object RoomDatabaseBackupManager { tmpFile.delete() } - // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 - // Room),再进入排他状态执行 checkpoint + 打包,备份期间没有新的 insert 注册, - // 数据库备份不会漏掉仍在 spool 中的统计事件;屏障失败明确抛错。 - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - try { - val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } - - val walFile = File(dbFile.absolutePath + "-wal") - val shmFile = File(dbFile.absolutePath + "-shm") + val walFile = File(dbFile.absolutePath + "-wal") + val shmFile = File(dbFile.absolutePath + "-shm") - writeZip(tmpFile, mapOf( - DB_NAME to dbFile, - "${DB_NAME}-wal" to walFile, - "${DB_NAME}-shm" to shmFile - )) - } + writeZip(tmpFile, mapOf( + DB_NAME to dbFile, + "${DB_NAME}-wal" to walFile, + "${DB_NAME}-shm" to shmFile + )) if (targetFile.exists()) { targetFile.delete() @@ -120,6 +114,13 @@ object RoomDatabaseBackupManager { throw IllegalStateException("Database file not found: ${dbFile.absolutePath}") } + try { + val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase + sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() + } catch (e: Exception) { + AppLogger.w(TAG, "wal_checkpoint failed", e) + } + val operitDir = OperitBackupDirs.roomDbDir() val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")) @@ -130,26 +131,14 @@ object RoomDatabaseBackupManager { tmpFile.delete() } - // P1 终审:排他快照屏障——先排空 spool(已 fsync 未入 Room 的统计事件全部进入 - // Room),再进入排他状态执行 checkpoint + 打包,备份期间没有新的 insert 注册, - // 数据库备份不会漏掉仍在 spool 中的统计事件;屏障失败明确抛错。 - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - try { - val sqliteDb: SupportSQLiteDatabase = AppDatabase.getDatabase(context).openHelper.writableDatabase - sqliteDb.query("PRAGMA wal_checkpoint(FULL)").close() - } catch (e: Exception) { - AppLogger.w(TAG, "wal_checkpoint failed", e) - } + val walFile = File(dbFile.absolutePath + "-wal") + val shmFile = File(dbFile.absolutePath + "-shm") - val walFile = File(dbFile.absolutePath + "-wal") - val shmFile = File(dbFile.absolutePath + "-shm") - - writeZip(tmpFile, mapOf( - DB_NAME to dbFile, - "${DB_NAME}-wal" to walFile, - "${DB_NAME}-shm" to shmFile - )) - } + writeZip(tmpFile, mapOf( + DB_NAME to dbFile, + "${DB_NAME}-wal" to walFile, + "${DB_NAME}-shm" to shmFile + )) if (targetFile.exists()) { targetFile.delete() @@ -224,3 +213,4 @@ object RoomDatabaseBackupManager { } } } + diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index 7235cb24a..ec2b4dc33 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -3,17 +3,12 @@ package com.ai.assistance.operit.data.backup import android.content.Context import android.net.Uri import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.stats.TokenStatSpool import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream -import java.io.IOException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.util.zip.ZipInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.withLock @@ -27,8 +22,6 @@ object RoomDatabaseRestoreManager { private const val AUTO_BACKUP_FILE_PREFIX = "room_db_backup_" private const val MANUAL_BACKUP_FILE_PREFIX = "room_db_manual_backup_" - internal var atomicMoveForTest: ((File, File) -> Unit)? = null - fun listRecentAutoBackups(context: Context, limit: Int = 3): List { val newDir = OperitBackupDirs.roomDbDir() val legacyDir = OperitBackupDirs.operitRootDir() @@ -95,11 +88,17 @@ object RoomDatabaseRestoreManager { } } - private suspend fun restoreFromBackupFileInternal(context: Context, zipFile: File) { + private fun restoreFromBackupFileInternal(context: Context, zipFile: File) { if (!zipFile.exists() || !zipFile.isFile) { throw IllegalArgumentException("Backup file not found: ${zipFile.absolutePath}") } + try { + AppDatabase.closeDatabase() + } catch (e: Exception) { + AppLogger.w(TAG, "closeDatabase failed", e) + } + val targetDb = context.getDatabasePath(DB_NAME) val targetWal = File(targetDb.absolutePath + "-wal") val targetShm = File(targetDb.absolutePath + "-shm") @@ -114,87 +113,62 @@ object RoomDatabaseRestoreManager { tmpWal.delete() tmpShm.delete() - try { - // P1 终审:两阶段恢复屏障。prepareBeforeCommit 关闭数据库并完成全部可失败 - // 的准备工作(解压 ZIP 到 tmp 文件 + 验证必需条目);commitReplacement 持久化 - // REPLACING 标记——只有该标记成功落盘后 restore epoch 才递增、恢复前开始的 - // 旧请求在收尾时被明确拒绝;block 只做目标文件删除/替换(不再有可失败的解压 - // 步骤)。备份损坏/缺条目/读取失败都在 commit 之前失败:epoch 不变、进程仍 - // 接受事件,绝不因选择了错误备份文件而锁死当前进程(审计 P1)。 - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = { - try { - AppDatabase.closeDatabase() - } catch (e: Exception) { - AppLogger.w(TAG, "closeDatabase failed", e) - } - extractAndValidate(zipFile, tmpDb, tmpWal, tmpShm) - requireOptionalCompanions( - targetWal = targetWal, - targetShm = targetShm, - restoredWal = tmpWal, - restoredShm = tmpShm, - ) - }, - commitReplacement = { - RestoreReplacingMarker.persist(context) - }, - block = { - atomicallyReplace(tmpDb, targetDb) - if (tmpWal.exists()) { - atomicallyReplace(tmpWal, targetWal) - } - if (tmpShm.exists()) { - atomicallyReplace(tmpShm, targetShm) - } - }, - ) - RestoreReplacingMarker.delete(context) - } catch (e: Exception) { - tmpDb.delete() - tmpWal.delete() - tmpShm.delete() - throw e - } - } - - /** - * 解压 ZIP 到 tmp 文件并验证必需条目(审计 P1:在 commit 之前完成全部可失败工作)。 - * 缺 [DB_NAME] 抛 [IllegalArgumentException];读取失败向上传播——调用方处于 - * prepareBeforeCommit,epoch 未递增、进程仍接受事件。 - */ - private fun extractAndValidate(zipFile: File, tmpDb: File, tmpWal: File, tmpShm: File) { var extractedDb = false var extractedWal = false var extractedShm = false - ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> - while (true) { - val entry = zis.nextEntry ?: break - val name = entry.name - - when (name) { - DB_NAME -> { - writeStreamToFile(zis, tmpDb) - extractedDb = true - } - "${DB_NAME}-wal" -> { - writeStreamToFile(zis, tmpWal) - extractedWal = true - } - "${DB_NAME}-shm" -> { - writeStreamToFile(zis, tmpShm) - extractedShm = true + try { + ZipInputStream(BufferedInputStream(FileInputStream(zipFile))).use { zis -> + while (true) { + val entry = zis.nextEntry ?: break + val name = entry.name + + when (name) { + DB_NAME -> { + writeStreamToFile(zis, tmpDb) + extractedDb = true + } + "${DB_NAME}-wal" -> { + writeStreamToFile(zis, tmpWal) + extractedWal = true + } + "${DB_NAME}-shm" -> { + writeStreamToFile(zis, tmpShm) + extractedShm = true + } } + + zis.closeEntry() } + } - zis.closeEntry() + if (!extractedDb) { + throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") } - } - if (!extractedDb) { - throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") + targetWal.delete() + targetShm.delete() + targetDb.delete() + + replaceFile(tmpDb, targetDb) + if (extractedWal) { + replaceFile(tmpWal, targetWal) + } else { + tmpWal.delete() + targetWal.delete() + } + + if (extractedShm) { + replaceFile(tmpShm, targetShm) + } else { + tmpShm.delete() + targetShm.delete() + } + } catch (e: Exception) { + tmpDb.delete() + tmpWal.delete() + tmpShm.delete() + throw e } } @@ -209,31 +183,14 @@ object RoomDatabaseRestoreManager { } } - private fun requireOptionalCompanions( - targetWal: File, - targetShm: File, - restoredWal: File, - restoredShm: File, - ) { - if (targetWal.exists() && !restoredWal.exists()) { - throw IOException("Backup does not contain ${targetWal.name} required by the current database") + private fun replaceFile(from: File, to: File) { + if (to.exists()) { + to.delete() } - if (targetShm.exists() && !restoredShm.exists()) { - throw IOException("Backup does not contain ${targetShm.name} required by the current database") - } - } - - private fun atomicallyReplace(from: File, to: File) { - atomicMoveForTest?.invoke(from, to) - try { - Files.move( - from.toPath(), - to.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (e: AtomicMoveNotSupportedException) { - throw IOException("Atomic database replacement is unavailable: ${from.name}", e) + if (!from.renameTo(to)) { + from.copyTo(to, overwrite = true) + from.delete() } } } + diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt deleted file mode 100644 index 0945caa8e..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenStatsDao.kt +++ /dev/null @@ -1,955 +0,0 @@ -package com.ai.assistance.operit.data.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatCleanupItemEntity -import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.model.TokenStatRangeCutoffEntity -import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity -import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver -import com.ai.assistance.operit.data.stats.TokenActivityEventRow -import com.ai.assistance.operit.data.stats.TokenStatsGroupMetadataSnapshot -import com.ai.assistance.operit.data.stats.TokenStatsLifetimeRead -import com.ai.assistance.operit.data.stats.TokenStatsQuerySnapshot -import androidx.room.Transaction -import java.util.UUID -import kotlinx.coroutines.flow.Flow - -/** - * 统计账本 DAO(阶段 1)。 - * - * - 事件按 [TokenStatEventEntity.eventId] 幂等插入(重复标识忽略,不重复入账)。 - * - baseline 以 identityId 为主键整体替换(受控补导时 REPLACE;普通导入只新增)。 - * - 身份绝不可走删除式 REPLACE(会级联删除该身份的事件):新增用 INSERT IGNORE, - * 分组变更走显式安全 UPDATE。 - * - 价格覆盖:**唯一写入入口是 [upsertPriceOverride]**(校验 scope 枚举 + - * 规范化业务字段,见 [TokenStatPriceOverrideEntity.normalized]); - * 底层 [insertPriceOverride] 为 protected,不暴露任意 entity 插入的公开路径, - * 防止绕过规范化写入导致唯一索引失效。 - */ -@Dao -abstract class TokenStatsDao { - - // ==== 事件 ==== - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertEvent(event: TokenStatEventEntity) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertEvents(events: List) - - @Query("SELECT * FROM token_stat_events WHERE eventId = :eventId") - abstract suspend fun getEvent(eventId: String): TokenStatEventEntity? - - @Query("SELECT * FROM token_stat_events") - abstract suspend fun getAllEvents(): List - - /** 活动热力图按 `(startedAtMs, eventId)` 键集分页读取轻量投影。 */ - @Query( - "SELECT eventId, startedAtMs, uncachedInputTokens, cachedInputTokens, cacheWriteTokens, " + - "totalInputTokens, outputTokens, reasoningTokens, reasoningIncludedInOutput, " + - "cacheWriteSeparateBilling " + - "FROM token_stat_events " + - "WHERE (startedAtMs > :afterStartMs OR " + - "(startedAtMs = :afterStartMs AND eventId > :afterEventId)) " + - "ORDER BY startedAtMs ASC, eventId ASC LIMIT :limit" - ) - abstract suspend fun getTokenActivityRowsPage( - afterStartMs: Long, - afterEventId: String, - limit: Int, - ): List - - @Query("SELECT COUNT(*) FROM token_stat_events") - abstract suspend fun countEvents(): Int - - /** - * 阶段 3 统计查询:单次读取指定时间范围(半开区间 [startMs, endMs), - * 按 [TokenStatEventEntity.startedAtMs] 归属)内的全部事件,在内存中单遍聚合。 - * 走 `index_token_stat_events_startedAtMs` 索引;禁止逐桶/逐模型拆分查询。 - */ - @Query( - "SELECT * FROM token_stat_events " + - "WHERE startedAtMs >= :startMs AND startedAtMs < :endMs" - ) - abstract suspend fun getEventsInRange(startMs: Long, endMs: Long): List - - /** - * 阶段 3 统计查询:单次读取时间范围内属于给定展示模型分组(identity 的 - * displayModelId,单一事实来源)的事件。IN 列表由调用方提供,模型数再多也 - * 只有一条查询,不产生按模型 N+1。 - */ - @Query( - "SELECT e.* FROM token_stat_events e " + - "INNER JOIN token_stat_identities i ON e.statIdentityId = i.identityId " + - "WHERE e.startedAtMs >= :startMs AND e.startedAtMs < :endMs " + - "AND i.displayModelId IN (:displayModelIds)" - ) - abstract suspend fun getEventsInRangeForDisplayModels( - startMs: Long, - endMs: Long, - displayModelIds: List, - ): List - - /** 时间范围内是否存在事件(初始回退选择用,EXISTS 短路,走 startedAtMs 索引)。 */ - @Query( - "SELECT EXISTS(" + - "SELECT 1 FROM token_stat_events " + - "WHERE startedAtMs >= :startMs AND startedAtMs < :endMs" + - ")" - ) - abstract suspend fun rangeHasEvents(startMs: Long, endMs: Long): Boolean - - /** - * 生命周期分页读取(P2-1):`(startedAtMs, eventId)` 键集分页,升序、无重复、 - * 无遗漏;配合 [loadLifetimeSnapshot] 在**同一事务**内逐页读取,避免整表 - * 实体化的内存峰值。调用方只在 `page.size == limit` 时推进游标继续取下一页。 - */ - @Query( - "SELECT * FROM token_stat_events " + - "WHERE (startedAtMs > :afterStartMs OR " + - "(startedAtMs = :afterStartMs AND eventId > :afterEventId)) " + - "ORDER BY startedAtMs ASC, eventId ASC LIMIT :limit" - ) - abstract suspend fun getEventsPage( - afterStartMs: Long, - afterEventId: String, - limit: Int, - ): List - - /** - * 单条 IN 查询允许的最大参数个数(P2-2):SQLite 变量上限默认 999, - * 留 99 余量取 900;超过时在同一事务内分块查询再合并。 - */ - companion object { - const val MAX_IN_VALUES = 900 - } - - /** - * 阶段 3 范围查询的**同事务只读快照**(P1-2):identity/display model/价格覆盖 - * (重估口径才读)/事件在同一个 Room 事务内固定读取,事务外纯聚合;并发写入 - * 要么整体可见要么整体不可见,杜绝“summary 有事件但模型桶缺失”的拆分状态。 - * - * [displayModelIds] 语义(P2-2):null = 全部模型;空列表 = **无事件**(不是 - * 全部);非空 = 走 JOIN 单条 IN 查询;超过 [MAX_IN_VALUES] 时在**同一事务** - * 内按 ≤900 分块查询(去重后),合并结果按 (startedAtMs, eventId) 稳定排序。 - */ - @Transaction - open suspend fun loadRangeSnapshot( - startMs: Long, - endMs: Long, - displayModelIds: List?, - includeOverrides: Boolean, - ): TokenStatsQuerySnapshot { - val identitiesById = getAllIdentities().associateBy { it.identityId } - val displayModelsById = getAllDisplayModels().associateBy { it.displayModelId } - val overrides = if (includeOverrides) getAllPriceOverrides() else emptyList() - val events = - when { - displayModelIds == null -> getEventsInRange(startMs, endMs) - displayModelIds.isEmpty() -> emptyList() - else -> getEventsInRangeForDisplayModelsChunked(startMs, endMs, displayModelIds) - } - return TokenStatsQuerySnapshot( - events = events, - identitiesById = identitiesById, - displayModelsById = displayModelsById, - overrides = overrides, - baselines = emptyList(), - ) - } - - /** - * 生命周期快照(P1-2/P2-1):identity/display model/价格覆盖/baseline 在 - * **同一事务**内一次读取;事件按 `(startedAtMs, eventId)` 键集分页(每页至多 - * [pageSize] 条)逐页回调 [onEventsPage],由聚合器增量累加——避免整表实体化 - * 峰值,且分页与事务同界(页面间快照一致)。 - */ - @Transaction - open suspend fun loadLifetimeSnapshot( - includeOverrides: Boolean, - pageSize: Int, - onEventsPage: ( - List, - Map, - List, - ) -> Unit, - ): TokenStatsLifetimeRead { - val identitiesById = getAllIdentities().associateBy { it.identityId } - val displayModelsById = getAllDisplayModels().associateBy { it.displayModelId } - val overrides = if (includeOverrides) getAllPriceOverrides() else emptyList() - val baselines = getAllBaselines() - var afterStartMs = Long.MIN_VALUE - var afterEventId = "" - var totalEvents = 0L - while (true) { - val page = getEventsPage(afterStartMs, afterEventId, pageSize) - if (page.isEmpty()) break - totalEvents += page.size - onEventsPage(page, identitiesById, overrides) - if (page.size < pageSize) break - val last = page.last() - afterStartMs = last.startedAtMs - afterEventId = last.eventId - } - return TokenStatsLifetimeRead( - identitiesById = identitiesById, - displayModelsById = displayModelsById, - overrides = overrides, - baselines = baselines, - totalEvents = totalEvents, - ) - } - - /** - * 活动视图的同事务分页快照。每页只保留计算 token、自然日与小时所需的列,调用方 - * 增量压缩后立即释放页面,内存不再随账本事件总数线性增长。 - */ - @Transaction - open suspend fun loadActivitySnapshot( - pageSize: Int, - onRowsPage: (List) -> Unit, - ) { - require(pageSize > 0) { "activity page size must be positive" } - var afterStartMs = Long.MIN_VALUE - var afterEventId = "" - while (true) { - val page = getTokenActivityRowsPage(afterStartMs, afterEventId, pageSize) - if (page.isEmpty()) break - onRowsPage(page) - if (page.size < pageSize) break - val last = page.last() - afterStartMs = last.startedAtMs - afterEventId = last.eventId - } - } - - /** - * 分组元数据快照(阶段 4 P1 修复):全量身份 + 展示模型行在**同一个事务**内 - * 固定读取,与统计筛选(时间/模型/分类/状态)无关——分组管理与合并的 - * 成员/目标必须来自完整归属,而筛选范围明细只包含有事件的身份/分组 - * (事件存在与否不影响身份的分组成员身份)。 - */ - @Transaction - open suspend fun loadGroupMetadataSnapshot(): TokenStatsGroupMetadataSnapshot { - val identities = getAllIdentities() - val displayModels = getAllDisplayModels() - return TokenStatsGroupMetadataSnapshot( - identities = identities, - displayModels = displayModels, - ) - } - - private suspend fun getEventsInRangeForDisplayModelsChunked( - startMs: Long, - endMs: Long, - displayModelIds: List, - ): List { - val distinct = displayModelIds.distinct() - if (distinct.size <= MAX_IN_VALUES) { - return getEventsInRangeForDisplayModels(startMs, endMs, distinct) - } - val merged = ArrayList() - for (chunk in distinct.chunked(MAX_IN_VALUES)) { - merged += getEventsInRangeForDisplayModels(startMs, endMs, chunk) - } - // 分块结果合并后按 (startedAtMs, eventId) 稳定排序(聚合对顺序不敏感, - // 这里只是为了契约明确;分块都在同一事务快照内,不产生拆分状态)。 - return merged.sortedWith(compareBy({ it.startedAtMs }, { it.eventId })) - } - - @Query("SELECT * FROM token_stat_events WHERE statIdentityId = :identityId") - abstract fun observeEventsByIdentity(identityId: String): Flow> - - @Query("DELETE FROM token_stat_events WHERE statIdentityId = :identityId") - abstract suspend fun deleteEventsByIdentity(identityId: String): Int - - /** 按成员身份批量删除事件(仅由事务方法分块调用,IN 数量受 [MAX_IN_VALUES] 限制)。 */ - @Query("DELETE FROM token_stat_events WHERE statIdentityId IN (:identityIds)") - protected abstract suspend fun deleteEventsByIdentitiesQuery(identityIds: List): Int - - /** 按成员身份批量删除 baseline(仅由事务方法分块调用)。 */ - @Query("DELETE FROM token_stat_baselines WHERE identityId IN (:identityIds)") - protected abstract suspend fun deleteBaselinesByIdentitiesQuery(identityIds: List): Int - - /** 分块删除指定身份集合的事件(同一调用方事务内执行,结果计数累加)。 */ - protected suspend fun deleteEventsByIdentities(identityIds: List): Int { - var deleted = 0 - for (chunk in identityIds.distinct().chunked(MAX_IN_VALUES)) { - deleted += deleteEventsByIdentitiesQuery(chunk) - } - return deleted - } - - /** 分块删除指定身份集合的 baseline。 */ - protected suspend fun deleteBaselinesByIdentities(identityIds: List): Int { - var deleted = 0 - for (chunk in identityIds.distinct().chunked(MAX_IN_VALUES)) { - deleted += deleteBaselinesByIdentitiesQuery(chunk) - } - return deleted - } - - /** 删除半开区间 [startMs, endMs) 内的事件(走 startedAtMs 索引;仅事务方法调用)。 */ - @Query("DELETE FROM token_stat_events WHERE startedAtMs >= :startMs AND startedAtMs < :endMs") - protected abstract suspend fun deleteEventsInRange(startMs: Long, endMs: Long): Int - - @Query( - "DELETE FROM token_stat_events WHERE statIdentityId IN " + - "(SELECT identityId FROM token_stat_identities " + - "WHERE provider = :provider AND model = :model)" - ) - abstract suspend fun deleteEventsByProviderModel(provider: String, model: String): Int - - @Query("DELETE FROM token_stat_events") - abstract suspend fun deleteAllEvents(): Int - - // ==== 统计身份 ==== - // 身份绝不可走删除式 REPLACE:REPLACE = DELETE + INSERT,会通过外键 - // 级联删除该身份下的全部事件(token_stat_events ON DELETE CASCADE)。 - // 新增身份用 INSERT IGNORE(已存在则跳过);分组变更走显式安全 UPDATE。 - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertIdentityIfAbsent(identity: TokenStatIdentityEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertIdentitiesIfAbsent(identities: List): List - - @Query( - "UPDATE token_stat_identities SET displayModelId = :displayModelId " + - "WHERE identityId = :identityId" - ) - abstract suspend fun updateIdentityDisplayModel(identityId: String, displayModelId: String): Int - - @Query("SELECT * FROM token_stat_identities WHERE identityId = :identityId") - abstract suspend fun getIdentity(identityId: String): TokenStatIdentityEntity? - - @Query( - "SELECT * FROM token_stat_identities " + - "WHERE configId = :configId AND provider = :provider AND model = :model " + - "LIMIT 1" - ) - abstract suspend fun getIdentityByTriple( - configId: String, - provider: String, - model: String, - ): TokenStatIdentityEntity? - - @Query("SELECT * FROM token_stat_identities") - abstract suspend fun getAllIdentities(): List - - /** - * 请求接受边界原子操作(P1-1):身份不存在时创建(INSERT IGNORE,绝不 REPLACE)、 - * 默认展示分组补齐、读取当前 generation,全部在**同一事务**内完成。展示分组删除 - * 与请求开始按 SQLite 事务串行化(写事务原子性):删除要么看见该身份并写 IDENTITY - * tombstone(删除前接受的事件被跳过),要么请求捕获 ≥ tombstone 的新 generation - * (删除后请求正常入账)——首次请求的身份不再可能绕过分组删除 tombstone 复活旧事件。 - */ - @Transaction - open suspend fun ensureIdentityAndCaptureGenerationTx( - identity: TokenStatIdentityEntity, - displayModel: TokenStatDisplayModelEntity, - ): Long { - insertIdentityIfAbsent(identity) - insertDisplayModelIfAbsent(displayModel) - return currentResetGeneration() - } - - // ==== 展示模型分组 ==== - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun upsertDisplayModel(displayModel: TokenStatDisplayModelEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun upsertDisplayModels(displayModels: List) - - @Query("SELECT * FROM token_stat_display_models WHERE displayModelId = :displayModelId") - abstract suspend fun getDisplayModel(displayModelId: String): TokenStatDisplayModelEntity? - - @Query("SELECT * FROM token_stat_display_models") - abstract suspend fun getAllDisplayModels(): List - - // ==== 展示分组受控写入(阶段 4 别名/合并) ==== - // 身份绝不可走删除式 REPLACE(级联删事件),只走安全 UPDATE; - // 展示模型行是纯展示元数据,创建用 INSERT IGNORE,改名用 UPDATE。 - - @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract suspend fun insertDisplayModelIfAbsent(model: TokenStatDisplayModelEntity): Long - - @Query( - "UPDATE token_stat_display_models SET displayName = :displayName " + - "WHERE displayModelId = :displayModelId" - ) - abstract suspend fun updateDisplayModelName(displayModelId: String, displayName: String): Int - - /** 展示分组行不存在时创建(displayModelId 同时作为规范化模型名),已存在则忽略。 */ - private suspend fun ensureDisplayModelRow(displayModelId: String, displayName: String? = null) { - if (getDisplayModel(displayModelId) == null) { - insertDisplayModelIfAbsent( - TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = displayModelId, - displayName = displayName ?: displayModelId, - ) - ) - } - } - - /** - * 把一组身份安全移动到目标展示分组(P4 别名/合并): - * 目标分组行不存在时先创建;身份只走 [updateIdentityDisplayModel] 的 - * 安全 UPDATE,绝不 REPLACE(REPLACE = DELETE + INSERT,会经外键级联 - * 删除该身份下的全部事件)。同一事务内完成,避免半移状态。 - */ - @Transaction - open suspend fun moveIdentitiesToDisplayModelTx( - identityIds: List, - displayModelId: String, - ) { - require(displayModelId.isNotBlank()) { "displayModelId must not be blank" } - ensureDisplayModelRow(displayModelId) - for (identityId in identityIds.distinct()) { - updateIdentityDisplayModel(identityId, displayModelId) - } - } - - /** - * 创建自定义展示分组(新 displayModelId + 展示名)并把指定身份移入, - * 同一事务内完成;[groupId] 必须不与既有分组冲突。 - */ - @Transaction - open suspend fun createDisplayGroupTx( - groupId: String, - groupName: String, - identityIds: List, - ) { - require(groupId.isNotBlank()) { "groupId must not be blank" } - require(groupName.isNotBlank()) { "groupName must not be blank" } - require(getDisplayModel(groupId) == null) { "display model already exists: $groupId" } - insertDisplayModelIfAbsent( - TokenStatDisplayModelEntity( - displayModelId = groupId, - normalizedModel = groupId, - displayName = groupName.trim(), - ) - ) - for (identityId in identityIds.distinct()) { - updateIdentityDisplayModel(identityId, groupId) - } - } - - /** - * 恢复默认规范分组:把指定展示组下每个身份按其自身模型名归回默认组 - * (displayModelId = 规范化模型名,[TokenStatIdentityResolver.displayModelIdFor]), - * 默认组行不存在时自动创建。同一事务内完成;事件/baseline 随身份跟随, - * 无任何删除或 REPLACE。 - */ - @Transaction - open suspend fun restoreDefaultGroupsTx(displayModelId: String) { - val identities = getAllIdentities().filter { it.displayModelId == displayModelId } - for (identity in identities) { - val defaultId = TokenStatIdentityResolver.displayModelIdFor(identity.model) - ensureDisplayModelRow(defaultId, displayName = identity.model) - updateIdentityDisplayModel(identity.identityId, defaultId) - } - } - - // ==== 价格覆盖 ==== - // 唯一性由 (scope, provider, model, configId) 规范化业务字段的唯一索引强制; - // rowId 是内部自增主键,不承载业务语义。公开写入唯一入口会校验 scope 枚举 - // 并规范化字段(TokenStatPriceOverrideEntity.normalized),非法输入抛 - // IllegalArgumentException;底层插入不公开,防止绕过规范化。 - - /** - * 价格覆盖唯一写入入口:校验 scope 固定枚举、规范化 provider/model/configId - * 后落库;规范化后相同业务组合在数据库唯一索引上冲突,REPLACE 后写覆盖。 - * @throws IllegalArgumentException scope 非固定枚举名或 provider/model 空白。 - */ - suspend fun upsertPriceOverride( - scope: String, - provider: String, - model: String, - configId: String?, - billingMode: String, - pricingCurrency: String, - inputPricePerMillion: Double? = null, - cachedInputPricePerMillion: Double? = null, - cacheWritePricePerMillion: Double? = null, - outputPricePerMillion: Double? = null, - pricePerRequest: Double? = null, - ) { - insertPriceOverride( - TokenStatPriceOverrideEntity.normalized( - scope = scope, - provider = provider, - model = model, - configId = configId, - billingMode = billingMode, - pricingCurrency = pricingCurrency, - inputPricePerMillion = inputPricePerMillion, - cachedInputPricePerMillion = cachedInputPricePerMillion, - cacheWritePricePerMillion = cacheWritePricePerMillion, - outputPricePerMillion = outputPricePerMillion, - pricePerRequest = pricePerRequest, - ) - ) - } - - /** 规范化后的实际落库(受保护:唯一入口是 [upsertPriceOverride])。 */ - @Insert(onConflict = OnConflictStrategy.REPLACE) - protected abstract suspend fun insertPriceOverride(override: TokenStatPriceOverrideEntity) - - @Query( - "SELECT * FROM token_stat_price_overrides " + - "WHERE scope = :scope AND provider = :provider " + - "AND model = :model AND configId = :configId LIMIT 1" - ) - abstract suspend fun getPriceOverride( - scope: String, - provider: String, - model: String, - configId: String, - ): TokenStatPriceOverrideEntity? - - @Query("SELECT * FROM token_stat_price_overrides") - abstract suspend fun getAllPriceOverrides(): List - - /** 按规范化业务组合删除价格覆盖(阶段 4 管理入口;参数须为规范化后的值)。 */ - @Query( - "DELETE FROM token_stat_price_overrides " + - "WHERE scope = :scope AND provider = :provider " + - "AND model = :model AND configId = :configId" - ) - abstract suspend fun deletePriceOverride( - scope: String, - provider: String, - model: String, - configId: String, - ): Int - - // ==== baseline ==== - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun upsertBaseline(baseline: TokenStatBaselineEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract suspend fun upsertBaselines(baselines: List) - - @Query("SELECT * FROM token_stat_baselines WHERE identityId = :identityId") - abstract suspend fun getBaseline(identityId: String): TokenStatBaselineEntity? - - @Query("SELECT * FROM token_stat_baselines") - abstract suspend fun getAllBaselines(): List - - @Query("SELECT COUNT(*) FROM token_stat_baselines") - abstract suspend fun countBaselines(): Int - - @Query("DELETE FROM token_stat_baselines WHERE identityId = :identityId") - abstract suspend fun deleteBaseline(identityId: String): Int - - @Query( - "DELETE FROM token_stat_baselines WHERE identityId IN " + - "(SELECT identityId FROM token_stat_identities " + - "WHERE provider = :provider AND model = :model)" - ) - abstract suspend fun deleteBaselinesByProviderModel(provider: String, model: String): Int - - @Query("DELETE FROM token_stat_baselines") - abstract suspend fun deleteAllBaselines(): Int - - - // ==== legacy cleanup outbox(阶段 5 P1 闭环) ==== - // 跨存储删除的线性化点:operation/items 与 tombstone/删除在**同一 Room 事务** - // 提交(见 deleteDisplayModelEventsTx / resetModelTx / deleteAllStatisticsTx)。 - // drain 顺序固定:Room 读 PENDING → DataStore apply(marker 幂等)→ Room ACK; - // 失败保持 PENDING 下次重试。历史行不删除(导入 fence 与备份 lineage)。 - - @Query( - "SELECT * FROM token_stat_cleanup_operations " + - "WHERE status = 'PENDING' ORDER BY createdAtMs ASC, operationId ASC" - ) - abstract suspend fun getPendingCleanupOperations(): List - - @Query( - "SELECT * FROM token_stat_cleanup_operations " + - "ORDER BY createdAtMs ASC, operationId ASC" - ) - abstract suspend fun getAllCleanupOperations(): List - - @Query("SELECT * FROM token_stat_cleanup_items WHERE operationId = :operationId") - abstract suspend fun getCleanupItems(operationId: String): List - - @Query("SELECT COUNT(*) FROM token_stat_cleanup_operations WHERE status = 'PENDING'") - abstract suspend fun countPendingCleanupOperations(): Int - - /** - * drain ACK:只把仍为 PENDING 的 operation 标记 APPLIED(@return 0 = 已由 - * 其他排空完成,幂等安全)。 - */ - @Query( - "UPDATE token_stat_cleanup_operations SET status = 'APPLIED' " + - "WHERE operationId = :operationId AND status = 'PENDING'" - ) - abstract suspend fun ackCleanupOperation(operationId: String): Int - - /** 删除 cleanup operation(items 经外键 ON DELETE CASCADE 跟随删除)。 */ - @Query("DELETE FROM token_stat_cleanup_operations WHERE operationId = :operationId") - abstract suspend fun deleteCleanupOperation(operationId: String): Int - - @Insert(onConflict = OnConflictStrategy.IGNORE) - protected abstract suspend fun insertCleanupOperation( - operation: TokenStatCleanupOperationEntity - ): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - protected abstract suspend fun insertCleanupItems( - items: List - ): List - - /** - * 在删除事务内创建 PENDING cleanup operation(+ 不可变 items 快照)。 - * 只供本类事务方法调用;items 为空表示 ALL kind 或无需逐项清理。 - */ - protected suspend fun createCleanupOperation( - scope: String, - targetRef: String, - deleteBaselines: Boolean, - items: List>, - ): TokenStatCleanupOperationEntity { - val operation = - TokenStatCleanupOperationEntity( - operationId = UUID.randomUUID().toString(), - scope = scope, - targetRef = targetRef, - deleteBaselines = deleteBaselines, - status = TokenStatCleanupOperationEntity.STATUS_PENDING, - createdAtMs = System.currentTimeMillis(), - ) - insertCleanupOperation(operation) - if (items.isNotEmpty()) { - insertCleanupItems( - items.map { (identityId, provider, model) -> - TokenStatCleanupItemEntity( - operationId = operation.operationId, - identityId = identityId, - provider = provider, - model = model, - ) - } - ) - } - return operation - } - - /** - * 导入 fence(P1 闭环):当前快照(含同时读取的 applied marker ID 集合)能否 - * 安全用于 baseline 导入。返回 true 当且仅当: - * 1. 不存在 PENDING cleanup operation(未排空的清理不得被导入覆盖); - * 2. Room 中**全部** cleanup operation ID 都包含在该快照的 marker 集合里—— - * 否则快照早于某次 legacy cleanup 完成,直接应用会复活已删除的 baseline。 - * 必须在 Room 事务内调用(与快照读取后的写入线性化)。 - */ - @Transaction - open suspend fun cleanupFenceSatisfied(markerOperationIds: Set): Boolean { - if (countPendingCleanupOperations() > 0) return false - return getAllCleanupOperations().all { it.operationId in markerOperationIds } - } - - // ==== 重置 tombstone(reset cutoff) ==== - // reset 与 spool 排空的一致同步边界:tombstone 与删除在同一事务提交, - // 排空插入在同一事务内检查,SQLite 事务串行化杜绝并发复活(P1-3)。 - // 阶段 5 扩展:IDENTITY kind(按展示分组删除,精确到身份)与 - // token_stat_range_cutoffs 表(时间范围删除)共用同一 generation 计数器, - // 删除后新接受的事件(acceptedGeneration ≥ cutoff)永不误伤。 - - @Insert(onConflict = OnConflictStrategy.REPLACE) - protected abstract suspend fun upsertResetCutoff(cutoff: TokenStatResetCutoffEntity) - - @Query( - "SELECT * FROM token_stat_reset_cutoffs " + - "WHERE kind = 'FULL' LIMIT 1" - ) - abstract suspend fun fullResetCutoff(): TokenStatResetCutoffEntity? - - @Query("SELECT * FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") - abstract suspend fun modelResetCutoffs(): List - - /** IDENTITY tombstone(阶段 5 展示分组删除):provider 空串、model 列 = identityId。 */ - @Query( - "SELECT * FROM token_stat_reset_cutoffs " + - "WHERE kind = 'IDENTITY' AND provider = '' AND model = :identityId LIMIT 1" - ) - protected abstract suspend fun identityResetCutoff(identityId: String): TokenStatResetCutoffEntity? - - @Query("DELETE FROM token_stat_reset_cutoffs WHERE kind = 'MODEL'") - protected abstract suspend fun deleteModelResetCutoffs() - - @Query("DELETE FROM token_stat_reset_cutoffs WHERE kind = 'IDENTITY'") - protected abstract suspend fun deleteIdentityResetCutoffs() - - /** - * 统一 generation 计数器:跨 reset_cutoffs 与 range_cutoffs 两表取最大值。 - * 阶段 5 必须统一:范围删除 tombstone 与 reset tombstone 共用单调序列, - * 否则“删除当前范围”后新请求捕获的 acceptedGeneration 可能低于范围 tombstone, - * 导致新事件被误判为删除前事件而跳过入账。 - */ - @Query( - "SELECT COALESCE(MAX(generation), 0) FROM (" + - "SELECT generation FROM token_stat_reset_cutoffs " + - "UNION ALL " + - "SELECT generation FROM token_stat_range_cutoffs" + - ")" - ) - abstract suspend fun currentResetGeneration(): Long - - // ==== 阶段 5:时间范围删除 tombstone ==== - - @Insert(onConflict = OnConflictStrategy.REPLACE) - protected abstract suspend fun upsertRangeCutoff(cutoff: TokenStatRangeCutoffEntity) - - @Query("SELECT * FROM token_stat_range_cutoffs") - abstract suspend fun rangeCutoffs(): List - - /** - * RANGE tombstone 覆盖检查(排空插入用):是否存在 generation **大于**事件 - * 接受 generation 且半开区间 [startMs, endMs) 包含事件 startedAtMs 的 - * tombstone。单条 EXISTS 短路查询,避免把 range cutoffs 全表 materialize - * 后在 JVM 侧遍历(行数 = 用户范围删除次数)。 - */ - @Query( - "SELECT EXISTS(" + - "SELECT 1 FROM token_stat_range_cutoffs " + - "WHERE generation > :acceptedGeneration " + - "AND startMs <= :startedAtMs AND endMs > :startedAtMs" + - ")" - ) - protected abstract suspend fun rangeCutoffCoversEvent( - acceptedGeneration: Long, - startedAtMs: Long, - ): Boolean - - @Query("DELETE FROM token_stat_range_cutoffs") - protected abstract suspend fun deleteAllRangeCutoffs() - - /** - * 删除时间范围 [startMs, endMs) 内的事件:写入 RANGE tombstone 并与删除 - * 同一事务提交。**绝不触碰 baseline**(baseline 无时间分布,只有按模型/全部 - * 删除且用户确认后才删除)。身份/展示分组/价格覆盖一律保留。 - * @return 删除的事件数。 - */ - @Transaction - open suspend fun deleteRangeEventsTx(startMs: Long, endMs: Long): Int { - require(endMs > startMs) { "range end must be after start" } - val generation = Math.addExact(currentResetGeneration(), 1L) - upsertRangeCutoff( - TokenStatRangeCutoffEntity( - generation = generation, - startMs = startMs, - endMs = endMs, - ) - ) - return deleteEventsInRange(startMs, endMs) - } - - /** - * 按展示分组删除(阶段 5 + P1 闭环):事务内从 **identity 全表**解析组成员(不依赖 - * 任何统计筛选),为该组全部成员写 IDENTITY tombstone(精确到身份,同一 - * provider:model 的其他分组不受影响),再按成员删除事件;[deleteBaselines] - * 为 true 时同步删除这些成员的 baseline,并**在同一事务内**为其中 configId - * 为空串的 legacy 成员持久化 cleanup operation + items(不可变快照,供 - * DataStore 累计键排空;为 false 时 baseline 与旧键一律保留、不建 operation)。 - * 身份行/展示分组/价格覆盖不删除(保持“只清计数、保留配置”语义)。 - * 任何读取失败都会让整个事务回滚(不产生 tombstone/operation 半状态)。 - */ - @Transaction - open suspend fun deleteDisplayModelEventsTx( - displayModelId: String, - deleteBaselines: Boolean, - ): TokenStatDisplayGroupDeletionResult { - require(displayModelId.isNotBlank()) { "displayModelId must not be blank" } - val members = getAllIdentities().filter { it.displayModelId == displayModelId } - if (members.isEmpty()) return TokenStatDisplayGroupDeletionResult(0, null) - val memberIds = members.map { it.identityId } - val generation = Math.addExact(currentResetGeneration(), 1L) - for (identityId in memberIds) { - upsertResetCutoff( - TokenStatResetCutoffEntity( - kind = TokenStatResetCutoffEntity.KIND_IDENTITY, - provider = "", - model = identityId, - generation = generation, - ) - ) - } - val deleted = deleteEventsByIdentities(memberIds) - var operation: TokenStatCleanupOperationEntity? = null - if (deleteBaselines) { - deleteBaselinesByIdentities(memberIds) - val legacyMembers = members.filter { it.configId == "" } - if (legacyMembers.isNotEmpty()) { - operation = - createCleanupOperation( - scope = TokenStatCleanupOperationEntity.SCOPE_DISPLAY_GROUP, - targetRef = displayModelId, - deleteBaselines = true, - items = - legacyMembers.map { - Triple(it.identityId, it.provider, it.model) - }, - ) - } - } - return TokenStatDisplayGroupDeletionResult(deleted, operation) - } - - /** - * 全部删除(阶段 5 + P1 闭环):写入 FULL tombstone 并与删除同一事务提交; - * [deleteBaselines] 为 true 时同时清空全部 baseline,并创建 ALL kind 的 - * cleanup operation(无 items,排空时清除全部旧累计键——不触碰价格等配置); - * 为 false 时 baseline 与旧累计键一律保留、不建 operation。 - * 顺带清理 MODEL/IDENTITY/RANGE tombstone(与既有 - * [resetAllStatisticsTx] 的卫生语义一致:FULL 覆盖所有更早的删除边界, - * 事件只按 FULL generation 判断,旧边界不再需要)。 - */ - @Transaction - open suspend fun deleteAllStatisticsTx( - deleteBaselines: Boolean, - ): TokenStatAllDeletionResult { - val generation = Math.addExact(currentResetGeneration(), 1L) - upsertResetCutoff( - TokenStatResetCutoffEntity( - kind = TokenStatResetCutoffEntity.KIND_FULL, - provider = "", - model = "", - generation = generation, - ) - ) - deleteModelResetCutoffs() - deleteIdentityResetCutoffs() - deleteAllRangeCutoffs() - val deleted = deleteAllEvents() - var operation: TokenStatCleanupOperationEntity? = null - if (deleteBaselines) { - deleteAllBaselines() - operation = - createCleanupOperation( - scope = TokenStatCleanupOperationEntity.SCOPE_ALL, - targetRef = "", - deleteBaselines = true, - items = emptyList(), - ) - } - return TokenStatAllDeletionResult(deleted, operation) - } - - /** - * 全量重置(既有语义,阶段 1 确认):事件 + 全部 baseline 一并删除,并创建 - * ALL kind 的 legacy cleanup operation(P1 闭环:跨存储窗口统一走 outbox)。 - * 阶段 5 保留为旧重置流程的别名;新删除流程直接使用 - * [deleteAllStatisticsTx](可单独选择是否删除 baseline)。 - * @return 创建的 cleanup operation(null 表示无需清理——本路径恒删除 baseline)。 - */ - @Transaction - open suspend fun resetAllStatisticsTx(): TokenStatCleanupOperationEntity? = - deleteAllStatisticsTx(deleteBaselines = true).cleanupOperation - - /** - * 按模型重置(P1 闭环):写入 MODEL tombstone(每 provider/model REPLACE 覆盖, - * 取最近时刻)并与删除同一事务提交;覆盖该模型下所有配置实例身份,并为其中 - * configId 为空串的 legacy 成员持久化 cleanup operation + items(精确到成员, - * 不误清其他模型/配置身份对应的旧键)。 - * @return 创建的 cleanup operation(null = 无 legacy 成员,无需清旧键)。 - */ - @Transaction - open suspend fun resetModelTx(provider: String, model: String): TokenStatCleanupOperationEntity? { - val generation = Math.addExact(currentResetGeneration(), 1L) - upsertResetCutoff( - TokenStatResetCutoffEntity( - kind = TokenStatResetCutoffEntity.KIND_MODEL, - provider = provider, - model = model, - generation = generation, - ) - ) - deleteEventsByProviderModel(provider, model) - deleteBaselinesByProviderModel(provider, model) - // 与 delete*ByProviderModel 相同(非规范化)匹配口径:只登记实际被删的 legacy 成员 - val legacyMembers = - getAllIdentities().filter { - it.configId == "" && it.provider == provider && it.model == model - } - return if (legacyMembers.isEmpty()) { - null - } else { - createCleanupOperation( - scope = TokenStatCleanupOperationEntity.SCOPE_MODEL, - targetRef = "$provider:$model", - deleteBaselines = true, - items = legacyMembers.map { Triple(it.identityId, it.provider, it.model) }, - ) - } - } - - /** - * 排空路径的事件插入入口:tombstone 检查与插入在同一事务内。 - * @return false = 事件被 reset/删除 tombstone 覆盖(跳过;调用方视为已处理, - * 段可删除,不重放);true = 已插入。 - * - * 阶段 5 检查链(与删除矩阵一一对应): - * - FULL:全量删除/重置后不接受任何更早接受的事件; - * - IDENTITY:按展示分组删除后不接受该身份更早接受的事件(精确到身份); - * - MODEL:按 provider:model 重置后不接受该模型更早接受的事件; - * - RANGE:范围删除后不接受 startedAtMs 落在已删范围且更早接受的事件 - * (单条 EXISTS 短路,不整表 materialize range cutoffs)。 - * 统一 generation 计数(两表 UNION)保证“接受于删除前”判断不依赖墙钟。 - */ - @Transaction - open suspend fun insertEventIfNotResetCovered(event: TokenStatEventEntity): Boolean { - val full = fullResetCutoff() - if (full != null && event.acceptedGeneration < full.generation) return false - val identity = - getIdentity(event.statIdentityId) - ?: error("identity missing for event ${event.eventId} (ensureIdentity must run first)") - val identityCutoff = identityResetCutoff(event.statIdentityId) - if (identityCutoff != null && event.acceptedGeneration < identityCutoff.generation) return false - val models = modelResetCutoffs() - for (cutoff in models) { - if (event.acceptedGeneration < cutoff.generation && - TokenStatIdentityResolver.normalizeProvider(cutoff.provider) == - TokenStatIdentityResolver.normalizeProvider(identity.provider) && - TokenStatIdentityResolver.normalizeModelName(cutoff.model) == - TokenStatIdentityResolver.normalizeModelName(identity.model) - ) { - return false - } - } - // RANGE tombstone 用单条 EXISTS 判断(半开区间 [startMs, endMs), - // generation > acceptedGeneration 等价于 acceptedGeneration < generation)。 - if (rangeCutoffCoversEvent(event.acceptedGeneration, event.startedAtMs)) return false - insertEvent(event) - return true - } -} - -/** - * 按展示分组删除的结果:删除的事件数与(baseline=yes 且组内存在 legacy 成员时) - * 在同一事务内创建的 cleanup operation(否则为 null)。 - */ -data class TokenStatDisplayGroupDeletionResult( - val deletedEvents: Int, - val cleanupOperation: TokenStatCleanupOperationEntity?, -) - -/** 全部删除的结果:删除的事件数与(baseline=yes 时)ALL kind cleanup operation。 */ -data class TokenStatAllDeletionResult( - val deletedEvents: Int, - val cleanupOperation: TokenStatCleanupOperationEntity?, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt new file mode 100644 index 000000000..3109b1b69 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt @@ -0,0 +1,383 @@ +package com.ai.assistance.operit.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity + +data class TokenUsageModelAggregateRow( + val provider: String, + val model: String, + val configId: String?, + /** Exact for request and released-total rows; a lower bound when conversation rows contribute. */ + val requests: Long, + val requestCountKnown: Long, + val usageRows: Long, + val uncachedInputTokens: Long, + val uncachedInputKnown: Long, + val cachedInputTokens: Long, + val cachedInputKnown: Long, + val cacheWriteTokens: Long, + val cacheWriteKnown: Long, + val totalInputTokens: Long, + val totalInputKnown: Long, + val outputTokens: Long, + val outputKnown: Long, + val reasoningTokens: Long, + val reasoningKnown: Long, + val ttftTotalMs: Long, + val ttftSamples: Long, + val durationTotalMs: Long, + val durationSamples: Long, +) { + val providerModel: String + get() = "$provider:$model" +} + +data class TokenUsageBreakdownRow( + val key: String, + val provider: String, + val model: String, + val configId: String?, + val requests: Long, + val requestCountKnown: Long, + val usageRows: Long, + val uncachedInputTokens: Long, + val uncachedInputKnown: Long, + val cachedInputTokens: Long, + val cachedInputKnown: Long, + val cacheWriteTokens: Long, + val cacheWriteKnown: Long, + val totalInputTokens: Long, + val totalInputKnown: Long, + val outputTokens: Long, + val outputKnown: Long, + val reasoningTokens: Long, + val reasoningKnown: Long, + val ttftTotalMs: Long, + val ttftSamples: Long, + val durationTotalMs: Long, + val durationSamples: Long, +) { + val providerModel: String + get() = "$provider:$model" +} + +data class TokenUsageIdentityRow( + val configId: String?, + val provider: String, + val model: String, +) + +data class TokenUsageActivityDayRow( + val localDate: String, + val configId: String?, + val provider: String, + val model: String, + val tokens: Long, +) + +@Dao +abstract class TokenUsageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertRecord(record: TokenUsageRecordEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertRecords(records: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertStatsModel(model: TokenStatsModelEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertStatsModels(models: List) + + @Query( + """ + SELECT * FROM token_stats_models + WHERE configId = :configId AND provider = :provider AND model = :model + """ + ) + abstract suspend fun getStatsModel( + configId: String, + provider: String, + model: String, + ): TokenStatsModelEntity? + + @Query("SELECT * FROM token_stats_models ORDER BY provider, model, configId") + abstract suspend fun getAllStatsModels(): List + + @Query( + """ + UPDATE token_stats_models + SET billingMode = NULL, + currency = NULL, + inputPricePerMillion = NULL, + cachedInputPricePerMillion = NULL, + cacheWritePricePerMillion = NULL, + outputPricePerMillion = NULL, + pricePerRequest = NULL + WHERE configId = :configId AND provider = :provider AND model = :model + """ + ) + abstract suspend fun clearPricing(configId: String, provider: String, model: String): Int + + @Query( + """ + DELETE FROM token_stats_models + WHERE billingMode IS NULL + AND currency IS NULL + AND inputPricePerMillion IS NULL + AND cachedInputPricePerMillion IS NULL + AND cacheWritePricePerMillion IS NULL + AND outputPricePerMillion IS NULL + AND pricePerRequest IS NULL + """ + ) + abstract suspend fun deleteEmptyStatsModels(): Int + + @Query( + """ + SELECT + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source = 'REQUEST' + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY provider, model, configId + ORDER BY provider, model, configId + """ + ) + abstract suspend fun aggregateRequestModelsForLifetime( + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY provider, model, configId + ORDER BY provider, model, configId + """ + ) + abstract suspend fun aggregateModelsInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + category AS `key`, + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY category, provider, model, configId + ORDER BY category, provider, model, configId + """ + ) + abstract suspend fun aggregateCategoriesInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + status AS `key`, + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY status, provider, model, configId + ORDER BY status, provider, model, configId + """ + ) + abstract suspend fun aggregateStatusesInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT configId, provider, model + FROM token_usage_records + GROUP BY configId, provider, model + ORDER BY provider, model, configId + """ + ) + abstract suspend fun getObservedIdentities(): List + + @Query( + """ + SELECT DISTINCT provider || ':' || model + FROM token_usage_records + ORDER BY 1 + """ + ) + abstract suspend fun getObservedProviderModels(): List + + @Query( + """ + SELECT + strftime('%Y-%m-%d', occurredAtMs / 1000, 'unixepoch', 'localtime') AS localDate, + configId AS configId, + provider AS provider, + model AS model, + COALESCE(SUM( + COALESCE( + totalInputTokens, + CASE + WHEN uncachedInputTokens IS NOT NULL + AND cachedInputTokens IS NOT NULL + AND cacheWriteTokens IS NOT NULL + THEN uncachedInputTokens + cachedInputTokens + cacheWriteTokens + END, + 0 + ) + COALESCE(outputTokens, 0) + ), 0) AS tokens + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY localDate, configId, provider, model + ORDER BY localDate, provider, model, configId + """ + ) + abstract suspend fun getActivityDaysInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 473fed571..7d6bece96 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -10,34 +10,20 @@ import com.ai.assistance.operit.data.dao.ChatContentDao import com.ai.assistance.operit.data.dao.ChatDao import com.ai.assistance.operit.data.dao.MessageDao import com.ai.assistance.operit.data.dao.MessageVariantDao -import com.ai.assistance.operit.data.dao.TokenStatsDao +import com.ai.assistance.operit.data.dao.TokenUsageDao import com.ai.assistance.operit.data.model.ChatEntity import com.ai.assistance.operit.data.model.MessageEntity import com.ai.assistance.operit.data.model.MessageVariantEntity -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatCleanupItemEntity -import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.model.TokenStatRangeCutoffEntity -import com.ai.assistance.operit.data.model.TokenStatResetCutoffEntity +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity /** 应用数据库,包含聊天表和消息表 */ @Database( entities = [ ChatEntity::class, MessageEntity::class, MessageVariantEntity::class, - TokenStatIdentityEntity::class, - TokenStatDisplayModelEntity::class, - TokenStatPriceOverrideEntity::class, - TokenStatEventEntity::class, - TokenStatBaselineEntity::class, - TokenStatResetCutoffEntity::class, - TokenStatRangeCutoffEntity::class, - TokenStatCleanupOperationEntity::class, - TokenStatCleanupItemEntity::class, + TokenUsageRecordEntity::class, + TokenStatsModelEntity::class, ], version = 21, exportSchema = false @@ -50,7 +36,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun messageVariantDao(): MessageVariantDao abstract fun chatContentDao(): ChatContentDao - abstract fun tokenStatsDao(): TokenStatsDao + abstract fun tokenUsageDao(): TokenUsageDao companion object { @Volatile @@ -241,11 +227,7 @@ abstract class AppDatabase : RoomDatabase() { } } - /** - * v20 → v21:token 统计账本表(全部为纯新增,幂等可重入)。 - * 事件表通过外键级联到身份表;baseline 冻结价格语义见 - * [com.ai.assistance.operit.data.stats.TokenBaselineMigrator]。 - */ + /** v20 -> v21: final two-table token statistics schema. Intermediate v21 was unpublished. */ internal val MIGRATION_20_21 = object : Migration(20, 21) { override fun migrate(db: SupportSQLiteDatabase) { @@ -266,242 +248,115 @@ abstract class AppDatabase : RoomDatabase() { private fun runSql(exec: (String) -> Unit) { exec( """ - CREATE TABLE IF NOT EXISTS `token_stat_identities` ( - `identityId` TEXT NOT NULL, - `configId` TEXT NOT NULL, + CREATE TABLE IF NOT EXISTS `token_usage_records` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `importKey` TEXT, + `occurredAtMs` INTEGER, + `source` TEXT NOT NULL, + `configId` TEXT, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, - `displayModelId` TEXT NOT NULL, - PRIMARY KEY(`identityId`) + `category` TEXT, + `status` TEXT, + `requestCount` INTEGER, + `uncachedInputTokens` INTEGER, + `cachedInputTokens` INTEGER, + `cacheWriteTokens` INTEGER, + `totalInputTokens` INTEGER, + `outputTokens` INTEGER, + `reasoningTokens` INTEGER, + `ttftMs` INTEGER, + `durationMs` INTEGER ) """.trimIndent() ) exec( - "CREATE UNIQUE INDEX IF NOT EXISTS " + - "`index_token_stat_identities_configId_provider_model` " + - "ON `token_stat_identities` (`configId`, `provider`, `model`)" + "CREATE INDEX IF NOT EXISTS `index_token_usage_records_occurredAtMs` " + + "ON `token_usage_records` (`occurredAtMs`)" ) exec( - "CREATE INDEX IF NOT EXISTS `index_token_stat_identities_displayModelId` " + - "ON `token_stat_identities` (`displayModelId`)" + "CREATE INDEX IF NOT EXISTS " + + "`index_token_usage_records_provider_model_configId_occurredAtMs` " + + "ON `token_usage_records` " + + "(`provider`, `model`, `configId`, `occurredAtMs`)" ) exec( - """ - CREATE TABLE IF NOT EXISTS `token_stat_display_models` ( - `displayModelId` TEXT NOT NULL, - `normalizedModel` TEXT NOT NULL, - `displayName` TEXT NOT NULL, - PRIMARY KEY(`displayModelId`) - ) - """.trimIndent() + "CREATE INDEX IF NOT EXISTS `index_token_usage_records_source_occurredAtMs` " + + "ON `token_usage_records` (`source`, `occurredAtMs`)" + ) + exec( + "CREATE INDEX IF NOT EXISTS " + + "`index_token_usage_records_category_status_occurredAtMs` " + + "ON `token_usage_records` (`category`, `status`, `occurredAtMs`)" ) exec( "CREATE UNIQUE INDEX IF NOT EXISTS " + - "`index_token_stat_display_models_normalizedModel` " + - "ON `token_stat_display_models` (`normalizedModel`)" + "`index_token_usage_records_importKey` " + + "ON `token_usage_records` (`importKey`)" ) exec( """ - CREATE TABLE IF NOT EXISTS `token_stat_price_overrides` ( - `rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - `scope` TEXT NOT NULL, + CREATE TABLE IF NOT EXISTS `token_stats_models` ( + `configId` TEXT NOT NULL, `provider` TEXT NOT NULL, `model` TEXT NOT NULL, - `configId` TEXT NOT NULL, - `billingMode` TEXT NOT NULL, - `pricingCurrency` TEXT NOT NULL, + `billingMode` TEXT, + `currency` TEXT, `inputPricePerMillion` REAL, `cachedInputPricePerMillion` REAL, `cacheWritePricePerMillion` REAL, `outputPricePerMillion` REAL, - `pricePerRequest` REAL + `pricePerRequest` REAL, + PRIMARY KEY(`configId`, `provider`, `model`) ) """.trimIndent() ) - exec( - "CREATE UNIQUE INDEX IF NOT EXISTS " + - "`index_token_stat_price_overrides_scope_provider_model_configId` " + - "ON `token_stat_price_overrides` (`scope`, `provider`, `model`, `configId`)" - ) + // Copy history once so statistics deletion remains independent from chat storage. exec( """ - CREATE TABLE IF NOT EXISTS `token_stat_events` ( - `eventId` TEXT NOT NULL, - `statIdentityId` TEXT NOT NULL, - `category` TEXT NOT NULL, - `status` TEXT NOT NULL, - `startedAtMs` INTEGER NOT NULL, - `endedAtMs` INTEGER NOT NULL, - `firstTokenAtMs` INTEGER, - `uncachedInputTokens` INTEGER, - `cachedInputTokens` INTEGER, - `cacheWriteTokens` INTEGER, - `outputTokens` INTEGER, - `reasoningTokens` INTEGER, - `reasoningIncludedInOutput` INTEGER, - `billingMode` TEXT NOT NULL, - `pricingCurrency` TEXT NOT NULL, - `inputPricePerMillion` REAL, - `cachedInputPricePerMillion` REAL, - `cacheWritePricePerMillion` REAL, - `outputPricePerMillion` REAL, - `pricePerRequest` REAL, - `pricingSource` TEXT NOT NULL, - `costInPricingCurrency` REAL, - PRIMARY KEY(`eventId`), - FOREIGN KEY(`statIdentityId`) - REFERENCES `token_stat_identities`(`identityId`) - ON UPDATE NO ACTION ON DELETE CASCADE + INSERT INTO `token_usage_records` ( + `occurredAtMs`, `source`, `configId`, `provider`, `model`, + `category`, `status`, `requestCount`, `uncachedInputTokens`, + `cachedInputTokens`, `cacheWriteTokens`, `totalInputTokens`, + `outputTokens`, `reasoningTokens`, `ttftMs`, `durationMs` ) + SELECT + `timestamp`, 'CONVERSATION', NULL, `provider`, `modelName`, + 'CHAT', 'COMPLETED', NULL, MAX(`inputTokens` - `cachedInputTokens`, 0), + `cachedInputTokens`, NULL, `inputTokens`, `outputTokens`, NULL, + NULLIF(`waitDurationMs`, 0), NULLIF(`outputDurationMs`, 0) + FROM `messages` + WHERE `sender` = 'ai' + AND TRIM(`provider`) <> '' + AND TRIM(`modelName`) <> '' + AND (`inputTokens` > 0 OR `cachedInputTokens` > 0 OR `outputTokens` > 0) """.trimIndent() ) - exec( - "CREATE INDEX IF NOT EXISTS " + - "`index_token_stat_events_statIdentityId_startedAtMs` " + - "ON `token_stat_events` (`statIdentityId`, `startedAtMs`)" - ) - exec( - "CREATE INDEX IF NOT EXISTS `index_token_stat_events_startedAtMs` " + - "ON `token_stat_events` (`startedAtMs`)" - ) - exec( - "CREATE INDEX IF NOT EXISTS `index_token_stat_events_category_startedAtMs` " + - "ON `token_stat_events` (`category`, `startedAtMs`)" - ) exec( """ - CREATE TABLE IF NOT EXISTS `token_stat_baselines` ( - `identityId` TEXT NOT NULL, - `inputTokens` INTEGER NOT NULL, - `cachedInputTokens` INTEGER NOT NULL, - `outputTokens` INTEGER NOT NULL, - `requestCount` INTEGER NOT NULL, - `pricingCurrency` TEXT NOT NULL, - `costInPricingCurrency` REAL, - `isEstimated` INTEGER NOT NULL, - `fingerprint` TEXT NOT NULL, - `importedAtMs` INTEGER NOT NULL, - `frozenBillingMode` TEXT NOT NULL, - `frozenInputPricePerMillion` REAL, - `frozenCachedInputPricePerMillion` REAL, - `frozenOutputPricePerMillion` REAL, - `frozenPricePerRequest` REAL, - PRIMARY KEY(`identityId`), - FOREIGN KEY(`identityId`) - REFERENCES `token_stat_identities`(`identityId`) - ON UPDATE NO ACTION ON DELETE CASCADE + INSERT INTO `token_usage_records` ( + `occurredAtMs`, `source`, `configId`, `provider`, `model`, + `category`, `status`, `requestCount`, `uncachedInputTokens`, + `cachedInputTokens`, `cacheWriteTokens`, `totalInputTokens`, + `outputTokens`, `reasoningTokens`, `ttftMs`, `durationMs` ) + SELECT + variants.`messageTimestamp`, 'CONVERSATION', NULL, + variants.`provider`, variants.`modelName`, 'CHAT', 'COMPLETED', NULL, + MAX(variants.`inputTokens` - variants.`cachedInputTokens`, 0), + variants.`cachedInputTokens`, NULL, variants.`inputTokens`, + variants.`outputTokens`, NULL, NULLIF(variants.`waitDurationMs`, 0), + NULLIF(variants.`outputDurationMs`, 0) + FROM `message_variants` AS variants + WHERE TRIM(variants.`provider`) <> '' + AND TRIM(variants.`modelName`) <> '' + AND ( + variants.`inputTokens` > 0 + OR variants.`cachedInputTokens` > 0 + OR variants.`outputTokens` > 0 + ) """.trimIndent() ) - // 事件表增加脱敏诊断列与费用计算所需的结构化列: - // - `acceptedGeneration`:reset tombstone 一致性边界(排空事务检查); - // - `totalInputTokens`:provider 明确上报的总输入(拆分未知时重估直接读取); - // - `cacheWriteSeparateBilling`:缓存写入是否独立计费; - // - `diagnosticsJson`:来源标签、usageObserved、usageReportCount 等诊断元数据。 - // 另新增 `token_stat_reset_cutoffs` 表(reset tombstone)。全部为纯新增, - // 幂等可重入(重复执行时列/表已存在即跳过)。 - try { - exec( - "ALTER TABLE `token_stat_events` ADD COLUMN " + - "`acceptedGeneration` INTEGER NOT NULL DEFAULT 0" - ) - } catch (_: Exception) { - // 列已存在(幂等重放),忽略 - } - try { - exec( - "ALTER TABLE `token_stat_events` ADD COLUMN `totalInputTokens` INTEGER" - ) - } catch (_: Exception) { - // 列已存在(幂等重放),忽略 - } - try { - exec( - "ALTER TABLE `token_stat_events` ADD COLUMN " + - "`cacheWriteSeparateBilling` INTEGER" - ) - } catch (_: Exception) { - // 列已存在(幂等重放),忽略 - } - try { - exec( - "ALTER TABLE `token_stat_events` ADD COLUMN `diagnosticsJson` TEXT" - ) - } catch (_: Exception) { - // 列已存在(幂等重放),忽略 - } - try { - exec( - """ - CREATE TABLE IF NOT EXISTS `token_stat_reset_cutoffs` ( - `kind` TEXT NOT NULL, - `provider` TEXT NOT NULL, - `model` TEXT NOT NULL, - `generation` INTEGER NOT NULL, - PRIMARY KEY(`kind`, `provider`, `model`) - ) - """.trimIndent() - ) - } catch (_: Exception) { - // 表已存在(幂等重放),忽略 - } - try { - exec( - """ - CREATE TABLE IF NOT EXISTS `token_stat_range_cutoffs` ( - `generation` INTEGER NOT NULL, - `startMs` INTEGER NOT NULL, - `endMs` INTEGER NOT NULL, - PRIMARY KEY(`generation`) - ) - """.trimIndent() - ) - } catch (_: Exception) { - // 表已存在(幂等重放),忽略 - } - try { - exec( - """ - CREATE TABLE IF NOT EXISTS `token_stat_cleanup_operations` ( - `operationId` TEXT NOT NULL, - `scope` TEXT NOT NULL, - `targetRef` TEXT NOT NULL, - `deleteBaselines` INTEGER NOT NULL, - `status` TEXT NOT NULL, - `createdAtMs` INTEGER NOT NULL, - PRIMARY KEY(`operationId`) - ) - """.trimIndent() - ) - } catch (_: Exception) { - // 表已存在(幂等重放),忽略 - } - try { - exec( - """ - CREATE TABLE IF NOT EXISTS `token_stat_cleanup_items` ( - `operationId` TEXT NOT NULL, - `identityId` TEXT NOT NULL, - `provider` TEXT NOT NULL, - `model` TEXT NOT NULL, - PRIMARY KEY(`operationId`, `identityId`), - FOREIGN KEY(`operationId`) - REFERENCES `token_stat_cleanup_operations`(`operationId`) - ON UPDATE NO ACTION ON DELETE CASCADE - ) - """.trimIndent() - ) - } catch (_: Exception) { - // 表已存在(幂等重放),忽略 - } - try { - exec( - "CREATE INDEX IF NOT EXISTS `index_token_stat_cleanup_items_operationId` " + - "ON `token_stat_cleanup_items` (`operationId`)" - ) - } catch (_: Exception) { - // 索引已存在(幂等重放),忽略 - } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt deleted file mode 100644 index a4d0c6b19..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatBaselineEntity.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.PrimaryKey - -/** - * 旧 DataStore 累计统计迁移出的 baseline(生命周期总览数据)。 - * - * - baseline 无可靠时间分布,不进入事件表,也不进入时间趋势图。 - * - 每个统计身份至多一行;**首次迁移即冻结**:普通启动/价格 setter 永不重估 - * baseline。只有真实备份恢复流程完成(偏好文件恢复后显式触发的受控补导, - * 见 TokenBaselineImportRunner.markRestorePending/consumePendingRestore) - * 才以 `forceReplace` 语义整体重导一次。 - * - [fingerprint] 只摘要**旧迁移源的累计计数**(不含价格设置),用于诊断与 - * 恢复补导的幂等核对,不再作为普通重导的判据。 - * - [frozen*] 记录本次估算所用价格快照(冻结):恢复补导前保持原值, - * 改价不会改写历史估算。 - * - 费用始终按迁移时可用配置估算并标记 [isEstimated],不能伪装成精确历史账单; - * 无可用定价时 [costInPricingCurrency] 为 null(未知,而非 0)。 - * - 展示分组不在此表重复保存:唯一事实来源是 - * [TokenStatIdentityEntity.displayModelId](经 identityId 外键关联)。 - */ -@Entity( - tableName = "token_stat_baselines", - foreignKeys = [ - ForeignKey( - entity = TokenStatIdentityEntity::class, - parentColumns = ["identityId"], - childColumns = ["identityId"], - onDelete = ForeignKey.CASCADE, - ), - ], -) -data class TokenStatBaselineEntity( - @PrimaryKey @ColumnInfo(name = "identityId") val identityId: String, - @ColumnInfo(name = "inputTokens") val inputTokens: Long, - @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Long, - @ColumnInfo(name = "outputTokens") val outputTokens: Long, - @ColumnInfo(name = "requestCount") val requestCount: Long, - @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, - @ColumnInfo(name = "costInPricingCurrency") val costInPricingCurrency: Double? = null, - @ColumnInfo(name = "isEstimated") val isEstimated: Boolean = true, - @ColumnInfo(name = "fingerprint") val fingerprint: String, - @ColumnInfo(name = "importedAtMs") val importedAtMs: Long, - @ColumnInfo(name = "frozenBillingMode") val frozenBillingMode: String, - @ColumnInfo(name = "frozenInputPricePerMillion") val frozenInputPricePerMillion: Double? = null, - @ColumnInfo(name = "frozenCachedInputPricePerMillion") val frozenCachedInputPricePerMillion: Double? = null, - @ColumnInfo(name = "frozenOutputPricePerMillion") val frozenOutputPricePerMillion: Double? = null, - @ColumnInfo(name = "frozenPricePerRequest") val frozenPricePerRequest: Double? = null, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt deleted file mode 100644 index f23c9a39d..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupItemEntity.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index - -/** - * legacy cleanup operation 的不可变成员快照(阶段 5 P1 闭环)。 - * - * 在创建 operation 的**同一个 Room 删除事务**内从 identity 全表快照解析, - * 只包含 configId 为空串的旧系统迁移身份;provider/model 为不可变快照 - * (成员后续移动/删除不影响已登记的清理目标)。外键级联保证 operation - * 删除时 items 跟随清理(生产流程保留历史,不主动删除)。 - */ -@Entity( - tableName = "token_stat_cleanup_items", - primaryKeys = ["operationId", "identityId"], - foreignKeys = [ - ForeignKey( - entity = TokenStatCleanupOperationEntity::class, - parentColumns = ["operationId"], - childColumns = ["operationId"], - onDelete = ForeignKey.CASCADE, - ) - ], - indices = [Index(value = ["operationId"])], -) -data class TokenStatCleanupItemEntity( - @ColumnInfo(name = "operationId") val operationId: String, - @ColumnInfo(name = "identityId") val identityId: String, - @ColumnInfo(name = "provider") val provider: String, - @ColumnInfo(name = "model") val model: String, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt deleted file mode 100644 index e98cb355d..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatCleanupOperationEntity.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -/** - * 旧 DataStore 累计键清理 outbox operation(阶段 5 P1 闭环)。 - * - * 跨存储删除的**唯一线性化点**是 Room 删除事务:同一事务内解析展示组成员 - * 快照、写 tombstone、删事件、删 baseline,并且**只对旧系统迁移身份** - * (configId 为空串,其累计键按 provider:model 共享且是 baseline 的迁移源) - * 持久化本表 operation + [TokenStatCleanupItemEntity] 不可变快照 - * (operationId + identityId + provider + model)。事务读取失败整体回滚, - * 不会产生半删除或丢失清理信号。 - * - * 状态机(PENDING → APPLIED): - * - PENDING:Room 删除已提交,DataStore 累计键尚未清理(或尚未确认); - * - APPLIED:drain 已在 DataStore 单次 edit 内清键并写入 marker 后 ACK。 - * 失败保持 PENDING,下次冷启动(baseline 导入之前 / pending restore 之前) - * 与删除后立即重试排空。历史行不删除:作为导入 fence(快照 marker 校验) - * 与备份 lineage 使用。 - * - * scope: - * - [SCOPE_DISPLAY_GROUP]:按展示分组删除(targetRef = displayModelId), - * items 精确到该组 configId 为空的成员; - * - [SCOPE_MODEL]:按 provider:model 重置(targetRef = provider:model), - * items 精确到匹配的 configId 为空成员; - * - [SCOPE_ALL]:全量删除(targetRef 为空串),无 items, - * apply 时清除全部旧累计键(不触碰价格等配置)。 - */ -@Entity(tableName = "token_stat_cleanup_operations") -data class TokenStatCleanupOperationEntity( - @PrimaryKey @ColumnInfo(name = "operationId") val operationId: String, - @ColumnInfo(name = "scope") val scope: String, - @ColumnInfo(name = "targetRef") val targetRef: String, - @ColumnInfo(name = "deleteBaselines") val deleteBaselines: Boolean, - @ColumnInfo(name = "status") val status: String, - @ColumnInfo(name = "createdAtMs") val createdAtMs: Long, -) { - companion object { - const val SCOPE_DISPLAY_GROUP = "DISPLAY_GROUP" - const val SCOPE_MODEL = "MODEL" - const val SCOPE_ALL = "ALL" - - const val STATUS_PENDING = "PENDING" - const val STATUS_APPLIED = "APPLIED" - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt deleted file mode 100644 index 872241f90..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatDisplayModelEntity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey - -/** - * 展示模型分组:默认把规范化后同名模型合并为一个展示模型,并允许用户设置手动别名。 - * - * 合并只影响展示与聚合;每条事件仍按自己的身份与价格计算。 - * [displayModelId] 默认为规范化模型名;用户设置别名后 [displayName] 保存别名, - * 其余身份仍通过 [displayModelId] 归属同一分组。 - */ -@Entity( - tableName = "token_stat_display_models", - indices = [ - Index(value = ["normalizedModel"], unique = true), - ], -) -data class TokenStatDisplayModelEntity( - @PrimaryKey @ColumnInfo(name = "displayModelId") val displayModelId: String, - @ColumnInfo(name = "normalizedModel") val normalizedModel: String, - @ColumnInfo(name = "displayName") val displayName: String, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt deleted file mode 100644 index ec5bf8d5b..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatEventEntity.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -/** - * 逐请求统计事件账本(阶段 1 数据契约)。 - * - * - 一行代表一次真实请求/尝试;[eventId] 由记录链路提供稳定标识,用于防止重复入账。 - * - 数值型 token/价格/成本字段为 null 表示“未知”,禁止静默当作 0; - * 0 表示 provider 确认该分量(如无缓存读取、无缓存写入)为 0。 - * token 列使用 [Long]:provider 原值可能超过 Int 范围,聚合与费用计算全程 - * Long 运算,绝不因 Int 溢出产生负数落账(与 baseline 表一致)。 - * - 事件保存发生时的原币价格快照与原币成本([pricingCurrency]/[costInPricingCurrency]), - * 不冻结汇率;跨币种展示换算始终使用当前手动汇率。 - * - [reasoningIncludedInOutput] 是 provider 适配层规范化后的边界声明: - * true = provider 的 output 计数已包含推理 token(计费时不得再加推理); - * false = 推理 token 独立计数,需按输出单价补算; - * null = provider 未声明,计费时按“已包含”处理以避免重复收费。 - * - [totalInputTokens]:provider 明确上报的总输入(含缓存命中/写入),在 - * cached/uncached 拆分未知时仍可表达输入量;费用计算与当前价格重估直接读取, - * 无需解析 [diagnosticsJson]。 - * - [cacheWriteSeparateBilling]:provider 的缓存写入计费模型(结构化保存,供 - * 当前价格重估直接读取;null = 旧行未声明,重估时按 provider/来源推断)。 - * - [diagnosticsJson] 保存必要且脱敏的原始诊断字段(usage 来源标签、是否观察到 - * usage、usage 上报次数等),**不**保存正文、API key、Cookie 或 endpoint 凭据。 - * - 不保存提示词/回复正文,也不保存任何凭据。 - */ -@Entity( - tableName = "token_stat_events", - foreignKeys = [ - ForeignKey( - entity = TokenStatIdentityEntity::class, - parentColumns = ["identityId"], - childColumns = ["statIdentityId"], - onDelete = ForeignKey.CASCADE, - ), - ], - indices = [ - Index(value = ["statIdentityId", "startedAtMs"]), - Index(value = ["startedAtMs"]), - Index(value = ["category", "startedAtMs"]), - ], -) -data class TokenStatEventEntity( - @PrimaryKey @ColumnInfo(name = "eventId") val eventId: String, - @ColumnInfo(name = "statIdentityId") val statIdentityId: String, - @ColumnInfo(name = "category") val category: String, - @ColumnInfo(name = "status") val status: String, - /** 请求开始时捕获的持久单调 generation;reset 不依赖墙钟判断先后。 */ - @ColumnInfo(name = "acceptedGeneration") val acceptedGeneration: Long = 0L, - @ColumnInfo(name = "startedAtMs") val startedAtMs: Long, - @ColumnInfo(name = "endedAtMs") val endedAtMs: Long, - @ColumnInfo(name = "firstTokenAtMs") val firstTokenAtMs: Long? = null, - @ColumnInfo(name = "uncachedInputTokens") val uncachedInputTokens: Long? = null, - @ColumnInfo(name = "cachedInputTokens") val cachedInputTokens: Long? = null, - @ColumnInfo(name = "cacheWriteTokens") val cacheWriteTokens: Long? = null, - @ColumnInfo(name = "totalInputTokens") val totalInputTokens: Long? = null, - @ColumnInfo(name = "outputTokens") val outputTokens: Long? = null, - @ColumnInfo(name = "reasoningTokens") val reasoningTokens: Long? = null, - @ColumnInfo(name = "reasoningIncludedInOutput") val reasoningIncludedInOutput: Boolean? = null, - @ColumnInfo(name = "cacheWriteSeparateBilling") val cacheWriteSeparateBilling: Boolean? = null, - @ColumnInfo(name = "billingMode") val billingMode: String, - @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, - @ColumnInfo(name = "inputPricePerMillion") val inputPricePerMillion: Double? = null, - @ColumnInfo(name = "cachedInputPricePerMillion") val cachedInputPricePerMillion: Double? = null, - @ColumnInfo(name = "cacheWritePricePerMillion") val cacheWritePricePerMillion: Double? = null, - @ColumnInfo(name = "outputPricePerMillion") val outputPricePerMillion: Double? = null, - @ColumnInfo(name = "pricePerRequest") val pricePerRequest: Double? = null, - @ColumnInfo(name = "pricingSource") val pricingSource: String, - @ColumnInfo(name = "costInPricingCurrency") val costInPricingCurrency: Double? = null, - @ColumnInfo(name = "diagnosticsJson") val diagnosticsJson: String? = null, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt deleted file mode 100644 index 7bcff63db..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatIdentityEntity.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.ForeignKey -import androidx.room.Index -import androidx.room.PrimaryKey - -/** - * 统计身份:按 API 配置实例 + provider + model 区分的最小身份。 - * - * 同一 provider/model 配置在不同 API 配置实例下是不同的统计身份,避免不同价格或 - * 路由相互覆盖。旧 DataStore 累计数据不区分配置实例,其身份使用空 [configId]。 - * [identityId] 由 [com.ai.assistance.operit.data.stats.TokenStatIdentityResolver] 稳定生成。 - */ -@Entity( - tableName = "token_stat_identities", - indices = [ - Index(value = ["configId", "provider", "model"], unique = true), - Index(value = ["displayModelId"]), - ], -) -data class TokenStatIdentityEntity( - @PrimaryKey @ColumnInfo(name = "identityId") val identityId: String, - @ColumnInfo(name = "configId") val configId: String, - @ColumnInfo(name = "provider") val provider: String, - @ColumnInfo(name = "model") val model: String, - @ColumnInfo(name = "displayModelId") val displayModelId: String, -) { - /** 兼容旧系统约定的 “provider:model” 复合标识(含空配置实例)。 */ - val providerModel: String - get() = "$provider:$model" -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt deleted file mode 100644 index a0f7a20e0..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatPriceOverrideEntity.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.Index -import androidx.room.PrimaryKey - -/** 价格覆盖的作用范围(固定枚举,数据库只持久化枚举名)。 */ -enum class PriceOverrideScope { - PROVIDER_MODEL, - CONFIG; - - companion object { - /** - * 严格解析:非固定枚举名返回 null。写入边界用它拒绝非法 scope, - * 数据库只可能包含本枚举的 name。 - */ - fun fromNameOrNull(name: String?): PriceOverrideScope? = - entries.firstOrNull { it.name == name } - } -} - -/** - * 价格覆盖:`内置模型默认价 -> provider/model 覆盖 -> 特定 API 配置覆盖` 层级中的 - * 用户覆盖层(后两层)。 - * - * - [scope] = PROVIDER_MODEL:适用于所有配置实例的 provider:model。 - * - [scope] = CONFIG:仅适用于指定 [configId] 的配置实例,优先级最高。 - * 价格为原币单价(每百万 token,或按次计费单价),由 [pricingCurrency] 声明币种。 - * 价格字段为 null 表示该计费方式下不使用。 - * - * 唯一性由**规范化业务字段本身**在数据库层强制(UNIQUE 索引): - * `(scope, provider, model, configId)` 四个字段均为非空规范化值—— - * provider/model 规范化(trim + 小写 + 空白压缩),[configId] 仅 trim; - * PROVIDER_MODEL 范围用空串 `""` 表示“不限定配置实例”。 - * [rowId] 只是内部自增主键,不承载业务语义,REPLACE 后可能变化。 - * - * 写入必须经过 [TokenStatPriceOverrideEntity.normalized](或等价边界): - * 该工厂是唯一保证“规范化后才落库”的构造入口,非法 scope / 空白 provider/model - * 直接抛 [IllegalArgumentException]。DAO 的公开写入方法只接受本工厂产物, - * 不保留任意 entity 的公开插入路径。 - */ -@Entity( - tableName = "token_stat_price_overrides", - indices = [Index(value = ["scope", "provider", "model", "configId"], unique = true)], -) -data class TokenStatPriceOverrideEntity( - @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "rowId") val rowId: Long = 0, - @ColumnInfo(name = "scope") val scope: String, - @ColumnInfo(name = "provider") val provider: String, - @ColumnInfo(name = "model") val model: String, - @ColumnInfo(name = "configId") val configId: String, - @ColumnInfo(name = "billingMode") val billingMode: String, - @ColumnInfo(name = "pricingCurrency") val pricingCurrency: String, - @ColumnInfo(name = "inputPricePerMillion") val inputPricePerMillion: Double? = null, - @ColumnInfo(name = "cachedInputPricePerMillion") val cachedInputPricePerMillion: Double? = null, - @ColumnInfo(name = "cacheWritePricePerMillion") val cacheWritePricePerMillion: Double? = null, - @ColumnInfo(name = "outputPricePerMillion") val outputPricePerMillion: Double? = null, - @ColumnInfo(name = "pricePerRequest") val pricePerRequest: Double? = null, -) { - companion object { - private fun normalizeProvider(provider: String): String = provider.trim().lowercase() - - private fun normalizeModel(model: String): String = - model.trim().lowercase().replace(Regex("\\s+"), " ") - - /** - * 规范化构造(唯一写入入口):scope 必须是固定枚举名,provider/model 规范化, - * configId 仅 trim;PROVIDER_MODEL 范围强制 configId 为空串。 - * 非法 scope 或规范化后为空白的 provider/model 抛 [IllegalArgumentException]。 - * 规范化后相同业务组合在数据库中必然冲突并 REPLACE 覆盖(见实体唯一索引)。 - */ - fun normalized( - scope: String, - provider: String, - model: String, - configId: String?, - billingMode: String, - pricingCurrency: String, - inputPricePerMillion: Double? = null, - cachedInputPricePerMillion: Double? = null, - cacheWritePricePerMillion: Double? = null, - outputPricePerMillion: Double? = null, - pricePerRequest: Double? = null, - ): TokenStatPriceOverrideEntity { - val validScope = - PriceOverrideScope.fromNameOrNull(scope) - ?: throw IllegalArgumentException("非法价格覆盖 scope: $scope") - val canonicalProvider = normalizeProvider(provider) - val canonicalModel = normalizeModel(model) - require(canonicalProvider.isNotEmpty()) { "provider must not be blank" } - require(canonicalModel.isNotEmpty()) { "model must not be blank" } - val canonicalConfigId = - if (validScope == PriceOverrideScope.PROVIDER_MODEL) { - "" - } else { - configId?.trim().orEmpty() - } - // CONFIG 作用域必须携带具体配置 ID:空 configId 的覆盖不会匹配任何 - // 配置实例,属于输入错误,直接拒绝落库(P1-7)。 - if (validScope == PriceOverrideScope.CONFIG) { - require(canonicalConfigId.isNotEmpty()) { "configId must not be blank for CONFIG scope" } - } - return TokenStatPriceOverrideEntity( - scope = validScope.name, - provider = canonicalProvider, - model = canonicalModel, - configId = canonicalConfigId, - billingMode = billingMode, - pricingCurrency = pricingCurrency, - inputPricePerMillion = inputPricePerMillion, - cachedInputPricePerMillion = cachedInputPricePerMillion, - cacheWritePricePerMillion = cacheWritePricePerMillion, - outputPricePerMillion = outputPricePerMillion, - pricePerRequest = pricePerRequest, - ) - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt deleted file mode 100644 index 65e27dab6..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatRangeCutoffEntity.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey - -/** - * 统计时间范围删除 tombstone(阶段 5)—— 范围删除与 spool 排空的一致同步边界。 - * - * - 每次“删除时间范围事件”在同一 Room 事务内写入一行(generation 为主键: - * 由 [com.ai.assistance.operit.data.dao.TokenStatsDao.currentResetGeneration] - * 跨 reset/range 两表统一递增,任意两次删除/重置都不会撞 generation)。 - * - 排空插入事件时([com.ai.assistance.operit.data.dao.TokenStatsDao.insertEventIfNotResetCovered]), - * `acceptedGeneration < generation && startedAtMs ∈ [startMs, endMs)` 的事件跳过, - * 不复活已删除范围的数据;删除后新接受的事件(acceptedGeneration ≥ generation) - * 即使落在同一范围内也正常入账。 - * - 范围删除**只**删除有时间戳的事件,绝不触碰 baseline/身份/分组/价格覆盖。 - * - 行数 = 用户范围删除次数(每行 24 字节级),全量重置时随其他 tombstone 一并清理。 - */ -@Entity(tableName = "token_stat_range_cutoffs") -data class TokenStatRangeCutoffEntity( - @PrimaryKey @ColumnInfo(name = "generation") val generation: Long, - @ColumnInfo(name = "startMs") val startMs: Long, - @ColumnInfo(name = "endMs") val endMs: Long, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt deleted file mode 100644 index 117c5d787..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatResetCutoffEntity.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.ai.assistance.operit.data.model - -import androidx.room.ColumnInfo -import androidx.room.Entity - -/** - * 统计重置 tombstone(reset cutoff)—— reset 与 spool 排空的一致同步边界(P1-3)。 - * - * - FULL 行(kind = "FULL",provider/model 为空串):最近一次全量重置 generation。 - * 排空插入事件时,事件 acceptedGeneration < FULL.generation 则跳过(不复活)。 - * - MODEL 行(kind = "MODEL"):最近一次按模型重置时刻,每 (provider, model) - * 至多一行(REPLACE 覆盖)。 - * - 写入与删除在同一 Room 事务提交(见 - * [com.ai.assistance.operit.data.dao.TokenStatsDao.resetAllStatisticsTx] / - * [com.ai.assistance.operit.data.dao.TokenStatsDao.resetModelTx]);排空插入在 - * 同一事务内检查 tombstone([com.ai.assistance.operit.data.dao.TokenStatsDao.insertEventIfNotResetCovered])。 - * SQLite 事务串行化保证“检查-插入”与“写 tombstone-删除”不交错:并发中已接受 - * 但未入 Room 的事件在 reset 后不会复活。 - * generation 由 Room 事务从所有 tombstone 的最大值递增产生,跨重启持久且不受 - * 同毫秒事件或设备时间回拨影响。 - */ -@Entity( - tableName = "token_stat_reset_cutoffs", - primaryKeys = ["kind", "provider", "model"], -) -data class TokenStatResetCutoffEntity( - @ColumnInfo(name = "kind") val kind: String, - @ColumnInfo(name = "provider") val provider: String, - @ColumnInfo(name = "model") val model: String, - @ColumnInfo(name = "generation") val generation: Long, -) { - companion object { - const val KIND_FULL = "FULL" - const val KIND_MODEL = "MODEL" - - /** - * 按展示分组删除的身份级 tombstone(阶段 5):provider 为空串、 - * model 列存 identityId。精确到身份——同一 provider:model 的其他分组 - * 不受影响;排空插入时事件 identityId 匹配且 acceptedGeneration 更低则跳过。 - */ - const val KIND_IDENTITY = "IDENTITY" - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt new file mode 100644 index 000000000..fa6f9670f --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt @@ -0,0 +1,21 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.Entity +/** User-owned price settings for one provider/model identity. */ +@Entity( + tableName = "token_stats_models", + primaryKeys = ["configId", "provider", "model"], +) +data class TokenStatsModelEntity( + /** Empty means provider/model-wide pricing and the configuration-unscoped identity. */ + val configId: String, + val provider: String, + val model: String, + val billingMode: String? = null, + val currency: String? = null, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt new file mode 100644 index 000000000..565c7c30e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt @@ -0,0 +1,30 @@ +package com.ai.assistance.operit.data.model + +data class TokenUsageIdentity( + val configId: String?, + val provider: String, + val model: String, +) { + fun encode(): String = if (configId == null) { + listOf(UNSCOPED_PREFIX, provider, model).joinToString(SEPARATOR.toString()) + } else { + listOf(CONFIG_PREFIX, configId, provider, model).joinToString(SEPARATOR.toString()) + } + + companion object { + private const val CONFIG_PREFIX = "config" + private const val UNSCOPED_PREFIX = "unscoped" + private const val SEPARATOR = '\u001f' + + fun decode(value: String): TokenUsageIdentity { + val parts = value.split(SEPARATOR) + return when { + parts.size == 3 && parts[0] == UNSCOPED_PREFIX -> + TokenUsageIdentity(null, parts[1], parts[2]) + parts.size == 4 && parts[0] == CONFIG_PREFIX -> + TokenUsageIdentity(parts[1], parts[2], parts[3]) + else -> throw IllegalArgumentException("invalid token usage identity") + } + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt new file mode 100644 index 000000000..f0e38cec5 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt @@ -0,0 +1,47 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** A token usage fact. Imported cumulative counters have no occurrence time. */ +@Entity( + tableName = "token_usage_records", + indices = [ + Index(value = ["occurredAtMs"]), + Index(value = ["provider", "model", "configId", "occurredAtMs"]), + Index(value = ["source", "occurredAtMs"]), + Index(value = ["category", "status", "occurredAtMs"]), + Index(value = ["importKey"], unique = true), + ], +) +data class TokenUsageRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + /** Stable only for one-time imported totals; normal request and conversation rows use null. */ + val importKey: String? = null, + val occurredAtMs: Long?, + val source: String, + val configId: String?, + val provider: String, + val model: String, + val category: String?, + val status: String?, + /** Null means a conversation record proves usage but not the exact provider-call count. */ + val requestCount: Long?, + val uncachedInputTokens: Long? = null, + val cachedInputTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val totalInputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val ttftMs: Long? = null, + val durationMs: Long? = null, +) { + val providerModel: String + get() = "$provider:$model" +} + +object TokenUsageRecordSource { + const val REQUEST = "REQUEST" + const val CONVERSATION = "CONVERSATION" +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 374bb323b..86e003ad0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -1,10 +1,8 @@ package com.ai.assistance.operit.data.preferences import android.content.Context -import com.ai.assistance.operit.util.AppLogger import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey @@ -13,13 +11,15 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.ai.assistance.operit.data.model.ApiProviderType +import com.ai.assistance.operit.data.model.BillingMode import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ParameterCategory import com.ai.assistance.operit.data.model.ParameterValueType -import com.ai.assistance.operit.data.stats.LegacyProviderModelKeyDecoder +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.ReleasedProviderModelKeyDecoder +import com.ai.assistance.operit.data.stats.ModelPriceSettings import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistry -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -33,18 +33,9 @@ import kotlinx.serialization.json.Json private val Context.apiDataStore: DataStore by preferencesDataStore(name = "api_settings") -internal fun usdToCnyStorageValue(rate: Double): Float? = - rate.toFloat().takeIf { it.isFinite() && it > 0f } - -internal fun validStoredUsdToCnyRate(stored: Float?): Double? = +private fun validReleasedUsdToCnyRate(stored: Float?): Double? = stored?.takeIf { it.isFinite() && it > 0f }?.toDouble() -internal fun resolveUsdToCnyExchangeRate(stored: Float?): Double = - validStoredUsdToCnyRate(stored) ?: 7.2 - -internal fun resolveUsdToCnyRateWithEstimate(stored: Float?): Pair = - validStoredUsdToCnyRate(stored)?.let { it to false } ?: (7.0 to true) - class ApiPreferences private constructor(private val context: Context) { // Define our preferences keys @@ -53,7 +44,10 @@ class ApiPreferences private constructor(private val context: Context) { private var INSTANCE: ApiPreferences? = null /** JVM tests can avoid initializing the application-scoped ToolPkg runtime. */ - internal var toolPkgProviderNamesProvider: (() -> List)? = null + internal var toolPkgProviderAliasesProvider: (() -> Map)? = null + + /** JVM tests can provide saved ToolPkg provider IDs without reading model-config DataStore. */ + internal var configuredProviderIdsProvider: (suspend () -> List)? = null fun getInstance(context: Context): ApiPreferences { return INSTANCE ?: synchronized(this) { @@ -93,51 +87,47 @@ class ApiPreferences private constructor(private val context: Context) { } // 动态生成供应商:模型的Token键 - fun getTokenInputKey(providerModel: String) = + private fun getTokenInputKey(providerModel: String) = longPreferencesKey("token_input_${providerModel.replace(":", "_")}") - fun getTokenCachedInputKey(providerModel: String) = + private fun getTokenCachedInputKey(providerModel: String) = longPreferencesKey("token_cached_input_${providerModel.replace(":", "_")}") - fun getTokenOutputKey(providerModel: String) = + private fun getTokenOutputKey(providerModel: String) = longPreferencesKey("token_output_${providerModel.replace(":", "_")}") // 模型定价键 - fun getModelInputPriceKey(providerModel: String) = + private fun getModelInputPriceKey(providerModel: String) = floatPreferencesKey("model_input_price_${providerModel.replace(":", "_")}") - fun getModelCachedInputPriceKey(providerModel: String) = + private fun getModelCachedInputPriceKey(providerModel: String) = floatPreferencesKey("model_cached_input_price_${providerModel.replace(":", "_")}") - fun getModelOutputPriceKey(providerModel: String) = + private fun getModelOutputPriceKey(providerModel: String) = floatPreferencesKey("model_output_price_${providerModel.replace(":", "_")}") // 请求次数统计键 - fun getRequestCountKey(providerModel: String) = + private fun getRequestCountKey(providerModel: String) = intPreferencesKey("request_count_${providerModel.replace(":", "_")}") // 计费方式键 - fun getBillingModeKey(providerModel: String) = + private fun getBillingModeKey(providerModel: String) = stringPreferencesKey("billing_mode_${providerModel.replace(":", "_")}") // 按次计费价格键 - fun getPricePerRequestKey(providerModel: String) = + private fun getPricePerRequestKey(providerModel: String) = floatPreferencesKey("price_per_request_${providerModel.replace(":", "_")}") - /** 旧系统价格/计费方式键前缀(与 [legacyPriceSettingsFrom] 的键构造对应)。 */ - val LEGACY_PRICE_KEY_PREFIXES = - listOf("model_input_price_", "model_cached_input_price_", "model_output_price_", - "billing_mode_", "price_per_request_") - - val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") + private val RELEASED_MODEL_PRICE_KEY_PREFIXES = + listOf( + "model_input_price_", + "model_cached_input_price_", + "model_output_price_", + "billing_mode_", + "price_per_request_", + ) - private val STATS_TARGET_CURRENCY = stringPreferencesKey("stats_target_currency") - private val STATS_COST_MODE = stringPreferencesKey("stats_cost_mode") - private val STATS_INCLUDE_LEGACY = booleanPreferencesKey("stats_include_legacy") - private val STATS_TIME_PRESET = stringPreferencesKey("stats_time_preset") - private val STATS_TIME_CUSTOM_START = longPreferencesKey("stats_time_custom_start") - private val STATS_TIME_CUSTOM_END = longPreferencesKey("stats_time_custom_end") - private val STATS_TIME_MANUAL = booleanPreferencesKey("stats_time_manual") + private val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on") val FEATURE_TOGGLES_JSON = stringPreferencesKey("feature_toggles_json") @@ -223,26 +213,6 @@ class ApiPreferences private constructor(private val context: Context) { const val DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions" const val DEFAULT_MODEL_NAME = "deepseek-v4-flash" - // legacy cleanup applied marker 键前缀(P1 闭环):marker 与累计键清理在 - // 同一次 DataStore.edit 内原子完成;marker 已存在时同 operation 重试为 - // 幂等 no-op。marker ID 集合与 baseline 快照同一次读取,供导入 fence 校验 - // (见 legacyStatsSnapshotWithMarkers)。 - val LEGACY_CLEANUP_MARKER_PREFIX = "legacy_cleanup_applied_" - - fun legacyCleanupMarkerKey(operationId: String): Preferences.Key = - booleanPreferencesKey("$LEGACY_CLEANUP_MARKER_PREFIX$operationId") - - /** 移除指定键名的累计计数键(实例方法与 outbox 纯变更函数共用)。 */ - internal fun removeTokenCountKeysForMutation( - preferences: MutablePreferences, - vararg keyNames: String, - ) { - val names = keyNames.toSet() - preferences.asMap().keys - .filter { it.name in names } - .forEach { preferences.remove(it) } - } - private const val TAG = "ApiPreferences" } @@ -553,123 +523,6 @@ class ApiPreferences private constructor(private val context: Context) { } } - // Save Disable Status Tags setting - /** - * 更新指定供应商:模型的token计数 - * @param providerModel 供应商:模型标识符,格式如"DEEPSEEK:deepseek-chat" - * @param inputTokens 新增的输入token - * @param outputTokens 新增的输出token - * @param cachedInputTokens 新增的缓存命中token - */ - suspend fun updateTokensForProviderModel( - providerModel: String, - inputTokens: Long, - outputTokens: Long, - cachedInputTokens: Long = 0L - ) { - context.apiDataStore.edit { preferences -> - val inputKey = getTokenInputKey(providerModel) - val cachedInputKey = getTokenCachedInputKey(providerModel) - val outputKey = getTokenOutputKey(providerModel) - - val currentInputTokens = readTokenCount(preferences, inputKey.name) - val currentCachedInputTokens = readTokenCount(preferences, cachedInputKey.name) - val currentOutputTokens = readTokenCount(preferences, outputKey.name) - - removeTokenCountKeys( - preferences, - inputKey.name, - cachedInputKey.name, - outputKey.name - ) - preferences[inputKey] = currentInputTokens + inputTokens - preferences[cachedInputKey] = currentCachedInputTokens + cachedInputTokens - preferences[outputKey] = currentOutputTokens + outputTokens - } - } - - /** - * 获取指定供应商:模型的输入token数量 - */ - suspend fun getInputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenInputKey(providerModel).name) - } - - /** - * 获取指定供应商:模型的缓存输入token数量 - */ - suspend fun getCachedInputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - } - - /** - * 获取指定供应商:模型的输出token数量 - */ - suspend fun getOutputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenOutputKey(providerModel).name) - } - - /** - * 获取所有供应商:模型的token统计 - * @return Map<供应商:模型, Triple<输入tokens, 输出tokens, 缓存tokens>> - */ - suspend fun getAllProviderModelTokens(): Map> { - val preferences = context.apiDataStore.data.first() - val result = mutableMapOf>() - val providerNames = registeredToolPkgProviderNames() - - // 遍历所有preferences,查找token相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("token_input_")) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix("token_input_"), providerNames) - val inputTokens = readTokenCountValue(value) - val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) - val cachedInputTokens = - readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - if (inputTokens > 0L || outputTokens > 0L || cachedInputTokens > 0L) { - result[providerModel] = Triple(inputTokens, outputTokens, cachedInputTokens) - } - } - } - - return result - } - - /** - * 获取所有供应商:模型的token统计的Flow - * @return Flow>> - */ - val allProviderModelTokensFlow: Flow>> = - context.apiDataStore.data.map { preferences -> - val result = mutableMapOf>() - val providerNames = registeredToolPkgProviderNames() - - // 遍历所有preferences,查找token相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("token_input_")) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix("token_input_"), providerNames) - val inputTokens = readTokenCountValue(value) - val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) - val cachedInputTokens = - readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - if (inputTokens > 0L || outputTokens > 0L || cachedInputTokens > 0L) { - result[providerModel] = Triple(inputTokens, outputTokens, cachedInputTokens) - } - } - } - - result - } - // Save custom system prompt template suspend fun saveCustomSystemPromptTemplate(template: String) { context.apiDataStore.edit { preferences -> @@ -684,179 +537,115 @@ class ApiPreferences private constructor(private val context: Context) { } } - /** - * legacy cleanup applied marker 键前缀(P1 闭环):marker 与累计键清理在 - * **同一次** DataStore.edit 内原子完成;marker 已存在时同 operation 重试 - * 为幂等 no-op。marker ID 集合与 baseline 快照同一次读取,供导入 fence 校验 - * (见 [legacyStatsSnapshotWithMarkers])。 - */ - val LEGACY_CLEANUP_MARKER_PREFIX = "legacy_cleanup_applied_" - - fun legacyCleanupMarkerKey(operationId: String): Preferences.Key = - booleanPreferencesKey("$LEGACY_CLEANUP_MARKER_PREFIX$operationId") - - /** 读取全部已应用的 legacy cleanup marker operationId 集合(导入 fence 用)。 */ - suspend fun appliedLegacyCleanupMarkerIds(): Set { - val preferences = context.apiDataStore.data.first() - return appliedMarkerIdsFrom(preferences) - } - - /** - * 应用一次 legacy cleanup(P1 闭环 drain 的 DataStore 侧): - * 单次 DataStore.edit 内,若该 operation 的 applied marker 不存在,则精准清除 - * 累计键并写入 marker;marker 已存在则幂等 no-op(崩溃后重放不二次清键)。 - * [providerModels] 为 null 表示 ALL kind:清除全部旧累计键 - * (token_input_ / token_cached_input_ / token_output_ / request_count_ 前缀), - * **绝不触碰价格/计费方式等配置键**与 marker 键。取消向上传播。 - */ - suspend fun applyLegacyCleanup(operationId: String, providerModels: List?) { - require(operationId.isNotBlank()) { "operationId must not be blank" } - context.apiDataStore.edit { preferences -> - applyLegacyCleanupMutation(preferences, operationId, providerModels) - } - } - - private fun appliedMarkerIdsFrom(preferences: Preferences): Set = - preferences.asMap().keys.asSequence() - .map { it.name } - .filter { it.startsWith(LEGACY_CLEANUP_MARKER_PREFIX) } - .map { it.removePrefix(LEGACY_CLEANUP_MARKER_PREFIX) } - .toSet() - - /** - * 旧累计统计快照 + **同一次读取**的 applied marker ID 集合(P1 闭环导入 fence): - * baseline 快照与 marker 集合来自同一个 DataStore 读取,Room 事务内校验全部 - * cleanup operation ID 均包含在该 marker 集合(且无 PENDING)后才允许导入, - * 杜绝“先读旧快照 → cleanup 完成 → 旧快照写回”复活已删除的 baseline。 - */ - suspend fun legacyStatsSnapshotWithMarkers(): LegacyStatsSnapshotRead { + /** One read of the released main token data before ownership moves to Room. */ + suspend fun readTokenStatsMigrationSnapshot(): TokenStatsMigrationSnapshot { val preferences = context.apiDataStore.data.first() - return LegacyStatsSnapshotRead( - snapshot = - com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( - preferences.asMap().mapKeys { it.key.name }, - registeredToolPkgProviderNames(), - ), - cleanupMarkerIds = appliedMarkerIdsFrom(preferences), + val counterPrefixes = listOf( + "token_input_", + "token_cached_input_", + "token_output_", + "request_count_", ) - } - - /** - * 重置所有供应商:模型的token计数,并同步清空新统计账本(事件 + baseline)。 - * P1 闭环:顺序改为 **Room 先删(同一事务写 FULL tombstone + 删除 + 创建 - * ALL cleanup operation)→ 排空 DataStore 累计键(marker 幂等)**,消除 - * 旧的“先清 DataStore 再删新账本”跨存储窗口(新账本删除失败时旧计数不会被 - * 静默清掉;排空失败时 operation 保持 PENDING 由下次启动重试)。 - * @return true = 旧计数与新账本均清零成功;false = 任一步失败 - * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 - * 协程取消(CancellationException)不在此吞掉,向上传播。 - */ - suspend fun resetAllProviderModelTokenCounts(): Boolean { - return try { - com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator - .resetAllStatistics(context) - true - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e(TAG, "重置全部统计:新账本清理失败", e) - false - } - } - - /** - * 重置指定供应商:模型的token计数,并同步清空该模型在新账本中的事件与 baseline - * (所有配置实例身份,见 TokenStatsResetCoordinator)。P1 闭环:顺序与 - * [resetAllProviderModelTokenCounts] 一致(Room 先删 + 创建精确 items 的 - * cleanup operation → 排空 DataStore 累计键)。 - * @return true = 旧计数与新账本均清零成功;false = 任一步失败 - * (已记录错误日志,调用方可据此提示用户重试,不假装成功)。 - * 协程取消(CancellationException)不在此吞掉,向上传播。 - */ - suspend fun resetProviderModelTokenCounts(providerModel: String): Boolean { - return try { - com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator - .resetStatisticsForProviderModel(context, providerModel) - true - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e(TAG, "重置模型统计:新账本清理失败", e) - false - } - } - - /** - * 读取指定 provider:model 的旧系统用户价格设置(阶段 2 事件记录用)。 - * 旧约定:价格键缺失或为 0 视为未设置(0 与“未设置”不可区分), - * 只有 > 0 的值才算用户设置;无任何设置时返回 null。 - */ - suspend fun legacyPriceSettingsFor( - providerModel: String - ): com.ai.assistance.operit.data.stats.LegacyPriceSettings? { - val preferences = context.apiDataStore.data.first() - return legacyPriceSettingsFrom(preferences, providerModel) - } - - /** - * 旧系统**全部** provider:model 用户价格设置的一次快照读取(阶段 3 统计查询 - * 重估口径用):整个偏好文件只读一次(P1-2,杜绝按 identity 逐条读取 DataStore - * 的多次挂起)。键约定与 [legacyPriceSettingsFor] 完全一致:价格键缺失或为 0 - * 视为未设置,只有 > 0 的值才算用户设置;无任何设置的模型不出现。 - */ - suspend fun allLegacyPriceSettings(): Map { - val preferences = context.apiDataStore.data.first() - val candidates = linkedSetOf() - val providerNames = registeredToolPkgProviderNames() + val encodedTotals = linkedSetOf() + val encodedPrices = linkedSetOf() preferences.asMap().keys.forEach { key -> - val name = key.name - for (prefix in LEGACY_PRICE_KEY_PREFIXES) { - if (name.startsWith(prefix) && name.length > prefix.length) { - candidates += LegacyProviderModelKeyDecoder.decode( - name.substring(prefix.length), providerNames) - break + counterPrefixes.firstOrNull { key.name.startsWith(it) }?.let { prefix -> + encodedTotals += key.name.removePrefix(prefix) + } + RELEASED_MODEL_PRICE_KEY_PREFIXES + .firstOrNull { key.name.startsWith(it) } + ?.let { prefix -> encodedPrices += key.name.removePrefix(prefix) } + } + val providerAliases = releasedTokenProviderAliases() + val totals = encodedTotals.mapNotNull { encoded -> + val key = ReleasedProviderModelKeyDecoder.decode(encoded, providerAliases) + val input = readTokenCount(preferences, getTokenInputKey(key.storedProviderModel).name) + val cached = readTokenCount(preferences, getTokenCachedInputKey(key.storedProviderModel).name) + val output = readTokenCount(preferences, getTokenOutputKey(key.storedProviderModel).name) + val requestCount = preferences.asMap().entries + .firstOrNull { it.key.name == getRequestCountKey(key.storedProviderModel).name } + ?.value + .let { it as? Number } + ?.toLong() + ?.coerceAtLeast(0L) + ?: 0L + ReleasedTokenUsageTotal( + provider = key.provider, + model = key.model, + inputTokens = input, + cachedInputTokens = cached, + outputTokens = output, + requestCount = requestCount, + ).takeIf { input > 0L || cached > 0L || output > 0L || requestCount > 0L } + }.groupBy { it.provider to it.model } + .map { (_, totals) -> totals.reduce(ReleasedTokenUsageTotal::plus) } + val prices = encodedPrices.mapNotNull { encoded -> + val key = ReleasedProviderModelKeyDecoder.decode(encoded, providerAliases) + val billingMode = preferences[getBillingModeKey(key.storedProviderModel)]?.let(BillingMode::valueOf) + val inputPrice = positivePrice(preferences[getModelInputPriceKey(key.storedProviderModel)]) + val cachedInputPrice = positivePrice(preferences[getModelCachedInputPriceKey(key.storedProviderModel)]) + val outputPrice = positivePrice(preferences[getModelOutputPriceKey(key.storedProviderModel)]) + val pricePerRequest = positivePrice(preferences[getPricePerRequestKey(key.storedProviderModel)]) + if ( + billingMode == null && + inputPrice == null && + cachedInputPrice == null && + outputPrice == null && + pricePerRequest == null + ) { + return@mapNotNull null + } + val settings = ModelPriceSettings( + billingMode = billingMode, + currency = if (billingMode == BillingMode.COUNT) PricingCurrency.CNY else PricingCurrency.USD, + inputPricePerMillion = inputPrice, + cachedInputPricePerMillion = cachedInputPrice, + outputPricePerMillion = outputPrice, + pricePerRequest = pricePerRequest, + ) + ReleasedTokenPriceSetting(key.provider, key.model, settings) + }.groupBy { it.provider to it.model } + .map { (identity, prices) -> + val settings = prices.map(ReleasedTokenPriceSetting::settings).distinct() + require(settings.size == 1) { + "Conflicting released prices for ${identity.first}:${identity.second}" } + ReleasedTokenPriceSetting(identity.first, identity.second, settings.single()) } - } - return candidates.associateWith { providerModel -> - legacyPriceSettingsFrom(preferences, providerModel) - } + return TokenStatsMigrationSnapshot( + totals = totals, + prices = prices, + usdToCnyRate = validReleasedUsdToCnyRate(preferences[USD_TO_CNY_EXCHANGE_RATE]), + ) } - /** 恢复内置定价时清除旧系统遗留的 provider:model 价格层。 */ - suspend fun clearLegacyPriceSettings(providerModel: String) { + /** Remove released keys and every unpublished token-statistics key after import. */ + suspend fun clearMigratedTokenStatsData() { context.apiDataStore.edit { preferences -> - preferences.remove(getModelInputPriceKey(providerModel)) - preferences.remove(getModelCachedInputPriceKey(providerModel)) - preferences.remove(getModelOutputPriceKey(providerModel)) - preferences.remove(getBillingModeKey(providerModel)) - preferences.remove(getPricePerRequestKey(providerModel)) - } - } - - private fun legacyPriceSettingsFrom( - preferences: Preferences, - providerModel: String - ): com.ai.assistance.operit.data.stats.LegacyPriceSettings? { - val billingRaw = preferences[getBillingModeKey(providerModel)] - val settings = - com.ai.assistance.operit.data.stats.LegacyPriceSettings( - billingMode = billingRaw?.let { com.ai.assistance.operit.data.model.BillingMode.fromString(it) }, - inputPricePerMillion = - preferences[getModelInputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, - cachedInputPricePerMillion = - preferences[getModelCachedInputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, - outputPricePerMillion = - preferences[getModelOutputPriceKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, - pricePerRequest = - preferences[getPricePerRequestKey(providerModel)]?.toDouble()?.takeIf { it > 0.0 }, + val keyPrefixes = listOf( + "token_input_", + "token_cached_input_", + "token_output_", + "request_count_", + "model_input_price_", + "model_cached_input_price_", + "model_cache_write_price_", + "model_output_price_", + "model_pricing_currency_", + "billing_mode_", + "price_per_request_", + "stats_", ) - return settings.takeIf { it.hasAnyUserSetting() } + val keys = preferences.asMap().keys.filter { key -> + key == USD_TO_CNY_EXCHANGE_RATE || keyPrefixes.any(key.name::startsWith) + } + keys.forEach { key -> + @Suppress("UNCHECKED_CAST") + preferences.remove(key as Preferences.Key) + } + } } - private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) = - removeTokenCountKeysForMutation(preferences, *keyNames) - private fun readTokenCount(preferences: Preferences, keyName: String): Long { val values = preferences.asMap().entries .filter { it.key.name == keyName } @@ -873,283 +662,35 @@ class ApiPreferences private constructor(private val context: Context) { } } - // 获取模型输入价格(每百万tokens的美元价格) - suspend fun getModelInputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelInputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 获取模型缓存输入价格(每百万tokens的美元价格) - suspend fun getModelCachedInputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelCachedInputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 获取模型输出价格(每百万tokens的美元价格) - suspend fun getModelOutputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelOutputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 设置模型输入价格(每百万tokens的美元价格) - suspend fun setModelInputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelInputPriceKey(providerModel)] = price.toFloat() - } - } - - // 设置模型缓存输入价格(每百万tokens的美元价格) - suspend fun setModelCachedInputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelCachedInputPriceKey(providerModel)] = price.toFloat() - } - } - - // 设置模型输出价格(每百万tokens的美元价格) - suspend fun setModelOutputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelOutputPriceKey(providerModel)] = price.toFloat() - } - } - - // ===== Request Count Statistics 请求次数统计相关方法 ===== - - /** - * 增加指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符,格式如"DEEPSEEK:deepseek-chat" - */ - suspend fun incrementRequestCountForProviderModel(providerModel: String) { - context.apiDataStore.edit { preferences -> - val countKey = getRequestCountKey(providerModel) - val currentCount = preferences[countKey] ?: 0 - preferences[countKey] = currentCount + 1 - } - } - - /** - * 获取指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符 - * @return 请求次数 - */ - suspend fun getRequestCountForProviderModel(providerModel: String): Int { - val preferences = context.apiDataStore.data.first() - return preferences[getRequestCountKey(providerModel)] ?: 0 - } - - /** - * 获取所有供应商:模型的请求次数统计 - * @return Map<供应商:模型, 请求次数> - */ - suspend fun getAllProviderModelRequestCounts(): Map { - val preferences = context.apiDataStore.data.first() - val result = mutableMapOf() - val providerNames = registeredToolPkgProviderNames() - - // 遍历所有preferences,查找请求次数相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("request_count_")) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix("request_count_"), providerNames) - val count = value as? Int ?: 0 - if (count > 0) { - result[providerModel] = count - } + private suspend fun releasedTokenProviderAliases(): Map { + val registered = toolPkgProviderAliasesProvider?.invoke() + ?: ToolPkgAiProviderRegistry.releasedTokenProviderAliases() + val configured = configuredProviderIdsProvider?.invoke() + ?: ModelConfigManager(context).getAllConfigSummaries().map { it.apiProviderTypeId } + val configuredAliases = + configured + .map(String::trim) + .filter(String::isNotEmpty) + .filter { ApiProviderType.fromProviderTypeId(it) == null } + .associateWith { it } + return buildMap { + configuredAliases.forEach { (providerId, identity) -> + put(providerId, identity) + put("TOOLPKG_${providerId.lowercase()}", identity) } - } - - return result - } - - /** - * 重置指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符 - */ - suspend fun resetProviderModelRequestCount(providerModel: String) { - context.apiDataStore.edit { preferences -> - preferences[getRequestCountKey(providerModel)] = 0 - } - } - - // ===== Billing Mode 计费方式相关方法 ===== - - /** - * 获取指定供应商:模型的计费方式 - * @param providerModel 供应商:模型标识符 - * @return 计费方式,默认为TOKEN - */ - suspend fun getBillingModeForProviderModel(providerModel: String): com.ai.assistance.operit.data.model.BillingMode { - val preferences = context.apiDataStore.data.first() - val modeString = preferences[getBillingModeKey(providerModel)] - return com.ai.assistance.operit.data.model.BillingMode.fromString(modeString) - } - - /** - * 设置指定供应商:模型的计费方式 - * @param providerModel 供应商:模型标识符 - * @param mode 计费方式 - */ - suspend fun setBillingModeForProviderModel(providerModel: String, mode: com.ai.assistance.operit.data.model.BillingMode) { - context.apiDataStore.edit { preferences -> - preferences[getBillingModeKey(providerModel)] = mode.name - } - } - - // ===== Price Per Request 按次计费价格相关方法 ===== - - /** - * 获取指定供应商:模型的按次计费价格 - * @param providerModel 供应商:模型标识符 - * @return 每次请求的价格,未设置时返回0.0 - */ - suspend fun getPricePerRequestForProviderModel(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getPricePerRequestKey(providerModel)]?.toDouble() ?: 0.0 - } - - /** - * 设置指定供应商:模型的按次计费价格(人民币) - * @param providerModel 供应商:模型标识符 - * @param price 每次请求的价格 - */ - suspend fun setPricePerRequestForProviderModel(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getPricePerRequestKey(providerModel)] = price.toFloat() + // The active registration supplies the display identity used by new requests. + putAll(registered) } } - suspend fun getUsdToCnyExchangeRate(): Double { - val preferences = context.apiDataStore.data.first() - return resolveUsdToCnyExchangeRate(preferences[USD_TO_CNY_EXCHANGE_RATE]) + private fun splitProviderModel(providerModel: String): Pair? { + val separator = providerModel.indexOf(':') + if (separator <= 0 || separator == providerModel.lastIndex) return null + return providerModel.substring(0, separator) to providerModel.substring(separator + 1) } - /** - * 统计页汇率读取(阶段 4):区分“用户手动设置”与“未设置”。 - * 未设置时返回默认估算 7.0([com.ai.assistance.operit.data.stats.TokenCostCurrency] - * 契约)并标记 estimated = true,界面必须显示估算提示;不联网获取汇率。 - */ - suspend fun usdToCnyRateWithEstimate(): Pair { - val preferences = context.apiDataStore.data.first() - return resolveUsdToCnyRateWithEstimate(preferences[USD_TO_CNY_EXCHANGE_RATE]) - } - - suspend fun setUsdToCnyExchangeRate(rate: Double) { - val stored = requireNotNull(usdToCnyStorageValue(rate)) { - "USD to CNY exchange rate must remain finite and positive as Float" - } - context.apiDataStore.edit { preferences -> - preferences[USD_TO_CNY_EXCHANGE_RATE] = stored - } - } - - // ===== 统计页偏好(阶段 4;与汇率共用 api_settings 文件,备份自动覆盖) ===== - - suspend fun getStatsTargetCurrency(): com.ai.assistance.operit.data.collects.PricingCurrency { - val preferences = context.apiDataStore.data.first() - val raw = preferences[STATS_TARGET_CURRENCY] - return if (raw.equals(com.ai.assistance.operit.data.collects.PricingCurrency.USD.name, ignoreCase = true)) { - com.ai.assistance.operit.data.collects.PricingCurrency.USD - } else { - com.ai.assistance.operit.data.collects.PricingCurrency.CNY - } - } - - suspend fun setStatsTargetCurrency( - currency: com.ai.assistance.operit.data.collects.PricingCurrency - ) { - context.apiDataStore.edit { preferences -> - preferences[STATS_TARGET_CURRENCY] = currency.name - } - } - - suspend fun getStatsCostMode(): com.ai.assistance.operit.data.stats.TokenStatsCostMode { - val preferences = context.apiDataStore.data.first() - val raw = preferences[STATS_COST_MODE] - return com.ai.assistance.operit.data.stats.TokenStatsCostMode.entries - .firstOrNull { it.name == raw } - ?: com.ai.assistance.operit.data.stats.TokenStatsCostMode.HISTORICAL - } - - suspend fun setStatsCostMode(mode: com.ai.assistance.operit.data.stats.TokenStatsCostMode) { - context.apiDataStore.edit { preferences -> - preferences[STATS_COST_MODE] = mode.name - } - } - - private fun registeredToolPkgProviderNames(): List = - toolPkgProviderNamesProvider?.invoke() - ?: ToolPkgAiProviderRegistry.list().map { it.displayName } - - /** 旧版累计 baseline 是否加入生命周期累计;缺省开启以保持升级前后的总计连续。 */ - suspend fun getStatsIncludeLegacy(): Boolean { - val preferences = context.apiDataStore.data.first() - return preferences[STATS_INCLUDE_LEGACY] ?: true - } - - suspend fun setStatsIncludeLegacy(include: Boolean) { - context.apiDataStore.edit { preferences -> - preferences[STATS_INCLUDE_LEGACY] = include - } - } - - /** - * 统计页时间选择(阶段 4):null = 从未有任何选择(首次进入,允许自动回退)。 - * CUSTOM 预设必须同时存在合法自定义边界,否则视为未选择(防御损坏状态)。 - */ - suspend fun getStatsTimeSelection(): com.ai.assistance.operit.data.stats.TokenStatsTimeSelection? { - val preferences = context.apiDataStore.data.first() - val presetRaw = preferences[STATS_TIME_PRESET] ?: return null - val preset = com.ai.assistance.operit.data.stats.TokenStatsPreset.entries - .firstOrNull { it.name == presetRaw } - ?: return null - if (preset != com.ai.assistance.operit.data.stats.TokenStatsPreset.CUSTOM) { - return com.ai.assistance.operit.data.stats.TokenStatsTimeSelection(preset) - } - val start = preferences[STATS_TIME_CUSTOM_START] ?: return null - val end = preferences[STATS_TIME_CUSTOM_END] ?: return null - if (end <= start) return null - return com.ai.assistance.operit.data.stats.TokenStatsTimeSelection(preset, start, end) - } - - /** - * 统计页时间选择是否由用户手动做出(阶段 4)。 - * false = 首次自动回退结果;旧版本持久化的选择没有该键,按 false 处理 - * (选择本身仍被复用,只是不再区分来源,迁移合理)。 - */ - suspend fun getStatsSelectionWasManual(): Boolean { - val preferences = context.apiDataStore.data.first() - return preferences[STATS_TIME_MANUAL] ?: false - } - - /** - * 统计页时间选择保存(阶段 4):[manual] = 用户手动选择(true)或首次 - * 自动回退(false)。清除时([selection] = null)一并移除 manual 键, - * 回到“从未选择”的首次回退语义。 - */ - suspend fun setStatsTimeSelection( - selection: com.ai.assistance.operit.data.stats.TokenStatsTimeSelection?, - manual: Boolean, - ) { - context.apiDataStore.edit { preferences -> - if (selection == null) { - preferences.remove(STATS_TIME_PRESET) - preferences.remove(STATS_TIME_CUSTOM_START) - preferences.remove(STATS_TIME_CUSTOM_END) - preferences.remove(STATS_TIME_MANUAL) - return@edit - } - preferences[STATS_TIME_PRESET] = selection.preset.name - preferences[STATS_TIME_MANUAL] = manual - if (selection.preset == com.ai.assistance.operit.data.stats.TokenStatsPreset.CUSTOM) { - preferences[STATS_TIME_CUSTOM_START] = selection.customStartMs ?: 0L - preferences[STATS_TIME_CUSTOM_END] = selection.customEndMs ?: 0L - } else { - preferences.remove(STATS_TIME_CUSTOM_START) - preferences.remove(STATS_TIME_CUSTOM_END) - } - } - } + private fun positivePrice(value: Float?): Double? = + value?.toDouble()?.takeIf { it.isFinite() && it > 0.0 } suspend fun saveMaxImageHistoryUserTurns(turns: Int) { context.apiDataStore.edit { preferences -> @@ -1181,51 +722,33 @@ class ApiPreferences private constructor(private val context: Context) { } } -/** - * 单次 DataStore.edit 内的 legacy cleanup 变更(P1 闭环,纯函数): - * - marker 已存在 → 严格 no-op(崩溃后重放不二次清键,也不写任何值); - * - [providerModels] == null(ALL kind)→ 清除全部旧累计键 - * (token_input_/token_cached_input_/token_output_/request_count_ 前缀), - * 价格/计费方式等配置键与 marker 键一律保留; - * - 否则只清除这些 provider:model 的累计键与 request_count; - * 之后写入 operation marker(与清理同一次 edit 原子提交)。 - * 独立为纯函数以便 Windows JVM 测试直接验证键级语义(DataStore.edit 只是薄壳)。 - */ -internal fun applyLegacyCleanupMutation( - preferences: MutablePreferences, - operationId: String, - providerModels: List?, +data class TokenStatsMigrationSnapshot( + val totals: List, + val prices: List, + val usdToCnyRate: Double?, +) + +data class ReleasedTokenUsageTotal( + val provider: String, + val model: String, + val inputTokens: Long, + val cachedInputTokens: Long, + val outputTokens: Long, + val requestCount: Long, ) { - require(operationId.isNotBlank()) { "operationId must not be blank" } - val markerKey = ApiPreferences.legacyCleanupMarkerKey(operationId) - if (preferences[markerKey] == true) return - if (providerModels == null) { - val keysToRemove = - preferences.asMap().keys.filter { key -> - key.name.startsWith("token_input_") || - key.name.startsWith("token_cached_input_") || - key.name.startsWith("token_output_") || - key.name.startsWith("request_count_") - } - keysToRemove.forEach { preferences.remove(it) } - } else { - providerModels.distinct().forEach { providerModel -> - ApiPreferences.removeTokenCountKeysForMutation( - preferences, - ApiPreferences.getTokenInputKey(providerModel).name, - ApiPreferences.getTokenCachedInputKey(providerModel).name, - ApiPreferences.getTokenOutputKey(providerModel).name, - ) - preferences.remove(ApiPreferences.getRequestCountKey(providerModel)) - } + operator fun plus(other: ReleasedTokenUsageTotal): ReleasedTokenUsageTotal { + require(provider == other.provider && model == other.model) + return copy( + inputTokens = Math.addExact(inputTokens, other.inputTokens), + cachedInputTokens = Math.addExact(cachedInputTokens, other.cachedInputTokens), + outputTokens = Math.addExact(outputTokens, other.outputTokens), + requestCount = Math.addExact(requestCount, other.requestCount), + ) } - preferences[markerKey] = true } -/** - * baseline 快照 + 同一次 DataStore 读取的 applied marker ID 集合(导入 fence 用)。 - */ -data class LegacyStatsSnapshotRead( - val snapshot: com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot, - val cleanupMarkerIds: Set, +data class ReleasedTokenPriceSetting( + val provider: String, + val model: String, + val settings: ModelPriceSettings, ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt deleted file mode 100644 index 776c82cd1..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/CleanupOutbox.kt +++ /dev/null @@ -1,848 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal CleanupOutbox responsibilities extracted from [TokenStatSpool]. */ -internal fun TokenStatSpool.stuckAckTrashEvidenceLocked(context: Context): List { - val dir = spoolDir(context.applicationContext) - if (!dir.isDirectory) return emptyList() - // P1-6:根目录枚举统一走 [listDir](测试可注入失败);失败(null)即明确抛错, - // 绝不当作“没有 trash”返回(否则 export 会成功遗漏全部 stuck 证据)。 - val rootFiles = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for ack trash: ${dir.absolutePath}") - // 先完整枚举结果,再对每个候选做 NOFOLLOW 目录验证(File.isDirectory 会跟随符号链接) - return rootFiles - .filter { f -> - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) && - Files.isDirectory(f.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS) - } - .mapNotNull { trash -> - // P1-6:trash 枚举失败(null)是明确失败,绝不当作空目录跳过该证据 - val files = listDir(trash) - ?: throw IOException("cannot enumerate ack trash directory: ${trash.name}") - files.takeIf { it.isNotEmpty() }?.let { trash } - } - .sortedBy { it.name } -} -/** - * ack 的主 manifest 重写(P1-1,调用方持 lifecycleMutex):发布前投影实际总量 + 最坏 - * sidecar 增量,超限有界失败不写文件;测试注入缝照常生效。失败抛 [IOException]。 - */ -internal suspend fun TokenStatSpool.rewriteAckManifestLocked(context: Context, manifestFile: File, newContent: String) { - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - throw IOException("tombstone manifest rewrite refused: metadata budget over the total cap") - } - if (metadataWriteErrorForTest?.invoke(manifestFile) == true) { - throw IOException("tombstone manifest write failed (injected)") - } - summaryStore(manifestFile).write(newContent) -} -/** - * 写入 ack trash 状态文件(P1-2,调用方持 lifecycleMutex):UNCOMMITTED + mapping(只含 - * 已 stage 文件)。P1-1:发布前投影预算,超限返回 false。写失败返回 false(调用方保留 - * 无状态 trash,维护 fail-closed 保留,绝不误删)。原子崩溃安全写入(sidecar 可恢复)。 - * - * P2 终审:mapping 身份(bytes+sha256)必须从**实际当前所在文件**捕获——回滚 move 已 - * 可见但目录项 sync 失败时,文件可能已回到原路径(trash 内已无此文件),此时从已移走 - * 的 target 盲读会得到 0 字节/空哈希的伪身份,甚至使整个状态写入失败;因此 target - * 存在读 target,否则读 original(两者内容同一,身份一致)。两者都不存在(文件消失, - * 不可能的正常路径)→ 返回 false fail-closed,绝不写残缺 mapping。 - */ -internal suspend fun TokenStatSpool.writeUncommittedTrashState( - context: Context, - trashDir: File, - staged: List>, -): Boolean { - return try { - val mappingEntries = staged.map { (original, target) -> - // P2 终审:身份从实际所在位置捕获(trash 或 original),绝不盲读已移走的 target。 - val location = - when { - target.exists() -> target - original.exists() -> original - else -> null - } - if (location == null) { - logE( - "statistics ack trash state identity unavailable; refusing to write mapping: " + - "${original.name}", - ) - return false - } - AckMappingEntry( - original = original.name, - trashName = target.name, - bytes = location.length(), - sha256 = sha256Hex(location.readBytes()), - ) - } - val stateContent = buildAckStateContent(ACK_STATE_UNCOMMITTED, mappingEntries) - if (metadataWriteBudgetExceeded(context, stateContent.toByteArray(Charsets.UTF_8).size)) { - logE("statistics ack trash state publish refused: metadata budget over the total cap") - return false - } - summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)).write(stateContent) - true - } catch (e: Exception) { - logE("statistics ack trash state write failed", e) - false - } -} -internal data class RollbackStagedResult( - val success: Boolean, - val syncFailed: Boolean, -) -/** - * ack 的 rename 回滚(reviewer P1 + P2 终审):把已 stage 进 trash 的文件按逆序移回原位; - * 全部成功且目录项全部确认持久才删除本轮 trash 目录(含状态/mapping 文件)。P2:每个 - * 移动/删除都是目录项变更——move 后必须严格 sync([requireSpoolDirSync],跨 spool 根与 - * trash 两个目录),任一非 OK 置 [RollbackStagedResult.syncFailed]:上层保留 - * UNCOMMITTED 状态并失败,绝不带着未确认状态声称回滚完成(变更可见时下一轮按 - * mapping+identity 幂等完成)。某个回滚失败时保留 trash 及其证据(状态仍为 UNCOMMITTED - * + mapping,维护入口按状态机判定/回滚,绝不误删),同样报告失败。 - */ -internal fun TokenStatSpool.rollbackStagedRenames( - staged: List>, - trashDir: File, -): RollbackStagedResult { - val dir = trashDir.parentFile ?: return RollbackStagedResult(false, false) - var allRolledBack = true - var syncFailed = false - for ((original, target) in staged.asReversed()) { - if (!target.exists()) continue - if (original.exists() || !atomicMoveForAck(target, original)) { - allRolledBack = false - logE( - "statistics ack rollback failed for ${original.name}; evidence stays in ${trashDir.name}", - ) - continue - } - // P2 终审:回滚 move 跨 spool 根与 trash 两个目录——两者目录项都必须确认持久; - // 非 OK 置 syncFailed(调用方保留 UNCOMMITTED 状态并失败)。 - if (!requireSpoolDirSync(dir, trashDir)) { - logE("statistics ack rollback move not durable: ${original.name}") - syncFailed = true - } - } - if (allRolledBack && !syncFailed) { - if (!deleteAckTrashDirNoFollow(trashDir)) { - logE("statistics ack trash directory cleanup failed after rollback: ${trashDir.name}") - allRolledBack = false - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable after rollback: ${trashDir.name}") - syncFailed = true - } - } - return RollbackStagedResult(allRolledBack, syncFailed) -} -/** - * 目标路径归属预检(reviewer P1,防目录穿越):ack 只接受 spool 根目录下的单层相对 - * 文件名——非空、不含路径分隔符、不是 "."/"..",且解析后父目录仍是 spool 根目录。 - */ -internal fun TokenStatSpool.requireSafeEvidenceName(dir: File, name: String) { - if (!isSafeEvidenceName(dir, name)) { - throw IOException("unsafe acknowledged evidence name: $name") - } -} -/** 单层相对名检查(reviewer P1,防目录穿越):非空、不含分隔符、解析后父目录是 dir。 */ -internal fun TokenStatSpool.isSafeEvidenceName(dir: File, name: String): Boolean = - name.isNotBlank() && - name != "." && - name != ".." && - !name.contains('/') && - !name.contains('\\') && - File(dir, name).parentFile?.canonicalFile == dir.canonicalFile -/** trash 内文件名检查(P1-2):同 [isSafeEvidenceName],父目录必须是 trash 目录本身。 */ -internal fun TokenStatSpool.isSafeTrashName(trash: File, name: String): Boolean = - name.isNotBlank() && - name != "." && - name != ".." && - !name.contains('/') && - !name.contains('\\') && - File(trash, name).parentFile?.canonicalFile == trash.canonicalFile -internal data class AckMappingEntry( - val original: String, - val trashName: String, - val bytes: Long, - val sha256: String, -) -internal fun TokenStatSpool.ackMappingLine(entry: AckMappingEntry): String = - JSONObject() - .put("o", entry.original) - .put("t", entry.trashName) - .put("b", entry.bytes) - .put("s", entry.sha256) - .toString() -internal fun TokenStatSpool.parseAckMappingLine(line: String): AckMappingEntry? = try { - val obj = JSONObject(line) - val original = obj.optString("o").takeIf { it.isNotEmpty() } ?: return null - val trashName = obj.optString("t").takeIf { it.isNotEmpty() } ?: return null - AckMappingEntry( - original = original, - trashName = trashName, - bytes = obj.optLong("b", -1L), - sha256 = obj.optString("s", ""), - ) -} catch (_: Exception) { - null -} -/** - * P1-2 修复:状态 mapping 的**全有或全无**解析(调用方持 lifecycleMutex)。任一条件失败 - * 返回 null,调用方对整个 trash fail-closed 保留(不执行 delete/rollback/manifest 改动): - * - header 之后的每一行都必须解析成功(mapNotNull 静默丢弃损坏行会漏掉未回滚的证据, - * 导致 trash 被整体删除); - * - bytes/sha256 必须完整(缺失即身份不可校验); - * - 原名/trash 名必须单层安全(防穿越),且无重复(同名两份证据无法可靠处置); - * - trash 内所有普通文件(排除状态文件及其 `.new`/`.bak`/`.tmp*` sidecar)都必须被 - * mapping 覆盖(mapping 数量与证据文件集合完整对应);存在符号链接/特殊文件也 fail-closed。 - * - * mapping 条目引用的 trash 文件**缺失**是允许的(该文件可能已在先前一次回滚中移回原槽位, - * 由 [rollbackUncommittedTrash] 按原槽位身份判定),因此这里只校验“trash 里的每个文件都 - * 有 mapping”,不要求反向一一对应。 - */ -internal fun TokenStatSpool.parseAckMappingStrict( - dir: File, - trash: File, - lines: List, -): List? { - val entries = mutableListOf() - val originals = HashSet() - val trashNames = HashSet() - for (raw in lines.drop(1)) { - val entry = parseAckMappingLine(raw) ?: return null - if (entry.bytes < 0L || entry.sha256.isEmpty()) return null - if (!isSafeEvidenceName(dir, entry.original)) return null - if (!isSafeTrashName(trash, entry.trashName)) return null - if (!originals.add(entry.original)) return null - if (!trashNames.add(entry.trashName)) return null - entries += entry - } - val stateBase = ACK_TRASH_STATE_FILE_NAME - // P1-5 fail-closed:trash 目录枚举失败(null)时内部证据集合不可知——mapping 无法 - // 证明覆盖了全部证据文件,任何 rollback 后对 trash 的整体删除都会丢失未枚举的证据, - // 立即返回 null 使整个 trash 被保留。 - val trashFiles = listDir(trash) - if (trashFiles == null) { - logE("statistics ack trash directory enumeration failed; retaining trash: ${trash.name}") - return null - } - val unaccounted = trashFiles.any { file -> - val name = file.name - val isStateSidecar = - name == stateBase || - name == "$stateBase.new" || - name == "$stateBase.bak" || - name.startsWith("$stateBase.tmp") - if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { - // 符号链接/特殊文件:无法按身份管理,fail-closed - true - } else if (isStateSidecar) { - false - } else { - name !in trashNames - } - } - return if (unaccounted) null else entries -} -/** 状态文件内容:首行状态 + 每行一条 mapping(状态与 mapping 一次原子写入,无半写窗口)。 */ -internal fun TokenStatSpool.buildAckStateContent(state: String, entries: List): String = - state + "\n" + entries.joinToString("\n") { ackMappingLine(it) } + "\n" -/** ack 只管理 spool 根目录中的普通文件,不跟随符号链接或其他特殊路径。 */ -internal fun TokenStatSpool.requireManageableEvidenceFile(dir: File, file: File) { - val dirPath = dir.canonicalFile.toPath() - val filePath = file.toPath() - if (file.parentFile?.canonicalFile?.toPath() != dirPath || - Files.isSymbolicLink(filePath) || - !Files.isRegularFile(filePath, java.nio.file.LinkOption.NOFOLLOW_LINKS) || - file.canonicalFile.parentFile?.toPath() != dirPath - ) { - throw IOException("unmanageable acknowledged evidence path: ${file.name}") - } -} -/** - * P1-3:stuck ack trash 目录的删除前校验(防目录穿越/符号链接):只接受 spool 根内匹配 - * [ACK_TRASH_PREFIX] 的真实普通目录(NOFOLLOW_LINKS),拒绝符号链接与特殊路径。 - */ -internal fun TokenStatSpool.requireAckTrashDirForDelete(dir: File, trash: File) { - if (!trash.name.startsWith(ACK_TRASH_PREFIX)) { - throw IOException("not an ack trash directory: ${trash.name}") - } - if (Files.isSymbolicLink(trash.toPath())) { - throw IOException("ack trash must not be a symbolic link: ${trash.name}") - } - if (!Files.isDirectory(trash.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) { - throw IOException("ack trash must be a real directory: ${trash.name}") - } - if (trash.canonicalFile.parentFile?.toPath() != dir.canonicalFile.toPath()) { - throw IOException("ack trash escapes the spool root: ${trash.name}") - } -} -/** - * P1-3:递归删除 ack trash 目录(NOFOLLOW):不跟随符号链接(链接本身被删除,绝不触及 - * 其目标),只删除普通文件与空目录;任何遍历/删除失败返回 false(调用方保留并报错)。 - */ -internal fun TokenStatSpool.deleteAckTrashDirNoFollow(trash: File): Boolean { - return try { - Files.walkFileTree( - trash.toPath(), - EnumSet.noneOf(FileVisitOption::class.java), - Int.MAX_VALUE, - object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - Files.delete(file) - return FileVisitResult.CONTINUE - } - - override fun postVisitDirectory(dir: Path, exc: IOException?): FileVisitResult { - if (exc != null) throw exc - Files.delete(dir) - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { - throw exc - } - }, - ) - true - } catch (e: Exception) { - logE("statistics ack trash no-follow deletion failed: ${trash.name}", e) - false - } -} -/** prepare/rollback 必须是同 filesystem 的原子 move,且绝不覆盖同名目标。 */ -internal fun TokenStatSpool.atomicMoveForAck(from: File, to: File): Boolean { - ackAtomicMoveForTest?.invoke(from, to)?.let { return it } - // 兼容现有故障注入缝;生产为 null 时仍走真正的 ATOMIC_MOVE。 - segmentRenameForTest?.invoke(from, to)?.let { return it } - if (to.exists()) return false - return try { - Files.move(from.toPath(), to.toPath(), StandardCopyOption.ATOMIC_MOVE) - true - } catch (e: AtomicMoveNotSupportedException) { - false - } catch (e: IOException) { - false - } -} -/** - * P1-2 维护/后台重试:清理删除失败被隔离的残留。ack trash 按持久状态机处置(**只有显式 - * COMMITTED 有界补删**;UNCOMMITTED 一律按 mapping+identity 回滚——P1-1:绝不根据主 - * manifest 缺失推断已提交;状态缺失/损坏或回滚长期失败的 trash 保留为 StuckAckEvidence - * 由 UI 管理,绝不自动删除);pending-delete 证据(容量内来源)移回完整证据区;tombstoned - * 段按记录的处置动作重试(over-cap → 删除,容量内 → 移回完整证据区),处置前必须按稳定 - * identity(file+bytes+sha256)校验当前文件仍是记录的段(P1-2:同名不同 hash 的健康新段 - * 绝不删/移,只移除陈旧记录)。成功后从 manifest 移除记录;文件已物理消失也移除记录 - * (除非身份仍停留在未提交 trash 中——P1-2:绝不让崩溃窗口判定把未确认的证据误判为已提交 - * 而删除)。P1-4:存在无法完整严格解析/读取的 UNCOMMITTED ack trash( - * [scanUncommittedTrashHolds] 的 hasUnknown)时,本轮**整轮跳过** stale 判定与 manifest - * 重写(缺失/不匹配条目的身份可能正被其持有),记录日志并有界返回。 - * - * P1-2 终审:**任何目录项变更(rename/delete)后 syncDir 非 OK 都不推进状态**—— - * 不返回 RECORDED、不移除 manifest 条目、不把变更视为完成:pending-delete 恢复 rename - * 非 OK 时尽力把文件移回 pending-delete 名(重建明确可重试记录)、seal_failed 删除与 - * tombstone 处置非 OK 时保留 manifest 条目,并返回 false 让 drain 退避重试。由于文件 - * 操作可能已可见,下一轮 bootstrap gate([ensureDirectoryDurabilityConfirmed])sync OK - * 后按 identity 幂等完成,绝不丢证据。失败仅记录(tombstone 本身就是有界可见错误证据), - * 绝不阻塞健康排空——本函数返回 true 时 drain 继续处理健康段。 - * - * @return false 表示本轮存在目录项未确认持久的变更(调用方 [drainCore] 退避重试); - * 其它失败(rename/delete 返回 false、状态无效、枚举失败)保留对应可重试记录并返回 true。 - */ -internal suspend fun TokenStatSpool.retryPendingCleanup(context: Context): Boolean { - val dir = spoolDir(context) - if (!dir.isDirectory) return true - var roundOk = true - // P1-3:不设 canonical isFile 前置——仅 sidecar 存在时也必须先恢复再处置受管段。 - // P1-2 fail-closed:manifest 不可读则维护中止并抛明确 IOException(drain 退避重试), - // 绝不当作“无受管记录”继续——那会让扫描器把受管段当健康段处理;ack trash 的“已提交” - // 判定也依赖主 manifest,不能拿空集冒充。manifest 不存在(从未有受管记录)视为空集。 - val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) - val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) - val lines = rawLines?.lineSequence()?.filter { it.isNotBlank() }?.toList() ?: emptyList() - // reviewer P1:ack trash 状态机(只有显式 COMMITTED 有界补删;UNCOMMITTED 一律按 - // mapping+identity 回滚——P1-1:绝不根据主 manifest 缺失推断已提交,普通 quarantine - // 证据从不在 manifest 中,缺失恒成立,旧推断会误删未确认的证据)。符号链接目录绝不 - // 进入处置(跟随链接可能删除链接目标的内容)。 - // P1-5:spool 根枚举失败(null)时本轮跳过 trash 处置并记录——枚举失败绝不是 - // “没有 trash 目录”,绝不静默放行;身份持有判定由 [scanUncommittedTrashHolds] 的 - // hasUnknown fail-closed 另行兜底(stale 清理整轮跳过)。 - val ackTrashCandidates = listDir(dir) - if (ackTrashCandidates == null) { - logE("statistics spool directory enumeration failed; deferring ack trash disposal") - } else { - ackTrashCandidates - .filter { f -> - f.isDirectory && - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) - } - .forEach { trash -> - if (!handleAckTrashDir(dir, trash)) roundOk = false - } - } - // P1-2/P1-4:处置后仍停留在未提交 trash 中的身份 → manifest 条目绝不按 stale 移除; - // 存在无法完整严格解析的 UNCOMMITTED trash 时(hasUnknown)本轮保守跳过 stale 处置 - val trashHold = scanUncommittedTrashHolds(context) - val heldInTrash = trashHold.known - dir.listFiles { f -> f.isFile && f.name.startsWith(PENDING_DELETE_PREFIX) } - ?.forEach { file -> - val target = File( - dir, - "$QUARANTINE_PREFIX${file.name.removePrefix(PENDING_DELETE_PREFIX)}", - ) - if (renameForTest(file, target)) { - if (!requireSpoolDirSync(dir)) { - // P1-2 终审:rename 已可见但目录项未确认持久——不推进状态:尽力把文件 - // 移回 pending-delete 名(重建明确可重试记录),并让本轮失败退避;崩溃 - // 后文件在任一名字下都保留证据,下一轮按名字/身份幂等完成。P1-1: - // 非 OK 同时失效 gate。 - logE( - "statistics pending-delete evidence restore rename not durable; " + - "restoring retryable record: ${target.name}", - ) - val reverseRenamed = target.exists() && renameForTest(target, file) - if (reverseRenamed) { - // P2 终审:反向 rename 同样是目录项变更——未确认持久绝不算 - // “已重建可重试记录”(变更可见时下一轮 bootstrap 重新确认后按 - // 名字幂等完成);失败保留 pending 记录并退避,绝不静默。 - if (!requireSpoolDirSync(dir)) { - logE( - "statistics pending-delete evidence restore reverse rename " + - "not durable; keeping retryable record: ${file.name}", - ) - } - } else if (target.exists()) { - logE( - "statistics pending-delete evidence restore reverse rename failed: ${file.name}", - ) - } - roundOk = false - } else { - logE("statistics pending-delete evidence restored to quarantine: ${target.name}") - } - } - } - // P2:seal copy 失败隔离的部分目标(seal_failed_*,scanner 忽略):active 保留完整 - // 内容,删除隔离副本安全无数据损失;删除失败只记录(文件作为受管失败发布证据计入 - // 证据区,占用可见且有界——quarantineEvidence/导出/ack 可管理),下一轮维护再试。 - // P1-2 终审:删除成功但目录项未确认持久 → 本轮不推进(roundOk=false,退避重试)。 - dir.listFiles { f -> f.isFile && f.name.startsWith(SEAL_FAILED_PREFIX) } - ?.forEach { file -> - if (!(segmentDeleteForTest?.invoke(file) ?: file.delete())) { - logE( - "statistics spool seal-failed target cleanup deferred; " + - "visible as managed failed-publication evidence: ${file.name}", - ) - } else if (!requireSpoolDirSync(dir)) { - logE("statistics spool seal-failed target deletion not durable: ${file.name}") - roundOk = false - } - } - if (lines.isEmpty()) return roundOk - // P1-4 fail-closed:hasUnknown 时,缺失(!file.exists())或不匹配(MISMATCH)条目 - // 的身份可能正被无法解析的 ack trash 持有——本轮绝不移除任何这类 manifest 条目。 - // 整轮跳过 MATCH 处置与 manifest 重写(简单正确),记录日志并有界返回(不持锁 - // 等待);trash 状态恢复后下一轮维护再清理。 - if (trashHold.hasUnknown) { - logE( - "statistics ack trash state partially unknown; deferring tombstone stale " + - "cleanup and manifest rewrite this round", - ) - return roundOk - } - val remaining = lines.filterNot { line -> - val entry = parseTombstoneLine(line) ?: return@filterNot false - val file = File(dir, entry.file) - when { - // 物理消失:P1-2 先查未提交 trash——身份在其中时条目必须保留(证据仍存在, - // 等待回滚或提交判定),绝不按 stale 移除。P1-2 终审:删除/移动可能在上轮 - // 可见但未确认持久——本轮先 sync 确认“消失”持久才允许移除条目。P1-1: - // 非 OK 同时失效 gate。 - !file.exists() -> { - val confirmed = requireSpoolDirSync(dir) - if (!confirmed) { - logE( - "statistics tombstone entry absence not durable; retaining entry: ${entry.file}", - ) - roundOk = false - } - confirmed && heldInTrash[entry.file] != (entry.bytes to entry.sha256) - } - else -> when (tombstoneIdentityCheck(entry, file)) { - // P1-2:身份不可校验(UNREADABLE)→ 保留记录与文件,本轮不处置 - IdentityCheck.UNREADABLE -> false - // 身份不匹配:同名新文件 ≠ 陈旧记录的前提是旧身份已无处可寻;旧身份仍 - // 停留在未提交 trash 中时条目必须保留(崩溃窗口判定依赖它),只处置新文件 - IdentityCheck.MISMATCH -> heldInTrash[entry.file] != (entry.bytes to entry.sha256) - IdentityCheck.MATCH -> - if (entry.overCap) { - val deleted = - (segmentDeleteForTest?.invoke(file) ?: file.delete()) || !file.exists() - // P1-2 终审:删除成功但目录项未确认持久 → 保留 manifest 条目 - // (可重试记录)并让本轮失败退避;绝不带着未确认删除推进状态。 - if (!deleted) { - false - } else if (!requireSpoolDirSync(dir)) { - logE( - "statistics tombstone over-cap segment deletion not durable; " + - "keeping entry: ${file.name}", - ) - roundOk = false - false - } else { - true - } - } else { - // 容量内:重试移回完整证据区(不超硬 cap 才允许) - val target = - File(dir, "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${entry.file}") - val fits = - quarantineEvidenceLocked(context).sumOf { it.length() } + file.length() <= MAX_QUARANTINE_BYTES - val renamed = fits && renameForTest(file, target) - // P1-2 终审:rename 成功但目录项未确认持久 → 保留 manifest 条目 - // (可重试记录)并让本轮失败退避;rename 可见时下一轮按消失条目 - // 路径 sync 确认后幂等移除。 - if (!renamed) { - false - } else if (!requireSpoolDirSync(dir)) { - logE( - "statistics tombstone evidence restore rename not durable; " + - "keeping entry: ${target.name}", - ) - roundOk = false - false - } else { - true - } - } - } - } - } - if (remaining.size == lines.size) return roundOk - try { - val newContent = remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件) - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") - return roundOk - } - // P1-2 终审:manifest 重写是目录项变更(严格 store)——写失败即保留全部条目 - // (不移除 manifest),并让本轮失败退避重试。 - summaryStore(manifestFile).write(newContent) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - roundOk = false - } - return roundOk -} -/** - * ack trash 状态机处置(P1-2,调用方持 lifecycleMutex)。trash 目录内的原子状态文件 - * [ACK_TRASH_STATE_FILE_NAME] 首行为 UNCOMMITTED/COMMITTED,后续为 mapping 行 - * (原名 → trash 名 + bytes + sha256)。规则(P1-1 修复): - * - 状态文件尚未写入(无 canonical 与 sidecar)且目录为空:staging 严格发生在状态写入 - * 成功之后,此时不可能有已 stage 的证据 → 空目录直接删除(无证据损失)。 - * - COMMITTED:唯一允许后台删除的状态——有界补删,失败下次再试。 - * - UNCOMMITTED:**一律**按 mapping+identity 回滚到原路径,绝不根据主 manifest 缺失推断 - * 已提交(普通 quarantine 证据从不在 manifest 中,缺失恒成立,旧推断会把未确认的证据 - * 误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚的损坏 sealed 会被扫描器重新 - * 隔离,ack 视失败但不丢证据)。mapping 必须全有或全无有效(P1-2:逐行解析、无重复、 - * 无穿越、与 trash 内证据文件集合完整对应),任一失败 → 整个 trash fail-closed 保留 - * (由 UI 作为 StuckAckEvidence 管理),绝不执行 delete/rollback/manifest 改动。 - * - 状态文件缺失/不可读/无效:非空 trash 保留并报告(fail-closed),绝不删除。 - * - * P1-2 终审:删除成功(空目录/COMMITTED)或回滚移动后目录项必须确认持久—— - * [syncDir] 非 OK 返回 false(调用方 [retryPendingCleanup] 令本轮退避重试,绝不推进); - * 删除返回 false 只记录(trash 本身就是可重试记录,下一轮再试)。 - * - * @return false 表示本轮存在目录项未确认持久的变更;其余情形(含删除失败、状态无效) - * 返回 true(保留可重试记录,不阻塞健康排空)。 - */ -internal suspend fun TokenStatSpool.handleAckTrashDir(dir: File, trash: File): Boolean { - val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) - val store = summaryStore(stateFile) - val content: String? = - if (stateFile.exists() || - File(trash, "$ACK_TRASH_STATE_FILE_NAME.new").exists() || - File(trash, "$ACK_TRASH_STATE_FILE_NAME.bak").exists() - ) { - try { - readMetadata(store, stateFile) - } catch (e: Exception) { - logE("statistics ack trash state unreadable; retaining trash: ${trash.name}", e) - null - } - } else if (listDir(trash)?.isEmpty() == true) { - // 崩溃于 stage 开始之前:trash 内没有任何证据文件(stage 是原子移动,空目录 - // = 无证据可保护),删除空目录无损失。枚举失败(listDir 返回 null)绝不当空 - // 目录——内容不可知时走下方 fail-closed 保留分支,绝不删除。stage 已开始或 - // 完成后崩溃(目录非空、状态未写)→ 同样走到保留分支。 - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics empty ack trash cleanup deferred: ${trash.name}") - } else if (!requireSpoolDirSync(dir)) { - logE("statistics empty ack trash deletion not durable: ${trash.name}") - return false - } - return true - } else { - null - } - if (content == null) { - logE("statistics ack trash state missing; retaining trash: ${trash.name}") - return true - } - val lines = content.lineSequence().filter { it.isNotBlank() }.toList() - return when (lines.firstOrNull()) { - ACK_STATE_COMMITTED -> { - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics ack trash cleanup deferred: ${trash.name}") - true - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable: ${trash.name}") - false - } else { - true - } - } - ACK_STATE_UNCOMMITTED -> { - // P1-2:全有或全无解析——任一损坏/重复/穿越/对应缺失都使整个 trash - // fail-closed 保留,绝不基于部分 mapping 做破坏性决策。 - val entries = parseAckMappingStrict(dir, trash, lines) - ?: run { - logE("statistics ack trash state mapping invalid; retaining trash: ${trash.name}") - return true - } - // P1-1:UNCOMMITTED 永远尝试回滚(identity 验证,目标被不同内容占用绝不覆盖)。 - val result = rollbackUncommittedTrash(dir, trash, entries) - if (!result.allResolved) { - logE( - "statistics ack trash rollback not fully resolved; " + - "retaining retryable trash: ${trash.name}", - ) - } - // P1-2 终审:回滚移动/删除的目录项未确认持久 → 本轮失败退避(trash 保留为 - // 可重试记录,下一轮按 identity 幂等完成);普通回滚失败(移动失败、槽位被 - // 占用等)保留记录并继续本轮,绝不阻塞健康排空。 - !result.syncFailed - } - else -> { - logE("statistics ack trash state invalid (${lines.firstOrNull() ?: ""}); retaining trash: ${trash.name}") - true - } - } -} -internal data class TrashRollbackResult( - val allResolved: Boolean, - val syncFailed: Boolean, -) -/** - * 未提交 trash 回滚(P1-2,调用方持 lifecycleMutex):按 mapping 逐条 identity 验证后 - * 恢复。trash 内文件必须仍与 mapping 身份(bytes+sha256)一致才允许移动;原槽位被不同 - * 内容占用时绝不覆盖(保留 trash 证据并 fail-closed);全部恢复成功才删除 trash 目录, - * 否则保留(递归容量统计计入占用)并报告。无法恢复的文件绝不删除。 - * - * P1-2 终审:每个移动/删除都是目录项变更——成功后 [syncDir] 非 OK 置 syncFailed - * (调用方本轮退避;变更可见时下一轮按 identity 幂等完成,崩溃后 trash 重现由状态机 - * 重放),绝不基于未确认状态声称已恢复。 - */ -internal fun TokenStatSpool.rollbackUncommittedTrash( - dir: File, - trash: File, - entries: List, -): TrashRollbackResult { - var allResolved = true - var syncFailed = false - for (entry in entries) { - // 防御:mapping 名字必须是 spool 根/trash 内的合法单层文件名 - if (!isSafeEvidenceName(dir, entry.original) || !isSafeTrashName(trash, entry.trashName)) { - allResolved = false - logE("statistics ack trash mapping has unsafe names; retaining trash: ${trash.name}") - continue - } - val trashFile = File(trash, entry.trashName) - val original = File(dir, entry.original) - if (!trashFile.exists()) { - // mapping 有记录但 trash 中无此文件:文件从未被 stage(状态/映射写于 staging - // 之前)。原槽位同身份即视为已恢复;否则无法验证 → 保留。 - if (!(original.exists() && identityMatches(original, entry))) { - allResolved = false - logE("statistics ack trash rollback cannot verify ${entry.original}; retaining trash: ${trash.name}") - } - continue - } - if (!identityMatches(trashFile, entry)) { - allResolved = false - logE("statistics ack trash file identity mismatch; retaining evidence: ${entry.original}") - continue - } - when { - !original.exists() -> { - if (!atomicMoveForAck(trashFile, original)) { - allResolved = false - logE("statistics ack trash rollback move failed for ${entry.original}; evidence stays in ${trash.name}") - } else if (!requireSpoolDirSync(dir, trash)) { - logE("statistics ack trash rollback move not durable: ${entry.original}") - syncFailed = true - } - } - identityMatches(original, entry) -> { - // 原槽位已是同身份内容:trash 副本冗余,删除副本即可 - if (!(segmentDeleteForTest?.invoke(trashFile) ?: trashFile.delete())) { - allResolved = false - logE("statistics ack trash redundant copy deletion failed: ${entry.original}") - } else if (!requireSpoolDirSync(trash)) { - logE("statistics ack trash redundant copy deletion not durable: ${entry.original}") - syncFailed = true - } - } - else -> { - // 原槽位被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed - allResolved = false - logE("statistics ack trash rollback target occupied by different content; retaining evidence: ${entry.original}") - } - } - } - if (allResolved) { - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - logE("statistics ack trash deletion failed after successful rollback: ${trash.name}") - } else if (!requireSpoolDirSync(dir)) { - logE("statistics ack trash deletion not durable after rollback: ${trash.name}") - syncFailed = true - } - } - return TrashRollbackResult(allResolved, syncFailed) -} -/** P1-2:文件与 mapping 身份比对(bytes + 原始字节 SHA-256,绝不跟随符号链接)。 */ -internal fun TokenStatSpool.identityMatches(file: File, entry: AckMappingEntry): Boolean { - if (!file.isFile || file.length() != entry.bytes) return false - if (Files.isSymbolicLink(file.toPath())) return false - if (!Files.isRegularFile(file.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false - if (segmentReadErrorForTest?.invoke(file) == true) return false - return try { - sha256Hex(file.readBytes()) == entry.sha256 - } catch (e: Exception) { - false - } -} -internal data class UncommittedTrashScan( - val known: Map>, - val hasUnknown: Boolean, -) -/** - * P1-2:仍在未提交 trash 中的身份集合(原名 → bytes+sha256),供 manifest 条目的 stale - * 判定使用——身份仍在 trash 中时条目绝不能移除,否则会把未确认的证据误判为已提交而删除。 - * 符号链接目录不读取(不跟随)。调用方持 lifecycleMutex。 - * - * P1-4 fail-closed:返回结构化结果。任何**非空**、非明确 COMMITTED、无法完整严格解析/ - * 读取的 ack trash(状态缺失/不可读、首行非法、mapping 任一损坏/缺身份/不安全/重复、 - * trash 内存在 mapping 未覆盖的证据文件)都会让 [UncommittedTrashScan.hasUnknown] = true, - * 调用方据此保守处置(stale 清理整轮跳过、scanner 不按 MISMATCH 移除条目)——该 trash - * 可能正持有已知集合之外的证据身份,绝不基于残缺信息做破坏性决策。mapping 完整严格 - * 解析成功时按全有或全无计入 [UncommittedTrashScan.known](与 [parseAckMappingStrict] - * 一致)。空目录不可能持有证据(stage 是原子移动,见 [handleAckTrashDir]),不贡献 - * 身份也不置 unknown。 - */ -internal suspend fun TokenStatSpool.scanUncommittedTrashHolds(context: Context): UncommittedTrashScan { - val dir = spoolDir(context) - if (!dir.isDirectory) return UncommittedTrashScan(emptyMap(), false) - val result = HashMap>() - var hasUnknown = false - // P1-5 fail-closed:spool 根枚举失败(null)时,任何 ack trash 目录都可能存在但 - // 不可见——身份持有情况完全不可知,置 unknown 阻止调用方做 stale/删除类决策。 - val rootFiles = listDir(dir) - if (rootFiles == null) { - logE( - "statistics spool directory enumeration failed; treating uncommitted ack trash " + - "state as unknown", - ) - return UncommittedTrashScan(emptyMap(), true) - } - rootFiles - .filter { f -> - f.isDirectory && - f.name.startsWith(ACK_TRASH_PREFIX) && - !Files.isSymbolicLink(f.toPath()) - } - .forEach { trash -> - val files = listDir(trash) - if (files == null) { - // 目录枚举失败:内部证据状态不可知 → fail-closed - hasUnknown = true - return@forEach - } - if (files.isEmpty()) return@forEach - val stateFile = File(trash, ACK_TRASH_STATE_FILE_NAME) - val content = try { - readMetadata(summaryStore(stateFile), stateFile) - } catch (e: Exception) { - logE("statistics ack trash state unreadable during scan; treating as unknown", e) - null - } ?: run { - // 状态缺失/不可读:非空 trash 中的证据身份不可知 - hasUnknown = true - return@forEach - } - val lines = content.lineSequence().filter { it.isNotBlank() }.toList() - when (lines.firstOrNull()) { - // 显式 COMMITTED:删除已授权,无回滚保护义务,不贡献身份也不置 unknown - ACK_STATE_COMMITTED -> Unit - ACK_STATE_UNCOMMITTED -> { - val entries = parseAckMappingStrict(dir, trash, lines) - if (entries == null) { - // 全有或全无解析失败:该 trash 可能持有任意身份的证据 - hasUnknown = true - } else { - entries.forEach { result[it.original] = it.bytes to it.sha256 } - } - } - // 首行非法/内容为空:无法判定状态 → fail-closed - else -> hasUnknown = true - } - } - return UncommittedTrashScan(result, hasUnknown) -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt deleted file mode 100644 index 1f02c4541..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshot.kt +++ /dev/null @@ -1,268 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.model.ApiProviderType -import com.ai.assistance.operit.data.model.BillingMode - -/** 旧 DataStore 中某个 provider:model 的累计统计与用户价格设置。 */ -data class LegacyProviderModelStats( - val providerModel: String, - val inputTokens: Long, - val cachedInputTokens: Long, - val outputTokens: Long, - val requestCount: Long, - val priceSettings: LegacyPriceSettings, -) { - val hasAnyData: Boolean - get() = inputTokens > 0L || cachedInputTokens > 0L || outputTokens > 0L || requestCount > 0L -} - -/** - * 旧 DataStore(api_settings)累计统计的一次快照解析。 - * - * 键约定与 [com.ai.assistance.operit.data.preferences.ApiPreferences] 保持一致: - * `token_input_` / `token_cached_input_` / `token_output_` - * (Long)、`request_count_`(Int)、`model_input_price_` 等(Float)、 - * `billing_mode_`(String)、`price_per_request_`(Float), - * 其中 `` 为 “provider:model” 的 “:” 被替换为 “_” 后的形式。 - * - * 旧系统语义:键缺失 = 该计数为 0(累计值从 0 开始);价格键缺失 = 未设置。 - * 解析结果只用于迁移估算,不保留正文或凭据。 - */ -data class LegacyTokenStatsSnapshot( - val providerModels: Map, -) { - companion object { - fun parse( - rawPreferences: Map, - additionalProviderNames: Collection = emptyList(), - ): LegacyTokenStatsSnapshot { - val builders = linkedMapOf() - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(TOKEN_INPUT_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(TOKEN_INPUT_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .inputTokens = readTokenCountValue(value) - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(TOKEN_CACHED_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(TOKEN_CACHED_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .cachedInputTokens = readTokenCountValue(value) - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(TOKEN_OUTPUT_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(TOKEN_OUTPUT_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .outputTokens = readTokenCountValue(value) - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(REQUEST_COUNT_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(REQUEST_COUNT_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .requestCount = (value as? Int)?.toLong() ?: 0L - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(PRICE_INPUT_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(PRICE_INPUT_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - val price = (value as? Float)?.toDouble() - if (price != null) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - inputPricePerMillion = price.takeIf { it > 0.0 } - ) - } - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(PRICE_CACHED_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(PRICE_CACHED_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - val price = (value as? Float)?.toDouble() - if (price != null) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - cachedInputPricePerMillion = price.takeIf { it > 0.0 } - ) - } - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(PRICE_OUTPUT_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(PRICE_OUTPUT_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - val price = (value as? Float)?.toDouble() - if (price != null) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - outputPricePerMillion = price.takeIf { it > 0.0 } - ) - } - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(BILLING_MODE_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(BILLING_MODE_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - val mode = BillingMode.fromString(value as? String) - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - billingMode = mode - ) - } - } - } - - rawPreferences.forEach { (key, value) -> - val keyName = key - if (keyName.startsWith(PRICE_PER_REQUEST_PREFIX)) { - val providerModel = - LegacyProviderModelKeyDecoder.decode( - keyName.removePrefix(PRICE_PER_REQUEST_PREFIX), - additionalProviderNames, - ) - if (providerModel.isNotBlank()) { - val price = (value as? Float)?.toDouble() - if (price != null) { - builders.getOrPut(providerModel) { StatsBuilder(providerModel) } - .priceSettings = - builders.getValue(providerModel).priceSettings.copy( - pricePerRequest = price.takeIf { it > 0.0 } - ) - } - } - } - } - - return LegacyTokenStatsSnapshot( - providerModels = - builders.values - .map { it.build() } - .filter { it.hasAnyData } - .associateBy { it.providerModel } - ) - } - - private class StatsBuilder(val providerModel: String) { - var inputTokens: Long = 0L - var cachedInputTokens: Long = 0L - var outputTokens: Long = 0L - var requestCount: Long = 0L - var priceSettings: LegacyPriceSettings = LegacyPriceSettings() - - fun build(): LegacyProviderModelStats = - LegacyProviderModelStats( - providerModel = providerModel, - inputTokens = inputTokens, - cachedInputTokens = cachedInputTokens, - outputTokens = outputTokens, - requestCount = requestCount, - priceSettings = priceSettings, - ) - } - - private fun readTokenCountValue(value: Any?): Long = - when (value) { - is Long -> value - is Int -> if (value < 0) value.toLong() and 0xFFFF_FFFFL else value.toLong() - else -> 0L - } - - private const val TOKEN_INPUT_PREFIX = "token_input_" - private const val TOKEN_CACHED_PREFIX = "token_cached_input_" - private const val TOKEN_OUTPUT_PREFIX = "token_output_" - private const val REQUEST_COUNT_PREFIX = "request_count_" - private const val PRICE_INPUT_PREFIX = "model_input_price_" - private const val PRICE_CACHED_PREFIX = "model_cached_input_price_" - private const val PRICE_OUTPUT_PREFIX = "model_output_price_" - private const val BILLING_MODE_PREFIX = "billing_mode_" - private const val PRICE_PER_REQUEST_PREFIX = "price_per_request_" - } -} - -/** 统一旧键解码,优先匹配内置或当前注册 provider 的完整名称。 */ -internal object LegacyProviderModelKeyDecoder { - private val builtInProviderNames = ApiProviderType.entries.map { it.name } - - fun decode(encoded: String, additionalProviderNames: Collection = emptyList()): String { - val matchedProvider = - (builtInProviderNames.asSequence() + additionalProviderNames.asSequence()) - .map(String::trim) - .filter(String::isNotEmpty) - .distinct() - .sortedByDescending(String::length) - .firstOrNull { encoded == it || encoded.startsWith("${it}_") } - return when { - matchedProvider == null -> encoded.replaceFirst("_", ":") - encoded.length == matchedProvider.length -> matchedProvider - else -> "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt index 1eb3a5979..0f72ad05e 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt @@ -42,19 +42,6 @@ data class ProviderUsageSnapshot( val completeSnapshot: Boolean = false, val source: String, ) { - /** 转换为阶段 1 费用计算输入。 */ - fun toTokenUsageInput(): TokenUsageInput = - TokenUsageInput( - uncachedInputTokens = uncachedInputTokens, - cachedInputTokens = cachedInputTokens, - cacheWriteTokens = cacheWriteTokens, - totalInputTokens = totalInputTokens, - outputTokens = outputTokens, - reasoningTokens = reasoningTokens, - reasoningIncludedInOutput = reasoningIncludedInOutput, - cacheWriteSeparateBilling = cacheWriteSeparateBilling, - ) - /** 是否有任何已知用量分量(含明确 0;完全无已知字段才为 false)。 */ fun hasKnownFields(): Boolean = uncachedInputTokens != null || @@ -221,11 +208,7 @@ object ProviderUsageNormalizer { ?.let { sumNumericFields(it) } ?.takeIf { it >= 0 } val output = usage.optLong("output_tokens", -1).takeIf { it >= 0 } - val fallbackInput = usage.optLong("prompt_tokens", -1).takeIf { it >= 0 } - - val uncached = input ?: fallbackInput?.let { total -> - if (cached != null) (total - cached).coerceAtLeast(0) else total - } + val uncached = input // 总输入 = input + cache_read + cache_creation(官方文档语义); // 全部已知才确定总量;无任何缓存分量时总输入即 input_tokens。 val totalInput = @@ -298,16 +281,16 @@ object ProviderUsageNormalizer { /** 本地模型(llama.cpp/MNN):本地实测计数,缓存分量明确为 0;单次完整上报。 */ fun local( - uncachedInputTokens: Int, - outputTokens: Int, + uncachedInputTokens: Long, + outputTokens: Long, source: String, ): ProviderUsageSnapshot = ProviderUsageSnapshot( - uncachedInputTokens = uncachedInputTokens.coerceAtLeast(0).toLong(), + uncachedInputTokens = uncachedInputTokens.coerceAtLeast(0L), cachedInputTokens = 0L, cacheWriteTokens = 0L, - totalInputTokens = uncachedInputTokens.coerceAtLeast(0).toLong(), - outputTokens = outputTokens.coerceAtLeast(0).toLong(), + totalInputTokens = uncachedInputTokens.coerceAtLeast(0L), + outputTokens = outputTokens.coerceAtLeast(0L), reasoningTokens = null, reasoningIncludedInOutput = null, cacheWriteSeparateBilling = false, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt deleted file mode 100644 index c471b12aa..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/Quarantine.kt +++ /dev/null @@ -1,433 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal Quarantine responsibilities extracted from [TokenStatSpool]. */ -/** Stable identity of a managed failed segment. */ -internal data class TombstoneEntry( - val file: String, - val bytes: Long, - val sha256: String, - val overCap: Boolean, -) - -internal fun TokenStatSpool.tombstoneIdentityCheck( - entry: TombstoneEntry, - file: File, -): IdentityCheck { - if (entry.sha256.isEmpty() || !file.isFile || file.length() != entry.bytes) { - return IdentityCheck.MISMATCH - } - if (segmentReadErrorForTest?.invoke(file) == true) return IdentityCheck.UNREADABLE - return try { - if (sha256Hex(file.readBytes()) == entry.sha256) IdentityCheck.MATCH else IdentityCheck.MISMATCH - } catch (e: Exception) { - IdentityCheck.UNREADABLE - } -} -internal enum class TombstoneResult { RECORDED, CAPACITY_FULL, FAILED } -internal suspend fun TokenStatSpool.quarantineEvidenceLocked(context: Context): List { - val dir = spoolDir(context.applicationContext) - val managed = readTombstoneLines(context).mapNotNull { line -> - val entry = parseTombstoneLine(line) ?: return@mapNotNull null - val file = File(dir, entry.file) - // P1-2:只有身份可校验(MATCH)的受管段才作为 evidence 暴露——UNREADABLE 绝不 - // 出现在可导出/可 ack 的列表里(身份不可校验时 ack 无法安全删除),保留 manifest。 - file.takeIf { it.isFile && tombstoneIdentityCheck(entry, file) == IdentityCheck.MATCH } - } - return (quarantineAreaFiles(dir) + managed).sortedBy { it.name } -} -/** - * 完整证据区(quarantine_* 前缀 + seal 发布失败隔离的 seal_failed_*,不含受管失败段与 - * 有界元数据/sidecar)。P1-6:证据区是 export/ack/info 的证据来源,根目录枚举失败(null) - * 时抛 [IOException](fail-closed)——绝不把失败当作空证据区返回,否则 export 会在遗漏 - * 完整证据时仍成功。P2 终审:seal_failed_*(受管失败发布证据)与 quarantine_* 同等参与 - * 可见/计数/导出/ack——长期删除失败时用户可确认删除并释放容量,绝不无限隐藏占用。 - * 注意:这里**不走** [directoryListingForTest] seam(seam 只覆盖 sealed 队列/待处理判定 - * 与 ack/trash 安全路径,见 [listDir]),生产路径的原始枚举失败同样按 null 显式失败处理。 - */ -internal fun TokenStatSpool.quarantineAreaFiles(dir: File): List { - // 目录不存在 = 证据区尚未创建,空集是真实状态(append 准入在创建目录之前检查); - // 目录**存在**但枚举失败(null)才是 fail-closed 抛错场景。 - if (!dir.isDirectory) return emptyList() - val files = dir.listFiles() - ?: throw IOException("cannot enumerate spool directory for quarantine evidence: ${dir.absolutePath}") - return files - .filter { - it.isFile && - (it.name.startsWith(QUARANTINE_PREFIX) || it.name.startsWith(SEAL_FAILED_PREFIX)) && - it.name != QUARANTINE_SUMMARY_NAME && - it.name != TOMBSTONE_MANIFEST_NAME && - !it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") && - !it.name.startsWith("$TOMBSTONE_MANIFEST_NAME.") - } - .sortedBy { it.name } -} -/** - * P1-1 append 准入检查(调用方持 lifecycleMutex):受管失败集合 - * (entry 数/受管段字节)或完整证据区字节任一到达硬上限即拒绝新统计——此时新损坏段 - * 将无处可去(不能删除/重命名、受管集合已满),继续接收只会让磁盘/重扫无界。 - */ -internal suspend fun TokenStatSpool.managedFailureCapacityExceeded(context: Context): Boolean { - val dir = spoolDir(context) - val entries = readTombstoneLines(context).mapNotNull(::parseTombstoneLine) - if (entries.isEmpty() && quarantineAreaFiles(dir).isEmpty()) return false - return entries.size >= MAX_TOMBSTONE_ENTRIES || - entries.sumOf { it.bytes.coerceAtLeast(0L) } >= MAX_MANAGED_BYTES || - quarantineAreaFiles(dir).sumOf { it.length() } >= MAX_QUARANTINE_BYTES -} -internal suspend fun TokenStatSpool.quarantineSummaryInfoLocked( - context: Context, -): TokenStatSpool.QuarantineSummaryInfo? { - val file = File(spoolDir(context.applicationContext), QUARANTINE_SUMMARY_NAME) - val content = try { - readMetadata(summaryStore(file), file) - } catch (e: Exception) { - logE("statistics quarantine summary read failed", e) - null - } ?: return null - val lines = content.lineSequence().filter { it.isNotEmpty() }.toList() - if (lines.isEmpty()) return null - return TokenStatSpool.QuarantineSummaryInfo( - recordCount = lines.size, - summaryBytes = file.length(), - ) -} -internal fun TokenStatSpool.summaryStore(file: File) = - TokenStatMetaStore( - file, - quarantineAtomicMoveForTest ?: ::atomicMoveReplacing, - // P1-3 终审:spool 的 summary/manifest/ack state 统一走严格目录同步——write 只有 - // 目录项确认持久([syncDir] == OK)才成功;read 的 sidecar 恢复 rename 同样严格。 - // P1-1 终审修复:任一非 OK 同时失效 bootstrap gate([requireSpoolDirSync])—— - // 元数据目录项未确认后下一次使用必须重新确认,绝不带着“已确认”标记继续。 - strictDirectorySync = { dir -> requireSpoolDirSync(dir) }, - ) -/** - * 有界元数据读取(P1-2,调用方持 lifecycleMutex):测试注入缝模拟读取失败(抛明确 - * IOException,调用方据此 fail-closed),生产路径委托 [TokenStatMetaStore.read] - * (崩溃安全恢复 canonical/.new/.bak/tmp 完整值)。 - */ -internal suspend fun TokenStatSpool.readMetadata(store: TokenStatMetaStore, file: File): String? { - if (metadataReadErrorForTest?.invoke(file) == true) { - throw IOException("statistics metadata read failed (injected): ${file.name}") - } - return store.read() -} -/** 首选同目录原子替换(Windows MoveFileEx / POSIX rename);不支持或失败返回 false 走回退。 */ -internal fun TokenStatSpool.atomicMoveReplacing(from: File, to: File): Boolean = try { - Files.move( - from.toPath(), - to.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - true -} catch (e: AtomicMoveNotSupportedException) { - false -} catch (e: IOException) { - false -} -/** Explicit post-export acknowledgment for the bounded rolling summary and every sidecar. */ -internal suspend fun TokenStatSpool.deleteQuarantineSummaryLocked(context: Context) { - val dir = spoolDir(context.applicationContext) - val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) - summaryStore(summaryFile).delete() - val remaining = listDir(dir) - ?: throw IOException("cannot verify quarantine summary deletion: ${dir.absolutePath}") - if (remaining.any { it.name == QUARANTINE_SUMMARY_NAME || it.name.startsWith("$QUARANTINE_SUMMARY_NAME.") }) { - throw IOException("statistics quarantine summary deletion failed: ${summaryFile.absolutePath}") - } -} -/** - * 超限损坏段的硬边界替换(P1-1/P1-2):崩溃安全地发布“已裁剪到双上限”的新完整摘要 - * (旧完整或新完整,绝不截断),随后把段移出 sealed 扫描队列;段删除失败绝不阻塞健康 - * 排空(改为 pending-delete 证据或 tombstone 跳过)。摘要发布失败抛异常 → 保留旧摘要 - * 与待处理段,返回 false,绝不声称成功。 - */ -internal suspend fun TokenStatSpool.summarizeOverCapSegment( - context: Context, - segment: File, - rawBytes: ByteArray, - text: String, - corruptLineCount: Int, -): Boolean { - val summaryFile = File(spoolDir(context), QUARANTINE_SUMMARY_NAME) - val store = summaryStore(summaryFile) - return try { - val sha = sha256Hex(rawBytes) - // 崩溃安全读取旧完整摘要(中断残留会被恢复),绝不基于半写内容裁剪 - val oldContent = readMetadata(store, summaryFile) - val oldLines = - oldContent?.lineSequence()?.filter { it.isNotEmpty() }?.toList() ?: emptyList() - val record = - JSONObject() - .put("ts", System.currentTimeMillis()) - .put("file", segment.name) - .put("bytes", segment.length()) - .put("sha256", sha) - .put("lineCount", text.lineSequence().filter { it.isNotEmpty() }.count()) - .put("corruptLines", corruptLineCount) - .toString() - // 崩溃重试幂等:同一段已有记录且未超限则不再追加;超限旧摘要仍会被裁剪自愈 - val alreadyRecorded = - oldLines.any { line -> - try { - val obj = JSONObject(line) - obj.optString("file") == segment.name && obj.optString("sha256") == sha - } catch (_: Exception) { - false - } - } - val withinCaps = - oldLines.size <= MAX_QUARANTINE_SUMMARY_LINES && - oldLines.sumOf { utf8RecordBytes(it) } <= MAX_QUARANTINE_SUMMARY_BYTES - if (!alreadyRecorded || !withinCaps) { - val newContent = buildTrimmedSummary(oldLines, record) - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量(canonical/.new/.bak/tmp 四 - // 槽位),超限有界失败:保留旧摘要与待处理段,返回 false 让 drain 退避重试, - // 绝不发布任何正式文件。 - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE( - "statistics quarantine summary publish refused: metadata budget over the " + - "total cap; keeping old summary and pending segment: ${segment.name}", - ) - return false - } - store.write(newContent) - } - if (disposeOverCapSegment(context, segment, rawBytes) == TombstoneResult.FAILED) { - return false - } - logE("statistics quarantine hard cap: over-cap corrupt segment summarized and removed: ${segment.name}") - true - } catch (e: Exception) { - logE("statistics quarantine summary write failed: ${segment.name}", e) - false - } -} -/** - * 构建“已裁剪到双上限”的新完整摘要内容(行数与 UTF-8 字节总数都满足上限,保留最新记录)。 - * 真实记录约 200 字节,单行不可能超过字节上限;循环只保证至少保留最新一行。 - */ -internal fun TokenStatSpool.buildTrimmedSummary(oldLines: List, record: String): String { - var keep = oldLines.map(::normalizeOversizedSummaryLine) + record - while (keep.size > 1 && - (keep.size > MAX_QUARANTINE_SUMMARY_LINES || - keep.sumOf { utf8RecordBytes(it) } > MAX_QUARANTINE_SUMMARY_BYTES) - ) { - keep = keep.drop(1) - } - return keep.joinToString("\n") + "\n" -} -/** - * P2-1:摘要上限按 UTF-8 实际字节计(Kotlin String.length 是 UTF-16 code unit,非 ASCII - * 字符会低估);单行 UTF-8 字节超上限时替换为固定 ASCII 缩略记录(hash/bytes,不含正文), - * 输出恒 ≤ [MAX_QUARANTINE_SUMMARY_BYTES]。 - */ -internal fun TokenStatSpool.utf8RecordBytes(line: String): Int = - line.toByteArray(Charsets.UTF_8).size + 1 -internal fun TokenStatSpool.normalizeOversizedSummaryLine(line: String): String { - if (utf8RecordBytes(line) <= MAX_QUARANTINE_SUMMARY_BYTES) return line - val bytes = line.toByteArray(Charsets.UTF_8) - return JSONObject() - .put("truncated", true) - .put("bytes", bytes.size) - .put("sha256", sha256Hex(bytes)) - .toString() -} -internal suspend fun TokenStatSpool.disposeOverCapSegment( - context: Context, - segment: File, - rawBytes: ByteArray, -): TombstoneResult { - if (segmentDeleteForTest?.invoke(segment) ?: segment.delete()) { - // P1-2 终审:删除是目录项变更——sync 非 OK 绝不返回 RECORDED(否则本轮声称成功 - // 而崩溃后段可能复活;下一轮按摘要身份幂等重删)。P1-1:非 OK 同时失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap segment deletion not durable: ${segment.name}") - return TombstoneResult.FAILED - } - return TombstoneResult.RECORDED - } - if (!segment.exists()) { - // 段已消失(上一轮可见删除):先确认删除持久才允许推进队列,绝不基于未确认 - // 状态返回 RECORDED。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap segment absence not durable: ${segment.name}") - return TombstoneResult.FAILED - } - return TombstoneResult.RECORDED - } - val fitsBudget = - quarantineEvidenceLocked(context).sumOf { it.length() } + segment.length() <= MAX_QUARANTINE_BYTES - if (fitsBudget) { - val pending = File( - segment.parentFile, - "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (renameForTest(segment, pending)) { - // P1-2 终审:rename 后目录项必须确认持久,非 OK 返回 FAILED(本轮退避重试; - // rename 可见时下一轮直接跳过/按身份幂等处置,崩溃后 pending 名重现由维护重放) - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics over-cap pending-delete rename not durable: ${pending.name}") - return TombstoneResult.FAILED - } - logE("statistics over-cap segment deletion failed; retained as pending-delete evidence: ${pending.name}") - return TombstoneResult.RECORDED - } - } else { - logE("statistics over-cap segment deletion failed and full evidence exceeds the hard cap; summary retains hash/bytes: ${segment.name}") - } - logE("statistics over-cap segment pending-delete rename failed; tombstoning: ${segment.name}") - return tombstoneSegment(context, segment, rawBytes, overCap = true) -} -/** - * 读取 tombstone manifest(崩溃安全恢复)得到原始行;解析交给 [parseTombstoneLine]。 - * P1-3:不设 canonical isFile 前置——canonical 缺失而内容只在 `.new`/`.bak` sidecar - * 时也必须先经 [TokenStatMetaStore.read] 恢复完整值再返回;否则仅 sidecar 存在 - * 时 info/ack/容量/扫描会误判为空。 - * P1-2 fail-closed:读取失败必须抛明确 [IOException](不返回 empty)——调用方(append - * 容量检查、scanner、快照、维护)据此中止并退避;返回空只允许出现在“manifest 不存在 - * (无受管记录)”这一真实状态。 - */ -internal suspend fun TokenStatSpool.readTombstoneLines(context: Context): List { - val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) - val content = readMetadata(summaryStore(manifestFile), manifestFile) ?: return emptyList() - return content.lineSequence().filter { it.isNotBlank() }.toList() -} -internal fun TokenStatSpool.parseTombstoneLine(line: String): TombstoneEntry? = try { - val obj = JSONObject(line) - val file = obj.optString("file").takeIf { it.isNotEmpty() } ?: return null - TombstoneEntry( - file = file, - bytes = obj.optLong("bytes", -1L), - sha256 = obj.optString("sha256", ""), - overCap = obj.optBoolean("overCap", false), - ) -} catch (_: Exception) { - null -} -internal enum class IdentityCheck { MATCH, MISMATCH, UNREADABLE } -/** - * 有界 skip/tombstone manifest 更新(P1-1/P1-2):**不滚动**的活跃受管失败集合,条目 - * 只在文件物理消失/身份变化后由维护入口移除;达到 entry/字节硬上限时返回 - * [TombstoneResult.CAPACITY_FULL](调用方跳过该段继续健康,新统计 append 随后被拒绝), - * 写失败返回 [TombstoneResult.FAILED](drain 退避重试),绝不静默放行。 - */ -internal suspend fun TokenStatSpool.tombstoneSegment( - context: Context, - segment: File, - rawBytes: ByteArray, - overCap: Boolean, -): TombstoneResult { - val manifestFile = File(spoolDir(context), TOMBSTONE_MANIFEST_NAME) - val store = summaryStore(manifestFile) - return try { - val oldLines = - readMetadata(store, manifestFile)?.lineSequence()?.filter { it.isNotEmpty() }?.toList() - ?: emptyList() - val sha = sha256Hex(rawBytes) - val entry = - JSONObject() - .put("ts", System.currentTimeMillis()) - .put("file", segment.name) - // P2 终审:字节数按原始字节计算——候选文件可能已被隔离/删除(目录项 sync - // 失败路径),File.length() 对不存在的文件恒为 0,会让崩溃后重现文件的 - // 身份判定失效;rawBytes 是调用方现场读取的稳定身份。 - .put("bytes", rawBytes.size.toLong()) - .put("sha256", sha) - .put("overCap", overCap) - .toString() - // 崩溃重试幂等:同一身份已记录则不再追加(同一文件同一内容) - val alreadyRecorded = - oldLines.any { line -> - val existing = parseTombstoneLine(line) - existing?.file == segment.name && existing?.sha256 == sha - } - if (alreadyRecorded) return TombstoneResult.RECORDED - val wouldBeEntries = oldLines.size + 1 - val wouldBeBytes = (oldLines + entry).sumOf { utf8RecordBytes(it) } - if (wouldBeEntries > MAX_TOMBSTONE_ENTRIES || wouldBeBytes > MAX_TOMBSTONE_MANIFEST_BYTES) { - logE( - "statistics tombstone capacity full; segment stays in queue for a later retry: ${segment.name}", - ) - return TombstoneResult.CAPACITY_FULL - } - val newContent = (oldLines + entry).joinToString("\n") + "\n" - // P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(FAILED → drain - // 退避重试,绝不发布任何正式文件)。 - if (metadataWriteBudgetExceeded(context, newContent.toByteArray(Charsets.UTF_8).size)) { - logE( - "statistics tombstone manifest publish refused: metadata budget over the " + - "total cap; segment stays in queue: ${segment.name}", - ) - return TombstoneResult.FAILED - } - store.write(newContent) - TombstoneResult.RECORDED - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest write failed: ${segment.name}", e) - TombstoneResult.FAILED - } -} -/** - * 移除/裁剪后崩溃安全重写 manifest;写失败仅记录(下一次 drain 会再尝试)。 - * P1-1:发布前投影实际总量 + 最坏 sidecar 增量,超限有界失败(只记录,不写文件)。 - */ -internal suspend fun TokenStatSpool.rewriteTombstoneManifest(context: Context, remainingRawLines: List) { - try { - val content = remainingRawLines.joinToString("\n") + if (remainingRawLines.isEmpty()) "" else "\n" - if (metadataWriteBudgetExceeded(context, content.toByteArray(Charsets.UTF_8).size)) { - logE("statistics quarantine tombstone manifest rewrite refused: metadata budget over the total cap") - return - } - summaryStore(File(spoolDir(context), TOMBSTONE_MANIFEST_NAME)).write(content) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt new file mode 100644 index 000000000..2928e232b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt @@ -0,0 +1,69 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.model.ApiProviderType + +internal data class ReleasedProviderModelKey( + val storedProviderModel: String, + val provider: String, + val model: String, +) + +/** + * Decodes the released DataStore key format `provider:model -> provider_model`. + * + * Registered aliases are matched longest-first so ToolPkg IDs containing `_` keep their + * full identity. A provider that was removed before migration is decoded with the same + * first-separator rule used by the released implementation; its historical name is the + * only identity available in the key itself. + */ +internal object ReleasedProviderModelKeyDecoder { + private val builtInProviderAliases = ApiProviderType.entries.associate { it.name to it.name } + + fun decode( + encoded: String, + additionalProviderAliases: Map = emptyMap(), + ): ReleasedProviderModelKey { + val aliases = buildMap { + putAll(builtInProviderAliases) + additionalProviderAliases.forEach { (rawAlias, rawIdentity) -> + val alias = rawAlias.trim() + val identity = rawIdentity.trim() + require(alias.isNotEmpty() && identity.isNotEmpty()) { + "released token provider aliases must not be blank" + } + val previous = put(alias, identity) + require(previous == null || previous == identity) { + "conflicting released token provider alias: $alias" + } + } + } + val knownProviderAlias = aliases.keys + .sortedByDescending(String::length) + .firstOrNull { encoded == it || encoded.startsWith("${it}_") } + val separator: Int + val providerAlias: String + if (knownProviderAlias != null) { + providerAlias = knownProviderAlias + separator = providerAlias.length + } else { + // Released keys for providers no longer present in the registry only retain + // the original provider:model separator encoded as the first underscore. + separator = encoded.indexOf('_') + require(separator > 0 && separator < encoded.lastIndex) { + "released token key does not contain a provider and model: $encoded" + } + providerAlias = encoded.substring(0, separator) + } + require(separator > 0 && separator < encoded.lastIndex) { + "released token key does not contain a provider and model: $encoded" + } + val model = encoded.substring(separator + 1) + val provider = + if (knownProviderAlias != null) aliases.getValue(providerAlias) else providerAlias + return ReleasedProviderModelKey( + storedProviderModel = "$providerAlias:$model", + provider = provider, + model = model, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt deleted file mode 100644 index 3cde410ed..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolDrain.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal SpoolDrain responsibilities extracted from [TokenStatSpool]. */ -/** - * 请求合并式 drain 调度(丢失唤醒修复):每次调用都在 [stateLock] 下置位 - * [drainRequested]——请求绝不丢失;仅当没有 worker 在跑/在队列([drainScheduled] - * 为 false)时才入队新任务。worker 每轮开始前消费请求,轮末在同一锁内决定 - * retire/立即 rerun/失败 backoff,请求在轮内到达时由同一 worker 接管。 - * - * RejectedExecution 恢复正确状态:请求保留(drainRequested=true,绝不丢),仅释放 - * 调度令牌(drainScheduled=false);下一次 schedule(append/replay/awaitInitialDrain) - * 会重建 executor(isShutdown 检查)并重新入队。 - */ -internal fun TokenStatSpool.scheduleDrain(context: Context, delayMs: Long = 0L) { - val generation: Long - synchronized(stateLock) { - if (writerExecutor.isShutdown) writerExecutor = newWriterExecutor() - drainRequested = true - if (drainScheduled) return - drainScheduled = true - generation = sessionGeneration - } - try { - if (rejectDrainScheduleForTest) { - throw RejectedExecutionException("drain schedule rejected (injected)") - } - val task = Runnable { runDrain(context, generation) } - if (delayMs == 0L) writerExecutor.execute(task) - else writerExecutor.schedule(task, delayMs, TimeUnit.MILLISECONDS) - } catch (e: RejectedExecutionException) { - synchronized(stateLock) { drainScheduled = false } - logE("statistics drain scheduling failed; request retained", e) - } -} -/** - * 每轮开始前消费 drain 请求(持 [stateLock])。返回 false 表示本轮无需运行: - * - 无请求:释放调度令牌并 retire; - * - 已被快照 generation 取代:不触碰任何标志——快照屏障已清 [drainScheduled], - * 新 generation 的请求由新 schedule 自行记账,旧 worker 绝不消费新请求。 - */ -internal fun TokenStatSpool.consumeDrainRequest(generation: Long): Boolean = synchronized(stateLock) { - when { - sessionGeneration != generation -> false - !drainRequested -> { - drainScheduled = false - false - } - else -> { - drainRequested = false - true - } - } -} -/** - * 轮末决策(持 [stateLock],同一锁内原子完成等待者与状态转移): - * - generation 已变:快照屏障已接管(其 drain/替换处理了被等待的数据),retire - * 且不触碰标志;等待者按成功完成。 - * - 本轮失败:完成等待者(false),释放调度令牌并计算退避延迟,稍后重试。 - * - 成功且有新请求(轮内到达):完成等待者(true)后立即 rerun,绝不丢请求。 - * - 成功且无请求:完成等待者(true),释放调度令牌并 retire。 - */ -internal fun TokenStatSpool.runDrain(context: Context, generation: Long) { - while (true) { - if (!consumeDrainRequest(generation)) return - var success = false - try { - success = runBlocking { - lifecycleMutex.withLock { - if (synchronized(stateLock) { sessionGeneration != generation }) return@withLock true - drainCore(context, generation) - } - } - } catch (e: Throwable) { - logE("statistics spool drain failed", e) - } - afterDrainRoundForTest?.invoke() - val retry: Long - val rerun: Boolean - synchronized(stateLock) { - if (sessionGeneration != generation) { - completeInitialDrainWaitersLocked(true) - return - } - completeInitialDrainWaitersLocked(success) - if (!success) { - drainScheduled = false - retry = retryDelayMs - retryDelayMs = (retryDelayMs * 2).coerceAtMost(RETRY_BACKOFF_CAP_MS) - rerun = false - } else if (drainRequested) { - retryDelayMs = RETRY_BACKOFF_BASE_MS - retry = 0L - rerun = true - } else { - retryDelayMs = RETRY_BACKOFF_BASE_MS - drainScheduled = false - retry = 0L - rerun = false - } - } - if (rerun) continue - if (retry > 0L) scheduleDrain(context, retry) - return - } -} -internal fun TokenStatSpool.completeInitialDrainWaitersLocked(success: Boolean) { - if (initialDrainWaiters.isEmpty()) return - initialDrainWaiters.forEach { waiter -> - if (waiter.isActive) waiter.complete(success) - } - initialDrainWaiters.clear() -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt deleted file mode 100644 index b9cd9971b..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolFileSystem.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import java.io.File -import java.io.IOException -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet - -/** Low-level filesystem primitives for the token-stat spool; contains no queue policy. */ -internal object SpoolFileSystem { - fun spoolDir(context: Context, directoryName: String): File = - File(context.filesDir, directoryName) - - fun syncDirectory( - dir: File, - logError: (String, Throwable?) -> Unit - ): TokenStatSpool.DirSyncResult = - try { - FileChannel.open(dir.toPath(), StandardOpenOption.READ).use { it.force(true) } - TokenStatSpool.DirSyncResult.OK - } catch (e: AccessDeniedException) { - logError( - "statistics spool directory fsync unsupported on this platform; " + - "fail-closed: no directory entry is confirmed durable", - e - ) - TokenStatSpool.DirSyncResult.UNSUPPORTED - } catch (e: Exception) { - logError("statistics spool directory fsync failed: ${dir.absolutePath}", e) - TokenStatSpool.DirSyncResult.FAILED - } - - fun syncFile(file: File, logError: (String, Throwable?) -> Unit): Boolean = - try { - FileChannel.open(file.toPath(), StandardOpenOption.WRITE).use { it.force(true) } - true - } catch (e: Exception) { - logError("statistics spool file fsync failed: ${file.name}", e) - false - } - - fun listDirectory( - dir: File, - seam: ((File) -> Array?)? - ): Array? = if (seam != null) seam(dir) else dir.listFiles() - - fun contentsEqual( - first: File, - second: File, - logError: (String, Throwable?) -> Unit - ): Boolean? { - if (first.length() != second.length()) return false - if (first.length() == 0L) return true - return try { - first.inputStream().use { firstInput -> - second.inputStream().use { secondInput -> - val firstBuffer = ByteArray(64 * 1024) - val secondBuffer = ByteArray(64 * 1024) - while (true) { - val firstCount = firstInput.read(firstBuffer) - val secondCount = secondInput.read(secondBuffer) - if (firstCount != secondCount) return@use false - if (firstCount < 0) return@use true - if (!firstBuffer.copyOfRange(0, firstCount) - .contentEquals(secondBuffer.copyOfRange(0, secondCount))) { - return@use false - } - } - @Suppress("UNREACHABLE_CODE") - true - } - } - } catch (e: Exception) { - logError("statistics spool seal duplicate content compare failed", e) - null - } - } - - fun totalBytes(dir: File, cap: Long): Long { - if (!dir.isDirectory) return 0L - val saturated = if (cap == Long.MAX_VALUE) cap else cap + 1L - var total = 0L - val visitor = object : SimpleFileVisitor() { - override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - if (attrs.isRegularFile) { - total += attrs.size() - if (total < 0L || total > cap) { - total = saturated - return FileVisitResult.TERMINATE - } - } - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed(file: Path, exc: IOException): FileVisitResult { - total = saturated - return FileVisitResult.TERMINATE - } - } - return try { - Files.walkFileTree( - dir.toPath(), - EnumSet.noneOf(FileVisitOption::class.java), - Int.MAX_VALUE, - visitor - ) - total - } catch (_: IOException) { - saturated - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt deleted file mode 100644 index 4134a6f09..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolLock.kt +++ /dev/null @@ -1,264 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal SpoolLock responsibilities extracted from [TokenStatSpool]. */ -/** - * P1-1 终审:每进程首次使用 spool 前的 durable bootstrap gate(调用方持 lifecycleMutex)。 - * - * 内存标记 [directoryDurabilityConfirmedThisProcess] 初始为 false,进程重启即清零 - * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟进程重启)。若 spool - * 目录**已存在**——无论它是本进程创建还是**上一进程**创建——都必须先 sync filesDir - * (确认 spool 目录项持久)再 sync spool 目录(确认 active/metadata 等可见目录项持久); - * 两者都 OK 之前不得写新行/返回 durable/做任何目录变更。这样上一进程已可见但未确认的 - * 目录项在本进程重新提交(崩溃后文件可能消失的窗口被关闭)。 - * - * 目录尚不存在时没有可确认的目录项,放行(首次创建协议在 [append] 中负责创建后同步 - * 父目录与新目录本身;其任一 sync 失败会把本标记保持/复位为 false,下一次使用重新走 - * 本 gate)。任一非 OK 均 fail-closed:不置位标记、返回 false,由调用方明确失败 - * (append 返回 false / drain 退避 / snapshot 抛 IOException)。 - */ -internal fun TokenStatSpool.ensureDirectoryDurabilityConfirmed(context: Context, dir: File): Boolean { - if (directoryDurabilityConfirmedThisProcess) return true - if (!dir.isDirectory) return true - val parent = dir.parentFile - // P1-1 终审:bootstrap 只在 filesDir 与 spool 目录两者都 OK 时才置位;任一非 OK - // 由 [requireSpoolDirSync] 保持/置回 false(本处进入时 flag 必为 false),绝不置位。 - if (parent == null || !requireSpoolDirSync(parent, dir)) { - logE( - "statistics spool directory durability unconfirmed; refusing writes " + - "until directory entries are re-confirmed: ${dir.absolutePath}", - ) - return false - } - directoryDurabilityConfirmedThisProcess = true - return true -} -internal suspend fun TokenStatSpool.withExclusiveSnapshotAccessInternal( - context: Context, - drainBefore: Boolean, - clearAfter: Boolean, - deferredRestoreCommit: (suspend () -> Unit)?, - block: suspend () -> T, -): T = lifecycleMutex.withLock { - val appContext = context.applicationContext - // P1-1 终审:快照/恢复前必须先确认 spool 目录项持久(上一进程可见未确认的目录项 - // 在本进程重新提交);失败明确中止,绝不带着未确认状态做 drain/替换/清理。 - if (!ensureDirectoryDurabilityConfirmed(appContext, spoolDir(appContext))) { - throw IOException( - "statistics spool directory durability could not be confirmed for snapshot", - ) - } - val generation = synchronized(stateLock) { - sessionGeneration += 1L - drainScheduled = false - if (clearAfter && deferredRestoreCommit == null) { - // P1 终审:恢复屏障开始即原子递增 restore epoch——所有在屏障前开始的请求 - // 收尾 append 时 epoch 不匹配而被明确拒绝;导出/快照(clearAfter=false) - // 不递增,进行中的请求在导出期间正常收尾。 - restoreEpoch += 1L - } - sessionGeneration - } - if (drainBefore && !drainCore(appContext, generation)) { - throw IOException("statistics spool could not be drained for snapshot") - } - if (drainBefore && hasPendingSegments(appContext)) { - throw IOException("statistics spool still contains pending events after drain") - } - if (drainBefore && hasQuarantineEvidenceForSnapshotLocked(appContext)) { - throw IOException( - "statistics quarantine evidence must be exported and acknowledged before snapshot", - ) - } - // 排他状态必须在 drain 阶段之后设置:drainBefore 自己的 insert 需要登记。 - // 此后不再有任何新登记(登记与标志检查原子),registry 只减不增。 - synchronized(stateLock) { exclusiveBarrierActive = true } - try { - if (!awaitActiveInsertsEmpty() || !awaitStatsDbAccessorsEmpty()) { - val live = synchronized(stateLock) { activeInserts.size + statsDbAccessTokens.size } - throw IOException( - "statistics Room insert still active ($live); " + - "snapshot/restore aborted before any file replacement", - ) - } - if (clearAfter) { - if (deferredRestoreCommit != null) { - var restoreFenceCommitted = false - try { - withContext(kotlinx.coroutines.NonCancellable) { - deferredRestoreCommit() - synchronized(stateLock) { - restoreEpoch += 1L - acceptingEventsThisProcess = false - } - restoreFenceCommitted = true - } - } catch (e: Exception) { - if (!restoreFenceCommitted) { - // The request fence is unchanged. Resume normal draining after releasing - // lifecycleMutex so durable old/new events can still reach the old DB. - scheduleDrain(appContext) - } - throw e - } - } else { - // P1 终审:替换开始(block 即将执行)——本进程不再接受任何统计事件,直到 - // 进程重启(UI 允许稍后重启;替换后失败同样保持拒绝,绝不写入已部分替换的 - // 数据库)。此前任何失败(bootstrap/drain/quiesce)都不触碰该标志,新请求 - // 可继续(替换前失败可恢复)。 - synchronized(stateLock) { acceptingEventsThisProcess = false } - } - } - val result = block() - if (clearAfter) clearForRestoreLocked(appContext) - result - } finally { - synchronized(stateLock) { exclusiveBarrierActive = false } - } -} -/** - * 统计数据库访问门控被屏障排他期拒绝的专用异常(reviewer P1-1 修复):快照/恢复屏障 - * 已进入排他状态([TokenStatSpool.exclusiveBarrierActive])时,任何新的统计数据库访问 - * 被**立即拒绝**(绝不无限等待——屏障的 block/prepareBeforeCommit 内误调用门控入口 - * 时也会立即失败而非自死锁)。调用方按 fail-open 语义处理: - * - [TokenTrackingAIService.newRequest]:跳过统计直调 delegate(模型调用不受影响); - * - [TokenStatsLedger.resolvePricingForRequest]:抛给收尾边界 → UNKNOWN 价格事件。 - */ -internal class TokenStatsBarrierActiveException : - java.io.IOException("token statistics database access rejected: snapshot/restore barrier active") - -/** - * 统一统计数据库访问门控(reviewer P1-1 修复):请求边界的身份事务 - * ([TokenStatsLedger.ensureIdentityAndCaptureGeneration])与请求收尾的价格解析 - * ([TokenStatsLedger.prepareEventLineDetached])等**直接 Room 访问**通过注册表 - * ([TokenStatSpool.statsDbAccessTokens])与快照/恢复屏障互斥,**不持有 lifecycleMutex、 - * 不做无限等待**: - * - 屏障进入排他状态([TokenStatSpool.exclusiveBarrierActive]=true)**前**注册的访问者由 - * 屏障有界等待清空——替换前已进入的事务在替换前完成,绝不与 checkpoint/ZIP 打包/ - * 文件替换竞争;恢复的 closeDatabase 窗口也由该等待覆盖; - * - 屏障排他期间到达的访问者**立即**抛 [TokenStatsBarrierActiveException](绝不重新打开 - * 正在替换的 Room,也不产生备份打包窗口内的新 WAL/身份写入;请求/收尾按 fail-open - * 语义跳过统计,模型调用不受影响); - * - 恢复替换完成后(accepting=false)由调用方在访问块内按 fence 语义判定 - * ([TokenTrackingAIService.newRequest] 返回 null fail-open)。 - * token 删除幂等:reset 清空集合后,旧访问者的 finally 只移除自己的 token。 - */ -internal suspend fun TokenStatSpool.withStatsDatabaseAccess(block: suspend () -> T): T { - val token = synchronized(stateLock) { - if (exclusiveBarrierActive) { - null - } else { - statsDbAccessSeq += 1L - statsDbAccessTokens.add(statsDbAccessSeq) - statsDbAccessSeq - } - } ?: throw TokenStatsBarrierActiveException() - try { - return block() - } finally { - synchronized(stateLock) { statsDbAccessTokens.remove(token) } - } -} - -/** 有界等待在册统计数据库访问者全部结束(与 [awaitActiveInsertsEmpty] 同构)。 */ -internal suspend fun TokenStatSpool.awaitStatsDbAccessorsEmpty(): Boolean { - val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) - while (true) { - if (synchronized(stateLock) { statsDbAccessTokens.isEmpty() }) return true - if (System.nanoTime() >= deadline) return false - delay(QUIESCE_POLL_INTERVAL_MS) - } -} - -/** - * Request/session fencing 判定(P1 终审,调用方持 lifecycleMutex):请求开始捕获的 - * [sessionEpoch] 必须等于当前 [restoreEpoch](恢复屏障开始时原子递增使旧请求失效), - * 且本进程仍接受事件(恢复替换开始后为 false 直至重启)。任一不满足 → 明确拒绝, - * 绝不写入可能已被恢复替换的 spool。 - */ -internal fun TokenStatSpool.fenceAcceptsRestore(sessionEpoch: Long): Boolean = - synchronized(stateLock) { acceptingEventsThisProcess && sessionEpoch == restoreEpoch } -/** - * 硬超时等待已登记 insert 全部结束。等待期间不持有 [stateLock](轮询只短暂取快照), - * 因此绝不阻塞普通 drain/append;[delay] 可被协程取消,超时由调用方转换为明确失败。 - */ -internal suspend fun TokenStatSpool.awaitActiveInsertsEmpty(): Boolean { - val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(exclusiveQuiesceTimeoutMs) - while (true) { - if (synchronized(stateLock) { activeInserts.isEmpty() }) return true - if (System.nanoTime() >= deadline) return false - delay(QUIESCE_POLL_INTERVAL_MS) - } -} -internal fun TokenStatSpool.clearForRestoreLocked(context: Context) { - val dir = spoolDir(context) - // P1-1 终审修复:删除开始前立即失效 bootstrap gate——删除本身是目录项变更,删除后 - // 任何 sync 失败都不得让“已确认”内存标记继续生效,下一次使用必须重新确认(或重新 - // 走首次创建协议)。 - directoryDurabilityConfirmedThisProcess = false - if (dir.exists()) { - val deleted = spoolDeleteForTest?.invoke(dir) ?: dir.deleteRecursively() - if (!deleted || dir.exists()) { - throw IOException("statistics spool cleanup failed: ${dir.absolutePath}") - } - } - // P1-3 终审:spool 目录项删除(可能刚发生且可见)必须确认持久,否则 restore 失败并 - // 保留恢复状态;目录删除可见但 sync 失败时重试幂等(目录已不存在则跳过删除,本处 - // 仍 sync filesDir 确认“删除/不存在”持久后才放行)。P1-1:sync 非 OK 由 - // [requireSpoolDirSync] 同步失效 gate(本函数开头已失效,保持 false 供下次重新确认)。 - val parent = dir.parentFile - if (parent == null || !requireSpoolDirSync(parent)) { - throw IOException( - "statistics spool cleanup not durable; restore state retained: ${dir.absolutePath}", - ) - } - synchronized(stateLock) { - insertionWaiters.values.forEach { it.cancel() } - insertionWaiters.clear() - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt deleted file mode 100644 index 9a6b28e62..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolReader.kt +++ /dev/null @@ -1,401 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal SpoolReader responsibilities extracted from [TokenStatSpool]. */ -/** Called with lifecycleMutex held. */ -internal suspend fun TokenStatSpool.drainCore(context: Context, generation: Long): Boolean { - // P1-1 终审:replay/维护/快照 drain 前必须先确认 spool 目录项持久(上一进程可见 - // 未确认的目录项在本进程重新提交);失败退避重试,绝不带着未确认状态做任何目录变更。 - if (!ensureDirectoryDurabilityConfirmed(context, spoolDir(context))) { - logE("statistics spool drain deferred: directory durability unconfirmed") - return false - } - val dao = resolveDaoSafely(context) ?: return false - // P1-2 维护/后台重试:先清理删除失败被隔离的残留(pending-delete 与 tombstoned 段)。 - // 返回 false 表示本轮存在目录项未确认持久的变更——drain 退避重试,绝不推进状态。 - if (!retryPendingCleanup(context)) { - logE("statistics spool maintenance deferred: directory changes unconfirmed") - return false - } - var lastRound: List = emptyList() - while (synchronized(stateLock) { sessionGeneration == generation }) { - val segments = sealAndList(context) ?: return false - if (segments.isEmpty()) return true - // P1-1 有界推进:受管集合已满且仍无法处置的段会停留在队列(未入受管集合)。 - // 连续两轮同一集合说明无进展——跳过并返回,健康段已排空,绝不无限重扫/持锁。 - if (segments == lastRound) { - logE( - "statistics spool drain cannot make progress (managed-failure set full?); " + - "leaving ${segments.size} unmanageable segment(s): ${segments.joinToString { it.name }}", - ) - return true - } - lastRound = segments - for (segment in segments) if (!drainSegment(context, dao, segment)) return false - } - return true -} -internal suspend fun TokenStatSpool.sealAndList(context: Context): List? { - val dir = spoolDir(context) - if (!dir.isDirectory) return emptyList() - val active = File(dir, ACTIVE_FILE_NAME) - if (active.isFile && active.length() > 0L && !sealActive(context, dir)) { - return null - } - // P1-2:tombstone 按稳定 identity(file+bytes+sha256)跳过,绝不只信文件名 - return sealedFilesToProcess(context, dir, readTombstoneLines(context)) -} -internal suspend fun TokenStatSpool.hasPendingSegments(context: Context): Boolean { - val dir = spoolDir(context) - val processableNames = - sealedFilesToProcess(context, dir, readTombstoneLines(context)).mapTo(HashSet()) { it.name } - // P1-7 fail-closed:待处理判定依赖枚举完整性——根枚举失败(null)时绝不能当作 - // “没有 pending”返回(那会让快照在仍有待处理事件时误成功)。抛 IOException 由 - // [withExclusiveSnapshotAccess] 传播,[block] 绝不执行,文件保持原样。 - val files = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for pending segments: ${dir.absolutePath}") - return files.any { - it.isFile && - it.length() > 0L && - (it.name == ACTIVE_FILE_NAME || - (it.name.startsWith(SEALED_PREFIX) && - it.name.endsWith(SEALED_SUFFIX) && - it.name in processableNames)) - } -} -/** - * Raw snapshots intentionally exclude the active spool queue. Quarantine files and their - * summary/tombstone/trash metadata are not queue data and must never be silently omitted from a - * successful snapshot. Until raw restore has a selective evidence-preservation protocol, fail - * before ZIP creation and leave every evidence byte in place for explicit export/acknowledgment. - */ -internal suspend fun TokenStatSpool.hasQuarantineEvidenceForSnapshotLocked(context: Context): Boolean { - val dir = spoolDir(context.applicationContext) - if (quarantineAreaFiles(dir).isNotEmpty()) return true - if (stuckAckTrashEvidenceLocked(context).isNotEmpty()) return true - if (readTombstoneLines(context).isNotEmpty()) return true - val summaryFile = File(dir, QUARANTINE_SUMMARY_NAME) - return readMetadata(summaryStore(summaryFile), summaryFile)?.isNotBlank() == true -} -/** - * P1-2:对 sealed 队列应用受管失败集合。身份匹配的段跳过(受管);身份不匹配或文件已 - * 消失的条目是陈旧 tombstone(旧文件已删但 manifest 未更新,随后同名不同 hash 的健康 - * 段复用)——移除陈旧记录并正常处理新文件,绝不删/跳过健康。例外(P1-2):条目身份仍 - * 停留在未提交 ack trash 中时绝不按陈旧移除——该身份的证据还存在于 trash,移除会让崩溃 - * 窗口判定把未确认的证据误判为已提交而删除。身份**不可校验**(读取失败)的段本轮跳过 - * 且保留 manifest 条目:无法证明当前文件仍是记录中的段时,既不处理也不清理,绝不基于 - * 失败猜测破坏性决策。陈旧记录移除在持锁下崩溃安全重写 manifest。 - */ -internal suspend fun TokenStatSpool.sealedFilesToProcess(context: Context, dir: File, rawLines: List): List { - val trashHold = scanUncommittedTrashHolds(context) - val heldInTrash = trashHold.known - val entries = rawLines.mapNotNull(::parseTombstoneLine) - val stale = mutableListOf() - // P1-7 fail-closed:sealed 队列枚举失败(null)时抛 IOException——drain 据此退避 - // 重试、快照/恢复中止;绝不把失败当作空队列(否则 drain 会在仍有待处理段时误成功, - // 快照 barrier 也随之误判“无 pending”)。 - val allFiles = listDir(dir) - ?: throw IOException("cannot enumerate spool directory for sealed segments: ${dir.absolutePath}") - val files = allFiles - .filter { - it.isFile && - it.name.startsWith(SEALED_PREFIX) && - it.name.endsWith(SEALED_SUFFIX) - } - .sortedBy { it.sealIndex() } - .filter { file -> - val entry = entries.firstOrNull { it.file == file.name } - when { - entry == null -> true - else -> when (tombstoneIdentityCheck(entry, file)) { - IdentityCheck.MATCH -> false - IdentityCheck.MISMATCH -> { - // P1-2:旧身份仍停留在未提交 trash 中 → 保留条目(证据未 - // 消失),但仍正常处理同名新文件(旧文件在 trash 中不可能 - // 与当前文件同名共存,身份判定互不干扰)。 - // P1-4:存在无法完整严格解析的 UNCOMMITTED trash 时同样 - // 保留条目——旧身份可能正被其持有,scanner 绝不把可能受 - // trash 持有的身份当无保护而按 stale 移除。 - if (!trashHold.hasUnknown && - heldInTrash[entry.file] != (entry.bytes to entry.sha256) - ) { - stale += entry - } - true - } - IdentityCheck.UNREADABLE -> { - logE( - "statistics tombstone identity unreadable; keeping manifest " + - "entry and skipping the segment this round: ${file.name}", - ) - false - } - } - } - } - if (stale.isNotEmpty()) { - logE( - "statistics tombstone manifest has stale identities (vanished or reused-name files); " + - "removing: ${stale.joinToString { it.file }}", - ) - rewriteTombstoneManifest( - context = context, - remainingRawLines = rawLines.filterNot { line -> parseTombstoneLine(line)?.let { it in stale } == true }, - ) - } - return files -} -internal suspend fun TokenStatSpool.drainSegment( - context: Context, - dao: TokenStatsDao, - segment: File, -): Boolean { - val rawBytes = try { - if (segmentReadErrorForTest?.invoke(segment) == true) { - throw IOException("statistics spool segment read failed (injected): ${segment.name}") - } - segment.readBytes() - } catch (e: Exception) { - logE("statistics spool segment read failed: ${segment.name}", e) - return false - } - afterSegmentReadForTest?.invoke() - // 身份哈希一律基于原始字节(readText 会对非法 UTF-8 做替换再编码,与文件字节 - // 不一致会让 tombstone 身份永远无法匹配损坏段,造成反复重扫/重复条目)。 - val text = String(rawBytes, Charsets.UTF_8) - var corrupt = false - var corruptLineCount = 0 - for (line in text.lineSequence().filter { it.isNotEmpty() }) { - val request = try { - TokenStatRequestContext.fromSpoolLine(line) - } catch (e: Exception) { - corrupt = true - corruptLineCount += 1 - logE("statistics spool line corrupt; preserving segment evidence: ${segment.name}", e) - continue - } - if (!insertSafely(context, dao, request)) return false - synchronized(stateLock) { - insertionWaiters.remove(request.eventId)?.complete(Unit) - } - } - if (corrupt) { - val existingBytes = quarantineEvidenceLocked(context).sumOf { it.length() } - if (existingBytes + segment.length() > MAX_QUARANTINE_BYTES) { - // 硬边界(P2-1):容量内保留完整证据;超限的新损坏段只保留固定大小滚动 - // 摘要(计数/hash/字节/时间,不含正文),并移除原段,磁盘占用有界。 - // 健康排空不受影响:本段处理完立即继续后续 segment。 - if (!summarizeOverCapSegment(context, segment, rawBytes, text, corruptLineCount)) { - logE("statistics quarantine hard cap: over-cap segment retained: ${segment.name}") - return false - } - return true - } - val target = File( - segment.parentFile, - "$QUARANTINE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (!renameForTest(segment, target)) { - // P1-2:证据重命名失败也不能阻塞健康排空——容量内预算允许时先移入有界 - // pending-delete 诊断区(完整证据,维护入口会移回证据区),再失败才 tombstone - val pending = File( - segment.parentFile, - "$PENDING_DELETE_PREFIX${UUID.randomUUID().toString().replace("-", "")}_${segment.name}", - ) - if (renameForTest(segment, pending)) { - // P1 终审:rename 后目录项必须确认持久,非 OK fail-closed(段内容在任一 - // 名字下保留,绝不丢原始证据;drain 退避重试下一轮)。P1-1:非 OK 同时 - // 失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE( - "statistics corrupt segment pending-delete rename not durable; " + - "deferring round: ${pending.name}", - ) - return false - } - logE("statistics corrupt segment quarantine rename failed; retained as pending-delete evidence: ${pending.name}") - return true - } - logE("statistics corrupt segment quarantine rename failed; tombstoning: ${segment.name}") - // P1-1:受管集合满时跳过该段继续健康(有界重扫),写失败才退避重试 - return when (tombstoneSegment(context, segment, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> true - TombstoneResult.FAILED -> false - } - } - // P1 终审:证据 rename 后目录项必须确认持久,非 OK fail-closed——证据内容在 - // quarantine 名下保留(绝不丢原始),本轮退避由 drain 重试。P1-1:非 OK 同时 - // 失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE( - "statistics corrupt segment quarantine rename not durable; " + - "deferring round: ${target.name}", - ) - return false - } - return true - } - if (!(segmentDeleteForTest?.invoke(segment) ?: segment.delete())) { - logE("statistics spool segment deletion failed: ${segment.name}") - return false - } - // P1 终审:删除后目录项必须确认持久,非 OK fail-closed(行已入 Room,崩溃后文件 - // 复活会被 INSERT IGNORE 幂等重放,绝不丢数据;本轮退避下一轮继续)。P1-1:非 OK - // 同时失效 gate。 - if (!requireSpoolDirSync(segment.parentFile!!)) { - logE("statistics spool segment deletion not durable: ${segment.name}") - return false - } - return true -} -/** - * Room insert with a genuinely bounded lifecycle. The write runs on the dedicated single-thread - * insert worker; the drain waits at most [insertTimeoutMs] and on timeout releases the lifecycle - * lock WITHOUT joining the worker (SQLite can ignore interrupts forever, and an unbounded join - * under the lock would freeze every append/snapshot/replay). - * - * Safety across the snapshot/restore barrier (P1-2) has two halves: - * - Generation fencing: the task captures the session generation at submission and atomically - * re-checks it together with [exclusiveBarrierActive] before touching Room. A task that runs - * after a restore bumped the generation skips entirely (its durable segment belongs to the - * pre-restore state that restore replaces). - * - Active-insert registry: the same atomic section registers the insert BEFORE Room is - * entered and the `finally` unregisters it. A snapshot/restore therefore provably waits (or - * bounded-fails) for every insert that already passed the fence, instead of merely relying on - * a check-then-act race that could let an old DAO write into replaced files. - */ -internal suspend fun TokenStatSpool.insertSafely( - context: Context, - dao: TokenStatsDao, - request: TokenStatRequestContext, -): Boolean { - val generation = synchronized(stateLock) { sessionGeneration } - val task = FutureTask { - val registered = synchronized(stateLock) { - if (sessionGeneration != generation || exclusiveBarrierActive) { - false - } else { - activeInserts[request.eventId] = generation - true - } - } - if (!registered) return@FutureTask - try { - runBlocking { TokenStatsLedger.recordWith(context, dao, request) } - synchronized(stateLock) { - insertionWaiters.remove(request.eventId)?.complete(Unit) - } - } finally { - synchronized(stateLock) { activeInserts.remove(request.eventId) } - } - } - try { - insertExecutor.execute(task) - } catch (e: RejectedExecutionException) { - logE("statistics insert worker saturated; durable segment retained: ${request.eventId}", e) - return false - } - return try { - task.get(insertTimeoutMs, TimeUnit.MILLISECONDS) - true - } catch (e: TimeoutException) { - // The worker may legitimately outlive this wait; the durable segment stays for a later - // drain and the generation fence keeps a late write out of a restored database. - task.cancel(true) - logE("statistics Room insert timed out; durable segment retained: ${request.eventId}", e) - false - } catch (e: ExecutionException) { - logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e.cause ?: e) - false - } catch (e: CancellationException) { - logE("statistics Room insert cancelled; durable segment retained: ${request.eventId}", e) - false - } catch (e: Throwable) { - logE("statistics Room insert failed; durable segment retained: ${request.eventId}", e) - false - } -} -/** - * Database preparation with bounded single-flight semantics: at most one resolution runs at a - * time, and a timed-out resolution is reused by later drain cycles instead of spawning another - * thread (P2-1). A permanently wedged open cannot recover without a restart, but it can never - * block the lifecycle lock or leak threads. - */ -internal fun TokenStatSpool.resolveDaoSafely(context: Context): TokenStatsDao? { - val task = synchronized(stateLock) { - pendingDaoTask?.takeIf { !it.isDone } - ?: FutureTask { - (TokenStatsLedger.databaseProvider?.invoke(context) ?: AppDatabase.getDatabase(context)) - .tokenStatsDao() - }.also { created -> - pendingDaoTask = created - try { - databaseExecutor.execute(created) - } catch (e: RejectedExecutionException) { - pendingDaoTask = null - throw e - } - } - } - return try { - task.get(prepareTimeoutMs, TimeUnit.MILLISECONDS) - } catch (e: TimeoutException) { - task.cancel(true) - logE("statistics database preparation timed out; durable segments retained", e) - null - } catch (e: RejectedExecutionException) { - logE("statistics database preparation rejected; durable segments retained", e) - null - } catch (e: ExecutionException) { - logE("statistics database preparation failed; durable segments retained", e.cause ?: e) - null - } catch (e: Throwable) { - logE("statistics database preparation failed; durable segments retained", e) - null - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt deleted file mode 100644 index 9d382cd77..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolRecovery.kt +++ /dev/null @@ -1,524 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** Internal SpoolRecovery responsibilities extracted from [TokenStatSpool]. */ -internal enum class SealPublishResult { PUBLISHED, EXISTS, FAILED } -/** - * Seal 采用文件系统级原子“不替换”发布(P1-8 + P1 终审持久化协议): - * - * 1. 首选 `Files.createLink(target, active)`:同目录硬链接,目标创建原子且已存在时抛 - * [FileAlreadyExistsException](绝不替换既有 sealed 段);链接建立后按 P1 终审顺序 - * 持久化:sync 目录(链接目录项)→ 删除 active → sync 目录(删除持久化)。崩溃窗口 - * (链接已建、active 删除未发生或未持久化)两个名字指向同一 inode,由 - * [recoverSealDuplicates] 在下次 append/drain 时识别并删除 sealed 副本(内容保留在 - * active,绝不重复拼接——向 active 追加会连带改写已 seal 段)。 - * 2. 硬链接不受支持(FAT/exFAT 等)或临时失败时回退 copy 发布([publishSealedByCopy]): - * Android/Linux 的 Unix provider 以 O_CREAT|O_EXCL 原子创建目标(已存在即抛 - * [FileAlreadyExistsException]),Windows 以 CREATE_NEW 同样原子不替换。这比不带 - * REPLACE 的 `Files.move` 更强:Android 的普通 move 先做存在性预检再 rename(2) - * (rename 会静默替换预检之后出现的目标),保留 TOCTOU,不可单独依赖;ATOMIC_MOVE - * 在目标已存在时语义实现相关,同样不可依赖。copy 回退的崩溃窗口(复制完成、active - * 未删)产生两个内容相同的独立文件,同样由 [recoverSealDuplicates] 按内容识别去重。 - * - * 枚举失败(null)或恢复无法确认无重复时 seal 明确失败(fail-closed),绝不发布。 - */ -internal suspend fun TokenStatSpool.sealActive(context: Context, dir: File): Boolean { - val active = File(dir, ACTIVE_FILE_NAME) - if (!active.isFile || active.length() == 0L) return true - if (!recoverSealDuplicates(dir, active)) { - logE("statistics spool segment seal failed: seal recovery could not confirm no duplicates") - return false - } - // P1-7 fail-closed:seal 编号枚举失败(null)→ seal 明确失败,绝不回退到编号 1 - // (枚举失败时回退 1 会重名覆盖 sealed_1 等既有段,销毁其证据)。枚举成功但目标 - // 已被占用(异常残留)→ 递增到下一个安全编号,找不到则失败,绝不覆盖任何既有段。 - val index = nextSealIndex(dir) - if (index == null) { - logE("statistics spool segment seal failed: cannot enumerate spool directory: ${dir.absolutePath}") - return false - } - var candidate = index - while (true) { - val target = File(dir, "$SEALED_PREFIX$candidate$SEALED_SUFFIX") - // 可控 publication seam(P1-8):测试可在此创建同名不同内容的目标文件模拟冲突, - // 真实发布路径必须检测到占用并选择下一编号,目标原字节保持不变。 - if (beforeSealPublishForTest?.invoke(target) == false) { - logE("statistics spool segment seal failed: pre-publish hook refused: ${target.name}") - return false - } - when (publishSealedNoReplace(context, dir, active, target)) { - SealPublishResult.PUBLISHED -> return true - SealPublishResult.EXISTS -> { - candidate += 1L - if (candidate <= 0L) { - // Long 溢出防御:不再有可用编号 → 失败(绝不覆盖) - logE("statistics spool segment seal failed: no free sealed index: ${dir.absolutePath}") - return false - } - } - SealPublishResult.FAILED -> { - logE("statistics spool segment seal failed: ${target.name}") - return false - } - } - } -} -/** - * 原子“不替换”发布 active → target(调用方持 lifecycleMutex,契约见 [sealActive]): - * 首选硬链接;不受支持时回退 copy 发布([publishSealedByCopy])。目标已存在只返回 - * [SealPublishResult.EXISTS],绝不修改、替换或删除既有目标。 - * - * 持久化契约(P1 终审):两种路径都保证“target 的 data + 目录项(创建/链接/删除)已 - * fsync 确认后才可能返回 PUBLISHED”;任何前置失败保留 active(数据持有者)并返回 - * FAILED;删除 active 后的目录同步失败返回 FAILED 但保留已 durable 的 target,由 - * [recoverSealDuplicates] 恢复。 - */ -internal suspend fun TokenStatSpool.publishSealedNoReplace( - context: Context, - dir: File, - active: File, - target: File, -): SealPublishResult { - val linked = if (sealHardLinkForTest?.invoke(active, target) != false) { - try { - Files.createLink(target.toPath(), active.toPath()) - true - } catch (e: FileAlreadyExistsException) { - return SealPublishResult.EXISTS - } catch (e: Exception) { - // 平台/文件系统不支持硬链接或临时失败 → 回退 copy 发布 - false - } - } else { - // 测试注入:强制模拟硬链接不受支持 - false - } - if (linked) return publishSealedAfterHardLink(dir, active, target) - return publishSealedByCopy(context, dir, active, target) -} -/** - * 硬链接发布后置持久化(P1 终审):createLink 已原子建立同 inode 链接(active 数据在 - * append 时已 fsync)。顺序:sync 目录(持久化链接目录项)→ 删除 active → sync 目录 - * (持久化删除)。 - * - * - 删除 active 之前的任何失败:active 是唯一数据持有者,保留 active、回滚链接并返回 - * FAILED,绝不声称 PUBLISHED(否则崩溃窗口里 append 可能写进已 seal 段)。 - * - 删除 active 之后的目录同步失败:链接已 data+creation durable,active 删除可能未 - * 持久化——保留明确恢复状态(崩溃后 active 以同 inode 重现时由 [recoverSealDuplicates] - * 去重;未重现则 target 正常排空),返回 FAILED 阻止本轮后续 append 写入,绝不回滚 - * 已 durable 的 target。 - * - * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 - * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 - */ -internal fun TokenStatSpool.publishSealedAfterHardLink( - dir: File, - active: File, - target: File, -): SealPublishResult { - if (!requireSpoolDirSync(dir)) { - rollbackSealTarget(dir, target, "hardlink") - return SealPublishResult.FAILED - } - if (!deleteActiveAfterPublish(active)) { - // 链接已建但 active 删除失败:同 inode 重复。先尝试回滚链接;回滚也失败时 - // 保留给 [recoverSealDuplicates] 下次识别(内容仍在 active)。绝不可带着 - // active 返回成功——否则后续 drain 会把同一内容排空两次。 - rollbackSealTarget(dir, target, "hardlink") - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal hardlink: dir sync after active removal failed; " + - "durable link will be recovered: ${target.name}", - ) - return SealPublishResult.FAILED - } - return SealPublishResult.PUBLISHED -} -/** - * copy 回退发布(P1 终审 + P2):O_CREAT|O_EXCL / CREATE_NEW 原子创建目标(绝不替换 - * 既有目标;[FileAlreadyExistsException] → EXISTS 让调用方选下一编号)。 - * - * 持久化顺序:copy 目标 → fsync 目标数据([syncFile])→ sync 目录(目标创建持久)→ - * 删除 active → sync 目录(删除持久)。 - * - 删除 active 之前的任何失败:active 是完整内容持有者,保留 active,并按 P2 处置本次 - * 目标([disposeFailedCopyTarget]:identity 确认后隔离到 seal_failed_ 或安全 - * 删除;两者都失败则 tombstone skip,绝不当 normal sealed 排空),返回 FAILED。 - * - 删除 active 之后的目录同步失败:目标已 data+creation durable,active 删除可能未 - * 持久化(崩溃后 active 以原内容重现 → [recoverSealDuplicates] 按内容去重;未重现则 - * target 正常排空)——保留该明确恢复状态并返回 FAILED,阻止本轮后续 append 污染, - * 绝不回滚已 durable 的 target。 - * - * P1 终审:只有 [DirSyncResult.OK] 才能继续;[DirSyncResult.UNSUPPORTED] 与 FAILED 一样 - * fail-closed——目录项未确认持久时**绝不**删除唯一 fsynced active 或返回 PUBLISHED。 - */ -internal suspend fun TokenStatSpool.publishSealedByCopy( - context: Context, - dir: File, - active: File, - target: File, -): SealPublishResult { - val injected = sealCopyForTest?.invoke(active, target) - if (injected != null) { - if (!injected) { - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - } else { - try { - Files.copy(active.toPath(), target.toPath()) - } catch (e: FileAlreadyExistsException) { - return SealPublishResult.EXISTS - } catch (e: Exception) { - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - } - if (!syncFile(target)) { - // 目标数据未确认 durable:保留 active,处置本次目标 - disposeFailedCopyTarget(context, dir, target, active) - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - // 目标创建未确认持久:保留 active,处置本次目标 - if (!disposeFailedCopyTarget(context, dir, target, active)) { - logE( - "statistics spool seal copy failed; partial target disposal not durable: ${target.name}", - ) - } - return SealPublishResult.FAILED - } - if (!deleteActiveAfterPublish(active)) { - // 复制完成、active 未删:两个独立文件同内容。目标已 durable(data+creation), - // 删除目标放弃 sealed 副本(active 仍是完整内容持有者,无数据损失);回滚失败 - // 留给 [recoverSealDuplicates] 按内容去重。 - rollbackSealTarget(dir, target, "copy") - return SealPublishResult.FAILED - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy: dir sync after active removal failed; " + - "durable target will be recovered: ${target.name}", - ) - return SealPublishResult.FAILED - } - return SealPublishResult.PUBLISHED -} -internal fun TokenStatSpool.deleteActiveAfterPublish(active: File): Boolean = - sealActiveDeleteForTest?.invoke(active) ?: active.delete() -/** - * seal 前置失败回滚(P2 终审):删除刚发布的 target(active 仍是完整内容持有者,删除 - * 安全无数据损失),删除后必须经 [requireSpoolDirSync] 确认目录项持久——删除是目录项 - * 变更,未确认持久绝不视为回滚完成(P1-1:非 OK 同时失效 bootstrap gate,下一次使用 - * 重新确认)。返回 false 表示回滚未完成/未确认(target 删除失败或目录项未确认持久), - * 调用方保持失败状态;残留由 [recoverSealDuplicates] 按 inode/内容去重兜底。 - */ -internal fun TokenStatSpool.rollbackSealTarget(dir: File, target: File, kind: String): Boolean { - if (!target.delete()) { - logE("statistics spool seal rollback failed ($kind); duplicate will be recovered: ${target.name}") - return false - } - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal rollback deletion not durable ($kind); " + - "gate invalidated, duplicate will be recovered: ${target.name}", - ) - return false - } - return true -} -/** - * P2 终审修复:seal copy 失败后的部分目标处置(调用方持 lifecycleMutex)。身份前提:候选 - * 编号在 copy 前由 [nextSealIndex] 确认不存在、copy 无 REPLACE 语义、lifecycleMutex 内无本 - * 进程并发——异常后目标若存在只可能是本次 copy 的部分写入;[isPrefixOf] 前缀校验防御外部 - * 进程并发占用该名字时的误隔离(identity 确认)。处置顺序: - * 1. 原子 rename 到 `seal_failed_`(scanner 忽略该前缀、计入递归总 cap、维护清理、 - * 作为受管失败发布证据可见/导出/ack);rename 后目录项 sync 非 OK——隔离文件本身即受管 - * 证据,另按候选 sealed 身份写 tombstone(崩溃后该名字以同内容重现时 scanner 跳过,绝不 - * 普通排空),返回 false(调用方失败,绝不静默)。 - * 2. rename 失败 → 安全删除(active 保留完整内容,删除部分副本无数据损失);删除后目录项 - * sync 非 OK——删除可见但未确认:按候选 sealed 身份写 tombstone 保护崩溃后可能重现的 - * 名字,返回 false。 - * 3. rename/delete 都失败 → tombstone skip(记录稳定身份,scanner 跳过该具体文件,绝不当 - * normal sealed 排空);tombstone 写失败返回 false——drain 退避重试,不做任何破坏性决策。 - * - * @return true = 已留下受管证据(seal_failed 隔离文件/tombstone 条目)或已安全删除且目录项 - * 确认持久;false = 存在目录项未确认持久的变更(tombstone 已尽力写入受管证据),调用方 - * 必须失败,绝不只记录日志。 - */ -internal suspend fun TokenStatSpool.disposeFailedCopyTarget( - context: Context, - dir: File, - target: File, - active: File, -): Boolean { - if (!target.exists()) return true - if (!isPrefixOf(target, active)) { - logE( - "statistics spool seal copy failure target identity mismatch; " + - "leaving file untouched: ${target.name}", - ) - return true - } - val isolated = File(dir, "$SEAL_FAILED_PREFIX${UUID.randomUUID().toString().replace("-", "")}") - if (renameForTest(target, isolated)) { - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy failed; isolated target rename not durable: ${isolated.name}", - ) - // P2:隔离文件本身已是受管证据(seal_failed_*,可 UI/导出/ack/维护);再按 - // 候选 sealed 身份写 tombstone,崩溃后该名字以同内容重现时 scanner 跳过。 - tombstonePartialTarget(context, target, isolated) - return false - } - logE("statistics spool seal copy failed; partial target isolated: ${isolated.name}") - return true - } - // rename 失败:先取原始字节(删除成功后将无法再读取),再尝试安全删除 - val rawBytes = try { - target.readBytes() - } catch (e: Exception) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", - e, - ) - null - } - if (segmentDeleteForTest?.invoke(target) ?: target.delete()) { - if (!requireSpoolDirSync(dir)) { - logE( - "statistics spool seal copy failed; partial target deletion not durable: ${target.name}", - ) - // P2:删除可见但未确认——按候选 sealed 身份写 tombstone,崩溃后该名字 - // 以同内容重现时 scanner 跳过,绝不普通排空。 - if (rawBytes != null) { - tombstoneSegment(context, target, rawBytes, overCap = false) - } - return false - } - logE("statistics spool seal copy failed; partial target deleted: ${target.name}") - return true - } - if (rawBytes == null) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${target.name}", - ) - return false - } - return when (tombstoneSegment(context, target, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> { - logE( - "statistics spool seal copy failed; partial target tombstoned, scanner will skip: ${target.name}", - ) - true - } - TombstoneResult.FAILED -> { - logE( - "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${target.name}", - ) - false - } - } -} -/** - * P2:seal copy 失败目标的 tombstone 写入(调用方持 lifecycleMutex)。目标可能已被改名/ - * 删除,[bytesSource] 提供其原始字节;[tombstoneSegment] 按稳定身份(bytes+sha256)记录 - * [nameFile](候选 sealed 名),崩溃后该名字以同内容重现时 scanner 跳过。写失败仅记录—— - * 调用方本就返回失败,drain 退避重试。 - */ -internal suspend fun TokenStatSpool.tombstonePartialTarget( - context: Context, - nameFile: File, - bytesSource: File, -) { - val rawBytes = try { - bytesSource.readBytes() - } catch (e: Exception) { - logE( - "statistics spool seal copy failed; partial target unreadable, cannot tombstone: ${nameFile.name}", - e, - ) - return - } - when (tombstoneSegment(context, nameFile, rawBytes, overCap = false)) { - TombstoneResult.RECORDED, TombstoneResult.CAPACITY_FULL -> Unit - TombstoneResult.FAILED -> { - logE( - "statistics spool seal copy failed; partial target tombstone write failed; drain will retry: ${nameFile.name}", - ) - } - } -} -/** - * 部分目标 identity 确认(P2):目标必须是 source(active)的前缀(长度 ≤ 且逐字节 - * 相等)才允许处置;读取失败返回 false(fail-closed,绝不隔离不可确认的文件)。 - */ -internal fun TokenStatSpool.isPrefixOf(partial: File, source: File): Boolean { - if (partial.length() > source.length()) return false - if (partial.length() == 0L) return true - return try { - partial.inputStream().use { pIn -> - source.inputStream().use { sIn -> - val bufP = ByteArray(64 * 1024) - val bufS = ByteArray(64 * 1024) - var remaining = partial.length() - while (remaining > 0L) { - val want = minOf(bufP.size.toLong(), remaining).toInt() - val nP = pIn.read(bufP, 0, want) - if (nP <= 0) return false - val nS = sIn.read(bufS, 0, nP) - if (nS != nP) return false - if (!bufP.copyOfRange(0, nP).contentEquals(bufS.copyOfRange(0, nS))) { - return false - } - remaining -= nP - } - true - } - } - } catch (e: Exception) { - logE("statistics spool seal copy failure identity check failed", e) - false - } -} -/** - * 恢复 seal 崩溃窗口的重复(P1-8,调用方持 lifecycleMutex):active 与某个 sealed 段 - * 指向同一 inode(硬链接窗口:createLink 成功但 active 删除失败/崩溃)或内容完全相同 - * (copy 回退窗口:目标复制完成、active 删除未发生,两个独立 inode 同内容)时,删除 - * sealed 副本、保留 active 为唯一内容持有者——后续 append 才不会被连带写进已 seal 段, - * 同一内容也只会被排空一次。 - * - * 合法内容重复不可能发生(事件行含唯一 eventId,active 内容严格单调增长),因此 - * 内容相等只可能来自上述崩溃窗口;即使病理情况下误删副本,内容仍从 active 重新 - * seal 并排空,不丢数据。 - * - * fail-closed:spool 根枚举失败(null)、任一 sealed 候选无法 stat/读取或删除失败时 - * 返回 false——调用方(append/seal)拒绝继续,绝不带着“可能还有重复”的状态写入或发布。 - */ -internal fun TokenStatSpool.recoverSealDuplicates(dir: File, active: File): Boolean { - val files = listDir(dir) ?: return false - val activeKey = try { - Files.readAttributes( - active.toPath(), - BasicFileAttributes::class.java, - java.nio.file.LinkOption.NOFOLLOW_LINKS, - ).fileKey() - } catch (e: Exception) { - logE("statistics spool cannot stat active for seal recovery", e) - return false - } - var ok = true - var deletedAny = false - for (file in files) { - if (!file.isFile || !file.name.startsWith(SEALED_PREFIX) || !file.name.endsWith(SEALED_SUFFIX)) { - continue - } - val key = try { - Files.readAttributes( - file.toPath(), - BasicFileAttributes::class.java, - java.nio.file.LinkOption.NOFOLLOW_LINKS, - ).fileKey() - } catch (e: Exception) { - logE("statistics spool cannot stat sealed segment for seal recovery: ${file.name}", e) - ok = false - continue - } - val sameInode = key != null && key == activeKey - val contentDuplicate = if (sameInode) null else contentsEqual(file, active) - when { - sameInode || contentDuplicate == true -> { - if (!file.delete()) { - logE("statistics spool seal duplicate removal failed: ${file.name}") - ok = false - } else { - deletedAny = true - } - } - contentDuplicate == null -> { - // 无法确认是否重复(读取失败)→ fail-closed,绝不带着未知状态继续 - logE("statistics spool cannot compare sealed segment for seal recovery: ${file.name}") - ok = false - } - } - } - // P1-3 终审:重复副本删除是目录项变更——未确认持久绝不报告恢复完成(append/seal - // 据此 fail-closed)。active 仍是完整内容持有者,删除可见但未确认时崩溃后副本重现, - // 由下次恢复按 inode/内容幂等重删,绝不丢数据。P1-1:非 OK 同时失效 gate。 - if (deletedAny && !requireSpoolDirSync(dir)) { - logE("statistics spool seal duplicate removal not durable; recovery unconfirmed") - ok = false - } - return ok -} -/** - * A complete line always ends with '\n'; every append writes whole lines, so a file ending - * with '\n' has no partial tail. Only the final write of a crash can leave a tail without one. - */ -internal fun TokenStatSpool.activeEndsWithLineBreak(active: File): Boolean = - RandomAccessFile(active, "r").use { raf -> - raf.seek(raf.length() - 1L) - raf.read() == '\n'.code - } -/** 下一个建议的 sealed 编号;目录枚举失败(null)返回 null → seal 必须失败(P1-7)。 */ -internal fun TokenStatSpool.nextSealIndex(dir: File): Long? { - val files = listDir(dir) ?: return null - return files.mapNotNull { file -> - Regex("(?:quarantine_[^_]+_)?sealed_(\\d+)\\.jsonl").matchEntire(file.name) - ?.groupValues?.get(1)?.toLongOrNull() - }.maxOrNull()?.plus(1L) ?: 1L -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt deleted file mode 100644 index fe76dc2b4..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/SpoolWriter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit - -/** Worker construction and admission calculations for durable spool writes. */ -internal object SpoolWriter { - fun newDrainExecutor(): ScheduledThreadPoolExecutor = - ScheduledThreadPoolExecutor(1) { runnable -> - Thread(runnable, "operit-token-stats-writer").apply { isDaemon = true } - } - - fun newInsertExecutor(): ThreadPoolExecutor = - ThreadPoolExecutor( - 1, - 1, - 60L, - TimeUnit.SECONDS, - LinkedBlockingQueue(1) - ) { runnable -> Thread(runnable, "operit-token-stats-insert").apply { isDaemon = true } } - - fun newDatabaseExecutor(): ThreadPoolExecutor = - ThreadPoolExecutor( - 1, - 1, - 60L, - TimeUnit.SECONDS, - LinkedBlockingQueue(1) - ) { runnable -> Thread(runnable, "operit-token-stats-database").apply { isDaemon = true } } - - fun dataAdmissionMaxBytes(cap: Long, metadataReserveBytes: Long, maxLineBytes: Int): Long { - val reserve = minOf(metadataReserveBytes, cap - maxLineBytes).coerceAtLeast(0L) - return (cap - reserve).coerceAtLeast(0L) - } - - fun metadataWriteBudgetExceeded( - currentBytes: Long, - contentBytes: Int, - metadataCopyCount: Int, - cap: Long - ): Boolean = currentBytes + contentBytes.toLong() * metadataCopyCount > cap -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt index 01e70db59..0fe9b1386 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -1,7 +1,5 @@ package com.ai.assistance.operit.data.stats -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -9,66 +7,12 @@ import kotlin.math.ceil enum class TokenActivityViewMode { DAILY, WEEKLY, CUMULATIVE } -data class TokenActivityRecord( - val startedAtMs: Long, - val tokens: Long, -) - -data class TokenActivityEventRow( - val eventId: String = "", - val startedAtMs: Long, - val uncachedInputTokens: Long?, - val cachedInputTokens: Long?, - val cacheWriteTokens: Long?, - val totalInputTokens: Long?, - val outputTokens: Long?, - val reasoningTokens: Long?, - val reasoningIncludedInOutput: Boolean?, - /** null = 旧行未声明,按保守默认 true(独立计费)处理。 */ - val cacheWriteSeparateBilling: Boolean? = null, -) - -internal class TokenActivitySnapshot( +internal data class TokenActivitySnapshot( val zone: ZoneId, val dayTotals: Map, - val earliestYear: Int?, - val hourCounts: LongArray, - val totalRequests: Long, ) -internal class TokenActivityAccumulator(private val zone: ZoneId) { - private val dayTotals = HashMap() - private val hourCounts = LongArray(24) - private var earliestYear: Int? = null - private var totalRequests = 0L - - fun addPage(rows: List) { - rows.forEach { row -> - val dateTime = Instant.ofEpochMilli(row.startedAtMs).atZone(zone) - val date = dateTime.toLocalDate() - val tokens = row.toActivityRecord().tokens - dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, tokens) - hourCounts[dateTime.hour] = saturatedAdd(hourCounts[dateTime.hour], 1L) - earliestYear = minOf(earliestYear ?: date.year, date.year) - totalRequests = saturatedAdd(totalRequests, 1L) - } - } - - fun snapshot(): TokenActivitySnapshot = - TokenActivitySnapshot( - zone = zone, - dayTotals = dayTotals.toMap(), - earliestYear = earliestYear, - hourCounts = hourCounts.copyOf(), - totalRequests = totalRequests, - ) -} - -data class TokenActivityDay( - val date: LocalDate, - val tokens: Long, - val level: Int, -) +data class TokenActivityDay(val date: LocalDate, val tokens: Long, val level: Int) data class TokenActivityWeek( val startDate: LocalDate, @@ -84,182 +28,43 @@ data class TokenActivityStats( val longestStreak: Int = 0, ) -data class TokenActivityInsights( - val totalRequests: Long = 0L, - val topHours: List = emptyList(), -) - -data class TokenActivityYearData( +data class TokenActivityRangeData( val daily: List, val weekly: List, val cumulative: List, val stats: TokenActivityStats, ) -/** - * 逐事件 canonical token 总量推导(聚合器与活动热力图共用同一纯 helper)。 - * - * - 输入:权威 [totalInputTokens](provider 明确上报的总输入,含缓存命中/写入) - * 已知则直接使用;未知时按 [cacheWriteSeparateBilling] 决定 fallback: - * true(Anthropic:缓存写入独立计费,输入总量 = uncached + cached + cacheWrite, - * 漏加即漏算);false(OpenAI/Gemini/本地/ToolPkg:写入成本已包含在输入单价内, - * 输入总量 = uncached + cached,再加 cacheWrite 即重复)。null(旧行未声明) - * 按 true 保守默认,与费用重估([TokenCostCalculator])同一边界。 - * - 输出:outputTokens +([reasoningIncludedInOutput] == false 时的 reasoningTokens); - * 推理已包含在输出(true/null)时不再加,避免双重计数。 - * - 任一所必需分量未知(null)→ 整体 unknown(返回 null),绝不把 null 当作 0; - * 使用饱和加法([TokenCostCalculator.saturatedAdd]),Long 溢出钳制不回绕。 - * - 旧 baseline 无上述细分字段,只能按 input + output 合计(见 - * [com.ai.assistance.operit.ui.features.tokenstats.knownBaselineTokenSum])。 - */ -internal fun canonicalTotalTokens( - totalInputTokens: Long?, - uncachedInputTokens: Long?, - cachedInputTokens: Long?, - cacheWriteTokens: Long?, - cacheWriteSeparateBilling: Boolean?, - outputTokens: Long?, - reasoningTokens: Long?, - reasoningIncludedInOutput: Boolean?, -): Long? { - val input = - totalInputTokens ?: run { - val uncached = uncachedInputTokens ?: return null - val cached = cachedInputTokens ?: return null - val sum = TokenCostCalculator.saturatedAdd(uncached, cached) - if (cacheWriteSeparateBilling ?: true) { - val cacheWrite = cacheWriteTokens ?: return null - TokenCostCalculator.saturatedAdd(sum, cacheWrite) - } else { - sum - } - } - val output = outputTokens ?: return null - val billedOutput = - if (reasoningIncludedInOutput == false) { - val reasoning = reasoningTokens ?: return null - TokenCostCalculator.saturatedAdd(output, reasoning) - } else { - output - } - return TokenCostCalculator.saturatedAdd(input, billedOutput) -} - -/** [canonicalTotalTokens] 的事件实体重载(聚合器按事件列表逐条推导)。 */ -internal fun canonicalTotalTokens(event: TokenStatEventEntity): Long? = - canonicalTotalTokens( - totalInputTokens = event.totalInputTokens, - uncachedInputTokens = event.uncachedInputTokens, - cachedInputTokens = event.cachedInputTokens, - cacheWriteTokens = event.cacheWriteTokens, - cacheWriteSeparateBilling = event.cacheWriteSeparateBilling, - outputTokens = event.outputTokens, - reasoningTokens = event.reasoningTokens, - reasoningIncludedInOutput = event.reasoningIncludedInOutput, - ) - -internal fun TokenActivityEventRow.toActivityRecord(): TokenActivityRecord { - // 复用与聚合器相同的 canonical 推导;活动热力图只展示“已知 token 活动”, - // canonical 未知(必需分量缺失)的事件按 0 计,不假装精确——请求计数 - // 仍然准确(记录不因 0 被丢弃),未知明细由统计页的 unknown 计数表达。 - val tokens = - canonicalTotalTokens( - totalInputTokens = totalInputTokens, - uncachedInputTokens = uncachedInputTokens, - cachedInputTokens = cachedInputTokens, - cacheWriteTokens = cacheWriteTokens, - cacheWriteSeparateBilling = cacheWriteSeparateBilling, - outputTokens = outputTokens, - reasoningTokens = reasoningTokens, - reasoningIncludedInOutput = reasoningIncludedInOutput, - ) ?: 0L - return TokenActivityRecord(startedAtMs = startedAtMs, tokens = tokens) -} - object TokenActivityAggregator { - fun availableYears( - records: List, - zone: ZoneId, - nowMs: Long = System.currentTimeMillis(), - ): List = availableYears(snapshotOf(records, zone), nowMs) - - internal fun availableYears( - snapshot: TokenActivitySnapshot, - nowMs: Long = System.currentTimeMillis(), - ): List { - val zone = snapshot.zone - val currentYear = Instant.ofEpochMilli(nowMs).atZone(zone).year - val firstYear = snapshot.earliestYear?.coerceAtMost(currentYear) ?: currentYear - return (firstYear..currentYear).toList().reversed() - } - - fun insights(records: List, zone: ZoneId): TokenActivityInsights = - insights(snapshotOf(records, zone)) - - internal fun insights(snapshot: TokenActivitySnapshot): TokenActivityInsights { - return TokenActivityInsights( - totalRequests = snapshot.totalRequests, - topHours = snapshot.hourCounts.indices - .filter { snapshot.hourCounts[it] > 0L } - .sortedWith(compareByDescending { snapshot.hourCounts[it] }.thenBy { it }) - .take(3), - ) - } - - fun yearData( - records: List, - zone: ZoneId, - year: Int, - nowMs: Long = System.currentTimeMillis(), - ): TokenActivityYearData = yearData(snapshotOf(records, zone), year, nowMs) - - internal fun yearData( + /** Builds all three activity views from the same explicit calendar range. */ + internal fun rangeData( snapshot: TokenActivitySnapshot, - year: Int, - nowMs: Long = System.currentTimeMillis(), - ): TokenActivityYearData { - val zone = snapshot.zone - val nowDate = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() - val start = LocalDate.of(year, 1, 1) - val end = if (year == nowDate.year) nowDate else LocalDate.of(year, 12, 31) + range: TokenStatsTimeRange, + ): TokenActivityRangeData { + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(snapshot.zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(snapshot.zone).toLocalDate() return rangeData(snapshot.dayTotals, start, end) } - /** 默认活动窗口:包含今天在内的最近 365 个自然日。 */ - fun recentData( - records: List, - zone: ZoneId, - nowMs: Long = System.currentTimeMillis(), - ): TokenActivityYearData = recentData(snapshotOf(records, zone), nowMs) - - internal fun recentData( - snapshot: TokenActivitySnapshot, - nowMs: Long = System.currentTimeMillis(), - ): TokenActivityYearData { - val zone = snapshot.zone - val end = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() - return rangeData(snapshot.dayTotals, end.minusDays(364), end) - } - private fun rangeData( dayTotals: Map, start: LocalDate, end: LocalDate, - ): TokenActivityYearData { - val days = ChronoUnit.DAYS.between(start, end).toInt() + 1 - val raw = List(days) { index -> + ): TokenActivityRangeData { + val dayCount = ChronoUnit.DAYS.between(start, end).toInt() + 1 + val raw = List(dayCount) { index -> val date = start.plusDays(index.toLong()) TokenActivityDay(date, dayTotals[date] ?: 0L, 0) } - val dailyLevels = QuantileLevels.from(raw.map { it.tokens }) + val dailyLevels = QuantileLevels.from(raw.map(TokenActivityDay::tokens)) val daily = raw.map { it.copy(level = dailyLevels.level(it.tokens)) } var cumulativeTotal = 0L val cumulativeRaw = raw.map { - cumulativeTotal = saturatedAdd(cumulativeTotal, it.tokens) + cumulativeTotal = TokenCostCalculator.saturatedAdd(cumulativeTotal, it.tokens) it.copy(tokens = cumulativeTotal) } - val cumulativeLevels = QuantileLevels.from(cumulativeRaw.map { it.tokens }) + val cumulativeLevels = QuantileLevels.from(cumulativeRaw.map(TokenActivityDay::tokens)) val cumulative = cumulativeRaw.map { it.copy(level = cumulativeLevels.level(it.tokens)) } val firstWeek = start.minusDays((start.dayOfWeek.value % 7).toLong()) @@ -267,8 +72,9 @@ object TokenActivityAggregator { val weekCount = ChronoUnit.WEEKS.between(firstWeek, lastWeek).toInt() + 1 val weekTotals = LongArray(weekCount) raw.forEach { day -> - val index = ChronoUnit.WEEKS.between(firstWeek, day.date.minusDays((day.date.dayOfWeek.value % 7).toLong())).toInt() - weekTotals[index] = saturatedAdd(weekTotals[index], day.tokens) + val weekStart = day.date.minusDays((day.date.dayOfWeek.value % 7).toLong()) + val index = ChronoUnit.WEEKS.between(firstWeek, weekStart).toInt() + weekTotals[index] = TokenCostCalculator.saturatedAdd(weekTotals[index], day.tokens) } val weekLevels = QuantileLevels.from(weekTotals.toList()) val heights = barHeights(weekTotals.toList()) @@ -280,33 +86,7 @@ object TokenActivityAggregator { barHeight = heights[index], ) } - - return TokenActivityYearData( - daily = daily, - weekly = weekly, - cumulative = cumulative, - stats = stats(raw), - ) - } - - private fun snapshotOf(records: List, zone: ZoneId): TokenActivitySnapshot { - val dayTotals = HashMap() - val hourCounts = LongArray(24) - var earliestYear: Int? = null - records.forEach { record -> - val dateTime = Instant.ofEpochMilli(record.startedAtMs).atZone(zone) - val date = dateTime.toLocalDate() - dayTotals[date] = saturatedAdd(dayTotals[date] ?: 0L, record.tokens) - hourCounts[dateTime.hour] = saturatedAdd(hourCounts[dateTime.hour], 1L) - earliestYear = minOf(earliestYear ?: date.year, date.year) - } - return TokenActivitySnapshot( - zone = zone, - dayTotals = dayTotals, - earliestYear = earliestYear, - hourCounts = hourCounts, - totalRequests = records.size.toLong(), - ) + return TokenActivityRangeData(daily, weekly, cumulative, stats(raw)) } private fun stats(days: List): TokenActivityStats { @@ -315,13 +95,11 @@ object TokenActivityAggregator { var run = 0 var longest = 0 days.forEach { day -> - total = saturatedAdd(total, day.tokens) + total = TokenCostCalculator.saturatedAdd(total, day.tokens) peak = maxOf(peak, day.tokens) run = if (day.tokens > 0L) run + 1 else 0 longest = maxOf(longest, run) } - // currentStreak 只看 days **尾部**:从最后一天起连续正值;尾日 0 则 0 - // (绝不跳过尾部零日——今天无活动就是断更,不能用更早的活跃日续算)。 var current = 0 var index = days.lastIndex while (index >= 0 && days[index].tokens > 0L) { @@ -357,7 +135,8 @@ private class QuantileLevels(private val thresholds: LongArray) { return QuantileLevels(LongArray(6).also { it[3] = Long.MAX_VALUE }) } fun nearest(percentile: Double): Long { - val index = (ceil(nonZero.size * percentile).toInt() - 1).coerceIn(0, nonZero.lastIndex) + val index = (ceil(nonZero.size * percentile).toInt() - 1) + .coerceIn(0, nonZero.lastIndex) return nonZero[index] } return QuantileLevels( @@ -366,6 +145,3 @@ private class QuantileLevels(private val thresholds: LongArray) { } } } - -private fun saturatedAdd(left: Long, right: Long): Long = - if (right > 0L && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt deleted file mode 100644 index 16cd94328..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunner.kt +++ /dev/null @@ -1,367 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.withTransaction -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.util.AppLogger -import kotlinx.coroutines.CancellationException - -/** - * 旧 DataStore 累计统计 → baseline 的导入执行器(启动时一次性、冻结价格语义)。 - * - * 普通导入([ensureMigrated]):计数指纹变化时(旧系统累计 setter 增长,或用户 - * reset 后降低)用 baseline 行内**冻结价格**重估计数/成本,整体替换为快照绝对值; - * 计数不变时普通价格 setter 不重估。普通导入**只更新快照中明确存在的模型**, - * 快照缺失的模型保持原样(不删除——偏好文件可能暂时缺失/部分恢复;显式删除 - * 走 [TokenStatsResetCoordinator] 的用户重置路径)。受控补导(恢复驱动):真实 - * 恢复后的偏好快照,在**同一 Room 事务**内以 forceReplace 语义整体重导(替换 - * 冻结价格),并把恢复 generation 写入幂等锚点表;相同 generation 已应用则跳过 - * (崩溃后重放安全)。只有该完整受控补导才按恢复快照处理缺失模型(删除其旧系统 - * baseline,见 [TokenBaselineMigrator]);恢复快照**为空同样合法**——旧系统 - * 从未有统计即无 legacy baseline 可留,受控补导仍执行并删除全部 legacy baseline - * (保留非空 configId 的 baseline)。普通启动的空快照则安全 no-op(见 - * [runImport] 的空快照守卫)。 - * 整库(databases/)与偏好(datastore/)恢复顺序由 RawSnapshot 流程保证 - * (datastore 先于 databases 覆盖,登记在全部替换成功后、recovery state 完成前)。 - * - * P1 闭环(legacy cleanup outbox fence):两个导入入口在读取快照**之前**先排空 - * pending cleanup operation([TokenStatsResetCoordinator.drainPendingCleanupWith], - * Room 事务之外),并让快照携带**同一次读取**的 applied marker ID 集合;导入事务 - * 内经 [TokenStatsDao.cleanupFenceSatisfied] 校验(无 PENDING 且全部 operation ID - * 都在快照 markers 中)才应用快照,否则 no-op 等待下次启动重试——杜绝“先读旧 - * 快照 → cleanup 完成 → 旧快照写回”复活已删除的 baseline。受控补导被 fence 拒绝 - * 时**不记录 generation、不消费 marker**,保证信号不丢失。 - */ -object TokenBaselineImportRunner { - - private const val TAG = "TokenBaselineImport" - - /** - * 测试注入缝:生产代码始终为 null,走 [AppDatabase.getDatabase] 的真实事务; - * 测试注入时由测试自行提供真实 Room 数据库(JVM 驱动),验证导入语义。 - */ - internal var databaseProvider: ((Context) -> AppDatabase)? = null - - suspend fun ensureMigrated(context: Context) { - ensureMigratedStrict(context) - } - - /** Startup readiness entry point: false means this attempt must not be treated as ready. */ - internal suspend fun ensureMigratedStrict(context: Context): Boolean { - try { - return runImport(context.applicationContext, forceReplace = false) - } catch (e: CancellationException) { - // 取消必须向上传播,不能当作迁移失败吞掉 - throw e - } catch (e: Exception) { - // 迁移失败不影响主流程;下次启动会重试(指纹与事务保证幂等)。 - AppLogger.e(TAG, "旧累计统计导入失败(将在下次启动重试)", e) - return false - } - } - - // ==== 恢复生命周期:pending 标记 ==== - - /** - * 冷启动消费 pending 标记:有标记才补导。无标记(含 Room-only 恢复)不动作。 - * 补导与 generation 记录在同一事务中;相同 generation 已应用则跳过。 - * 新 generation(不静默删除信号)。 - */ - // ==== 导入 ==== - - internal suspend fun runImport(appContext: Context, forceReplace: Boolean): Boolean = - TokenStatSpool.withStatsDatabaseAccess { - runImportWithDatabaseAccess(appContext, forceReplace) - } - - /** 调用方已登记统计数据库访问 token;数据库实例必须在登记后解析。 */ - private suspend fun runImportWithDatabaseAccess( - appContext: Context, - forceReplace: Boolean, - ): Boolean { - val injected = databaseProvider - val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - val dao = database.tokenStatsDao() - // 普通启动守卫前先排空 pending legacy cleanup(删除后未完成的 DataStore - // 清理),再读快照:保证读到的是清理后的最新状态;排空失败抛异常由 - // ensureMigrated 捕获,下次启动重试(不吞)。 - TokenStatsResetCoordinator.drainPendingCleanupWith(appContext, dao) - val read = ApiPreferences.getInstance(appContext).legacyStatsSnapshotWithMarkers() - // 普通启动守卫:空快照直接返回,不触碰数据库(取消/空源都安全,绝不删除)。 - // 注意:受控补导(consumePendingLocked)不走此入口,空快照也以 - // forceReplace 语义执行删除计划。 - if (read.snapshot.providerModels.isEmpty()) return true - return if (injected != null) { - runImport(appContext, dao, read.snapshot, read.cleanupMarkerIds, forceReplace) - } else { - database.withTransaction { - runImport(appContext, dao, read.snapshot, read.cleanupMarkerIds, forceReplace) - } - } - } - - private suspend fun runImport( - appContext: Context, - dao: TokenStatsDao, - snapshot: LegacyTokenStatsSnapshot, - cleanupMarkerIds: Set, - forceReplace: Boolean, - ): Boolean { - // 导入 fence(P1 闭环):Room 侧无 PENDING cleanup 且**全部** cleanup - // operation ID 都包含在本快照的 marker 集合中,才允许应用该快照—— - // 否则快照早于某次 legacy cleanup(或清理尚未排空),应用会复活已删除 - // 的 baseline。拒绝时 no-op,下次启动重试。 - if (!dao.cleanupFenceSatisfied(cleanupMarkerIds)) { - AppLogger.w( - TAG, - "legacy cleanup 未排空或快照 marker 过期,跳过本次 baseline 导入(下次启动重试)" - ) - return false - } - val existingBaselines = dao.getAllBaselines().associateBy { it.identityId } - val existingIdentities = dao.getAllIdentities().associateBy { it.identityId } - val existingDisplayModels = dao.getAllDisplayModels().associateBy { it.displayModelId } - val plan = - TokenBaselineMigrator.planImport( - snapshot = snapshot, - existingBaselines = existingBaselines, - nowMs = System.currentTimeMillis(), - forceReplace = forceReplace, - resolveIdentity = { providerModel -> ensureIdentity(providerModel) }, - resolveDisplayModel = { providerModel -> - TokenBaselineMigrator.defaultDisplayModel(providerModel) - }, - resolvePricing = { providerModel -> - resolvePricingFor(providerModel, snapshot) - }, - existingIdentities = existingIdentities, - ) - val preserved = - TokenBaselineMigrator.preserveExistingGroups( - plan = plan, - existingIdentities = existingIdentities, - existingDisplayModels = existingDisplayModels, - ) - - // 身份绝不可 REPLACE(会级联删除该身份的事件): - // - 新身份 → INSERT IGNORE; - // - 已存在身份 → 只做安全 UPDATE(分组展示列),不改 identityId。 - val newIdentities = preserved.identities.filter { it.identityId !in existingIdentities } - if (newIdentities.isNotEmpty()) { - dao.insertIdentitiesIfAbsent(newIdentities) - } - preserved.identities - .filter { it.identityId in existingIdentities } - .filter { it.displayModelId != existingIdentities.getValue(it.identityId).displayModelId } - .forEach { dao.updateIdentityDisplayModel(it.identityId, it.displayModelId) } - - if (preserved.displayModels.isNotEmpty()) { - dao.upsertDisplayModels(preserved.displayModels) - } - if (preserved.baselines.isNotEmpty()) { - dao.upsertBaselines(preserved.baselines) - } - // 快照中消失的模型:仅受控补导(forceReplace)时删除其旧系统 baseline - //(恢复快照是权威全量;普通导入绝不删除——缺失可能只是偏好文件暂缺)。 - if (preserved.removedBaselineIdentityIds.isNotEmpty()) { - preserved.removedBaselineIdentityIds.forEach { dao.deleteBaseline(it) } - } - if (preserved.skippedProviderModels.isNotEmpty()) { - AppLogger.w( - TAG, - "跳过无法映射到模型身份的旧统计键: ${preserved.skippedProviderModels}" - ) - } - AppLogger.i( - TAG, - "旧累计统计导入完成: 导入 ${preserved.baselines.size} 个 baseline, " + - "跳过 ${preserved.skippedProviderModels.size} 个无模型键" - ) - return true - } - - private fun ensureIdentity(providerModel: String): TokenStatIdentityEntity { - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - return TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("", provider, model), - configId = "", - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - } - - /** - * baseline 估算只使用旧配置链(旧 DataStore 价格 → 内置默认价), - * 不读取新系统当前价格覆盖:保证已迁移快照不受用户后续改价影响(见 - * [TokenBaselineMigrator] 冻结语义)。 - */ - private fun resolvePricingFor( - providerModel: String, - snapshot: LegacyTokenStatsSnapshot, - ): ResolvedPricing { - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - return TokenPriceResolver.resolve( - provider = provider, - model = model, - configId = null, - overrides = emptyList(), - legacyOverride = snapshot.providerModels[providerModel]?.priceSettings, - defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), - ) - } -} - -/** - * 统计重置接线:把新账本(事件 + baseline)接入仓库现有“全量重置/按模型重置”机制。 - * - * - 全量重置:递增 durable FULL generation 并在同一 Room 事务内无条件删除事件 - * 与全部 baseline(不删除身份、展示分组与价格覆盖, - * 与旧系统“重置只清计数、保留配置”语义一致)。 - * - 按模型重置:写 MODEL generation tombstone(每 provider/model REPLACE) - * 并在同一事务内删除该 provider/model 下**所有配置实例**身份的事件与 baseline; - * 旧 DataStore 无配置实例区分,其 baseline 身份的 configId 为空串,同样被覆盖。 - * - spool 一致性(P1-3):排空插入在同一 Room 事务内检查 tombstone - * ([TokenStatsDao.insertEventIfNotResetCovered]),并发中已接受但未入 Room 的 - * 事件不会复活;重置后触发 [TokenStatSpool.replay] 让排空丢弃被覆盖的行。 - * - * 阶段 5 删除入口(与旧重置共用同一 spool 一致性机制): - * - [deleteEventsInRange]:只删时间范围内的事件(RANGE tombstone),绝不碰 baseline; - * - [deleteDisplayModel]:按**完整展示分组**删除事件,可单独选择是否删除该组 - * baseline(IDENTITY tombstone 精确到身份,不误伤同 provider:model 的其他分组); - * - [deleteAllEvents]:删除全部事件,可单独选择是否删除全部 baseline - * (FULL tombstone)。 - * - * 跨存储 legacy cleanup(P1 闭环):删除事务是唯一线性化点——删除 baseline 的 - * 入口([deleteDisplayModel]/[deleteAllEvents]/[resetAllStatistics]/ - * [resetStatisticsForProviderModel])在同一事务内为 configId 为空的 legacy 成员 - * 持久化 PENDING cleanup operation/items;事务提交后在 **Room 事务之外**调用 - * [drainPendingCleanup]:Room 读 PENDING → DataStore 单次 edit 精准清键并写 - * marker → Room ACK APPLIED。DataStore 失败抛异常、operation 保持 PENDING, - * 由冷启动(baseline 导入之前 / pending restore 之前)与下次删除入口重试。 - * - * [daoProvider] 为测试注入缝:生产代码始终为 null,走 [AppDatabase] 的真实事务; - * 测试注入时由测试自行验证调用语义(生产原子性由 DAO @Transaction 保证)。 - */ -object TokenStatsResetCoordinator { - - internal var daoProvider: ((Context) -> TokenStatsDao)? = null - - private const val TAG = "TokenStatsReset" - - suspend fun resetAllStatistics(context: Context) { - withMutation(context, drainCleanupAfter = true) { dao -> dao.resetAllStatisticsTx() } - TokenStatSpool.replay(context.applicationContext) - } - - suspend fun resetStatisticsForProviderModel(context: Context, providerModel: String) { - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - if (model.isBlank()) return - withMutation(context, drainCleanupAfter = true) { dao -> dao.resetModelTx(provider, model) } - TokenStatSpool.replay(context.applicationContext) - } - - /** 删除时间范围 [startMs, endMs) 内的事件;baseline 一律保留(阶段 5)。 */ - suspend fun deleteEventsInRange(context: Context, startMs: Long, endMs: Long) { - withMutation(context) { dao -> dao.deleteRangeEventsTx(startMs, endMs) } - TokenStatSpool.replay(context.applicationContext) - } - - /** - * 按展示分组删除(阶段 5):组成员在 DAO 事务内从 identity 全表解析; - * [deleteBaselines] 为 true 时同时删除该组成员的 baseline,并在同一事务内 - * 为其中 legacy 成员持久化 cleanup operation(事务外立即排空 DataStore)。 - */ - suspend fun deleteDisplayModel( - context: Context, - displayModelId: String, - deleteBaselines: Boolean, - ) { - withMutation(context, drainCleanupAfter = true) { dao -> - dao.deleteDisplayModelEventsTx(displayModelId, deleteBaselines) - } - TokenStatSpool.replay(context.applicationContext) - } - - /** 删除全部事件;[deleteBaselines] 为 true 时同时删除全部 baseline(阶段 5)。 */ - suspend fun deleteAllEvents(context: Context, deleteBaselines: Boolean) { - withMutation(context, drainCleanupAfter = true) { dao -> - dao.deleteAllStatisticsTx(deleteBaselines) - } - TokenStatSpool.replay(context.applicationContext) - } - - /** - * 排空 pending legacy cleanup(P1 闭环):固定顺序 - * **Room 读 PENDING operations/items → DataStore apply(marker 幂等)→ - * Room ACK APPLIED**,单 operation 依次完成。 - * - DataStore apply 失败抛异常且 operation 保持 PENDING(下次启动/下次删除 - * 重试;marker 已在时重放为幂等 no-op); - * - 协程取消([CancellationException])向上传播,不吞; - * - 不占用任何 Room 事务等待 DataStore(本函数在删除事务之外调用)。 - */ - suspend fun drainPendingCleanup(context: Context) { - val appContext = context.applicationContext - TokenStatSpool.withStatsDatabaseAccess { - val injected = daoProvider - val dao = - injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext).tokenStatsDao() - drainPendingCleanupWith(appContext, dao) - } - } - - /** - * 指定 DAO 的排空(runner/coordinator 共用):见 [drainPendingCleanup]。 - * runner 用它复用自己解析的数据库实例(测试注入路径)。 - */ - internal suspend fun drainPendingCleanupWith(appContext: Context, dao: TokenStatsDao) { - val pending = dao.getPendingCleanupOperations() - if (pending.isEmpty()) return - val prefs = ApiPreferences.getInstance(appContext) - for (operation in pending) { - val providerModels = - if (operation.scope == TokenStatCleanupOperationEntity.SCOPE_ALL) { - null - } else { - dao.getCleanupItems(operation.operationId) - .map { "${it.provider}:${it.model}" } - } - prefs.applyLegacyCleanup(operation.operationId, providerModels) - if (dao.ackCleanupOperation(operation.operationId) == 0) { - AppLogger.w(TAG, "cleanup operation ${operation.operationId} 已由其他排空完成") - } - } - } - - /** - * 完整登记一次统计变更:登记后才解析 DAO;Room 事务结束后、访问 token 释放前排空 - * cleanup outbox。spool replay 必须由调用方在本函数返回后执行,避免屏障等待锁环。 - */ - private suspend fun withMutation( - context: Context, - drainCleanupAfter: Boolean = false, - block: suspend (TokenStatsDao) -> Unit, - ) { - val appContext = context.applicationContext - TokenStatSpool.withStatsDatabaseAccess { - val injected = daoProvider - val dao: TokenStatsDao - if (injected != null) { - dao = injected(appContext) - block(dao) - } else { - val database = AppDatabase.getDatabase(appContext) - dao = database.tokenStatsDao() - database.withTransaction { block(dao) } - } - if (drainCleanupAfter) { - drainPendingCleanupWith(appContext, dao) - } - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt deleted file mode 100644 index 6b66ce5f3..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenBaselineMigrator.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import java.security.MessageDigest - -/** 一次导入的完整计划(纯决策,便于测试中断/恢复/幂等语义)。 */ -data class BaselineImportPlan( - val identities: List, - val displayModels: List, - val baselines: List, - val skippedProviderModels: List, - /** 受控补导(forceReplace)时应删除的 baseline identityId:恢复后的权威 - * 快照中该旧系统模型已消失,且该身份确为旧系统迁移身份(configId 为空)。 - * 普通导入恒为空(快照缺失不代表用户数据应被删除)。 */ - val removedBaselineIdentityIds: List = emptyList(), -) - -/** - * 旧 DataStore 累计统计到 baseline 的导入核心(纯逻辑,不依赖 Android)。 - * - * 语义(无启发式): - * - 每个统计身份至多一行 baseline。 - * - **普通启动**:计数指纹变化时(旧系统累计 setter 增长,或用户 reset 后降低), - * 用 baseline 行内**已冻结价格**重新估算并整体替换计数/成本——计数永远是 - * 快照的绝对值(不叠加增量),降低不会产生负增量;**计数不变**时普通价格 - * setter 不触发任何重估(指纹只含计数)。冻结价格(frozen*)与币种永不被 - * 普通启动替换。 - * - **受控补导**([forceReplace] = true,真实备份恢复完成后触发, - * 见 TokenBaselineImportRunner.consumePendingRestore):以恢复后的偏好快照 - * 重新解析定价并整体替换(含冻结价格),同时记录恢复 generation 保证幂等。 - * - **删除语义**:普通导入(forceReplace = false)绝不对快照中缺失的模型做任何 - * 删除——当前快照可能因偏好文件暂时缺失/部分恢复而不完整,删除会造成 - * baseline 丢失;显式删除走 TokenStatsResetCoordinator(用户重置)。只有 - * 完整受控补导(forceReplace = true,恢复后的偏好快照是旧系统的权威全量) - * 才把快照中缺失的模型列入 [BaselineImportPlan.removedBaselineIdentityIds], - * 且只针对旧系统迁移身份(configId 为空);配置实例身份(configId 非空) - * 的 baseline 不属于旧累计快照,绝不因恢复被删除。 - * - * [TokenStatBaselineEntity.fingerprint] 只摘要**旧迁移源的累计计数** - * (不含价格设置)。展示别名/分组:baseline 不保存 displayModelId(单一事实源 - * 是 identity),重导时通过 [preserveExistingGroups] 保留用户已设置的别名与分组。 - * - * - 迁移中断:整个导入必须由调用方放在单个数据库事务中,中断则整体回滚,重跑即可。 - * - 数据库恢复:baseline 表回到旧状态 → 身份无 baseline → 重新导入。 - * - baseline 无时间分布,不进入事件表。 - */ -object TokenBaselineMigrator { - - fun planImport( - snapshot: LegacyTokenStatsSnapshot, - existingBaselines: Map, - nowMs: Long, - forceReplace: Boolean = false, - resolveIdentity: (providerModel: String) -> TokenStatIdentityEntity, - resolveDisplayModel: (providerModel: String) -> TokenStatDisplayModelEntity, - resolvePricing: (providerModel: String) -> ResolvedPricing, - existingIdentities: Map = emptyMap(), - ): BaselineImportPlan { - val identities = mutableListOf() - val displayModels = mutableListOf() - val baselines = mutableListOf() - val skipped = mutableListOf() - val presentIdentityIds = mutableSetOf() - - snapshot.providerModels.keys.sorted().forEach { providerModel -> - val stats = snapshot.providerModels.getValue(providerModel) - val (_, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - if (model.isBlank()) { - skipped += providerModel - return@forEach - } - - val identity = resolveIdentity(providerModel) - presentIdentityIds += identity.identityId - val existing = existingBaselines[identity.identityId] - val fingerprint = fingerprint(stats) - - if (existing != null && !forceReplace) { - // 计数未变:普通价格 setter 不触发重估。 - if (existing.fingerprint == fingerprint) { - return@forEach - } - // 计数变化(增长或降低):用行内冻结价格重估,整体替换为快照绝对值。 - val pricing = frozenPricingOf(existing) - identities += identity - baselines += - existing.copy( - inputTokens = stats.inputTokens, - cachedInputTokens = stats.cachedInputTokens, - outputTokens = stats.outputTokens, - requestCount = stats.requestCount, - costInPricingCurrency = estimateCost(stats, pricing), - fingerprint = fingerprint, - importedAtMs = nowMs, - ) - return@forEach - } - - val displayModel = resolveDisplayModel(providerModel) - val pricing = resolvePricing(providerModel) - val estimatedCost = estimateCost(stats, pricing) - - identities += identity - displayModels += displayModel - baselines += - TokenStatBaselineEntity( - identityId = identity.identityId, - inputTokens = stats.inputTokens, - cachedInputTokens = stats.cachedInputTokens, - outputTokens = stats.outputTokens, - requestCount = stats.requestCount, - pricingCurrency = pricing.currency.name, - costInPricingCurrency = estimatedCost, - isEstimated = true, - fingerprint = fingerprint, - importedAtMs = nowMs, - frozenBillingMode = pricing.billingMode.name, - frozenInputPricePerMillion = pricing.inputPricePerMillion, - frozenCachedInputPricePerMillion = pricing.cachedInputPricePerMillion, - frozenOutputPricePerMillion = pricing.outputPricePerMillion, - frozenPricePerRequest = pricing.pricePerRequest, - ) - } - - // 快照中消失的模型 → 仅受控补导(forceReplace)删除,且只删除旧系统 - // 迁移身份(configId 为空)的 baseline: - // - 普通导入绝不删除:当前快照可能因偏好文件缺失/部分恢复而暂缺模型, - // 删除会造成用户数据丢失;显式删除由 TokenStatsResetCoordinator 提供。 - // - forceReplace 的恢复快照是旧累计统计的权威全量,缺失即旧系统无此 - // 模型;但配置实例身份(configId 非空)的 baseline 不属于旧累计快照 - // 的范围,恢复不得误删。 - // - 身份信息未知时保守保留(外键保证 baseline 必有身份,完整身份表由 - // 调用方传入;缺失该行是数据异常,不应据此删除)。 - val removed = - if (forceReplace) { - existingBaselines.keys - .filter { it !in presentIdentityIds } - .filter { existingIdentities[it]?.configId == "" } - .sorted() - } else { - emptyList() - } - - return BaselineImportPlan( - identities = identities, - displayModels = displayModels, - baselines = baselines, - skippedProviderModels = skipped, - removedBaselineIdentityIds = removed, - ) - } - - /** 从已持久化的冻结价格快照重建定价(普通启动计数变化时重估用)。 */ - fun frozenPricingOf(baseline: TokenStatBaselineEntity): ResolvedPricing { - val billingMode = BillingMode.fromString(baseline.frozenBillingMode) - val known = - if (billingMode == BillingMode.COUNT) { - baseline.frozenPricePerRequest != null - } else { - baseline.frozenInputPricePerMillion != null || - baseline.frozenOutputPricePerMillion != null - } - return ResolvedPricing( - billingMode = billingMode, - currency = parseCurrency(baseline.pricingCurrency), - inputPricePerMillion = baseline.frozenInputPricePerMillion, - cachedInputPricePerMillion = baseline.frozenCachedInputPricePerMillion, - outputPricePerMillion = baseline.frozenOutputPricePerMillion, - pricePerRequest = baseline.frozenPricePerRequest, - source = PricingSource.LEGACY_OVERRIDE, - known = known, - ) - } - - /** - * 重导时保留用户已设置的展示分组/别名(单一事实源): - * - identity 已存在 → 保留其 displayModelId(不因重导重置为默认分组); - * - 展示模型已存在 → 不重写(保留用户别名 [TokenStatDisplayModelEntity.displayName])。 - */ - fun preserveExistingGroups( - plan: BaselineImportPlan, - existingIdentities: Map, - existingDisplayModels: Map, - ): BaselineImportPlan { - val identities = - plan.identities.map { identity -> - val existing = existingIdentities[identity.identityId] - if (existing != null) { - if (existing.displayModelId == identity.displayModelId) identity else - identity.copy(displayModelId = existing.displayModelId) - } else { - identity - } - } - val displayModels = plan.displayModels.filter { it.displayModelId !in existingDisplayModels } - return plan.copy(identities = identities, displayModels = displayModels) - } - - /** - * baseline 估算费用:按导入时可用的**旧配置链**估算(旧 DataStore 价格 - * → 内置默认价,不读取新系统当前价格覆盖),并始终标记为估算。 - * TOKEN 模式下旧 inputTokens 为总输入(含缓存),按 legacy 公式 - * (非缓存输入 × 输入价 + 缓存 × 缓存价 + 输出 × 输出价)估算; - * 旧系统不跟踪缓存写入,估算按 legacy 计费语义视为 0(文档化,非猜测); - * 定价未知时成本为 null(未知),不得静默为 0。 - * 全部使用 Long/Double 计算,避免累计值超过 Int.MAX_VALUE 溢出。 - */ - private fun estimateCost( - stats: LegacyProviderModelStats, - pricing: ResolvedPricing, - ): Double? { - if (!pricing.known) return null - return when (pricing.billingMode) { - BillingMode.COUNT -> { - pricing.pricePerRequest?.times(stats.requestCount.toDouble()) - } - BillingMode.TOKEN -> { - val uncachedInput = (stats.inputTokens - stats.cachedInputTokens).coerceAtLeast(0L) - val cachedInput = stats.cachedInputTokens - val output = stats.outputTokens - val inputPrice = pricing.inputPricePerMillion ?: return null - val cachedPrice = pricing.cachedInputPricePerMillion ?: return null - val outputPrice = pricing.outputPricePerMillion ?: return null - uncachedInput / 1_000_000.0 * inputPrice + - cachedInput / 1_000_000.0 * cachedPrice + - output / 1_000_000.0 * outputPrice - } - } - } - - /** - * 幂等指纹:只摘要**旧迁移源的累计计数**。 - * 旧 DataStore 价格设置、新系统当前价格覆盖、展示别名/分组的任何变化 - * 都不改变指纹(价格变化不触发重导,见类注释的冻结语义)。 - */ - fun fingerprint(stats: LegacyProviderModelStats): String { - val canonical = - buildString { - append(stats.inputTokens).append('|') - append(stats.cachedInputTokens).append('|') - append(stats.outputTokens).append('|') - append(stats.requestCount) - } - val digest = MessageDigest.getInstance("SHA-256").digest(canonical.toByteArray(Charsets.UTF_8)) - return digest.joinToString(separator = "") { byte -> "%02x".format(byte) } - } - - private fun parseCurrency(raw: String): PricingCurrency = - if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD - - /** 展示模型默认分组:以规范化模型名为默认别名。 */ - fun defaultDisplayModel(providerModel: String): TokenStatDisplayModelEntity { - val (_, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - val normalized = TokenStatIdentityResolver.normalizeModelName(model) - return TokenStatDisplayModelEntity( - displayModelId = normalized, - normalizedModel = normalized, - displayName = normalized, - ) - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt index ab86eeb8e..95fd78ce3 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -1,240 +1,91 @@ package com.ai.assistance.operit.data.stats import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow import com.ai.assistance.operit.data.model.BillingMode -/** - * 事件用量输入(provider 适配层规范化后的结果)。 - * - * - null 字段表示“未知”,不允许静默当作 0;0 表示 provider 确认该分量为 0 - * (例如确认无缓存读取/无缓存写入)。任一未知分量导致 TOKEN 模式成本未知。 - * - token 字段为 [Long]:provider 原值可能超过 Int 范围,聚合与计费全程 Long - * 运算,绝不因 Int 溢出产生负数落账;负值在适配层已被拒绝为未知。 - * - [totalInputTokens]:provider 明确上报的总输入(含缓存命中/写入)。当 - * cached/uncached 拆分未知([uncachedInputTokens]/[cachedInputTokens] 为 null) - * 时,**只有**在输入与缓存输入单价相同(拆分不影响计费)的前提下才允许按 - * 总输入计费;单价不同则成本仍保持未知,绝不伪造 uncached。 - * - [cacheWriteSeparateBilling]:false = provider 无独立缓存写入计费概念 - * (OpenAI 兼容系/Gemini/本地/ToolPkg),cacheWriteTokens 缺失或为 0 都不阻碍 - * 费用计算(写入成本已包含在输入单价内);true = 缓存写入独立计费(Anthropic), - * 此时该分量未知会导致费用未知。 - * - [reasoningIncludedInOutput] 是推理 token 归一化边界: - * true = provider 的 output 计数已包含推理 token; - * false = 推理 token 独立计数,计费时按输出单价补算; - * null = provider 未声明,按“已包含”处理,避免重复收费。 - */ -data class TokenUsageInput( - val uncachedInputTokens: Long? = null, - val cachedInputTokens: Long? = null, - val cacheWriteTokens: Long? = null, - val totalInputTokens: Long? = null, - val outputTokens: Long? = null, - val reasoningTokens: Long? = null, - val reasoningIncludedInOutput: Boolean? = null, - val cacheWriteSeparateBilling: Boolean = true, -) - -/** 单次请求的原币成本计算结果;[amount] 为 null 表示未知(非 0)。 */ -data class TokenCostResult( - val amount: Double?, - val currency: PricingCurrency, - val billedInputTokens: Long? = null, - val billedCacheWriteTokens: Long? = null, - val billedOutputTokens: Long? = null, -) - -/** - * 原币费用计算。 - * - * - TOKEN 模式:计费输入 = uncached + cached(两者都必须已知,null 即未知→成本 null); - * 缓存写入在 [TokenUsageInput.cacheWriteSeparateBilling] 为 true 时独立计费 - * (未知→成本 null;0 跳过;>0 需要缓存写入单价,缺失则成本 null); - * 为 false(无独立缓存写入概念)时不单独计费,字段缺失不影响成本。 - * - COUNT 模式:成本 = 单次价格(每事件一次请求)。 - * - 价格为“每百万 token”原币单价;cached 单价缺省已由 [TokenPriceResolver] 回填。 - */ object TokenCostCalculator { + fun saturatedAdd(left: Long, right: Long): Long = + if (right > 0L && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right - fun billedOutputTokens(usage: TokenUsageInput): Long? { - val output = usage.outputTokens ?: return null - val separateReasoning = - if (usage.reasoningIncludedInOutput == false && usage.reasoningTokens != null) { - usage.reasoningTokens - } else { - 0L - } - return saturatedAdd(output, separateReasoning) - } - - fun billedInputTokens(usage: TokenUsageInput): Long? { - val uncached = usage.uncachedInputTokens ?: return null - val cached = usage.cachedInputTokens ?: return null - return saturatedAdd(uncached, cached) - } - - fun computeCost(usage: TokenUsageInput, pricing: ResolvedPricing): TokenCostResult { - // 统一边界(P1-1):未知定价(known = false,如未知模型的内置全 0 缺省或 - // 空价格覆盖行)无论用量是否完整,成本一律为 null(未知),绝不算出伪 0。 - // 阶段 2 落账与阶段 3 重估都走本入口,因此该防线同时保护两条路径。 - if (!pricing.known) { - return TokenCostResult(amount = null, currency = pricing.currency) - } + fun currentCost( + row: TokenUsageModelAggregateRow, + pricing: ResolvedTokenPricing, + targetCurrency: PricingCurrency, + usdToCnyRate: Double, + ): TokenStatsCostSummary { + val nativeAmount: Double + val unknown: Long if (pricing.billingMode == BillingMode.COUNT) { - val price = pricing.pricePerRequest?.takeIf { it.isFinite() } - return TokenCostResult( - amount = price, - currency = pricing.currency, - ) - } - - val billedOutput = billedOutputTokens(usage) - if (billedOutput == null) { - return TokenCostResult(amount = null, currency = pricing.currency) - } - val inputPrice = pricing.inputPricePerMillion - val cachedPrice = pricing.cachedInputPricePerMillion - val outputPrice = pricing.outputPricePerMillion - if (inputPrice == null || cachedPrice == null || outputPrice == null || - !inputPrice.isFinite() || !cachedPrice.isFinite() || !outputPrice.isFinite() - ) { - return TokenCostResult(amount = null, currency = pricing.currency) - } - - // 输入计费: - // - 拆分已知 → 按 uncached/cached 分量各自计价; - // - 拆分未知(cached details 缺失)但总输入已知 → 仅当输入与缓存输入 - // 单价相同(拆分不影响计费)时按总输入计价;单价不同则成本保持未知, - // 绝不把总输入伪装成 uncached。 - val cachedTokens = usage.cachedInputTokens - val uncachedTokens = usage.uncachedInputTokens - val billedInput: Long - val inputAmount: Double - if (cachedTokens != null && uncachedTokens != null) { - billedInput = saturatedAdd(uncachedTokens, cachedTokens) - inputAmount = - safeAdd( - scaledTokenCost(uncachedTokens, inputPrice), - scaledTokenCost(cachedTokens, cachedPrice), - ) ?: return TokenCostResult(amount = null, currency = pricing.currency) + nativeAmount = pricing.pricePerRequest * row.requests + unknown = + if (pricing.pricePerRequest > 0.0) { + (row.usageRows - row.requestCountKnown).coerceAtLeast(0L) + } else { + row.usageRows + } } else { - val total = usage.totalInputTokens - if (total == null || inputPrice != cachedPrice) { - return TokenCostResult(amount = null, currency = pricing.currency) + var amount = 0.0 + var unknownRequests = 0L + fun add(tokens: Long, known: Long, price: Double) { + if (price > 0.0) { + amount += tokens.toDouble() * price / 1_000_000.0 + unknownRequests = maxOf(unknownRequests, row.usageRows - known) + } } - billedInput = total - inputAmount = - scaledTokenCost(total, inputPrice) - ?: return TokenCostResult(amount = null, currency = pricing.currency) - } - var amount = - safeAdd(inputAmount, scaledTokenCost(billedOutput, outputPrice)) - ?: return TokenCostResult(amount = null, currency = pricing.currency) - - // 缓存写入: - // - 独立计费概念下未知 → 成本未知(不静默当作 0); - // - 确认 0 → 不参与; - // - > 0 且独立计费 → 需要缓存写入单价,缺失则成本未知; - // - 非独立计费(OpenAI 兼容系等)→ 写入成本已包含在输入单价内,不单独计费。 - val cacheWriteTokens = usage.cacheWriteTokens - if (cacheWriteTokens == null && usage.cacheWriteSeparateBilling) { - return TokenCostResult( - amount = null, - currency = pricing.currency, - billedInputTokens = billedInput, - billedCacheWriteTokens = null, - billedOutputTokens = billedOutput, - ) - } - if (cacheWriteTokens != null && cacheWriteTokens > 0 && usage.cacheWriteSeparateBilling) { - val cacheWritePrice = pricing.cacheWritePricePerMillion - if (cacheWritePrice == null || !cacheWritePrice.isFinite()) { - return TokenCostResult( - amount = null, - currency = pricing.currency, - billedInputTokens = billedInput, - billedCacheWriteTokens = cacheWriteTokens, - billedOutputTokens = billedOutput, - ) + if ( + pricing.inputPricePerMillion == pricing.cachedInputPricePerMillion && + pricing.inputPricePerMillion == pricing.cacheWritePricePerMillion + ) { + add(row.totalInputTokens, row.totalInputKnown, pricing.inputPricePerMillion) + } else { + add(row.uncachedInputTokens, row.uncachedInputKnown, pricing.inputPricePerMillion) + add(row.cachedInputTokens, row.cachedInputKnown, pricing.cachedInputPricePerMillion) + add(row.cacheWriteTokens, row.cacheWriteKnown, pricing.cacheWritePricePerMillion) } - amount = - safeAdd(amount, scaledTokenCost(cacheWriteTokens, cacheWritePrice)) - ?: return TokenCostResult( - amount = null, - currency = pricing.currency, - billedInputTokens = billedInput, - billedCacheWriteTokens = cacheWriteTokens, - billedOutputTokens = billedOutput, - ) + add(row.outputTokens, row.outputKnown, pricing.outputPricePerMillion) + nativeAmount = amount + unknown = + if ( + pricing.inputPricePerMillion <= 0.0 && + pricing.cachedInputPricePerMillion <= 0.0 && + pricing.cacheWritePricePerMillion <= 0.0 && + pricing.outputPricePerMillion <= 0.0 + ) { + row.usageRows + } else { + unknownRequests + } } - - return TokenCostResult( - amount = amount, - currency = pricing.currency, - billedInputTokens = billedInput, - billedCacheWriteTokens = cacheWriteTokens, - billedOutputTokens = billedOutput, + val converted = TokenCostCurrency.convertTo( + nativeAmount, + pricing.currency, + targetCurrency, + usdToCnyRate, + ) + return TokenStatsCostSummary( + currency = targetCurrency, + knownAmount = converted, + unknownContributionCount = unknown, + totalContributionCount = row.usageRows, + rateUsed = usdToCnyRate, + originalCurrencyAmounts = + if (nativeAmount > 0.0) mapOf(pricing.currency to nativeAmount) else emptyMap(), ) - } - - /** - * 饱和加法:溢出时钳制到 [Long.MAX_VALUE],绝不出现负数或回绕;调用方 - * 只接受非负分量,负数视为异常数据在适配层已拒绝,这里做最终防线。 - */ - internal fun saturatedAdd(left: Long, right: Long): Long = - if (right > 0 && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right - - private fun scaledTokenCost(tokens: Long, pricePerMillion: Double): Double? = - (tokens / 1_000_000.0 * pricePerMillion).takeIf { it.isFinite() } - - private fun safeAdd(left: Double?, right: Double?): Double? { - if (left == null || right == null) return null - return (left + right).takeIf { it.isFinite() } } } -/** - * 币种换算语义(阶段 1 核心): - * - * - 汇率只由用户手动设置;未设置时使用默认估算值 7.0(界面必须标记为估算)。 - * - 事件保存发生时的原币价格与原币成本,不冻结汇率;换算只发生在展示层, - * 使用当前手动汇率。因此修改汇率会改变“统一币种”的换算总计, - * 但不会改变历史原币成本。 - */ object TokenCostCurrency { - - /** 未设置手动汇率时的默认估算值(1 USD = 7.0 CNY)。 */ const val DEFAULT_USD_TO_CNY_RATE = 7.0 - /** 历史原币成本按当前手动汇率换算到目标币种;未知(null)原样保持未知。 */ - fun historicalCostConverted( - costInPricingCurrency: Double?, - costCurrency: PricingCurrency, - targetCurrency: PricingCurrency, - manualRate: Double, - ): Double? { - val cost = costInPricingCurrency ?: return null - return convertTo(cost, costCurrency, targetCurrency, manualRate) - } - - /** 当前价格重估:以当前解析价格 × 事件用量(原币),不触碰历史快照。 */ - fun revaluedCost(usage: TokenUsageInput, currentPricing: ResolvedPricing): TokenCostResult = - TokenCostCalculator.computeCost(usage, currentPricing) - fun convertTo( amount: Double, - from: PricingCurrency, - to: PricingCurrency, - manualRate: Double, - ): Double? { - require(manualRate.isFinite() && manualRate > 0.0) { "manual rate must be finite and positive" } - if (!amount.isFinite()) return null - val converted = if (from == to) { - amount - } else if (from == PricingCurrency.USD) { - amount * manualRate - } else { - amount / manualRate - } - return converted.takeIf { it.isFinite() } + source: PricingCurrency, + target: PricingCurrency, + usdToCnyRate: Double, + ): Double = when { + source == target -> amount + source == PricingCurrency.USD -> amount * usdToCnyRate + else -> amount / usdToCnyRate } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt index 9e2ae5896..dde22e12f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt @@ -1,232 +1,86 @@ package com.ai.assistance.operit.data.stats -import com.ai.assistance.operit.data.collects.ModelPricingDefaults +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -/** 旧系统(DataStore)中用户保存的价格设置;null 字段表示“未设置”。 */ -data class LegacyPriceSettings( +data class ModelPriceSettings( val billingMode: BillingMode? = null, + val currency: PricingCurrency? = null, val inputPricePerMillion: Double? = null, val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, val outputPricePerMillion: Double? = null, val pricePerRequest: Double? = null, ) { - /** - * 旧系统约定:价格键缺失时读数为 0,且 0 与“未设置”不可区分, - * 因此只有 > 0 的值才视为用户设置。 - */ fun hasAnyUserSetting(): Boolean = billingMode != null || - (inputPricePerMillion ?: 0.0) > 0.0 || - (cachedInputPricePerMillion ?: 0.0) > 0.0 || - (outputPricePerMillion ?: 0.0) > 0.0 || - (pricePerRequest ?: 0.0) > 0.0 + currency != null || + inputPricePerMillion != null || + cachedInputPricePerMillion != null || + cacheWritePricePerMillion != null || + outputPricePerMillion != null || + pricePerRequest != null } -/** - * 解析完成的定价:TOKEN 模式下价格均已按层级回填(cached 缺省回退到 input), - * [known] 为 false 表示“未知定价”,对应成本必须为 null,不得静默当作 0。 - * - * [cacheWritePricePerMillion] 无内置/旧系统数据来源时保持 null(未知): - * 事件中 cacheWriteTokens > 0 且价格未知时成本为 null;cacheWriteTokens == 0 - * (确认无缓存写入)时不需要该价格。不猜测缓存写入单价。 - */ -data class ResolvedPricing( +data class TokenPriceSettingsSnapshot( + val providerModels: Map, + val configs: Map, +) { + fun settingFor(providerModel: String, configId: String?): ModelPriceSettings? { + val model = providerModels[providerModel] + val config = configId?.let { configs[tokenPriceConfigKey(providerModel, it)] } + if (config == null) return model + return ModelPriceSettings( + billingMode = config.billingMode ?: model?.billingMode, + currency = config.currency ?: model?.currency, + inputPricePerMillion = config.inputPricePerMillion ?: model?.inputPricePerMillion, + cachedInputPricePerMillion = + config.cachedInputPricePerMillion ?: model?.cachedInputPricePerMillion, + cacheWritePricePerMillion = + config.cacheWritePricePerMillion ?: model?.cacheWritePricePerMillion, + outputPricePerMillion = config.outputPricePerMillion ?: model?.outputPricePerMillion, + pricePerRequest = config.pricePerRequest ?: model?.pricePerRequest, + ) + } +} + +internal fun tokenPriceConfigKey(providerModel: String, configId: String): String = + "$providerModel\u001f$configId" + +data class ResolvedTokenPricing( val billingMode: BillingMode, val currency: PricingCurrency, - val inputPricePerMillion: Double? = null, - val cachedInputPricePerMillion: Double? = null, - val cacheWritePricePerMillion: Double? = null, - val outputPricePerMillion: Double? = null, - val pricePerRequest: Double? = null, + val inputPricePerMillion: Double, + val cachedInputPricePerMillion: Double, + val cacheWritePricePerMillion: Double, + val outputPricePerMillion: Double, + val pricePerRequest: Double, val source: PricingSource, - val known: Boolean, ) -/** - * 价格层级解析:`内置模型默认价 -> provider/model 覆盖 -> 特定 API 配置覆盖`, - * 阶段 1 额外桥接旧 DataStore 中用户保存的 provider/model 价格([LegacyPriceSettings]), - * 顺序为:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧系统价格 > 内置默认价。 - */ +/** Resolves only the current price: user setting first, then the built-in model table. */ object TokenPriceResolver { - - const val SCOPE_CONFIG = "CONFIG" - const val SCOPE_PROVIDER_MODEL = "PROVIDER_MODEL" - - /** - * 构造已规范化的覆盖行(便捷工厂,等价于 - * [TokenStatPriceOverrideEntity.normalized])。 - * provider/model 规范化(trim + 小写 + 空白压缩)、configId 仅 trim; - * PROVIDER_MODEL 范围强制 configId 为空串(“不限定配置实例”)。 - * 非法 scope 或空白 provider/model 抛 [IllegalArgumentException]。 - * 规范化后相同业务组合在数据库中必然冲突并 REPLACE 覆盖(见实体唯一索引)。 - */ - fun normalizedOverride( - scope: String, - provider: String, - model: String, - configId: String?, - billingMode: BillingMode, - pricingCurrency: String, - inputPricePerMillion: Double? = null, - cachedInputPricePerMillion: Double? = null, - cacheWritePricePerMillion: Double? = null, - outputPricePerMillion: Double? = null, - pricePerRequest: Double? = null, - ): TokenStatPriceOverrideEntity = - TokenStatPriceOverrideEntity.normalized( - scope = scope, - provider = provider, - model = model, - configId = configId, - billingMode = billingMode.name, - pricingCurrency = pricingCurrency, - inputPricePerMillion = inputPricePerMillion, - cachedInputPricePerMillion = cachedInputPricePerMillion, - cacheWritePricePerMillion = cacheWritePricePerMillion, - outputPricePerMillion = outputPricePerMillion, - pricePerRequest = pricePerRequest, - ) - - /** - * 解析定价:按**规范化业务字段**(而非任何主键)匹配覆盖行, - * 行内容与查询键一致才命中,键/内容错配不可能造成错误解析。 - * 顺序:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧系统价格 > 内置默认价。 - */ fun resolve( - provider: String, - model: String, - configId: String?, - overrides: List, - legacyOverride: LegacyPriceSettings?, - defaults: ModelPricingDefaults, - ): ResolvedPricing { - val canonicalProvider = TokenStatIdentityResolver.normalizeProvider(provider) - val canonicalModel = TokenStatIdentityResolver.normalizeModelName(model) - val canonicalConfigId = configId?.trim().orEmpty() - - if (canonicalConfigId.isNotEmpty()) { - overrides.firstOrNull { - it.scope == SCOPE_CONFIG && - TokenStatIdentityResolver.normalizeProvider(it.provider) == canonicalProvider && - TokenStatIdentityResolver.normalizeModelName(it.model) == canonicalModel && - it.configId.trim() == canonicalConfigId - }?.let { return fromOverrideRow(it, PricingSource.CONFIG_OVERRIDE) } - } - - overrides.firstOrNull { - it.scope == SCOPE_PROVIDER_MODEL && - TokenStatIdentityResolver.normalizeProvider(it.provider) == canonicalProvider && - TokenStatIdentityResolver.normalizeModelName(it.model) == canonicalModel && - it.configId.isBlank() - }?.let { return fromOverrideRow(it, PricingSource.PROVIDER_MODEL_OVERRIDE) } - - if (legacyOverride != null && legacyOverride.hasAnyUserSetting()) { - return fromLegacy(legacyOverride, defaults) - } - - return fromDefaults(defaults) - } - - /** 数据库覆盖行:显式实体,null 表示未使用;价格 0 是用户的真实设置。 */ - private fun fromOverrideRow( - row: TokenStatPriceOverrideEntity, - source: PricingSource, - ): ResolvedPricing { - val billingMode = BillingMode.fromString(row.billingMode) - val currency = parseCurrency(row.pricingCurrency) - return if (billingMode == BillingMode.COUNT) { - ResolvedPricing( - billingMode = billingMode, - currency = currency, - pricePerRequest = row.pricePerRequest, - source = source, - known = row.pricePerRequest != null, - ) - } else { - val input = row.inputPricePerMillion - val cached = row.cachedInputPricePerMillion ?: input - val output = row.outputPricePerMillion - ResolvedPricing( - billingMode = billingMode, - currency = currency, - inputPricePerMillion = input, - cachedInputPricePerMillion = cached, - cacheWritePricePerMillion = row.cacheWritePricePerMillion, - outputPricePerMillion = output, - source = source, - known = input != null || output != null, - ) - } - } - - /** 旧系统价格:缺省分量回退到内置默认价;> 0 才算用户设置(旧约定 0 == 未设置)。 */ - private fun fromLegacy( - legacy: LegacyPriceSettings, - defaults: ModelPricingDefaults, - ): ResolvedPricing { - val billingMode = legacy.billingMode ?: defaults.billingMode - return if (billingMode == BillingMode.COUNT) { - val pricePerRequest = - legacy.pricePerRequest?.takeIf { it > 0.0 } ?: defaults.pricePerRequest - ResolvedPricing( - billingMode = billingMode, - currency = defaults.currency, - pricePerRequest = pricePerRequest, - source = PricingSource.LEGACY_OVERRIDE, - known = pricePerRequest > 0.0, - ) - } else { - val input = - legacy.inputPricePerMillion?.takeIf { it > 0.0 } - ?: defaults.inputPricePerMillion - val cached = - legacy.cachedInputPricePerMillion?.takeIf { it > 0.0 } - ?: defaults.cachedInputPricePerMillion - ?: input - val output = - legacy.outputPricePerMillion?.takeIf { it > 0.0 } - ?: defaults.outputPricePerMillion - ResolvedPricing( - billingMode = billingMode, - currency = defaults.currency, - inputPricePerMillion = input, - cachedInputPricePerMillion = cached, - // 旧系统没有缓存写入计费,不猜测:保持未知 - cacheWritePricePerMillion = null, - outputPricePerMillion = output, - source = PricingSource.LEGACY_OVERRIDE, - known = (input ?: 0.0) > 0.0 || (cached ?: 0.0) > 0.0 || (output ?: 0.0) > 0.0, - ) - } - } - - /** 内置默认价;默认值表对未知模型/供应商给出全 0 缺省(zeroPricing),视为未知。 */ - private fun fromDefaults(defaults: ModelPricingDefaults): ResolvedPricing { - val known = - if (defaults.billingMode == BillingMode.COUNT) { - defaults.pricePerRequest > 0.0 - } else { - defaults.inputPricePerMillion > 0.0 || - defaults.cachedInputPricePerMillion > 0.0 || - defaults.outputPricePerMillion > 0.0 - } - return ResolvedPricing( - billingMode = defaults.billingMode, - currency = defaults.currency, - inputPricePerMillion = defaults.inputPricePerMillion, - cachedInputPricePerMillion = defaults.cachedInputPricePerMillion, - // 内置价格表没有缓存写入单价,不猜测:保持未知 - cacheWritePricePerMillion = null, - outputPricePerMillion = defaults.outputPricePerMillion, - pricePerRequest = defaults.pricePerRequest, - source = if (known) PricingSource.DEFAULT else PricingSource.UNKNOWN, - known = known, + providerModel: String, + user: ModelPriceSettings?, + ): ResolvedTokenPricing { + val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) + return ResolvedTokenPricing( + billingMode = user?.billingMode ?: defaults.billingMode, + currency = user?.currency ?: defaults.currency, + inputPricePerMillion = user?.inputPricePerMillion ?: defaults.inputPricePerMillion, + cachedInputPricePerMillion = + user?.cachedInputPricePerMillion + ?: user?.inputPricePerMillion + ?: defaults.cachedInputPricePerMillion, + cacheWritePricePerMillion = + user?.cacheWritePricePerMillion + ?: user?.inputPricePerMillion + ?: defaults.inputPricePerMillion, + outputPricePerMillion = user?.outputPricePerMillion ?: defaults.outputPricePerMillion, + pricePerRequest = user?.pricePerRequest ?: defaults.pricePerRequest, + source = if (user?.hasAnyUserSetting() == true) PricingSource.USER else PricingSource.BUILT_IN, ) } - - private fun parseCurrency(raw: String): PricingCurrency = - if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt deleted file mode 100644 index 76b2fd65e..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatIdentityResolver.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import java.security.MessageDigest - -/** - * 统计身份的规范化与稳定标识生成。 - * - * - 身份 = (configId, provider, model):同一 provider/model 在不同配置实例下是不同身份, - * 旧 DataStore 累计数据不区分配置实例,使用空 [configId]。 - * - [identityId] 必须稳定:相同三元组总是得到相同 ID,避免重复入账或身份漂移。 - * - [normalizeModelName] 是展示合并的规范化依据:同名模型默认归入同一展示分组。 - */ -object TokenStatIdentityResolver { - - /** 规范化模型名:trim + 小写 + 压缩连续空白,作为展示分组的默认 key。 */ - fun normalizeModelName(modelName: String): String = - modelName.trim().lowercase().replace(Regex("\\s+"), " ") - - /** provider 标识规范化:trim + 小写(与配置系统 normalizeProviderId 一致)。 */ - fun normalizeProvider(provider: String): String = provider.trim().lowercase() - - /** 默认展示模型分组 ID:规范化模型名。 */ - fun displayModelIdFor(modelName: String): String = normalizeModelName(modelName) - - /** 生成稳定身份 ID(SHA-256),与展示名无关,只依赖身份三元组。 */ - fun identityId(configId: String, provider: String, model: String): String { - val canonicalConfigId = configId.trim() - val canonicalProvider = provider.trim() - val canonicalModel = model.trim() - require(canonicalProvider.isNotEmpty()) { "provider must not be blank" } - require(canonicalModel.isNotEmpty()) { "model must not be blank" } - val input = listOf(canonicalConfigId, canonicalProvider, canonicalModel) - .joinToString(separator = "\u0000") - val digest = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8)) - return digest.joinToString(separator = "") { byte -> "%02x".format(byte) } - } - - /** 解析 “provider:model” 复合标识(旧系统约定)为 (provider, model)。 */ - fun splitProviderModel(providerModel: String): Pair { - val trimmed = providerModel.trim() - val colonIndex = trimmed.indexOf(':') - if (colonIndex <= 0) return trimmed to "" - return trimmed.substring(0, colonIndex) to trimmed.substring(colonIndex + 1) - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt deleted file mode 100644 index 16d01af4b..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatMetaStore.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.util.UUID - -/** - * 统计 spool 元数据的崩溃安全存储(canonical + `.new` + `.bak` + 临时文件协议)。 - * - * 语义: - * - [write]:完整新内容先写唯一临时文件并 fsync,再经原子替换/回退协议提交到 - * canonical;任意中断后 canonical 必为完整旧值或完整新值(不存在半写内容)。 - * - [read]:canonical 缺失时从 `.new`/`.bak` sidecar 恢复完整值(优先新值); - * strict 模式下目录项持久确认失败抛 [IOException](fail-closed)。 - * - [delete]:删除 canonical 与全部 sidecar,残留由下次使用清理。 - * - * [strictDirectorySync] 为目录项持久确认回调(见 [Quarantine] 的 - * [TokenStatSpool.requireSpoolDirSync]);为 null 时尽力而为(普通测试路径)。 - */ -internal class TokenStatMetaStore( - private val file: File, - private val atomicMove: (File, File) -> Boolean = TokenStatMetaStore::defaultAtomicMove, - private val strictDirectorySync: ((File) -> Boolean)? = null, -) { - private val parent: File - get() = file.parentFile - ?: throw IllegalStateException("Marker file has no parent directory") - - private val newFile: File - get() = File(parent, "${file.name}.new") - - private val bakFile: File - get() = File(parent, "${file.name}.bak") - - suspend fun write(content: String) { - parent.mkdirs() - // 上次中断残留恢复:目标缺失时先把完整旧/新值放回目标,之后清理才不会 - // 丢失信号;目标存在时 .new/.bak 都已被目标内容取代,可安全清理。 - if (!file.isFile) { - when { - newFile.isFile -> { - strictRename(newFile, file) - strictDelete(bakFile) - } - bakFile.isFile -> { - strictRename(bakFile, file) - } - else -> Unit - } - } - strictDelete(bakFile) - strictDelete(newFile) - deleteStaleTmpFiles() - // 1. 写完整新内容到唯一临时文件并 fsync:此后内容在断电/崩溃后仍完整。 - val tmp = File(parent, "${file.name}.tmp${UUID.randomUUID()}") - try { - FileOutputStream(tmp).use { output -> - output.write(content.toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - strictRename(tmp, newFile) - } catch (e: Exception) { - deleteQuietly(tmp) - throw e - } - // 2. 首选原子替换(目标已存在时也允许);不支持则走回退协议。 - val atomicMoved = try { - atomicMove(newFile, file) - } catch (e: AtomicMoveNotSupportedException) { - false - } catch (e: IOException) { - false - } - if (atomicMoved) { - // 提交点:canonical 已替换为完整新值,目录项必须确认持久才允许成功。 - requireDirSyncDurable() - return - } - // 3. old/new/backup 回退:任意中断后目标必为完整旧或完整新值。 - if (file.exists()) strictRename(file, bakFile) - if (!newFile.renameTo(file)) { - // 提交失败:尽力把旧值放回目标,保持可读的完整旧内容。 - if (bakFile.exists()) { - strictRename(bakFile, file) - } - throw IOException("Failed to move new content into place: ${file.path}") - } - requireDirSyncDurable() - strictDelete(bakFile) - } - - suspend fun read(): String? { - if (file.isFile) { - strictDelete(newFile) - strictDelete(bakFile) - deleteStaleTmpFiles() - // 返回 canonical 内容前必须确认目录项持久(strict 模式)。 - requireDirSyncDurable() - return file.readText() - } - // 目标缺失:恢复完整值(.new 已 fsync,存在即完整;优先新值)。 - return when { - newFile.isFile -> { - strictRename(newFile, file) - strictDelete(bakFile) - requireDirSyncDurable() - file.readText() - } - bakFile.isFile -> { - strictRename(bakFile, file) - requireDirSyncDurable() - file.readText() - } - else -> { - deleteStaleTmpFiles() - null - } - } - } - - suspend fun delete() { - strictDelete(file) - strictDelete(newFile) - strictDelete(bakFile) - deleteStaleTmpFiles() - } - - private fun deleteStaleTmpFiles() { - parent.listFiles { f -> f.name.startsWith("${file.name}.tmp") }?.forEach { strictDelete(it) } - } - - /** 目录项持久确认(strict 模式):回调失败即抛 [IOException](fail-closed)。 */ - private fun requireDirSyncDurable() { - val strict = strictDirectorySync ?: return - if (!strict(parent)) { - throw IOException("Directory entry not confirmed durable for spool metadata: ${file.path}") - } - } - - /** strict 模式的重命名:失败抛 [IOException];成功后必须确认目录项持久。 */ - private fun strictRename(from: File, to: File) { - if (!from.renameTo(to)) { - throw IOException("Failed to rename ${from.path} to ${to.path}") - } - requireDirSyncDurable() - } - - /** strict 模式的删除:文件不存在时无目录项变更(不要求 sync);删除成功且 strict 时确认持久。 */ - private fun strictDelete(f: File) { - if (f.exists() && f.delete()) requireDirSyncDurable() - } - - private fun deleteQuietly(f: File) { - try { - f.delete() - } catch (_: Exception) { - } - } - - private companion object { - fun defaultAtomicMove(from: File, to: File): Boolean = try { - Files.move( - from.toPath(), - to.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - true - } catch (e: AtomicMoveNotSupportedException) { - false - } catch (e: IOException) { - false - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt deleted file mode 100644 index 978ce1403..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatRequestContext.kt +++ /dev/null @@ -1,360 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import org.json.JSONObject - -/** - * 一次统计请求/尝试的上下文(阶段 2 记录链路核心)。 - * - * - 一个上下文对应一次**逻辑请求**(一次 [com.ai.assistance.operit.api.chat.llmprovider.AIService.sendMessage])。 - * - [eventId] 在请求开始时生成一次,全生命周期稳定:同一请求的重试、重复回调、 - * 重复落账都复用同一标识,配合 DAO 的 IGNORE 插入实现幂等(不会重复入账)。 - * - 重试边界:provider 内部重试**不**产生独立事件;无论内部尝试多少次, - * 最终只落一个事件,状态为最终结果(成功 / 取消 / 超时 / 失败)。 - * - [onUsage] 可被 provider 多次回调,每次携带 attempt(provider 内部第几次尝试, - * 从 1 开始)。同一 attempt 的多次上报按 [ProviderUsageSnapshot.completeSnapshot] - * 合并:部分更新(false,流式增量如 Anthropic message_start/message_delta)按 - * “最新非空字段优先”合并,output 等累计字段直接取最新值、绝不相加;完整快照 - * (true,如最终响应 usage)整份覆盖,null 字段 = 明确未知,覆盖旧值(撤销)。 - * **不同 attempt 的用量按分量累加**,避免重试后漏掉已计费 attempt 的 token/费用。 - * - [aggregatedUsage] 返回按 attempt 聚合后的快照:分量只在所有 attempt 都已知时 - * 求和(Long 饱和加法,绝不 Int 溢出为负),任一 attempt 未知则该分量保持未知。 - * - 负数分量在任何入口都被拒绝为未知(防御:适配层已拒绝,这里做最终防线)。 - * - [onFirstToken] 只在真实首个响应 token/chunk(首个非空内容)到达时设置一次。 - * - * 不保存正文、API key、Cookie 或 endpoint 凭据。 - */ -class TokenStatRequestContext( - val eventId: String, - val category: TokenStatCategory, - val configId: String, - val provider: String, - val model: String, - val startedAtMs: Long, - /** 请求开始时从 Room 捕获;reset 后完成的旧请求仍属于 reset 前统计。 */ - val acceptedGeneration: Long = 0L, - /** - * 请求开始时同步捕获的 [TokenStatSpool.captureRestoreEpoch](P1 终审):纯内存、无 - * Room。收尾 [TokenStatSpool.append] 时验证其仍等于当前 restore epoch——恢复屏障开始 - * 即原子递增使所有旧请求失效,绝不写入可能已被恢复替换的 spool/Room。spool 重放 - * ([fromSpoolLine])不经过 append 的请求 fence,保持默认值即可。 - */ - val sessionEpoch: Long = 0L, -) { - /** 首个真实响应 token/chunk 时间;无内容响应保持 null。 */ - var firstTokenAtMs: Long? = null - private set - - /** 最后一次 provider 上报的规范化 usage;未上报保持 null(未知)。 */ - var lastUsage: ProviderUsageSnapshot? = null - private set - - /** provider 上报 usage 的总次数(诊断:重复回调防重证据)。 */ - var usageReportCount: Int = 0 - private set - - /** 观察到的最大的 attempt 序号(诊断:内部重试边界证据)。 */ - var attemptCount: Int = 0 - private set - - /** 最终状态;未结束时为 null。 */ - var status: TokenStatStatus? = null - private set - - /** 结束时间;未结束时为 [startedAtMs]。 */ - var endedAtMs: Long = startedAtMs - private set - - /** - * 冻结的“发生时”价格快照(P1-1):由 [TokenStatsLedger.prepareEventLine] 在 - * 请求收尾解析并随 spool 行持久化;排空([TokenStatsLedger.recordWith])只使用 - * 该快照,绝不重读当前价格。直接路径([TokenStatsLedger.record])不经 spool, - * 解析即落账,无需冻结。 - */ - internal var frozenPricing: FrozenEventPricing? = null - - /** 价格无法读取时的结构化、脱敏原因;不得用默认价格冒充历史快照。 */ - internal var pricingResolutionDiagnostic: String? = null - - /** attempt -> 该 attempt 最后一次上报的快照(同一 attempt 重复上报取最后)。 */ - private val attemptUsages = LinkedHashMap() - - /** spool 中的 usage 已在写入前聚合,重放时不得按原 attemptCount 再检查缺口。 */ - private var replayAggregatedUsage: ProviderUsageSnapshot? = null - - /** 仅在首个真实内容 chunk 到达时记录一次。 */ - fun onFirstToken(nowMs: Long = System.currentTimeMillis()) { - if (firstTokenAtMs == null) { - firstTokenAtMs = nowMs - } - } - - /** - * provider 上报 usage。同一 attempt 的重复上报按 - * [ProviderUsageSnapshot.completeSnapshot] 合并:部分更新按“最新非空字段优先” - * (流式增量快照,如 Anthropic message_start + message_delta:后一次只带 - * 累计 output,input/cache 保留前一次的值;output 等累计字段取最新值,绝不 - * 相加);完整快照整份覆盖(null = 明确未知,撤销旧值)。不同 attempt 分别 - * 记账,聚合时累加。 - */ - fun onUsage(usage: ProviderUsageSnapshot, attempt: Int = 1) { - val normalizedAttempt = attempt.coerceAtLeast(1) - val sanitized = sanitizeUsage(usage) - usageReportCount += 1 - lastUsage = sanitized - attemptUsages[normalizedAttempt] = - mergeSameAttemptSnapshot(attemptUsages[normalizedAttempt], sanitized) - if (normalizedAttempt > attemptCount) { - attemptCount = normalizedAttempt - } - } - - /** 防御:负值分量一律拒绝为未知(真实负值只会来自异常 provider 数据)。 */ - private fun sanitizeUsage(usage: ProviderUsageSnapshot): ProviderUsageSnapshot { - fun nonNegative(value: Long?): Long? = value?.takeIf { it >= 0 } - return ProviderUsageSnapshot( - uncachedInputTokens = nonNegative(usage.uncachedInputTokens), - cachedInputTokens = nonNegative(usage.cachedInputTokens), - cacheWriteTokens = nonNegative(usage.cacheWriteTokens), - totalInputTokens = nonNegative(usage.totalInputTokens), - outputTokens = nonNegative(usage.outputTokens), - reasoningTokens = nonNegative(usage.reasoningTokens), - reasoningIncludedInOutput = usage.reasoningIncludedInOutput, - cacheWriteSeparateBilling = usage.cacheWriteSeparateBilling, - completeSnapshot = usage.completeSnapshot, - source = usage.source, - ) - } - - /** - * 同一 attempt 的快照合并: - * - 完整快照([ProviderUsageSnapshot.completeSnapshot] = true):整份覆盖, - * null 字段 = 明确未知(撤销旧值); - * - 部分更新(false):最新上报的非空字段优先;新快照缺失的字段保留旧值。 - * 累计字段(output 等)直接取最新值,不能 start/delta 相加。 - */ - private fun mergeSameAttemptSnapshot( - previous: ProviderUsageSnapshot?, - latest: ProviderUsageSnapshot, - ): ProviderUsageSnapshot { - if (previous == null) return latest - if (latest.completeSnapshot) return latest - return ProviderUsageSnapshot( - uncachedInputTokens = latest.uncachedInputTokens ?: previous.uncachedInputTokens, - cachedInputTokens = latest.cachedInputTokens ?: previous.cachedInputTokens, - cacheWriteTokens = latest.cacheWriteTokens ?: previous.cacheWriteTokens, - totalInputTokens = latest.totalInputTokens ?: previous.totalInputTokens, - outputTokens = latest.outputTokens ?: previous.outputTokens, - reasoningTokens = latest.reasoningTokens ?: previous.reasoningTokens, - reasoningIncludedInOutput = - latest.reasoningIncludedInOutput ?: previous.reasoningIncludedInOutput, - cacheWriteSeparateBilling = latest.cacheWriteSeparateBilling, - completeSnapshot = false, - source = latest.source, - ) - } - - /** - * 按 attempt 聚合后的 usage:分量在所有上报 attempt 中都已知时才求和 - * (Long 饱和加法,绝不溢出为负),任一 attempt 该分量未知则聚合值保持未知; - * 来源/包含推理声明取最后一次。 - */ - fun aggregatedUsage(): ProviderUsageSnapshot? { - replayAggregatedUsage?.let { return it } - val snapshots = attemptUsages.values.toList() - if (snapshots.isEmpty()) return null - val allAttemptsReported = - attemptCount > 0 && (1..attemptCount).all { attemptUsages.containsKey(it) } - return ProviderUsageSnapshot( - uncachedInputTokens = sumComponent(snapshots, allAttemptsReported) { it.uncachedInputTokens }, - cachedInputTokens = sumComponent(snapshots, allAttemptsReported) { it.cachedInputTokens }, - cacheWriteTokens = sumComponent(snapshots, allAttemptsReported) { it.cacheWriteTokens }, - totalInputTokens = sumComponent(snapshots, allAttemptsReported) { it.totalInputTokens }, - outputTokens = sumComponent(snapshots, allAttemptsReported) { it.outputTokens }, - reasoningTokens = sumComponent(snapshots, allAttemptsReported) { it.reasoningTokens }, - reasoningIncludedInOutput = snapshots.lastOrNull()?.reasoningIncludedInOutput, - cacheWriteSeparateBilling = snapshots.lastOrNull()?.cacheWriteSeparateBilling ?: true, - completeSnapshot = true, - source = snapshots.lastOrNull()?.source ?: "unknown", - ) - } - - private fun sumComponent( - snapshots: List, - allAttemptsReported: Boolean, - pick: (ProviderUsageSnapshot) -> Long?, - ): Long? { - if (!allAttemptsReported) return null - val values = snapshots.mapNotNull(pick) - if (values.size != snapshots.size) return null - return values.fold(0L) { acc, value -> TokenCostCalculator.saturatedAdd(acc, value) } - } - - /** 结束请求:只能设置一次,后续调用被忽略。 */ - fun finish(status: TokenStatStatus, nowMs: Long = System.currentTimeMillis()) { - if (this.status == null) { - this.status = status - endedAtMs = nowMs - } - } - - // ==== 磁盘 spool 序列化(TokenStatSpool 写入前日志重放) ==== - // 只保存完整脱敏事件(无正文/凭据);聚合已完成,重放结果与原始请求一致。 - // v2 起行内携带“发生时”价格/成本快照(P1-1),排空只按快照落账。 - - /** 不可变基础 JSON(usage/状态/时间):调用方在请求收尾同步生成,冻结快照。 */ - internal fun toSpoolBaseJson(): JSONObject = - JSONObject().apply { - put("v", SPOOL_FORMAT_VERSION) - put("eventId", eventId) - put("category", category.name) - put("configId", configId) - put("provider", provider) - put("model", model) - put("startedAtMs", startedAtMs) - put("acceptedGeneration", acceptedGeneration) - put("endedAtMs", endedAtMs) - firstTokenAtMs?.let { put("firstTokenAtMs", it) } - status?.let { put("status", it.name) } - put("usageReportCount", usageReportCount) - put("attemptCount", attemptCount) - aggregatedUsage()?.let { usage -> - put("usage", usageToJson(usage)) - } - } - - /** 完整 v2 行:基础 JSON + 发生时价格/成本快照。 */ - internal fun toSpoolLine(pricing: ResolvedPricing, cost: Double?): String = - toSpoolBaseJson().apply { - put("pricing", pricingToJson(pricing, cost)) - }.toString() - - internal companion object { - private const val SPOOL_FORMAT_VERSION = 2 - - /** - * 从 spool 行恢复上下文(重放时直接使用聚合结果与冻结价格快照,语义与 - * 原始请求一致)。v2 严格解析:缺价格快照(v1 未发布格式)视为损坏行, - * 由 spool 整段隔离(保留证据),绝不静默用当前价格重放。 - */ - internal fun fromSpoolLine(line: String): TokenStatRequestContext { - val json = JSONObject(line) - if (json.optInt("v", 0) != SPOOL_FORMAT_VERSION) { - throw IllegalStateException("unsupported spool format version") - } - val context = - TokenStatRequestContext( - eventId = json.getString("eventId"), - category = TokenStatCategory.fromName(json.optString("category")), - configId = json.getString("configId"), - provider = json.getString("provider"), - model = json.getString("model"), - startedAtMs = json.getLong("startedAtMs"), - acceptedGeneration = json.getLong("acceptedGeneration"), - ) - json.opt("firstTokenAtMs")?.let { firstTokenAtMs -> - context.firstTokenAtMs = (firstTokenAtMs as Number).toLong() - } - context.endedAtMs = json.getLong("endedAtMs") - context.status = TokenStatStatus.fromName(json.optString("status")) - context.usageReportCount = json.optInt("usageReportCount", 0) - context.attemptCount = json.optInt("attemptCount", 0) - json.optJSONObject("usage")?.let { usageJson -> - val usage = usageFromJson(usageJson) - context.lastUsage = usage - context.replayAggregatedUsage = usage - } - val pricingJson = - json.optJSONObject("pricing") - ?: throw IllegalStateException("spool line missing pricing snapshot") - context.frozenPricing = pricingFromJson(pricingJson) - context.pricingResolutionDiagnostic = - pricingJson.optString("resolutionDiagnostic").takeIf { it.isNotBlank() } - return context - } - - internal fun pricingToJson( - pricing: ResolvedPricing, - cost: Double?, - resolutionDiagnostic: String? = null, - ): JSONObject = - JSONObject().apply { - put("billingMode", pricing.billingMode.name) - put("currency", pricing.currency.name) - pricing.inputPricePerMillion?.let { put("inputPricePerMillion", it) } - pricing.cachedInputPricePerMillion?.let { put("cachedInputPricePerMillion", it) } - pricing.cacheWritePricePerMillion?.let { put("cacheWritePricePerMillion", it) } - pricing.outputPricePerMillion?.let { put("outputPricePerMillion", it) } - pricing.pricePerRequest?.let { put("pricePerRequest", it) } - put("source", pricing.source.name) - put("known", pricing.known) - cost?.let { put("cost", it) } - resolutionDiagnostic?.let { put("resolutionDiagnostic", it) } - } - - private fun pricingFromJson(json: JSONObject): FrozenEventPricing { - val pricing = - ResolvedPricing( - billingMode = BillingMode.fromString(json.getString("billingMode")), - currency = - if (json.optString("currency").equals("CNY", ignoreCase = true)) { - PricingCurrency.CNY - } else { - PricingCurrency.USD - }, - inputPricePerMillion = json.optDoubleOrNull("inputPricePerMillion"), - cachedInputPricePerMillion = json.optDoubleOrNull("cachedInputPricePerMillion"), - cacheWritePricePerMillion = json.optDoubleOrNull("cacheWritePricePerMillion"), - outputPricePerMillion = json.optDoubleOrNull("outputPricePerMillion"), - pricePerRequest = json.optDoubleOrNull("pricePerRequest"), - source = PricingSource.fromName(json.optString("source")), - known = json.optBoolean("known", false), - ) - return FrozenEventPricing(pricing, json.optDoubleOrNull("cost")) - } - - private fun usageToJson(usage: ProviderUsageSnapshot): JSONObject = - JSONObject().apply { - usage.uncachedInputTokens?.let { put("uncachedInputTokens", it) } - usage.cachedInputTokens?.let { put("cachedInputTokens", it) } - usage.cacheWriteTokens?.let { put("cacheWriteTokens", it) } - usage.totalInputTokens?.let { put("totalInputTokens", it) } - usage.outputTokens?.let { put("outputTokens", it) } - usage.reasoningTokens?.let { put("reasoningTokens", it) } - usage.reasoningIncludedInOutput?.let { put("reasoningIncludedInOutput", it) } - put("cacheWriteSeparateBilling", usage.cacheWriteSeparateBilling) - put("completeSnapshot", usage.completeSnapshot) - put("source", usage.source) - } - - private fun usageFromJson(json: JSONObject): ProviderUsageSnapshot = - ProviderUsageSnapshot( - uncachedInputTokens = json.optLongOrNull("uncachedInputTokens"), - cachedInputTokens = json.optLongOrNull("cachedInputTokens"), - cacheWriteTokens = json.optLongOrNull("cacheWriteTokens"), - totalInputTokens = json.optLongOrNull("totalInputTokens"), - outputTokens = json.optLongOrNull("outputTokens"), - reasoningTokens = json.optLongOrNull("reasoningTokens"), - reasoningIncludedInOutput = - if (json.has("reasoningIncludedInOutput")) { - json.optBoolean("reasoningIncludedInOutput") - } else { - null - }, - cacheWriteSeparateBilling = json.optBoolean("cacheWriteSeparateBilling", true), - completeSnapshot = json.optBoolean("completeSnapshot", false), - source = json.optString("source", "unknown"), - ) - - private fun JSONObject.optLongOrNull(key: String): Long? = - if (has(key) && !isNull(key)) optLong(key, -1).takeIf { it >= 0 } else null - - private fun JSONObject.optDoubleOrNull(key: String): Double? = - if (has(key) && !isNull(key)) { - optDouble(key, Double.NaN).takeIf { !it.isNaN() } - } else { - null - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt deleted file mode 100644 index 6109bbf54..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatSpool.kt +++ /dev/null @@ -1,1418 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.channels.FileChannel -import java.nio.file.AccessDeniedException -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.FileVisitOption -import java.nio.file.FileVisitResult -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.SimpleFileVisitor -import java.nio.file.StandardCopyOption -import java.nio.file.StandardOpenOption -import java.nio.file.attribute.BasicFileAttributes -import java.util.EnumSet -import java.util.UUID -import java.security.MessageDigest -import java.util.concurrent.ExecutionException -import java.util.concurrent.FutureTask -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.RejectedExecutionException -import java.util.concurrent.ScheduledThreadPoolExecutor -import java.util.concurrent.ThreadPoolExecutor -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject - -/** - * Durable token-stat outbox. - * - * The acceptance boundary is a synchronous append plus fsync of a complete event line. Price - * resolution happens before this object is called; the background worker only inserts immutable - * rows into Room. There is deliberately no memory overflow queue: a disk failure is returned to - * the model-call boundary and cannot be mistaken for a durable statistic. - * - * [lifecycleMutex] is also the raw-snapshot barrier. A snapshot drains every pending line before - * checkpointing; restore invalidates queued workers before replacing files and verifies cleanup - * before reporting success. - * - * Room insert isolation across the snapshot/restore boundary is enforced by an active-insert - * registry plus generation fencing (P1-2): an insert task atomically re-checks the session - * generation and the exclusive flag, and only then registers itself before touching Room. A - * snapshot/restore bumps the generation, blocks new registrations, and waits a hard-bounded - * [exclusiveQuiesceTimeoutMs] for the registry to empty; on timeout it fails explicitly BEFORE - * any file replacement (or checkpoint), so an old insert that is wedged inside Room can never - * overlap a replaced database. Lock order is always `lifecycleMutex -> stateLock` and never - * reversed, so a normal drain/append can never deadlock on the registry wait. - * - * In-flight provider/stream requests are NOT visible to the insert registry (they have not - * reached [append] yet), so the registry alone cannot stop an old request from writing into a - * restored database. Request/session fencing (P1 终审) closes that gap: - * - Every request captures [restoreEpoch] synchronously at its start via [captureRestoreEpoch] - * (pure in-memory, no Room) and carries it in its [TokenStatRequestContext.sessionEpoch]. - * - [append] validates the captured epoch against the current [restoreEpoch] AND - * [acceptingEventsThisProcess] inside [lifecycleMutex] before any spool write. Raw restore uses - * [withExclusiveRestoreAccess] to increment [restoreEpoch] only after its durable REPLACING - * commit; every old request is then rejected before stores close or directories are replaced. - * - Once a restore replacement actually starts (right before [block] runs), the process stops - * accepting ALL statistics events ([acceptingEventsThisProcess] = false) until it restarts: - * the UI allows restarting later, so same-process new requests fail open — the tracking - * boundary ([newRequest]) skips statistics and calls the delegate directly ([isAcceptingEvents] - * drives that decision) — and can never pollute the new DB. - * A restore failure BEFORE replacement leaves accepting enabled (new requests continue); - * a failure after replacement has started keeps it disabled and requires a restart. - */ -internal object TokenStatSpool { - internal const val TAG = "TokenStatSpool" - internal const val SPOOL_DIR_NAME = "token_stats_spool" - internal const val ACTIVE_FILE_NAME = "active.jsonl" - internal const val SEALED_PREFIX = "sealed_" - internal const val SEALED_SUFFIX = ".jsonl" - internal const val QUARANTINE_PREFIX = "quarantine_" - - /** - * 恢复 REPLACING 持久化标记文件名(审计 P1:必须可被启动路径读取)。恢复替换开始 - * 前(commitReplacement)写入 filesDir 根,恢复成功后删除;进程崩溃在替换中途时 - * 标记保留,启动时由 [consumeAbandonedRestoreIfAny] 消费——旧 spool 绝不 replay 进 - * 可能已被替换的数据库。backup 包 [RestoreReplacingMarker] 引用本常量,避免两处漂移。 - */ - internal const val RESTORE_REPLACING_MARKER_FILE_NAME = "restore_replacing.flag" - - /** - * P2:seal copy 回退中途失败的部分目标隔离前缀(`seal_failed_`)。隔离文件 scanner - * 忽略(不匹配 [SEALED_PREFIX])、计入递归总 cap(占用可见)、由维护入口 [retryPendingCleanup] - * 清理(active 保留完整内容,隔离副本删除安全,无数据损失)。P2 终审:同时作为**受管失败 - * 发布证据**计入 [quarantineAreaFiles]——长期删除失败时可见(quarantineEvidence/info 字节)、 - * 可导出、可确认删除(ack 按 NOFOLLOW/path 根校验删除并释放容量),绝不无限隐藏占用。 - */ - internal const val SEAL_FAILED_PREFIX = "seal_failed_" - - /** - * ack 删除的事务化暂存目录前缀(reviewer P1):ack 先把全部待删文件 rename 进本轮唯一 - * 的 trash 目录(同 filesystem、可回滚),全部成功后才重写 manifest。P1-2:trash 目录内 - * 先原子写入状态文件 [ACK_TRASH_STATE_FILE_NAME](首行 UNCOMMITTED/COMMITTED + mapping - * 行),维护入口 [retryPendingCleanup] 按持久状态处置:**只有显式 COMMITTED 才允许后台 - * 补删**;UNCOMMITTED 一律按 mapping+identity 回滚(P1-1 修复:绝不根据主 manifest 缺失 - * 推断已提交——普通 quarantine 证据从未进入 manifest,缺失恒成立,旧推断会把未确认的 - * 证据误删;主 manifest 已发布但 COMMITTED marker 未写时,回滚后的损坏 sealed 会被扫描器 - * 重新隔离,ack 视失败但不丢证据)。状态缺失/损坏或回滚长期失败的 trash 作为 - * StuckAckEvidence 由 UI 管理(见 [stuckAckTrashEvidence]),绝不自动删除。trash 目录及 - * 其内容计入递归总容量(P1-1),占用绝不隐藏。目录名不可与任何证据/元数据文件前缀冲突。 - */ - internal const val ACK_TRASH_PREFIX = "quarantine_ack_trash_" - - /** ack trash 内的原子状态文件(P1-2):首行 = [ACK_STATE_UNCOMMITTED]/[ACK_STATE_COMMITTED],后续行 = mapping(原名 → trash 名 + bytes + sha256)。 */ - internal const val ACK_TRASH_STATE_FILE_NAME = "ack_state.jsonl" - internal const val ACK_STATE_UNCOMMITTED = "UNCOMMITTED" - internal const val ACK_STATE_COMMITTED = "COMMITTED" - internal const val MAX_LINE_BYTES = 8 * 1024 - - /** 单段封顶字节(P1-2 测试可注入更小值,端到端验证 seal 与总容量边界)。 */ - internal var MAX_SEGMENT_BYTES = 4L * 1024 * 1024 - - /** - * 总 spool 硬上限(P1-1/P1-2):active/sealed/pending-delete/quarantine/summary/manifest - * 及 sidecar/tmp 与 ack trash 等**全部管理文件**的实际字节总和(递归,见 - * [totalSpoolBytes]),任意时刻恒 ≤ 该值。 - * - * 数据准入上限 = 总上限 − [METADATA_RESERVE_BYTES](见 [dataAdmissionMaxBytes]):append - * 准入时投影(当前总量 + 本次行字节)超过即明确抛 [TokenStatsPersistenceException], - * 绝不发布新文件——Room 长期失败时 sealed 段因此也有界(256MiB / 4MiB 段 ≈ 64 段)。 - * 元数据(summary/manifest 及其 sidecar/tmp、ack trash 状态/mapping)写在发布前另行投影 - * `totalSpoolBytes + worstCaseAdditional ≤ 总上限`(见 [metadataWriteBudgetExceeded]), - * 因此 drain/ack 的元数据发布同样不可能把实际总量推过总上限。测试可注入更小值。 - */ - internal const val TOTAL_SPOOL_MAX_BYTES = 256L * 1024 * 1024 - internal var totalSpoolMaxBytesForTest: Long? = null - - /** - * Hard retention cap for full corrupt-segment evidence. Within the cap the complete evidence - * is preserved; a NEW corrupt segment that would exceed the cap is replaced by a bounded - * rolling summary (count/hash/bytes/time, never content) and removed, so disk usage stays - * bounded while healthy drains continue. - */ - internal const val MAX_QUARANTINE_BYTES = 16L * 1024 * 1024 - - /** Fixed-size rolling summary of over-cap corrupt segments (atomic update, never grows unbounded). */ - internal const val QUARANTINE_SUMMARY_NAME = "quarantine_summary.jsonl" - internal const val MAX_QUARANTINE_SUMMARY_BYTES = 64L * 1024 - internal const val MAX_QUARANTINE_SUMMARY_LINES = 256 - - /** - * 有界 skip/tombstone manifest(P1-2/P1-1):既不能删除也不能重命名出 sealed 队列的段身份 - * 记入此处(时间/文件名/字节/SHA-256/是否超限,不含正文),扫描器按稳定 identity - * (file+bytes+sha256)跳过该具体文件并继续后续健康段;维护入口(drain 重试)会再次尝试 - * 处置并移除记录。 - * - * 这是**不滚动**的活跃受管失败集合:条目只在对应文件物理消失或身份变化后移除(P1-2), - * 绝不能像历史摘要那样滚动丢弃仍存在文件的身份(P1-1:滚动会让旧段重新进入扫描队列, - * 造成无界重扫循环)。硬上限 [MAX_TOMBSTONE_ENTRIES]/[MAX_TOMBSTONE_MANIFEST_BYTES]/ - * [MAX_MANAGED_BYTES] 到达后停止接受新的统计 append(明确抛 - * [TokenStatsPersistenceException],不产生更多段),drain 有界跳过并继续健康段。 - */ - internal const val TOMBSTONE_MANIFEST_NAME = "quarantine_skip_manifest.jsonl" - - /** 活跃受管失败集合的身份硬上限:超过后新统计 append 明确失败(不能继续产生更多段)。 */ - internal const val MAX_TOMBSTONE_ENTRIES = 64 - - /** 受管失败集合的 manifest 文件字节硬上限(64 条约 200B,纯 ASCII,正常远达不到)。 */ - internal const val MAX_TOMBSTONE_MANIFEST_BYTES = 64L * 1024 - - // ── P1-1 元数据预留(总 cap 证明)──────────────────────────────────────────── - // 有界元数据文件(quarantine summary / tombstone manifest)各有 4 个磁盘槽位: - // canonical、`.new`、`.bak`、`.tmp<随机>`。原子替换或回退协议的任意中断窗口下四个 - // 槽位都可能同时各持一份完整副本(read 恢复/清理前),因此单份元数据的最坏磁盘 - // 占用 = 4 × 内容硬上限。所有 spool 元数据读写都持 lifecycleMutex(至多一个写进行 - // 中,Atomic tmp 唯一文件并发数 = 1),预留按单写者计算即可覆盖。 - /** 单份有界元数据的磁盘槽位数(canonical + .new + .bak + tmp)。 */ - internal const val METADATA_COPY_COUNT = 4 - - /** 有界元数据文件数:quarantine summary + tombstone manifest。 */ - internal const val METADATA_FILE_COUNT = 2 - - /** 单份有界元数据内容的字节硬上限(summary/manifest 中较大者,均为纯 ASCII 有界)。 */ - internal val MAX_METADATA_FILE_BYTES: Long = - maxOf(MAX_QUARANTINE_SUMMARY_BYTES, MAX_TOMBSTONE_MANIFEST_BYTES) - - /** - * 元数据预留(P1-1):数据准入上限 = 总上限 − 本预留。预留 = 2 个元数据文件 × 4 个 - * 槽位 × 内容硬上限,覆盖 summary 与 manifest 各 canonical/.new/.bak/tmp 最坏副本、 - * overflow summary(quarantine_summary 本身)与固定大小临时文件(tmp 内容同 bound)。 - * 生产值 512KiB = 2 × 4 × 64KiB,远小于 256MiB 总上限([init] 有 require 证明)。 - */ - internal val METADATA_RESERVE_BYTES: Long = - METADATA_FILE_COUNT * METADATA_COPY_COUNT * MAX_METADATA_FILE_BYTES - - init { - require(METADATA_RESERVE_BYTES < TOTAL_SPOOL_MAX_BYTES) { - "metadata reserve must be strictly smaller than the total spool cap: " + - "$METADATA_RESERVE_BYTES >= $TOTAL_SPOOL_MAX_BYTES" - } - } - - /** 受管失败集合中仍占用磁盘的段的总字节硬上限(64 × 单段 4MiB 封顶;段大小可注入时实时计算)。 */ - internal val MAX_MANAGED_BYTES get() = MAX_TOMBSTONE_ENTRIES * MAX_SEGMENT_BYTES - - /** 删除失败的 over-cap 段的诊断去向:完整证据(计入硬 cap,可导出/确认删除)。 */ - internal const val PENDING_DELETE_PREFIX = "quarantine_pending_delete_" - internal const val RETRY_BACKOFF_BASE_MS = 1_000L - internal const val RETRY_BACKOFF_CAP_MS = 30_000L - internal var insertTimeoutMs: Long = 5_000L - internal var prepareTimeoutMs: Long = 5_000L - - /** 排他快照/恢复等待已登记 insert 全部结束的硬超时;超时则操作明确失败,绝不替换文件。 */ - internal var exclusiveQuiesceTimeoutMs: Long = 5_000L - internal const val QUIESCE_POLL_INTERVAL_MS = 50L - - /** 文件 I/O 调度缝(P2-2):导出/确认删除的复制、fsync、扫描绝不运行在调用方(Main)线程。 */ - internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO - - internal val lifecycleMutex = Mutex() - internal val stateLock = Any() - - /** - * 调度令牌:有 worker 任务已入队/正在运行(用于入队去重)。快照屏障在递增 - * [sessionGeneration] 时同步清空,使旧 generation 的排队 worker 失效。 - */ - internal var drainScheduled = false - - /** - * 未消费的 drain 请求(丢失唤醒修复):每次 [scheduleDrain] 都在 [stateLock] 下 - * 置位;worker 每轮开始前消费,轮末在同一锁内决定 retire/立即 rerun/失败 backoff。 - * 请求在轮内到达时由同一 worker 接管,绝不依赖下一次外部触发;RejectedExecution - * 时请求保留、仅释放调度令牌(见 [scheduleDrain])。 - */ - internal var drainRequested = false - - /** - * [awaitInitialDrain] 的等待者:worker 轮末决策点持 [stateLock] 统一完成并清空; - * 完成/失败都不保留——下次调用重新登记并触发新轮(失败不缓存,可重试)。 - */ - internal val initialDrainWaiters = ArrayList>() - - internal var sessionGeneration = 0L - internal var retryDelayMs = RETRY_BACKOFF_BASE_MS - internal var writerExecutor = newWriterExecutor() - - /** - * 排他快照/恢复进行中:阻止新 insert 登记(与 insert 的登记在同一 critical section 原子判定)。 - */ - internal var exclusiveBarrierActive = false - - /** 已通过 fence 且正在 Room 内写入的 insert(eventId -> 提交时 generation)。 */ - internal val activeInserts = HashMap() - - /** - * 在册的统计数据库访问者 token 集合(reviewer P1-1 修复):请求边界的身份事务与收尾的 - * 价格解析等直接 Room 访问经 [com.ai.assistance.operit.data.stats.withStatsDatabaseAccess] - * 注册唯一 token([statsDbAccessSeq] 递增);快照/恢复屏障进入排他状态后有界等待其 - * 清空(绝不与文件打包/替换竞争)。token 删除幂等:reset 清空集合后,旧访问者的 - * finally 只移除自己的 token,不影响新注册(无负数/假零污染)。只由 stateLock 保护。 - */ - internal val statsDbAccessTokens = HashSet() - - /** [statsDbAccessTokens] 的递增 token 序号(stateLock 保护)。 */ - internal var statsDbAccessSeq = 0L - - /** - * Request/session fencing epoch(P1 终审):通用恢复屏障开始时递增;Raw restore 则在 - * 外部 REPLACING 状态成功持久化后、关闭 stores 前原子递增, - * 使所有在屏障开始前开始(已捕获旧 epoch)的 in-flight provider/stream 请求在收尾 - * [append] 时被明确拒绝——绝不写入可能已被恢复替换的 spool/Room。导出/快照屏障 - * (clearAfter=false)不递增:进行中的请求在导出期间正常收尾。进程内单调递增, - * 不随普通 [withExclusiveSnapshotAccess] 变化;测试经 [clearPendingStateForTest] 复位。 - */ - internal var restoreEpoch = 0L - - /** - * 本进程是否仍接受统计事件(P1 终审):恢复屏障的替换开始([block] 即将执行)时置 false, - * 直到进程重启(UI 允许稍后重启)。此后 [append] 拒绝一切事件;新的跟踪请求 fail-open - * ([isAcceptingEvents] 供 [newRequest] 判定——跳过统计直调 delegate),模型功能不受影响, - * 重启后统计恢复。替换前失败(drain/quiesce 阶段抛错)保持 true,新请求可继续。 - * 进程重启(含测试模拟)经 [resetExecutorsForTest]/[clearPendingStateForTest] 复位。 - */ - @Volatile - internal var acceptingEventsThisProcess = true - - /** - * Dedicated bounded insert worker. Room/SQLite writes can ignore thread interrupts, so the - * drain never joins this worker; a timed-out task is detached and the durable segment is - * retried later. The single daemon thread plus one queue slot is the hard bound (P2-1), so a - * permanently wedged database cannot leak threads or hold the lifecycle lock. - */ - internal var insertExecutor = newInsertExecutor() - - /** Dedicated bounded database-preparation worker with single-flight semantics (P2-1). */ - internal var databaseExecutor = newDatabaseExecutor() - internal var pendingDaoTask: FutureTask? = null - internal val insertionWaiters = HashMap>() - - /** 测试注入缝:返回 null 走真实删除;返回 false 模拟删除失败(P1-2 分支注入)。 */ - internal var segmentDeleteForTest: ((File) -> Boolean?)? = null - - /** 测试注入缝:返回 null 走真实 renameTo;返回 false 模拟段处置重命名失败。 */ - internal var segmentRenameForTest: ((File, File) -> Boolean?)? = null - internal var afterSegmentReadForTest: (() -> Unit)? = null - internal var spoolDeleteForTest: ((File) -> Boolean)? = null - - /** 测试注入缝:返回 false 强制摘要/manifest 原子替换不支持(走 old/new/backup 回退)。 */ - internal var quarantineAtomicMoveForTest: ((File, File) -> Boolean)? = null - - /** 测试注入缝:返回 true 时对应元数据文件(summary/manifest)读取抛 IOException(P1-2)。 */ - internal var metadataReadErrorForTest: ((File) -> Boolean)? = null - - /** 测试注入缝:返回 true 时对应元数据文件发布抛 IOException。 */ - internal var metadataWriteErrorForTest: ((File) -> Boolean)? = null - - /** - * 可控 seal publication seam(P1-8):候选编号选定后、实际发布前以目标文件调用。 - * 返回 false 模拟发布前失败(seal 明确失败);测试可在回调里创建同名不同内容的目标 - * 文件模拟冲突(返回 true),真实发布路径必须检测到占用并选下一编号,目标原字节 - * 保持不变。返回 null 表示无操作。 - */ - internal var beforeSealPublishForTest: ((File) -> Boolean?)? = null - - /** 测试注入缝:返回 true 时 [scheduleDrain] 的入队被模拟拒绝(RejectedExecution 状态恢复)。 */ - internal var rejectDrainScheduleForTest: Boolean = false - - /** 测试注入缝:每轮 drain(runBlocking 结束、轮末决策前)在 worker 线程调用。 */ - internal var afterDrainRoundForTest: (() -> Unit)? = null - - /** 测试注入缝:返回 false 强制模拟硬链接不受支持(走 copy 回退发布);其余走真实 createLink。 */ - internal var sealHardLinkForTest: ((File, File) -> Boolean?)? = null - - /** 测试注入缝:返回 false 模拟 seal 发布成功后删除 active 失败(硬链接崩溃窗口)。 */ - internal var sealActiveDeleteForTest: ((File) -> Boolean?)? = null - - /** - * 测试注入缝(P1 终审):返回 null 走真实 fsync;返回 false 模拟 seal 目标文件 fsync - * 失败(调用方必须保留 active、处置目标并返回 FAILED,绝不声称 PUBLISHED)。 - */ - internal var fileSyncForTest: ((File) -> Boolean?)? = null - - /** - * 测试注入缝(P1 终审):返回 null 走真实目录 fsync;返回 [DirSyncResult.OK] 模拟目录 - * fsync 成功;[DirSyncResult.FAILED] 模拟真实失败(发布路径 fail-closed); - * [DirSyncResult.UNSUPPORTED] 模拟平台明确不支持(发布路径同样 fail-closed——绝不当作 - * 成功继续删除唯一 fsynced active 或声称 PUBLISHED)。生产平台(Android/Linux)支持 - * 目录 fd fsync;Windows 仅 JVM 测试环境,测试统一注入 OK 运行正常路径,UNSUPPORTED - * 只用于显式 fail-closed 测试。不存在“原地排空”平台模式:UNSUPPORTED 与 FAILED 一样 - * 只让调用方 fail-closed 保留数据。 - */ - internal var dirSyncForTest: ((File) -> DirSyncResult?)? = null - - /** - * 每进程 spool 目录项持久确认标记(P1-1 终审):进程内首次成功完成 bootstrap gate - * ([ensureDirectoryDurabilityConfirmed])后为 true;初始 false,进程重启即清零 - * (测试经 [clearPendingStateForTest]/[resetExecutorsForTest] 模拟)。P1-1 修复: - * 任一 spool 目录项变更(新建/rename/delete,含元数据严格发布)后的目录 sync 非 OK, - * 或删除开始/重建 mkdir 失败,都**立即**重新置 false(统一经 [requireSpoolDirSync]), - * 下一次使用前必须先重新确认——绝不带着“已确认”内存标记声称 durable。**仅进程内 - * 有效**:上一进程可见但未确认的目录项在本进程重新提交,未声明 durable 的事件允许 - * 丢失,属 append 契约内。 - */ - @Volatile - internal var directoryDurabilityConfirmedThisProcess = false - - /** - * 测试注入缝(P2):返回 null 走真实 `Files.copy`;返回 false 模拟 copy 中途失败(seam - * 可在回调内先写入部分目标字节,发布路径必须按 identity 确认后隔离/删除/tombstone); - * 返回 true 模拟 copy 成功(seam 自行写入目标内容)。 - */ - internal var sealCopyForTest: ((File, File) -> Boolean?)? = null - - /** 测试注入缝:返回 null 走真实原子 move;返回 false 模拟 ack prepare/rollback 失败。 */ - internal var ackAtomicMoveForTest: ((File, File) -> Boolean?)? = null - - /** 测试注入缝:返回 true 时对应段原始字节读取失败 → 身份校验 UNREADABLE / drain 中止(P1-2)。 */ - internal var segmentReadErrorForTest: ((File) -> Boolean)? = null - - /** - * 测试注入缝:安全关键路径的目录枚举(默认行为与 [File.listFiles] 完全一致)。测试可按 - * 目录返回 null 模拟枚举失败——依赖枚举完整性的安全判定(mapping 全有或全无、trash 空 - * 目录判定、UNCOMMITTED 身份扫描、sealed 队列/待处理判定、seal 编号选择)据此显式 - * fail-closed,绝不把失败当空目录、空队列或编号 1。仅用于这些安全路径,不影响普通目录 - * 枚举(容量扫描等)。 - */ - internal var directoryListingForTest: ((File) -> Array?)? = null - - /** tombstone 写入结果:容量满 ≠ 写失败(容量满时跳过该段继续健康,写失败才退避重试)。 */ - - /** - * 身份判定(P1-1)只允许使用**实时**从原始字节计算的 SHA-256:length+mtime 不足以 - * 区分同名同长同 mtime 的不同内容,任何身份缓存复用旧 SHA 都会让 cleanup/ack 误删或 - * 隔离健康段。因此这里没有任何身份哈希缓存——每个破坏性决策(skip/delete/rename/ack) - * 都现场 hash 文件原始字节(单段 ≤4MiB,成本可接受)。 - */ - - internal fun newWriterExecutor() = SpoolWriter.newDrainExecutor() - - internal fun newInsertExecutor() = SpoolWriter.newInsertExecutor() - - internal fun newDatabaseExecutor() = SpoolWriter.newDatabaseExecutor() - - /** - * Append a complete immutable event. true means an fsync-backed durable copy exists. - * 受管失败集合(tombstone entries)或证据区到达硬上限时,在持锁下明确抛 - * [TokenStatsPersistenceException]:不能继续产生更多段,绝不返回伪 durable。 - * - * P1 终审:目录 fsync([syncDir])的 OK 是返回 durable 的唯一前提。首次创建 spool - * 目录时先同步父目录(filesDir)目录项、再同步新目录本身;首次创建 active 文件时写 - * +fd.sync 后必须同步 spool 目录确认目录项。任一非 OK 都 fail-closed(返回 false, - * 内容保留、可重试,但绝不声明 durable)。 - * - * P1-1 终审:每进程首次使用 spool 前先过 durable bootstrap gate - * ([ensureDirectoryDurabilityConfirmed])——若 spool 目录已存在(无论本进程还是 - * **上一进程**创建),先 sync filesDir 确认 spool 目录项、再 sync spool 目录确认 - * active/metadata 等可见目录项;两者 OK 前绝不写新行或返回 durable。上一进程已可见 - * 但未确认的目录项在本进程重新提交,绝不把两个事件混为一次成功。 - * - * P1 终审(request/session fencing):写 spool 前先验证请求开始捕获的 - * [sessionEpoch] 仍等于当前 [restoreEpoch] 且 [acceptingEventsThisProcess] 为 true - * (恢复屏障开始时原子递增 epoch 使所有旧请求失效;恢复替换开始后本进程不再接受任何 - * 事件直至重启)。任一不满足即明确失败(返回 false,调用方抛 - * [com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException]), - * 绝不写入可能已被恢复替换的 spool。默认 [sessionEpoch] 为调用时刻捕获(直接 spool - * 写入的请求边界即调用本身);生产请求边界经 [TokenStatRequestContext.sessionEpoch] - * 在请求开始时显式捕获。 - */ - suspend fun append( - context: Context, - line: String, - eventId: String, - sessionEpoch: Long = captureRestoreEpoch(), - ): Boolean = - lifecycleMutex.withLock { - val appContext = context.applicationContext - // P1 终审:restore fencing 必须在任何目录创建/容量检查/写入之前判定——旧请求 - // 绝不能碰(可能已被恢复替换的)spool,也绝不能把事件写进新 DB。 - if (!fenceAcceptsRestore(sessionEpoch)) { - logE( - "statistics append rejected after restore barrier: eventId=$eventId, " + - "epoch=$sessionEpoch, accepting=$acceptingEventsThisProcess", - ) - return@withLock false - } - try { - val bytes = (line + "\n").toByteArray(Charsets.UTF_8) - if (bytes.size > MAX_LINE_BYTES) { - logE("statistics event exceeds durable line limit: eventId=$eventId, bytes=${bytes.size}") - return@withLock false - } - val dir = spoolDir(appContext) - // P1-1 终审:bootstrap gate——本进程首次使用前必须先确认既有 spool 目录项 - // 持久(上进程可见未确认的目录项在此重新提交);失败 fail-closed。 - if (!ensureDirectoryDurabilityConfirmed(appContext, dir)) { - logE( - "statistics spool directory durability unconfirmed; refusing append: eventId=$eventId", - ) - return@withLock false - } - if (managedFailureCapacityExceeded(appContext)) { - logE("statistics managed-failure capacity exhausted; refusing new durable event: eventId=$eventId") - throw TokenStatsPersistenceException( - "token statistics persistence capacity exhausted: managed failure set is full", - ) - } - // P1 终审:首次创建 spool 目录必须确认目录项持久才允许声称 durable。 - // 创建成功后先同步父目录(filesDir,系统预存)的目录项,再同步新目录本身; - // 任一非 OK 本次 append 明确失败(已创建的目录可保留供重试,但未声明 durable, - // 且 [directoryDurabilityConfirmedThisProcess] 保持 false,下一次 append 由 - // bootstrap gate 重新确认)。 - val dirCreated = !dir.isDirectory - if (dirCreated) { - if (!dir.mkdirs()) { - // P1-1 终审修复:创建失败立即失效 gate——已确认的目录项状态不再 - // 可靠,下一次使用必须先重新确认,绝不带着“已确认”标记继续。 - directoryDurabilityConfirmedThisProcess = false - logE("statistics spool directory cannot be created: ${dir.absolutePath}") - return@withLock false - } - val parent = dir.parentFile - if (parent == null || !requireSpoolDirSync(parent, dir)) { - logE( - "statistics spool directory creation is not durable; " + - "directory retained for retry: ${dir.absolutePath}", - ) - return@withLock false - } - directoryDurabilityConfirmedThisProcess = true - } - val active = File(dir, ACTIVE_FILE_NAME) - // P1-8:active 非空时先恢复可能的 seal 崩溃窗口重复(硬链接或 copy 回退的 - // copy+delete 窗口)。不消除同 inode 重复就追加会把已 seal 段一起改写; - // 不消除 copy 窗口重复会让同一内容被排空两次。恢复失败 fail-closed—— - // 绝不带着“可能还有重复”的状态写入。 - if (active.isFile && active.length() > 0L && !recoverSealDuplicates(dir, active)) { - logE("statistics spool seal recovery failed; refusing append: eventId=$eventId") - return@withLock false - } - // P1-1 总硬上限:数据准入上限 = 总上限 − 元数据预留。全部管理文件实际字节 - // (递归含 ack trash 子目录,含元数据 canonical 与 sidecar/tmp 残留)+ 本次行 - // 超过即明确拒绝且不发布任何新文件(seal 只是同目录改名,不新增字节;空 active - // 计入 0 字节)。持有 lifecycleMutex,与并发 seal/drain 互斥,扫描结果即一致性快照。 - // P1 终审:不尝试任何“排空换容量”回退——容量超限就是明确拒绝。 - val cap = totalSpoolMaxBytesForTest ?: TOTAL_SPOOL_MAX_BYTES - if (totalSpoolBytes(dir, cap) + bytes.size > dataAdmissionMaxBytes(cap)) { - logE("statistics spool total size cap exceeded; refusing new durable event: eventId=$eventId") - throw TokenStatsPersistenceException( - "token statistics persistence capacity exhausted: total spool size cap reached", - ) - } - if (active.isFile && active.length() > 0L && !activeEndsWithLineBreak(active)) { - // A crash mid-write leaves a partial line without a trailing newline. Appending - // here would splice the healthy event onto the broken tail and quarantine the - // combined segment, losing a durable healthy event. Seal the incomplete tail as - // evidence first (the drain quarantines the partial line), then write clean. - if (!sealActive(appContext, dir)) return@withLock false - } - if (active.isFile && active.length() + bytes.size > MAX_SEGMENT_BYTES) { - // 段超限:先封段(发布持久化协议见 [sealActive]),失败则明确持久化失败 - // 但 active 保留。 - if (!sealActive(appContext, dir)) return@withLock false - } - // P1 终审:append 前记录 active 是否已存在。首次创建时写+fd.sync 之后必须 - // 同步 spool 目录确认目录项;非 OK 则本次不返回 durable(内容保留,且 - // [directoryDurabilityConfirmedThisProcess] 复位——下一次 append 由 bootstrap - // gate 重新确认目录项后才写入新行,绝不把两事件混为一次成功)。 - val activeExisted = active.isFile - FileOutputStream(File(dir, ACTIVE_FILE_NAME), true).use { output -> - output.write(bytes) - output.fd.sync() - } - if (!activeExisted && !requireSpoolDirSync(dir)) { - logE( - "statistics spool first active creation is not durable; " + - "content retained: eventId=$eventId", - ) - return@withLock false - } - synchronized(stateLock) { - insertionWaiters[eventId] = CompletableDeferred() - } - scheduleDrain(appContext) - true - } catch (e: TokenStatsPersistenceException) { - throw e - } catch (e: Exception) { - logE("statistics durable append failed: eventId=$eventId", e) - false - } - } - - /** - * Optional visibility wait; durability already succeeded before this is called. - * - * A restore may invalidate this waiter while the caller's own coroutine is still active - * (callers run under [kotlinx.coroutines.NonCancellable]). That is internal invalidation, not - * a persistence failure and not a coroutine cancellation: it must never escape over the - * caller's primary model outcome. Only a cancellation of the caller coroutine itself is - * rethrown. - */ - suspend fun awaitRoomVisibility(eventId: String, timeoutMs: Long) { - val waiter = synchronized(stateLock) { insertionWaiters[eventId] } ?: return - try { - withTimeoutOrNull(timeoutMs) { waiter.await() } - } catch (e: CancellationException) { - // Throws only if the caller coroutine itself was cancelled; under NonCancellable this - // is a no-op, so a restore-invalidated waiter simply returns (durability was already - // fsynced and restore semantics supersede visibility). - currentCoroutineContext().ensureActive() - } finally { - synchronized(stateLock) { - if (insertionWaiters[eventId] === waiter) insertionWaiters.remove(eventId) - } - } - } - - fun replay(context: Context) = scheduleDrain(context.applicationContext) - - - /** - * Raw snapshot barrier. Export uses [drainBefore] and checkpoints inside [block]; restore uses - * [clearAfter] so old workers are invalidated before replacement and old files are verified - * gone before the restore can succeed. - * - * Isolation guarantee (P1-2): after the drain phase the barrier enters an exclusive state that - * atomically rejects any new insert registration, then waits a hard-bounded time for every - * already-registered (in-flight, inside Room) insert to finish. On timeout it throws - * [IOException] BEFORE [block] runs, so no checkpoint or file replacement can overlap a live - * old insert; the durable spool is untouched and a later process restart can retry. [block] - * itself is only reached once the registry is provably empty. - * - * Request/session fencing (P1 终审): a restore barrier (clearAfter=true) additionally - * atomically increments [restoreEpoch] at its start, invalidating every in-flight - * provider/stream request that captured the previous epoch (their [append] is explicitly - * rejected, never writing the replaced spool/Room). Right before [block] runs (replacement - * starts) the process stops accepting ALL statistics events ([acceptingEventsThisProcess] = - * false) until restart. State machine: failure BEFORE [block] (drain/quiesce/bootstrap) - * leaves accepting enabled — new requests continue; failure after [block] started keeps it - * disabled — the process must restart. - */ - suspend fun withExclusiveSnapshotAccess( - context: Context, - drainBefore: Boolean, - clearAfter: Boolean = false, - block: suspend () -> T, - ): T = withExclusiveSnapshotAccessInternal( - context = context, - drainBefore = drainBefore, - clearAfter = clearAfter, - deferredRestoreCommit = null, - block = block, - ) - - /** - * Raw restore two-phase barrier. [prepareBeforeCommit] may do fallible, non-replacement work; - * [commitReplacement] must persist the external REPLACING state. Request fencing changes only - * after that commit succeeds, and before [block] closes stores or replaces any directory. - */ - /** - * 启动时消费崩溃遗留的恢复 REPLACING 标记(审计 P1 修复):进程在恢复替换开始 - * (commitReplacement 已持久化标记)后、成功完成(标记删除)前崩溃时,重启后必须 - * 在初始 drain/replay 之前处理——否则旧 spool 事件会 replay 进可能已被替换的数据库。 - * - * fail-closed 语义:标记存在即代表"上一次恢复未确认完成"——清理旧 spool(其内容属于 - * 恢复前的旧事件,绝不应进入当前数据库)并删除标记;任一失败抛 [IOException](调用方 - * 启动 readiness 因此失败并重试,绝不带不确定状态开始 replay)。无标记时返回 false, - * 正常启动不受影响。 - */ - suspend fun consumeAbandonedRestoreIfAny(context: Context): Boolean = - lifecycleMutex.withLock { - val appContext = context.applicationContext - val marker = File(appContext.filesDir, RESTORE_REPLACING_MARKER_FILE_NAME) - if (!marker.exists()) return@withLock false - AppLogger.w( - TAG, - "abandoned restore REPLACING marker found; discarding pre-restore spool " + - "before startup replay", - ) - // 旧 spool 是恢复前事件,绝不被 replay:清理 + 目录项同步(与恢复成功路径同协议)。 - clearForRestoreLocked(appContext) - if (!marker.delete()) { - throw IOException( - "abandoned restore marker could not be removed: ${marker.absolutePath}", - ) - } - val parent = marker.parentFile - if (parent == null || !requireSpoolDirSync(parent)) { - throw IOException( - "abandoned restore marker removal not durable: ${marker.absolutePath}", - ) - } - true - } - - suspend fun withExclusiveRestoreAccess( - context: Context, - prepareBeforeCommit: suspend () -> Unit, - commitReplacement: suspend () -> Unit, - block: suspend () -> T, - ): T = withExclusiveSnapshotAccessInternal( - context = context, - drainBefore = false, - clearAfter = true, - deferredRestoreCommit = { - prepareBeforeCommit() - commitReplacement() - }, - block = block, - ) - - - - /** - * 请求开始时同步捕获当前 restore epoch(P1 终审):纯内存读取,无需 Room。请求在 - * 收尾 [append] 时按该值验证未被恢复屏障取代。 - */ - fun captureRestoreEpoch(): Long = synchronized(stateLock) { restoreEpoch } - - /** 本进程是否仍接受新的统计事件/请求(恢复替换开始后为 false,直到进程重启)。 */ - fun isAcceptingEvents(): Boolean = synchronized(stateLock) { acceptingEventsThisProcess } - - - - /** - * Evidence management is explicit: callers can export these files, then acknowledge deletion. - * 摘要与 tombstone manifest 是有界元数据(不计入完整证据硬 cap,导出时单独带上); - * 两者的 `.new`/`.bak`/`.tmp*` 崩溃安全 sidecar 也不算证据,避免误导出与重复计容。 - * 受管失败段(P1-3):manifest 中仍存在且 identity 匹配的原损坏 sealed 文件必须作为 - * managed evidence 参与容量、UI 计数/字节、导出与 ack 删除,绝不隐藏。 - * - * P1-3:非空 ack trash 目录(未完成删除事务)也作为受管证据返回(追加在文件列表之后)—— - * 有效 UNCOMMITTED 的 trash 会被维护优先自动回滚、不常驻;状态缺失/损坏或回滚长期失败 - * 的 trash 必须可被 UI 计数/导出/确认删除,绝不隐藏。字节计数需配合 [stuckAckTrashBytes] - * (目录的 [File.length] 恒为 0)。 - * - * P1-1:公开入口统一持 [lifecycleMutex]——sidecar 恢复(read 会把 `.new`/`.bak` 改名回 - * canonical)与 append/drain 的容量扫描共享同一把锁,绝不与容量投影竞态。内部调用 - * (已在锁内)必须使用 [quarantineEvidenceLocked],避免重入。 - * - * P1-6:证据枚举失败(quarantine 区或 stuck ack trash)时抛 [IOException](fail-closed, - * 见 [stuckAckTrashEvidenceLocked]/[quarantineAreaFiles])——调用方必须按失败处理, - * 绝不能当作“0 证据”诱导删除。 - */ - suspend fun quarantineEvidence(context: Context): List = - lifecycleMutex.withLock { - quarantineEvidenceLocked(context) + stuckAckTrashEvidenceLocked(context) - } - - /** - * P1-3:非空 ack trash 目录(未完成删除事务)——维护无法(或尚未)自动解决的 stuck - * 证据,等待用户在 UI 中导出/确认删除。空目录不算(stage 前的崩溃窗口,维护会删除)。 - * 只接受真实普通目录,排除符号链接。 - * - * P1-6:枚举失败即抛 [IOException](fail-closed):根目录或任一 trash 子目录的枚举 - * 失败绝不能当作“没有 trash”/“空目录”返回——否则 export 会在遗漏全部 stuck 证据时 - * 仍报告成功,UI 也会收到误导性的空列表。只有枚举**成功**且目录为空才忽略。 - */ - suspend fun stuckAckTrashEvidence(context: Context): List = - lifecycleMutex.withLock { stuckAckTrashEvidenceLocked(context) } - - /** P1-3:全部 stuck ack trash 目录的实际字节总和(递归、NOFOLLOW、只计普通文件)。 */ - suspend fun stuckAckTrashBytes(context: Context): Long = - lifecycleMutex.withLock { - stuckAckTrashEvidenceLocked(context).sumOf { trash -> - totalSpoolBytes(trash, Long.MAX_VALUE) - } - } - - - - - - /** - * Structured summary of over-cap corrupt segments (P2-1). Within [MAX_QUARANTINE_BYTES] the - * full evidence is preserved; each new segment that would exceed the cap is replaced by one - * bounded rolling summary record (count/hash/bytes/time, never content) and removed, so the - * quarantine area has a hard disk bound while healthy drains continue. - */ - data class QuarantineSummaryInfo( - val recordCount: Int, - val summaryBytes: Long, - ) - - /** - * 崩溃安全读取摘要(P1-1):经 [TokenStatMetaStore] 恢复旧/新完整值后统计, - * 任意中断后得到的都是完整旧或完整新内容,绝不截断。公开入口持 [lifecycleMutex] - * (sidecar 恢复与容量扫描互斥,P1-1),内部调用使用 [quarantineSummaryInfoLocked]。 - * 读取失败(非测试注入的异常路径)返回 null:纯展示信息,不参与容量/维护判定。 - */ - suspend fun quarantineSummaryInfo(context: Context): QuarantineSummaryInfo? = - lifecycleMutex.withLock { quarantineSummaryInfoLocked(context) } - - - - - - /** - * Copy evidence (and the bounded over-cap summary and tombstone manifest) for support/export. - * Deletion still requires a separate acknowledged call. File I/O always runs on [ioDispatcher] (P2-2). - * 摘要/manifest 先经 [TokenStatMetaStore.read] 恢复 canonical(P2-2:崩溃窗口里 - * canonical 可能缺失、内容只在 `.new`/`.bak` sidecar),导出内容绝不遗漏元数据; - * sidecar 本身不直接导出。受管失败段(P1-3)以原文件名导出并附 manifest 供身份核对。 - */ - suspend fun exportQuarantineEvidence(context: Context, destinationDir: File): List = - lifecycleMutex.withLock { - withContext(ioDispatcher) { - // P2:导出目标必须是专用空目录——拒绝写入非空目录(上一次导出的残留 - // 绝不混入/冒充本次结果,也绝不删除目录中用户自己的文件)。UI 每次导出 - // 创建唯一子目录,并在失败/取消时只清理该唯一目录。 - if (destinationDir.isFile) { - throw IOException("quarantine export destination is not a directory: ${destinationDir.absolutePath}") - } - if (destinationDir.isDirectory) { - // P1-6 fail-closed:无法枚举目标目录内容时绝不能当作“空目录”继续导出—— - // 未知内容可能与本次结果混入,也无法验证“专用空目录”前提。 - val existing = destinationDir.listFiles() - if (existing == null) { - throw IOException( - "cannot enumerate quarantine export destination: ${destinationDir.absolutePath}", - ) - } - if (existing.isNotEmpty()) { - throw IOException("quarantine export destination is not empty: ${destinationDir.absolutePath}") - } - } - if (!destinationDir.exists() && !destinationDir.mkdirs()) { - throw IOException("cannot create quarantine export directory: ${destinationDir.absolutePath}") - } - val spool = spoolDir(context.applicationContext) - val summaryContent = try { - readMetadata(summaryStore(File(spool, QUARANTINE_SUMMARY_NAME)), File(spool, QUARANTINE_SUMMARY_NAME)) - } catch (e: Exception) { - logE("statistics quarantine summary read failed", e) - null - } - // P1-2 fail-closed:manifest 不可读则整个导出失败——受管证据必须以可追溯身份 - // 随导出提供,绝不静默导出缺失 manifest 的证据集。 - val manifestContent = - readMetadata(summaryStore(File(spool, TOMBSTONE_MANIFEST_NAME)), File(spool, TOMBSTONE_MANIFEST_NAME)) - val exported = mutableListOf() - quarantineEvidenceLocked(context).forEach { evidence -> - val target = File(destinationDir, evidence.name) - evidence.inputStream().use { input -> - FileOutputStream(target, false).use { output -> - input.copyTo(output) - output.fd.sync() - } - } - exported += target - } - summaryContent?.let { content -> - val target = File(destinationDir, QUARANTINE_SUMMARY_NAME) - FileOutputStream(target, false).use { output -> - output.write(content.toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - exported += target - } - manifestContent?.let { content -> - val target = File(destinationDir, TOMBSTONE_MANIFEST_NAME) - FileOutputStream(target, false).use { output -> - output.write(content.toByteArray(Charsets.UTF_8)) - output.fd.sync() - } - exported += target - } - // P1-3:未完成删除事务(非空 ack trash)复制到唯一子目录,包含状态文件与 - // sidecar。文件名做单层路径校验(防穿越),只接受普通文件、拒绝符号链接; - // 任一文件不合法/复制失败 → 整个导出失败(fail-closed,绝不部分导出冒充完整)。 - stuckAckTrashEvidenceLocked(context).forEach { trash -> - val targetDir = File(destinationDir, trash.name) - if (!targetDir.mkdir()) { - throw IOException( - "cannot create ack trash export directory: ${trash.name}", - ) - } - // P1-5:trash 枚举失败 → 整个导出明确失败(绝不部分导出冒充完整)。 - // P1-6:枚举走统一 seam([listDir]),与 stuck 证据筛选一致。 - val trashFiles = listDir(trash) - ?: throw IOException( - "cannot enumerate ack trash during export: ${trash.name}", - ) - trashFiles.forEach { file -> - if (Files.isSymbolicLink(file.toPath()) || - !Files.isRegularFile( - file.toPath(), - java.nio.file.LinkOption.NOFOLLOW_LINKS, - ) || - !isSafeTrashName(trash, file.name) - ) { - throw IOException( - "unsafe file inside ack trash during export: ${file.name}", - ) - } - val target = File(targetDir, file.name) - file.inputStream().use { input -> - FileOutputStream(target, false).use { output -> - input.copyTo(output) - output.fd.sync() - } - } - } - exported += targetDir - } - exported - } - } - - /** - * File I/O always runs on [ioDispatcher] (P2-2); the bounded rolling summary is kept unless - * the caller explicitly acknowledges it with [deleteSummary]. 受管失败段(P1-3): - * 按 identity 删除原损坏文件并移除对应 manifest 记录;文件已消失只移除记录;身份不匹配 - * 视为陈旧记录绝不删新身份文件。删除失败保留记录并抛错(UI 反馈失败,不声称全部成功)。 - * - * 严格两阶段(reviewer P1):Phase 1 在持锁下只读预检——先恢复/读取 manifest 并枚举本次 - * 全部目标(quarantine area + managed original),对所有 managed identity 现场 fresh 验证; - * 任一读/UNREADABLE/元数据错误都使整个操作失败:零删除、零 manifest 写。MISMATCH 只视为 - * 陈旧记录并计划移除 manifest 条目,绝不删除同名新文件。所有目标名先做路径归属预检 - * (防目录穿越)。Phase 2 预检全部通过后才动手:先把全部待删文件原子 rename 进本轮唯一 - * trash 目录(同 filesystem 的可回滚 prepare),任一 rename 失败则把已 rename 文件移回 - * 原位、manifest 不改并报错;全部 rename 成功后原子写入 trash 状态文件(P1-2: - * UNCOMMITTED + mapping:原名 → trash 名 + bytes + sha256,只含已 stage 文件;P1-1: - * 写入预算先投影,超限有界失败并回滚),再原子重写 manifest 移除对应条目,然后原子 - * 改状态为 COMMITTED,最后删除 trash(删除失败只记录——证据逻辑已 ack,维护入口按状态 - * 补删/回滚:只有显式 COMMITTED 才补删,UNCOMMITTED 一律回滚,绝不误删未提交的证据)。 - * trash 占用计入递归总容量(P1-1),占用绝不隐藏。 - * - * P1-3:`names` 也可包含非空 ack trash 目录名(StuckAckEvidence)——用户显式确认后删除 - * 整个 trash(无需 mapping 完整);仅接受 spool 根内匹配 [ACK_TRASH_PREFIX] 的真实普通 - * 目录(NOFOLLOW、拒绝符号链接)。全部名字都是 trash 目录时跳过 manifest 读取(其完整性 - * 与 trash 删除无关)。trash 删除放在文件事务完成后,文件侧失败时 trash 保持原样。 - */ - suspend fun acknowledgeAndDeleteQuarantine( - context: Context, - names: Set, - deleteSummary: Boolean = false, - ) = - lifecycleMutex.withLock { - withContext(ioDispatcher) { - val dir = spoolDir(context.applicationContext) - // ── Phase 1:只读预检(任何失败 → 零删除、零 manifest 写)──────────────── - names.forEach { requireSafeEvidenceName(dir, it) } - // P1-3:stuck ack trash 目录(未完成删除事务)的显式确认删除。名字必须是 - // spool 根内匹配 ACK_TRASH_PREFIX 的真实普通目录(NOFOLLOW、拒绝符号链接), - // 全部名字都是 trash 目录时无需依赖 manifest(用户已显式授权删除)。 - val trashDirs = names - .filter { it.startsWith(ACK_TRASH_PREFIX) && File(dir, it).isDirectory } - .map { it to File(dir, it) } - trashDirs.forEach { (_, trash) -> requireAckTrashDirForDelete(dir, trash) } - if (trashDirs.size == names.size) { - trashDirs.forEach { (_, trash) -> - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - throw IOException( - "cannot delete acknowledged stuck trash: ${trash.name}", - ) - } - // P1-3 终审:删除是目录项变更——sync 非 OK 绝不报告成功(保留状态、 - // 失败;重试幂等:trash 已可见删除则下次无操作,未删则由状态机/直接 - // 删除再处置)。P1-1:非 OK 同时失效 bootstrap gate。 - if (!requireSpoolDirSync(dir)) { - throw IOException( - "acknowledged stuck trash deletion not durable: ${trash.name}", - ) - } - } - if (deleteSummary) deleteQuarantineSummaryLocked(context) - return@withContext - } - val manifestFile = File(dir, TOMBSTONE_MANIFEST_NAME) - // P1-3:不设 canonical isFile 前置——仅 `.new`/`.bak` sidecar 存在时 - // read() 会先恢复 canonical 再返回,ack 才能按身份删除受管段。 - // P1-2 fail-closed:manifest 不可读则 ack 明确失败(不删任何文件)。 - val rawLines = readMetadata(summaryStore(manifestFile), manifestFile) - ?.lineSequence()?.filter { it.isNotBlank() }?.toList() - ?: emptyList() - val entries = rawLines.map { rawLine -> - parseTombstoneLine(rawLine) - ?: throw IOException("invalid tombstone manifest entry; refusing ack") - } - entries.forEach { requireSafeEvidenceName(dir, it.file) } - val quarantineFiles = quarantineAreaFiles(dir).filter { it.name in names } - quarantineFiles.forEach { requireManageableEvidenceFile(dir, it) } - val removeRawLines = mutableListOf() - val managedFiles = mutableListOf() - for ((index, rawLine) in rawLines.withIndex()) { - val entry = entries[index] - if (entry.file !in names) continue - val file = File(dir, entry.file) - when { - !file.exists() -> { - // 物理消失:只计划移除陈旧记录 - removeRawLines += rawLine - } - else -> when (tombstoneIdentityCheck(entry, file)) { - // P1-2:身份不可校验(UNREADABLE)时 ack 绝不能成功——既不能删 - // 也不能当陈旧记录移除,保留 manifest,让维护/用户稍后重试。 - IdentityCheck.UNREADABLE -> { - throw IOException( - "cannot verify identity of acknowledged managed evidence; " + - "refusing ack: ${entry.file}", - ) - } - IdentityCheck.MISMATCH -> { - // 陈旧记录:计划移除 manifest 条目,但绝不删除同名新文件 - removeRawLines += rawLine - } - IdentityCheck.MATCH -> { - requireManageableEvidenceFile(dir, file) - removeRawLines += rawLine - managedFiles += file - } - } - } - } - // 同一文件同时被 quarantine area 与 managed 枚举命中时只处理一次 - val allFiles = (quarantineFiles + managedFiles).distinct() - if (allFiles.isEmpty() && removeRawLines.isEmpty() && trashDirs.isEmpty()) { - return@withContext - } - - // ── Phase 2:预检全通过后的事务化删除────────────────────────────────── - // 文件删除中途失败无法事务化,因此先全部 rename 进本轮唯一 trash 目录(同卷 - // 原子、可回滚 prepare);全部成功后才重写 manifest,最后删除 trash。 - if (allFiles.isNotEmpty()) { - val trashDir = - File(dir, "$ACK_TRASH_PREFIX${UUID.randomUUID().toString().replace("-", "")}") - if (!trashDir.mkdir()) { - throw IOException( - "cannot create acknowledged-deletion trash directory: ${trashDir.name}", - ) - } - // P1-3 终审:trash 目录创建是目录项变更——sync 非 OK 明确失败(尚未 - // stage 任何证据,空 trash 由维护入口按“无状态空目录”清理,无证据损失)。 - if (!requireSpoolDirSync(dir)) { - throw IOException( - "ack trash directory creation not durable: ${trashDir.name}", - ) - } - val staged = mutableListOf>() - try { - for (file in allFiles) { - val target = File(trashDir, file.name) - if (target.exists() || !atomicMoveForAck(file, target)) { - throw IOException( - "cannot stage acknowledged evidence for deletion: ${file.name}", - ) - } - staged += file to target - // P1-3 终审:证据移动跨 spool 根与 trash 两个目录——两者目录项 - // 都必须确认持久;非 OK 走回滚/状态补写协议(见 catch),绝不 - // 带着未确认状态继续 stage 或声称成功。P1-1:非 OK 同时失效 gate。 - if (!requireSpoolDirSync(dir, trashDir)) { - throw IOException( - "ack staging not durable: ${file.name}", - ) - } - } - } catch (e: Exception) { - val rollback = rollbackStagedRenames(staged, trashDir) - if (rollback.syncFailed) { - logE( - "statistics ack rollback directory entries unconfirmed; " + - "keeping UNCOMMITTED state for maintenance: ${trashDir.name}", - ) - } - // P1-2:回滚失败(trash 仍持有已 stage 证据)时尽力补写状态 - // (UNCOMMITTED + mapping 已 stage 文件),使维护入口能按身份回滚; - // 回滚成功时 trash 已删除,无需补写。P2:回滚目录项未确认持久 - // (syncFailed)同样保留 trash 并补写状态,上层失败绝不静默。 - if (trashDir.exists()) { - writeUncommittedTrashState(context, trashDir, staged) - } - throw e - } - // P1-2:stage 全部成功后才写状态文件(UNCOMMITTED + mapping,只含已 stage - // 文件:原名 → trash 名 + 稳定身份 bytes+sha256)——回滚只需处理真正进过 - // trash 的文件。P1-1:写入预算先投影(4 槽位最坏副本),超限有界失败并 - // 回滚已 stage 文件,不写任何正式文件。 - if (!writeUncommittedTrashState(context, trashDir, staged)) { - val rollback = rollbackStagedRenames(staged, trashDir) - // P2:回滚目录项未确认持久(syncFailed)时 trash 保留证据——尽力再 - // 补写一次 UNCOMMITTED 状态(预算仍拒绝时仅记录,trash 由维护/UI 作为 - // StuckAckEvidence 管理),上层失败绝不静默。 - if (trashDir.exists()) { - writeUncommittedTrashState(context, trashDir, staged) - } - throw IOException( - "ack trash state publish refused: metadata budget over the total cap", - ) - } - if (removeRawLines.isNotEmpty()) { - val remaining = rawLines.filterNot { it in removeRawLines } - val newContent = - remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" - try { - rewriteAckManifestLocked(context, manifestFile, newContent) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - rollbackStagedRenames(staged, trashDir) - throw IOException( - "tombstone manifest rewrite failed after acknowledgment: ${e.message}", - ) - } - } - // 主 manifest 已发布 → 原子改 COMMITTED。翻转只是同目录内替换小内容(比 - // UNCOMMITTED 状态文件更小),最坏瞬态 ≤ 当前总量(已在预算内),无需另行 - // 投影。P1-3 终审:状态翻转经严格目录同步 store——write 只有目录项确认 - // 持久才成功;失败绝不报告成功(抛 IOException),trash 保持 UNCOMMITTED - // + mapping 由维护按状态机回滚(证据回到原路径后被扫描器重新隔离,ack - // 视失败但不丢证据)。 - try { - summaryStore(File(trashDir, ACK_TRASH_STATE_FILE_NAME)) - .write(ACK_STATE_COMMITTED + "\n") - } catch (e: Exception) { - logE( - "statistics ack trash commit flip failed; retaining trash for rollback", - e, - ) - throw IOException("ack trash commit failed: ${e.message}") - } - // 证据逻辑已 ack:trash 删除失败只记录(COMMITTED 状态保留,下次维护/ack - // 有界补删);P1-3 终审:删除**成功**后目录项必须确认持久,非 OK 绝不报告 - // 成功(失败;重试幂等——trash 已可见删除则下次 ack 无操作)。 - if (!(spoolDeleteForTest?.invoke(trashDir) ?: deleteAckTrashDirNoFollow(trashDir))) { - logE( - "statistics ack trash cleanup deferred; acknowledged evidence is already " + - "removed from the manifest: ${trashDir.name}", - ) - } else if (!requireSpoolDirSync(dir)) { - throw IOException( - "ack trash deletion not durable: ${trashDir.name}", - ) - } - } else if (removeRawLines.isNotEmpty()) { - // 只有陈旧 manifest 记录要移除:没有任何文件需要 stage/删除,直接重写 - // manifest(P1-1 预算投影 + 注入检查),失败抛错;无 trash 参与。 - val remaining = rawLines.filterNot { it in removeRawLines } - val newContent = - remaining.joinToString("\n") + if (remaining.isEmpty()) "" else "\n" - try { - rewriteAckManifestLocked(context, manifestFile, newContent) - } catch (e: Exception) { - logE("statistics quarantine tombstone manifest rewrite failed", e) - throw IOException( - "tombstone manifest rewrite failed after acknowledgment: ${e.message}", - ) - } - } - // P1-3:stuck trash 的确认删除放在文件事务完成之后——文件侧失败时 trash 保持 - // 原样(一致失败态,用户可重试);删除使用 NOFOLLOW 遍历,绝不跟随符号链接。 - // P1-3 终审:删除成功后目录项必须确认持久,非 OK 失败(重试幂等)。P1-1: - // 非 OK 同时失效 gate。 - for ((_, trash) in trashDirs) { - if (!(spoolDeleteForTest?.invoke(trash) ?: deleteAckTrashDirNoFollow(trash))) { - throw IOException("cannot delete acknowledged stuck trash: ${trash.name}") - } - if (!requireSpoolDirSync(dir)) { - throw IOException( - "acknowledged stuck trash deletion not durable: ${trash.name}", - ) - } - } - if (deleteSummary) deleteQuarantineSummaryLocked(context) - } - } - - - - - /** P2 终审:ack 回滚的结构化结果。success=false = 有文件未能移回原位(trash 保留为可 - * 重试记录,由维护按 mapping 处置);syncFailed=true = 存在目录项未确认持久的变更 - * (上层必须失败并保留 UNCOMMITTED/stuck 状态,绝不静默推进)。 */ - - - - - - /** ack trash mapping 条目(P1-2):spool 根原名 → trash 内名 + 稳定身份(bytes+sha256)。 */ - - - - - - - - - - - - - /** - * 可等待的初始 drain(P1 关键链路,启动 readiness 使用):请求一轮 drain(已有 - * worker 在跑则请求被保留并由同一 worker 接管),并挂起直到该轮结束。返回 true = - * 本轮成功(排空到轮内最后检查点,pre-replay 数据已入 Room);false = 本轮失败或 - * 超时——**不缓存**:后续调用重新登记并触发新轮(drain 自身另有退避重试)。 - * 并发调用 join 同一轮;不持 [stateLock] 挂起(等待者由 worker 轮末决策点完成)。 - */ - suspend fun awaitInitialDrain(context: Context, timeoutMs: Long): Boolean { - val appContext = context.applicationContext - val waiter = synchronized(stateLock) { - CompletableDeferred().also { initialDrainWaiters += it } - } - scheduleDrain(appContext) - return try { - withTimeoutOrNull(timeoutMs) { waiter.await() } ?: false - } finally { - synchronized(stateLock) { - initialDrainWaiters.removeAll { it === waiter } - } - } - } - - - - - - - - - /** seal 原子发布结果:成功 / 目标已存在(调用方换下一编号)/ 其他失败(终止本轮)。 */ - - - - - - - - - - /** - * 目录同步结果(P1 终审):OK 已持久;FAILED 真实失败;UNSUPPORTED 平台明确不支持目录 - * fsync——与 FAILED 一样 fail-closed(**绝不**当作成功继续删除唯一 fsynced active 或 - * 声称 PUBLISHED)。不存在“原地排空”平台模式:生产平台(Android/Linux)支持目录 - * fd fsync,Windows 仅 JVM 测试环境。 - */ - internal enum class DirSyncResult { OK, FAILED, UNSUPPORTED } - - /** - * 目录 fsync(P1 终审,调用方持 lifecycleMutex)。Android/Linux:`FileChannel.open(dir, - * READ)` + `force(true)` 即 fsync(2) 目录 fd,持久化目录项(新建/硬链接/删除)。 - * Windows:JDK 无法打开目录句柄(CreateFile 拒绝目录,实测抛 [AccessDeniedException]) - * ——平台明确不支持,返回 [DirSyncResult.UNSUPPORTED]。 - * - * 调用方契约(P1 终审):只有 [DirSyncResult.OK] 才能继续发布/删除;FAILED 与 - * UNSUPPORTED 一律 fail-closed——目录项未确认持久时绝不删除唯一 fsynced active、绝不 - * 声称 durable/PUBLISHED。生产路径不缓存平台能力、不进入任何特殊模式:每次目录项 - * 变更(新建/rename/link/delete)都调用本方法确认。 - */ - internal fun syncDir(dir: File): DirSyncResult { - val seam = dirSyncForTest - if (seam != null) return seam(dir) ?: realSyncDir(dir) - return realSyncDir(dir) - } - - internal fun realSyncDir(dir: File): DirSyncResult { - return SpoolFileSystem.syncDirectory(dir, ::logE) - } - - /** - * P1-1 终审修复:spool 目录项持久确认的统一入口(调用方持 lifecycleMutex)。任一目录 - * sync 非 OK 立即把 bootstrap gate 标记 [directoryDurabilityConfirmedThisProcess] 置 false - * ——此后任何声称 durable 前都必须重新确认目录项,绝不带着“已确认”内存标记继续。 - * 所有 spool 目录项变更(新建/rename/delete,含 ack 跨 spool 根与 trash 两个目录)后的 - * 目录 sync 都必须经本入口确认。 - */ - internal fun requireSpoolDirSync(vararg dirs: File): Boolean { - val ok = dirs.all { syncDir(it) == DirSyncResult.OK } - if (!ok) directoryDurabilityConfirmedThisProcess = false - return ok - } - - /** - * 文件 fsync(P1 终审):`FileChannel.force(true)` 持久化数据与元数据;失败返回 false - * (调用方保留 active、处置目标、返回 FAILED,绝不声称 PUBLISHED)。 - */ - internal fun syncFile(file: File): Boolean { - val seam = fileSyncForTest - if (seam != null) return seam(file) ?: realSyncFile(file) - return realSyncFile(file) - } - - internal fun realSyncFile(file: File): Boolean = SpoolFileSystem.syncFile(file, ::logE) - - - /** 逐字节比较(长度先短路;读失败返回 null,调用方按 fail-closed 处理)。 */ - internal fun contentsEqual(a: File, b: File): Boolean? { - return SpoolFileSystem.contentsEqual(a, b, ::logE) - } - - - - internal fun File.sealIndex(): Long = - name.removePrefix(SEALED_PREFIX).removeSuffix(SEALED_SUFFIX).toLongOrNull() ?: Long.MAX_VALUE - - - internal fun renameForTest(from: File, to: File): Boolean = - segmentRenameForTest?.invoke(from, to) ?: from.renameTo(to) - - - - - - - - /** 段身份校验结果(P1-2):读取失败 = UNREADABLE,绝不误判为陈旧而删/隔离/清理。 */ - - /** - * P1-2:稳定身份校验——文件名相同且字节数相同且原始字节 SHA-256 相同才是同一段 - * (MATCH)。bytes/sha256 缺失的旧条目(无身份)永不匹配 → MISMATCH(陈旧记录被清理)。 - * 原始字节读取失败 → UNREADABLE(保留 manifest 条目,调用方跳过或失败,不做破坏性决策)。 - * P1-1:SHA 永远现场从原始字节计算,绝不复用 length+mtime 缓存——同名同长同 mtime - * 的替换内容必须被识别为不同身份(陈旧记录被清理,健康段绝不删/跳/隔离)。 - */ - - - - - - /** - * 未提交 trash 回滚结果(P1-2 终审):allResolved=false 表示有文件无法恢复(trash 保留 - * 为可重试记录);syncFailed=true 表示存在目录项未确认持久的变更(本轮必须退避重试)。 - */ - - - - /** P1-4:未提交 ack trash 扫描结果:已知身份 + 是否存在无法完整严格解析/读取的 trash。 */ - - - internal fun sha256Hex(bytes: ByteArray): String { - val digest = MessageDigest.getInstance("SHA-256").digest(bytes) - return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } - } - - - - internal fun spoolDir(context: Context) = SpoolFileSystem.spoolDir(context, SPOOL_DIR_NAME) - - /** - * 安全关键路径的目录枚举统一入口(调用方持 lifecycleMutex):注入缝为 null 时与 - * [File.listFiles] 完全一致(不影响普通目录枚举);返回 null 表示枚举失败,调用方必须 - * fail-closed(保留/视为 unknown/中止),绝不把失败当作空目录、空队列或编号 1。 - * 覆盖 sealed 队列/待处理判定、seal 编号选择与 ack/trash 安全路径。 - * - * 注意:不能用 `seam?.invoke(dir) ?: dir.listFiles()`——注入缝**返回 null** 表示“枚举 - * 失败”,必须原样传递,绝不能回退到真实枚举(否则 fail-closed 注入失效,测试无法覆盖 - * 该失败分支)。 - */ - internal fun listDir(dir: File): Array? { - return SpoolFileSystem.listDirectory(dir, directoryListingForTest) - } - - /** - * 全部 spool 管理文件的实际字节总和(P1-1 修复:递归):覆盖 spool 根下所有子目录 - * (ack trash 等),只计 regular file,绝不跟随符号链接(NOFOLLOW_LINKS:链接按链接 - * 本身处理,符号链接目录不进入遍历)。总和超过 [cap] 或 Long 溢出时饱和返回 cap+1—— - * 调用方投影必拒绝,无需精确值;目录不存在返回 0;遍历失败按超限处理(fail-closed, - * 绝不因扫描失败而低估容量)。文件数受总 cap 约束有界,无需维护缓存。 - */ - internal fun totalSpoolBytes(dir: File, cap: Long): Long { - return SpoolFileSystem.totalBytes(dir, cap) - } - - /** - * 数据准入上限(P1-1)= 总上限 − 元数据预留。测试注入更小的总上限时预留同步收缩 - * (至少为数据保留一条完整行 [MAX_LINE_BYTES] 的空间,避免准入区间为负),生产值 - * 恒等于 [METADATA_RESERVE_BYTES]。 - */ - internal fun dataAdmissionMaxBytes(cap: Long): Long { - return SpoolWriter.dataAdmissionMaxBytes(cap, METADATA_RESERVE_BYTES, MAX_LINE_BYTES) - } - - /** - * 元数据发布预算(P1-1,调用方持 lifecycleMutex):发布 contentBytes 元数据时,最坏 - * 瞬时增量 = [METADATA_COPY_COUNT] × contentBytes(canonical/.new/.bak/tmp 四个槽位可能 - * 短暂同时各持一份完整副本)。投影“实际 [totalSpoolBytes](递归含 ack trash)+ 该增量” - * 仍 ≤ 总上限才允许发布,否则调用方有界失败且不写任何正式文件(sidecar 也不写)。spool - * 内所有元数据读写都持 lifecycleMutex,任意时刻至多一个 TokenStatMetaStore 写进行中 - * (Atomic tmp 唯一文件并发数 = 1),因此按单写者投影即可证明全部实际字节恒 ≤ 总上限。 - */ - internal fun metadataWriteBudgetExceeded(context: Context, contentBytes: Int): Boolean { - val cap = totalSpoolMaxBytesForTest ?: TOTAL_SPOOL_MAX_BYTES - return SpoolWriter.metadataWriteBudgetExceeded( - currentBytes = totalSpoolBytes(spoolDir(context), cap), - contentBytes = contentBytes, - metadataCopyCount = METADATA_COPY_COUNT, - cap = cap - ) - } - - internal fun shutdownWriterForTest() = synchronized(stateLock) { - drainScheduled = false - writerExecutor.shutdownNow() - } - - /** Discard wedged worker executors (e.g. an interrupt-ignoring insert) so later tests start clean. - * Simulates a process restart: the active-insert registry, visibility waiters, the exclusive - * flag, the event-acceptance fence and the bootstrap durability marker are all reset (a wedged - * task can never unregister itself; directory durability must be re-confirmed on the next use). */ - internal fun resetExecutorsForTest() = synchronized(stateLock) { - insertExecutor.shutdownNow() - databaseExecutor.shutdownNow() - pendingDaoTask = null - insertExecutor = newInsertExecutor() - databaseExecutor = newDatabaseExecutor() - activeInserts.clear() - statsDbAccessTokens.clear() - insertionWaiters.values.forEach { it.cancel() } - insertionWaiters.clear() - exclusiveBarrierActive = false - // P1 终审:模拟进程重启——restore 后本进程拒绝事件的状态随重启清除,重新接受 - acceptingEventsThisProcess = true - // P1-1 终审:模拟进程重启——bootstrap gate 标记清零,下一次使用重新确认目录项 - directoryDurabilityConfirmedThisProcess = false - } - - internal fun clearPendingStateForTest() = synchronized(stateLock) { - sessionGeneration += 1L - drainScheduled = false - drainRequested = false - // 未完成的初始 drain 等待者按失败完成(进程重启语义;测试内不应依赖旧轮) - initialDrainWaiters.forEach { if (it.isActive) it.complete(false) } - initialDrainWaiters.clear() - retryDelayMs = RETRY_BACKOFF_BASE_MS - // P1 终审:逐测试复位 restore fencing 状态(进程内标记绝不跨测试泄漏) - restoreEpoch = 0L - acceptingEventsThisProcess = true - // P1 终审:bootstrap gate 标记逐测试复位(进程内标记绝不跨测试泄漏) - directoryDurabilityConfirmedThisProcess = false - resetExecutorsForTest() - } - - internal fun emergencyQueueSizeForTest(): Int = 0 - internal fun pendingLatchCountForTest(): Int = synchronized(stateLock) { insertionWaiters.size } - internal fun activeInsertCountForTest(): Int = synchronized(stateLock) { activeInserts.size } - internal fun drainRequestPendingForTest(): Boolean = synchronized(stateLock) { drainRequested } - internal fun drainScheduledForTest(): Boolean = synchronized(stateLock) { drainScheduled } - internal fun initialDrainWaiterCountForTest(): Int = synchronized(stateLock) { initialDrainWaiters.size } - - internal fun logE(message: String, error: Throwable? = null) { - try { - if (error == null) AppLogger.e(TAG, message) else AppLogger.e(TAG, message, error) - } catch (_: Throwable) { - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt index ebae4764f..a64e847b6 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt @@ -33,25 +33,5 @@ enum class TokenStatStatus { } } -/** 事件价格快照的来源层级,用于解释单价的取值。 */ -enum class PricingSource { - /** 内置模型默认价(可能为 0 的“未知”缺省,见 [TokenPriceResolver])。 */ - DEFAULT, - - /** provider/model 覆盖。 */ - PROVIDER_MODEL_OVERRIDE, - - /** 特定 API 配置覆盖。 */ - CONFIG_OVERRIDE, - - /** 旧系统(DataStore)中用户保存的 provider/model 价格(阶段 1 桥接)。 */ - LEGACY_OVERRIDE, - - /** 无法解析出定价(未知,对应成本为 null)。 */ - UNKNOWN; - - companion object { - fun fromName(name: String?): PricingSource = - entries.firstOrNull { it.name == name } ?: UNKNOWN - } -} +/** Price-resolution provenance kept for calculation tests and diagnostics. */ +enum class PricingSource { BUILT_IN, USER, UNKNOWN } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt deleted file mode 100644 index d3139b8b2..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsAggregator.kt +++ /dev/null @@ -1,691 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import java.math.BigDecimal -import java.math.MathContext -import java.time.ZoneId -import java.util.EnumMap - -/** - * 统计聚合器(阶段 3 核心):纯函数、对事件列表单遍聚合。 - * - * - **unknown 与 0 严格区分**:token/成本字段为 null = 未知(provider 未上报), - * 0 = provider 确认该分量为 0。聚合结果显式携带 known/unknown 计数, - * 部分未知可表达([TokenStatsCostSummary.isFullyKnown]),绝不静默当作 0。 - * - **费用**:HISTORICAL 用事件价格快照原币成本([TokenStatEventEntity.costInPricingCurrency]), - * REVALUED 用当前分层价格 × 事件用量重算([TokenCostCalculator] 同一计费语义, - * 缓存写入/推理包含边界一致,不重复收费);原币按币种分别累计,再按当前手动 - * 汇率换算到目标币种(BigDecimal 累加防漂移,Double 只出现在边界)。 - * - **展示模型**:identity.displayModelId 是分组的单一事实来源(默认规范化同名 + - * 用户手动别名都落在这里);合并模型合计 == 各身份合计之和 == 范围总计。 - * - **性能**:时长由时间戳安全计算(非负校验 + 结束≥开始),无效样本记为 - * unknown 排除平均;token 合计用饱和加法,绝不因 Long 溢出出现负数。 - * - 传入的事件列表由调用方([TokenStatsQueryService])用同事务快照取回,本层不 - * 执行任何 IO;生命周期总览支持 [TokenStatsEventTotalsAccumulator] 分页增量累加 - * (P2-1),与单列表路径共用同一数学。 - */ -object TokenStatsAggregator { - - private val MC = MathContext.DECIMAL64 - - // ==== 生命周期累计总览(事件 + baseline,独立于时间/筛选) ==== - - fun lifetime( - events: List, - baselines: List, - identitiesById: Map, - overrides: List, - legacyPrices: Map, - params: TokenStatsQueryParams, - ): TokenStatsLifetimeOverview { - val accumulator = TokenStatsEventTotalsAccumulator(legacyPrices, params) - accumulator.addPage(events, identitiesById, overrides) - return lifetimeFrom(accumulator.totals(), baselines, params) - } - - /** - * 生命周期总览的增量路径(P2-1):事件合计由 [TokenStatsEventTotalsAccumulator] - * 分页累加(DAO 同事务分页喂入,不整表实体化),baseline 在事务外纯聚合。 - * 与单列表路径 [lifetime] 共用同一数学,结果必须逐字段一致。 - */ - fun lifetimeFrom( - eventsTotals: TokenStatsTotals, - baselines: List, - params: TokenStatsQueryParams, - ): TokenStatsLifetimeOverview { - val baselineTotals = baselineTotalsOf(baselines, params) - return TokenStatsLifetimeOverview( - eventTotals = eventsTotals, - baselineTotals = baselineTotals, - combinedRequests = - TokenCostCalculator.saturatedAdd(eventsTotals.requests, baselineTotals.requests), - ) - } - - /** - * 生命周期事件合计的增量累加器(P2-1):DAO 分页回调逐页喂入 [addPage], - * 事务结束后 [totals] 产出与单列表聚合完全一致的 [TokenStatsTotals]。 - * 内部沿用 [eventCost]/[buildCostSummary] 的同一计费与换算语义。 - */ - internal class TokenStatsEventTotalsAccumulator( - private val legacyPrices: Map, - private val params: TokenStatsQueryParams, - ) { - private var pricing: PricingContext? = null - private var identitiesById: Map = emptyMap() - private var requests = 0L - private val uncached = TokenComponentAccumulator() - private val cached = TokenComponentAccumulator() - private val cacheWrite = TokenComponentAccumulator() - private val totalInput = TokenComponentAccumulator() - private val output = TokenComponentAccumulator() - private val reasoning = TokenComponentAccumulator() - private val totalTokens = TokenComponentAccumulator() - private val originalCosts = EnumMap(PricingCurrency::class.java) - private var costUnknownCount = 0L - - fun addPage( - events: List, - identities: Map, - overrides: List, - ) { - if (pricing == null) pricing = PricingContext(overrides, legacyPrices, params) - identitiesById = identities - for (event in events) { - requests = TokenCostCalculator.saturatedAdd(requests, 1L) - uncached.accept(event.uncachedInputTokens) - cached.accept(event.cachedInputTokens) - cacheWrite.accept(event.cacheWriteTokens) - totalInput.accept(event.totalInputTokens) - output.accept(event.outputTokens) - reasoning.accept(independentlyBilledReasoning(event)) - totalTokens.accept(canonicalTotalTokens(event)) - val (amount, currency) = - eventCost(event, identities[event.statIdentityId], pricing!!, params) - if (amount == null || !amount.isFinite()) { - costUnknownCount += 1 - } else { - originalCosts.merge(currency, BigDecimal(amount)) { left, right -> left.add(right) } - } - } - } - - fun totals(): TokenStatsTotals = - TokenStatsTotals( - requests = requests, - uncachedInput = uncached.aggregate(requests), - cachedInput = cached.aggregate(requests), - cacheWrite = cacheWrite.aggregate(requests), - totalInput = totalInput.aggregate(requests), - output = output.aggregate(requests), - reasoning = reasoning.aggregate(requests), - totalTokens = totalTokens.aggregate(requests), - cost = - buildCostSummary( - original = originalCosts, - unknownCount = costUnknownCount, - totalCount = requests, - params = params, - ), - ) - - private class TokenComponentAccumulator { - private var knownSum = 0L - private var knownCount = 0L - private var unknownCount = 0L - - fun accept(value: Long?) { - if (value == null) { - unknownCount += 1 - } else { - knownCount += 1 - knownSum = TokenCostCalculator.saturatedAdd(knownSum, value) - } - } - - fun aggregate(totalEventCount: Long): TokenStatsTokenAggregate = - TokenStatsTokenAggregate( - knownSum = knownSum, - knownEventCount = knownCount, - unknownEventCount = unknownCount, - totalEventCount = totalEventCount, - ) - } - } - - // ==== 时间范围数据(汇总 + 趋势桶 + 明细) ==== - - fun rangeData( - events: List, - identitiesById: Map, - displayModelsById: Map, - overrides: List, - legacyPrices: Map, - range: TokenStatsTimeRange, - granularity: TokenStatsGranularity, - zone: ZoneId, - params: TokenStatsQueryParams, - ): TokenStatsRangeData { - // 分类/状态筛选在聚合入口统一应用:汇总、桶、明细反映同一筛选结果 - val filtered = filterByCategory(events, params).filterByStatus(params) - val pricing = pricingContext(overrides, legacyPrices, params) - - val summary = totalsOf(filtered, identitiesById, pricing, params) - val performance = performanceOf(filtered) - - val buckets = buildBuckets( - filtered, identitiesById, pricing, params, range, granularity, zone, - ) - - val displayModels = displayModelBreakdowns(filtered, identitiesById, displayModelsById, pricing, params) - val categories = categoryBreakdowns(filtered, identitiesById, pricing, params) - val statuses = statusBreakdowns(filtered, identitiesById, pricing, params) - - return TokenStatsRangeData( - range = range, - granularity = granularity, - eventCount = filtered.size.toLong(), - summary = summary, - performance = performance, - buckets = buckets, - displayModels = displayModels, - categories = categories, - statuses = statuses, - ) - } - - // ==== 桶构建 ==== - - private fun buildBuckets( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - range: TokenStatsTimeRange, - granularity: TokenStatsGranularity, - zone: ZoneId, - ): List { - val bucketStarts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) - val bucketEnds = - bucketStarts.indices.map { index -> - TokenStatsTimeRanges.bucketEndMs(bucketStarts, index, granularity, zone) - } - // 单遍分摊:每个事件恰好落入一个桶(补齐空桶由固定桶骨架保证) - val bucketEvents = Array(bucketStarts.size) { mutableListOf() } - for (event in events) { - val index = - TokenStatsTimeRanges.bucketIndexOf(event.startedAtMs, bucketStarts, granularity, zone) - ?: continue - bucketEvents[index].add(event) - } - return bucketStarts.indices.map { index -> - val bucketEventsForIndex = bucketEvents[index] - TokenStatsTrendBucket( - bucketStartMs = bucketStarts[index], - bucketEndMs = bucketEnds[index], - totals = totalsOf(bucketEventsForIndex, identitiesById, pricing, params), - byModel = modelBucketsOf(bucketEventsForIndex, identitiesById, pricing, params), - performance = performanceOf(bucketEventsForIndex), - ) - } - } - - private fun modelBucketsOf( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): Map { - val byDisplay = LinkedHashMap>() - for (event in events) { - val identity = identitiesById[event.statIdentityId] ?: continue - byDisplay.getOrPut(identity.displayModelId) { mutableListOf() }.add(event) - } - return byDisplay.mapValues { (_, modelEvents) -> - TokenStatsModelBucket( - requests = modelEvents.size.toLong(), - uncachedInput = sumKnownTokens(modelEvents) { it.uncachedInputTokens }, - cachedInput = sumKnownTokens(modelEvents) { it.cachedInputTokens }, - cacheWrite = sumKnownTokens(modelEvents) { it.cacheWriteTokens }, - output = sumKnownTokens(modelEvents) { it.outputTokens }, - reasoning = sumKnownTokens(modelEvents, ::independentlyBilledReasoning), - totalTokens = sumKnownTokens(modelEvents, ::canonicalTotalTokens), - totalTokensUnknownEventCount = - modelEvents.count { canonicalTotalTokens(it) == null }.toLong(), - unknownTokenEventCount = - modelEvents.count { - it.uncachedInputTokens == null || it.cachedInputTokens == null || it.outputTokens == null - }.toLong(), - cost = costSummaryOf(modelEvents, identitiesById, pricing, params), - ) - } - } - - // ==== 明细 ==== - - private fun displayModelBreakdowns( - events: List, - identitiesById: Map, - displayModelsById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): List { - val byDisplay = LinkedHashMap>() - for (event in events) { - val identity = identitiesById[event.statIdentityId] ?: continue - byDisplay.getOrPut(identity.displayModelId) { mutableListOf() }.add(event) - } - return byDisplay.map { (displayModelId, modelEvents) -> - val display = displayModelsById[displayModelId] - val byIdentity = LinkedHashMap>() - for (event in modelEvents) { - byIdentity.getOrPut(event.statIdentityId) { mutableListOf() }.add(event) - } - val identities = - byIdentity.map { (identityId, identityEvents) -> - val identity = identitiesById.getValue(identityId) - TokenStatsIdentityBreakdown( - identityId = identityId, - configId = identity.configId, - provider = identity.provider, - model = identity.model, - totals = totalsOf(identityEvents, identitiesById, pricing, params), - pricing = pricingInfoFor(identity, identityEvents, pricing, params), - ) - }.sortedWith(compareByDescending { it.totals.requests }) - TokenStatsDisplayModelBreakdown( - displayModelId = displayModelId, - displayName = display?.displayName ?: displayModelId, - normalizedModel = display?.normalizedModel ?: displayModelId, - totals = totalsOf(modelEvents, identitiesById, pricing, params), - identities = identities, - ) - }.sortedWith( - compareByDescending { it.totals.requests } - .thenBy { it.displayName.lowercase() } - ) - } - - private fun categoryBreakdowns( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): List = - events.groupBy { TokenStatCategory.fromName(it.category) } - .map { (category, categoryEvents) -> - TokenStatsCategoryBreakdown( - category = category, - totals = totalsOf(categoryEvents, identitiesById, pricing, params), - ) - } - .sortedWith( - compareByDescending { it.totals.requests } - .thenBy { it.category.name } - ) - - private fun statusBreakdowns( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): List = - TokenStatStatus.entries.mapNotNull { status -> - val statusEvents = events.filter { TokenStatStatus.fromName(it.status) == status } - if (statusEvents.isEmpty()) { - null - } else { - TokenStatsStatusBreakdown( - status = status, - totals = totalsOf(statusEvents, identitiesById, pricing, params), - ) - } - } - - // ==== 合计 ==== - - private fun totalsOf( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): TokenStatsTotals = - TokenStatsTotals( - requests = events.size.toLong(), - uncachedInput = tokenAggregateOf(events) { it.uncachedInputTokens }, - cachedInput = tokenAggregateOf(events) { it.cachedInputTokens }, - cacheWrite = tokenAggregateOf(events) { it.cacheWriteTokens }, - totalInput = tokenAggregateOf(events) { it.totalInputTokens }, - output = tokenAggregateOf(events) { it.outputTokens }, - reasoning = tokenAggregateOf(events, ::independentlyBilledReasoning), - totalTokens = tokenAggregateOf(events, ::canonicalTotalTokens), - cost = costSummaryOf(events, identitiesById, pricing, params), - ) - - private fun tokenAggregateOf( - events: List, - pick: (TokenStatEventEntity) -> Long?, - ): TokenStatsTokenAggregate { - var sum = 0L - var known = 0L - for (event in events) { - val value = pick(event) ?: continue - known += 1 - sum = TokenCostCalculator.saturatedAdd(sum, value) - } - return TokenStatsTokenAggregate( - knownSum = sum, - knownEventCount = known, - unknownEventCount = events.size.toLong() - known, - totalEventCount = events.size.toLong(), - ) - } - - private fun sumKnownTokens( - events: List, - pick: (TokenStatEventEntity) -> Long?, - ): Long = - events.fold(0L) { acc, event -> - val value = pick(event) ?: return@fold acc - TokenCostCalculator.saturatedAdd(acc, value) - } - - /** - * 推理 token 只在该计费被输出计数排除([TokenStatEventEntity.reasoningIncludedInOutput] - * == false)时才独立累计;provider 输出已含推理(OpenAI/Gemini/Anthropic)时再相加 - * 会双重计数。与活动聚合([TokenActivityModels])口径一致。 - */ - private fun independentlyBilledReasoning(event: TokenStatEventEntity): Long? = - if (event.reasoningIncludedInOutput == false) event.reasoningTokens else null - - // ==== 费用 ==== - - private fun costSummaryOf( - events: List, - identitiesById: Map, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): TokenStatsCostSummary { - val original = EnumMap(PricingCurrency::class.java) - var unknownCount = 0L - for (event in events) { - val (amount, currency) = eventCost(event, identitiesById[event.statIdentityId], pricing, params) - if (amount == null || !amount.isFinite()) { - unknownCount += 1 - continue - } - original.merge(currency, BigDecimal(amount)) { left, right -> left.add(right) } - } - return buildCostSummary(original, unknownCount, events.size.toLong(), params) - } - - /** - * 单事件原币费用贡献: - * - HISTORICAL:事件保存的“发生时”快照(null = 未知,不猜测、不当作 0); - * - REVALUED:当前分层价格 × 事件用量重算(同一 [TokenCostCalculator] 语义, - * 缓存写入独立计费与推理包含边界与落账时一致,不重复收费)。 - * **未知定价(identity 缺失或 [ResolvedPricing.known] = false)一律贡献 null - * (P1-1)**:即使默认价表给出全 0 价格,也不能把“无法定价”伪装成 0 元。 - */ - private fun eventCost( - event: TokenStatEventEntity, - identity: TokenStatIdentityEntity?, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): Pair = - if (params.mode == TokenStatsCostMode.REVALUED) { - val resolved = identity?.let { pricing.pricingFor(it) } - if (resolved == null || !resolved.known) { - null to parseCurrency(event.pricingCurrency) - } else { - val result = TokenCostCalculator.computeCost(event.toUsageInput(), resolved) - result.amount to result.currency - } - } else { - event.costInPricingCurrency to parseCurrency(event.pricingCurrency) - } - - private fun baselineTotalsOf( - baselines: List, - params: TokenStatsQueryParams, - ): TokenStatsBaselineTotals { - var requests = 0L - var inputTokens = 0L - var cachedInputTokens = 0L - var outputTokens = 0L - var anyEstimated = false - val original = EnumMap(PricingCurrency::class.java) - var unknownCount = 0L - for (baseline in baselines) { - requests = TokenCostCalculator.saturatedAdd(requests, baseline.requestCount) - inputTokens = TokenCostCalculator.saturatedAdd(inputTokens, baseline.inputTokens) - cachedInputTokens = - TokenCostCalculator.saturatedAdd(cachedInputTokens, baseline.cachedInputTokens) - outputTokens = TokenCostCalculator.saturatedAdd(outputTokens, baseline.outputTokens) - anyEstimated = anyEstimated || baseline.isEstimated - val amount = baseline.costInPricingCurrency - if (amount == null || !amount.isFinite()) { - unknownCount += 1 - } else { - original.merge( - parseCurrency(baseline.pricingCurrency), - BigDecimal(amount), - ) { left, right -> left.add(right) } - } - } - return TokenStatsBaselineTotals( - identityCount = baselines.size.toLong(), - requests = requests, - inputTokens = inputTokens, - cachedInputTokens = cachedInputTokens, - outputTokens = outputTokens, - cost = - buildCostSummary( - original = original, - unknownCount = unknownCount, - totalCount = baselines.size.toLong(), - params = params, - modeOverride = TokenStatsCostMode.HISTORICAL, - ), - anyEstimated = anyEstimated, - ) - } - - /** 原币合计 → 目标币种(BigDecimal 换算,边界才转 Double)。 */ - private fun buildCostSummary( - original: EnumMap, - unknownCount: Long, - totalCount: Long, - params: TokenStatsQueryParams, - modeOverride: TokenStatsCostMode? = null, - ): TokenStatsCostSummary { - val usd = original[PricingCurrency.USD] ?: BigDecimal.ZERO - val cny = original[PricingCurrency.CNY] ?: BigDecimal.ZERO - val convertedUsd = convertTo(usd, PricingCurrency.USD, params.targetCurrency, params.manualRate) - val convertedCny = convertTo(cny, PricingCurrency.CNY, params.targetCurrency, params.manualRate) - val amounts = - mapOf( - PricingCurrency.USD to usd, - PricingCurrency.CNY to cny, - ).filterValues { it.signum() != 0 } - .mapValues { (_, value) -> value.toDouble() } - return TokenStatsCostSummary( - currency = params.targetCurrency, - knownAmount = convertedUsd.add(convertedCny, MC).toDouble(), - unknownContributionCount = unknownCount, - totalContributionCount = totalCount, - rateUsed = params.manualRate, - rateIsEstimated = params.rateIsEstimated, - mode = modeOverride ?: params.mode, - originalCurrencyAmounts = amounts, - ) - } - - private fun convertTo( - amount: BigDecimal, - from: PricingCurrency, - to: PricingCurrency, - rate: Double, - ): BigDecimal { - if (from == to) return amount - return if (from == PricingCurrency.USD) { - amount.multiply(BigDecimal(rate), MC) - } else { - amount.divide(BigDecimal(rate), MC) - } - } - - // ==== 性能 ==== - - /** 性能聚合(internal 供测试直接调用)。 */ - internal fun performanceOf(events: List): TokenStatsPerformance { - var ttftKnown = 0L - var ttftTotal = 0L - var ttftUnknown = 0L - var generationKnown = 0L - var generationTotal = 0L - var generationUnknown = 0L - for (event in events) { - val started = event.startedAtMs - val first = event.firstTokenAtMs - val ended = event.endedAtMs - // 时长安全计算:非负时间戳 + 结束≥开始;无效样本记为 unknown 排除平均 - if (first != null && started >= 0 && first >= started) { - ttftKnown += 1 - ttftTotal = TokenCostCalculator.saturatedAdd(ttftTotal, first - started) - } else { - ttftUnknown += 1 - } - if (first != null && ended >= 0 && first >= 0 && ended >= first) { - generationKnown += 1 - generationTotal = TokenCostCalculator.saturatedAdd(generationTotal, ended - first) - } else { - generationUnknown += 1 - } - } - return TokenStatsPerformance( - ttft = durationAggregate(ttftKnown, ttftUnknown, ttftTotal), - generationDuration = durationAggregate(generationKnown, generationUnknown, generationTotal), - ) - } - - private fun durationAggregate(known: Long, unknown: Long, totalMs: Long): TokenStatsDurationAggregate = - TokenStatsDurationAggregate( - knownCount = known, - unknownCount = unknown, - totalMs = totalMs, - averageMs = if (known > 0L) totalMs / known.toDouble() else 0.0, - ) - - // ==== 价格上下文(重估用) ==== - - private fun pricingContext( - overrides: List, - legacyPrices: Map, - params: TokenStatsQueryParams, - ): PricingContext = PricingContext(overrides, legacyPrices, params) - - private class PricingContext( - private val overrides: List, - private val legacyPrices: Map, - private val params: TokenStatsQueryParams, - ) { - private val cache = HashMap() - - fun pricingFor(identity: TokenStatIdentityEntity): ResolvedPricing? { - if (params.mode != TokenStatsCostMode.REVALUED) return null - return cache.getOrPut(identity.identityId) { resolveCurrent(identity) } - } - - private fun resolveCurrent(identity: TokenStatIdentityEntity): ResolvedPricing { - val providerModel = identity.providerModel - return TokenPriceResolver.resolve( - provider = identity.provider, - model = identity.model, - configId = identity.configId, - overrides = overrides, - legacyOverride = legacyPrices[providerModel], - defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), - ) - } - } - - private fun pricingInfoFor( - identity: TokenStatIdentityEntity, - events: List, - pricing: PricingContext, - params: TokenStatsQueryParams, - ): TokenStatsPricingInfo? { - if (events.isEmpty()) return null - return if (params.mode == TokenStatsCostMode.REVALUED) { - pricing.pricingFor(identity)?.toPricingInfo() - } else { - val latest = events.maxByOrNull { it.startedAtMs } ?: return null - TokenStatsPricingInfo( - billingMode = BillingMode.fromString(latest.billingMode), - currency = parseCurrency(latest.pricingCurrency), - inputPricePerMillion = latest.inputPricePerMillion, - cachedInputPricePerMillion = latest.cachedInputPricePerMillion, - cacheWritePricePerMillion = latest.cacheWritePricePerMillion, - outputPricePerMillion = latest.outputPricePerMillion, - pricePerRequest = latest.pricePerRequest, - source = PricingSource.fromName(latest.pricingSource), - known = latest.pricingSource != PricingSource.UNKNOWN.name, - ) - } - } - - // ==== 工具 ==== - - private fun filterByCategory( - events: List, - params: TokenStatsQueryParams, - ): List { - val categories = params.categories ?: return events - return events.filter { TokenStatCategory.fromName(it.category) in categories } - } - - private fun List.filterByStatus( - params: TokenStatsQueryParams, - ): List { - val statuses = params.statuses ?: return this - return filter { TokenStatStatus.fromName(it.status) in statuses } - } - - private fun parseCurrency(raw: String): PricingCurrency = - if (raw.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD - - private fun TokenStatEventEntity.toUsageInput(): TokenUsageInput = - TokenUsageInput( - uncachedInputTokens = uncachedInputTokens, - cachedInputTokens = cachedInputTokens, - cacheWriteTokens = cacheWriteTokens, - totalInputTokens = totalInputTokens, - outputTokens = outputTokens, - reasoningTokens = reasoningTokens, - reasoningIncludedInOutput = reasoningIncludedInOutput, - // null = 旧行未声明独立计费概念:按保守默认 true(该字段未知时 - // 缓存写入未知会阻塞费用,与“未知不当作 0”一致) - cacheWriteSeparateBilling = cacheWriteSeparateBilling ?: true, - ) - - private fun ResolvedPricing.toPricingInfo(): TokenStatsPricingInfo = - TokenStatsPricingInfo( - billingMode = billingMode, - currency = currency, - inputPricePerMillion = inputPricePerMillion, - cachedInputPricePerMillion = cachedInputPricePerMillion, - cacheWritePricePerMillion = cacheWritePricePerMillion, - outputPricePerMillion = outputPricePerMillion, - pricePerRequest = pricePerRequest, - source = source, - known = known, - ) -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt deleted file mode 100644 index d2445d22d..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsLedger.kt +++ /dev/null @@ -1,352 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.util.AppLogger -import kotlinx.coroutines.CancellationException -import org.json.JSONObject - -/** - * 冻结的“发生时”价格/成本快照(P1-1):请求收尾解析一次,随 spool 行持久化; - * 排空与重放只使用该快照,绝不重读当前价格。成本在冻结时计算。 - */ -data class FrozenEventPricing( - val pricing: ResolvedPricing, - val cost: Double?, -) - -/** - * 统一 usage 记录器(阶段 2):把 [TokenStatRequestContext] 落成 - * [TokenStatEventEntity] 账本事件。 - * - * - **幂等**:事件以 [TokenStatEventEntity.eventId] 为主键 IGNORE 插入;同一 - * eventId 重复落账不会重复入账。 - * - **失败不破坏业务**:数据库/价格解析失败只记录日志并返回,绝不向上抛出 - * ([CancellationException] 除外——协程取消必须向上传播,不能当作写入失败吞掉)。 - * - 身份(configId+provider+model)不存在时自动 INSERT IGNORE 创建;展示模型分组 - * 缺失时自动补齐(默认规范化模型名分组)。 - * - 价格:CONFIG 覆盖 > PROVIDER_MODEL 覆盖 > 旧 DataStore 用户价格 > 内置默认价 - * ([TokenPriceResolver] 层级);成本用事件发生时的原币价格快照计算。 - * - 诊断字段 [TokenStatEventEntity.diagnosticsJson] 只保存脱敏来源标签与计数, - * 不保存正文、API key、Cookie 或 endpoint 凭据。 - * - * [databaseProvider] / [legacyPriceProvider] 为测试注入缝:生产代码始终为 null - * (真实 [AppDatabase] 与真实 DataStore 读取);测试注入真实 Room 数据库与 - * 桩价格来源验证语义。 - */ -object TokenStatsLedger { - - private const val TAG = "TokenStatsLedger" - - internal var databaseProvider: ((Context) -> AppDatabase)? = null - - internal var legacyPriceProvider: (suspend (Context, String) -> LegacyPriceSettings?)? = null - - /** Linearization token captured before a model invocation starts. Failure aborts that call. */ - suspend fun currentResetGeneration(context: Context): Long = - TokenStatSpool.withStatsDatabaseAccess { - val appContext = context.applicationContext - val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - database.tokenStatsDao().currentResetGeneration() - } - - /** - * 请求接受边界(P1-1 修复):在**同一 Room 事务**内确保身份存在(INSERT IGNORE + - * 默认展示分组补齐)并读取当前 generation。展示分组删除与请求开始因此按事务原子 - * 串行化:删除要么看见该身份并写 IDENTITY tombstone(删除前接受的事件被跳过), - * 要么请求捕获 ≥ tombstone 的新 generation(删除后请求正常入账)——首次请求的 - * 身份绝不可能绕过分组删除 tombstone 复活旧事件。 - * @throws CancellationException 协程取消向上传播。 - */ - internal suspend fun ensureIdentityAndCaptureGeneration( - context: Context, - configId: String, - provider: String, - model: String, - ): Long { - val appContext = context.applicationContext - val database = databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - return database.tokenStatsDao().ensureIdentityAndCaptureGenerationTx( - identityEntityFor(configId, provider, model), - displayModelEntityFor(model), - ) - } - - /** - * 记录一个请求事件。写入失败(非取消)只记录日志,不影响原响应/取消传播。 - * @throws CancellationException 协程取消时向上传播,不吞掉。 - */ - suspend fun record(context: Context, request: TokenStatRequestContext) { - try { - TokenStatSpool.withStatsDatabaseAccess { - val appContext = context.applicationContext - val injected = databaseProvider - val database = - injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - recordWith(appContext, database.tokenStatsDao(), request) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e( - TAG, - "统计事件写入失败(不影响业务): eventId=${request.eventId}, " + - "category=${request.category}, status=${request.status}, " + - "provider=${request.provider}, model=${request.model}", - e, - ) - } - } - - /** - * 请求收尾:解析并冻结“发生时”价格/成本快照,生成 spool v2 行(P1-1)。 - * 数据库/DataStore 不可用时生成明确 UNKNOWN 快照,价格和成本保持 null;默认价 - * 不能冒充当时可能存在但未能读取的用户覆盖。 - * @throws CancellationException 协程取消向上传播。 - */ - internal suspend fun prepareEventLine( - appContext: Context, - request: TokenStatRequestContext, - baseJson: JSONObject, - ): String { - val detached = prepareEventLineDetached(appContext, request) - request.frozenPricing = detached.frozenPricing - request.pricingResolutionDiagnostic = detached.diagnostic - return detached.line - } - - /** - * Detached 版本的收尾行生成:worker 线程在完全独立的 base JSON 上构建行,绝不读写 - * 调用方的 [TokenStatRequestContext] 或共享 JSON,因此超时后被弃置的任务不可能与 - * 调用方的 UNKNOWN 回退路径竞争同一组可变对象(P2-1)。 - */ - internal data class DetachedEventLine( - val line: String, - val frozenPricing: FrozenEventPricing, - val diagnostic: String?, - ) - - internal suspend fun prepareEventLineDetached( - appContext: Context, - request: TokenStatRequestContext, - ): DetachedEventLine { - val frozen = resolveFrozenPricing(appContext, request) - val line = - request.toSpoolBaseJson() - .apply { - put( - "pricing", - TokenStatRequestContext.pricingToJson( - frozen.pricing, - frozen.cost, - request.pricingResolutionDiagnostic, - ), - ) - } - .toString() - return DetachedEventLine(line, frozen, request.pricingResolutionDiagnostic) - } - - private suspend fun resolveFrozenPricing( - appContext: Context, - request: TokenStatRequestContext, - ): FrozenEventPricing { - val pricing = resolvePricingForRequest(appContext, request) - val usage = request.aggregatedUsage() - val cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } - return FrozenEventPricing(pricing, cost) - } - - /** 价格读取失败/超时:完整 usage 仍持久化,但价格与成本明确 unresolved。 */ - internal fun prepareUnresolvedEventLine( - request: TokenStatRequestContext, - baseJson: JSONObject, - diagnostic: String, - ): String { - val pricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - source = PricingSource.UNKNOWN, - known = false, - ) - request.pricingResolutionDiagnostic = diagnostic - request.frozenPricing = FrozenEventPricing(pricing, null) - return baseJson - .apply { - put( - "pricing", - TokenStatRequestContext.pricingToJson(pricing, null, diagnostic), - ) - } - .toString() - } - - /** - * 请求收尾价格解析(reviewer P1-1 修复):与快照/恢复屏障注册表互斥的 Room 读取—— - * 屏障排他期间(打包/替换中)被**立即拒绝**([TokenStatsBarrierActiveException]), - * 由收尾边界转为 UNKNOWN 价格事件,绝不与数据库文件复制竞争,也不无限等待; - * 恢复替换完成(accepting=false)后的收尾(旧 epoch 请求)仍可读取重建后的新库, - * 但 append 由请求 fence 明确拒绝,绝不写入。 - */ - internal suspend fun resolvePricingForRequest( - appContext: Context, - request: TokenStatRequestContext, - ): ResolvedPricing = TokenStatSpool.withStatsDatabaseAccess { - resolvePricingLocked(appContext, request) - } - - /** - * 无锁版本(drain/直接路径使用):调用方已受 generation + activeInserts 注册 - * ([TokenStatSpool.insertSafely])或屏障 lifecycleMutex(drainBefore)保护, - * 与替换窗口的互斥由既有机制保证,不再重复取锁(避免 drainCore 持锁时死锁)。 - */ - internal suspend fun resolvePricingLocked( - appContext: Context, - request: TokenStatRequestContext, - ): ResolvedPricing { - val injected = databaseProvider - val database = injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - val dao = database.tokenStatsDao() - val providerModel = "${request.provider}:${request.model}" - val overrides = dao.getAllPriceOverrides() - val legacyOverride = - legacyPriceProvider?.invoke(appContext, providerModel) - ?: ApiPreferences.getInstance(appContext).legacyPriceSettingsFor(providerModel) - return resolvePricingFrom(overrides, legacyOverride, request) - } - - private fun resolvePricingFrom( - overrides: List, - legacyOverride: LegacyPriceSettings?, - request: TokenStatRequestContext, - ): ResolvedPricing { - val providerModel = "${request.provider}:${request.model}" - return TokenPriceResolver.resolve( - provider = request.provider, - model = request.model, - configId = request.configId, - overrides = overrides, - legacyOverride = legacyOverride, - defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel), - ) - } - - /** - * 实际落账(含身份创建;错误直接向上抛,由调用方决定重试边界)。 - * 事件携带冻结价格快照(spool 重放)时直接使用,否则现场解析(直接路径)。 - * 插入经 [TokenStatsDao.insertEventIfNotResetCovered] 与 reset tombstone 同 - * 事务检查:被 reset 覆盖的事件跳过(视为已处理),不会复活(P1-3)。 - */ - internal suspend fun recordWith( - appContext: Context, - dao: TokenStatsDao, - request: TokenStatRequestContext, - ) { - val identity = ensureIdentity(dao, request) - - val frozen = request.frozenPricing - val pricing: ResolvedPricing - val cost: Double? - if (frozen != null) { - pricing = frozen.pricing - cost = frozen.cost - } else { - // 直接路径(不经 spool):调用方已受 generation/activeInserts 保护, - // 用无锁版本避免与屏障 drain 阶段的锁重入(reviewer P1-1)。 - pricing = resolvePricingLocked(appContext, request) - val usage = request.aggregatedUsage() - cost = usage?.let { TokenCostCalculator.computeCost(it.toTokenUsageInput(), pricing)?.amount } - } - val usage = request.aggregatedUsage() - - dao.insertEventIfNotResetCovered( - TokenStatEventEntity( - eventId = request.eventId, - statIdentityId = identity.identityId, - category = request.category.name, - status = (request.status ?: TokenStatStatus.FAILED).name, - acceptedGeneration = request.acceptedGeneration, - startedAtMs = request.startedAtMs, - endedAtMs = request.endedAtMs, - firstTokenAtMs = request.firstTokenAtMs, - uncachedInputTokens = usage?.uncachedInputTokens, - cachedInputTokens = usage?.cachedInputTokens, - cacheWriteTokens = usage?.cacheWriteTokens, - totalInputTokens = usage?.totalInputTokens, - outputTokens = usage?.outputTokens, - reasoningTokens = usage?.reasoningTokens, - reasoningIncludedInOutput = usage?.reasoningIncludedInOutput, - // 结构化保存缓存写入计费模型:当前价格重估直接读取,不解析 JSON - cacheWriteSeparateBilling = usage?.cacheWriteSeparateBilling, - billingMode = pricing.billingMode.name, - pricingCurrency = pricing.currency.name, - inputPricePerMillion = pricing.inputPricePerMillion, - cachedInputPricePerMillion = pricing.cachedInputPricePerMillion, - cacheWritePricePerMillion = pricing.cacheWritePricePerMillion, - outputPricePerMillion = pricing.outputPricePerMillion, - pricePerRequest = pricing.pricePerRequest, - pricingSource = pricing.source.name, - costInPricingCurrency = cost, - diagnosticsJson = buildDiagnosticsJson(request), - ) - ) - } - - /** 身份不存在时创建(INSERT IGNORE,绝不 REPLACE),并补齐默认展示分组。 */ - private suspend fun ensureIdentity( - dao: TokenStatsDao, - request: TokenStatRequestContext, - ): TokenStatIdentityEntity { - val identity = identityEntityFor(request.configId, request.provider, request.model) - dao.insertIdentityIfAbsent(identity) - dao.insertDisplayModelIfAbsent(displayModelEntityFor(request.model)) - return identity - } - - /** 身份行构造(请求边界与落账共用,与既有 [ensureIdentity] 规范化完全一致)。 */ - private fun identityEntityFor( - configId: String, - provider: String, - model: String, - ): TokenStatIdentityEntity = - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId(configId, provider, model), - configId = configId, - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - - /** 默认展示分组行构造(默认规范化模型名分组,与既有 [ensureIdentity] 完全一致)。 */ - private fun displayModelEntityFor(model: String): TokenStatDisplayModelEntity = - TokenStatDisplayModelEntity( - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), - displayName = model, - ) - - /** 脱敏诊断字段:来源标签、是否观察到 usage、上报次数、attempt 数;无正文/凭据。 */ - private fun buildDiagnosticsJson(request: TokenStatRequestContext): String? { - val usage = request.aggregatedUsage() - return JSONObject().apply { - if (usage != null) { - put("source", usage.source) - put("reasoningIncludedInOutput", usage.reasoningIncludedInOutput) - put("cacheWriteSeparateBilling", usage.cacheWriteSeparateBilling) - } - put("usageObserved", usage != null) - put("usageReportCount", request.usageReportCount) - put("attemptCount", request.attemptCount) - request.pricingResolutionDiagnostic?.let { put("pricingResolution", it) } - }.toString() - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt new file mode 100644 index 000000000..4ce483185 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt @@ -0,0 +1,83 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.ai.assistance.operit.data.collects.PricingCurrency +import kotlinx.coroutines.flow.first + +private val Context.tokenStatsDataStore: DataStore by + preferencesDataStore(name = "token_stats_preferences") + +/** Scalar statistics settings. Structured usage, grouping, and pricing stay in Room. */ +internal class TokenStatsPreferences(context: Context) { + companion object { + private val TARGET_CURRENCY = stringPreferencesKey("target_currency") + private val USD_TO_CNY_RATE = doublePreferencesKey("usd_to_cny_rate") + private val TIME_RANGE_START = longPreferencesKey("time_range_start") + private val TIME_RANGE_END = longPreferencesKey("time_range_end") + private val IMPORTED_AT = longPreferencesKey("imported_at_ms") + } + + private val dataStore = context.applicationContext.tokenStatsDataStore + + suspend fun importedAtMs(): Long? = dataStore.data.first()[IMPORTED_AT] + + suspend fun completeMigration( + importedAtMs: Long, + releasedUsdToCnyRate: Double?, + ) { + dataStore.edit { preferences -> + releasedUsdToCnyRate?.let { rate -> preferences[USD_TO_CNY_RATE] = rate } + preferences[IMPORTED_AT] = importedAtMs + } + } + + suspend fun loadRateWithEstimate(): Pair { + val stored = dataStore.data.first()[USD_TO_CNY_RATE] + return if (stored == null) { + TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE to true + } else { + require(stored.isFinite() && stored > 0.0) { "stored exchange rate is invalid" } + stored to false + } + } + + suspend fun saveRate(rate: Double) { + require(rate.isFinite() && rate > 0.0) { "exchange rate must be positive and finite" } + dataStore.edit { preferences -> preferences[USD_TO_CNY_RATE] = rate } + } + + suspend fun loadTargetCurrency(): PricingCurrency { + val stored = dataStore.data.first()[TARGET_CURRENCY] + return stored?.let { PricingCurrency.valueOf(it) } ?: PricingCurrency.CNY + } + + suspend fun saveTargetCurrency(currency: PricingCurrency) { + dataStore.edit { preferences -> preferences[TARGET_CURRENCY] = currency.name } + } + + suspend fun loadTimeRange(): TokenStatsTimeRange? { + val preferences = dataStore.data.first() + val startMs = preferences[TIME_RANGE_START] ?: return null + val endMs = checkNotNull(preferences[TIME_RANGE_END]) + return TokenStatsTimeRanges.customRange(startMs, endMs) + } + + suspend fun saveTimeRange(range: TokenStatsTimeRange?) { + dataStore.edit { preferences -> + if (range == null) { + preferences.remove(TIME_RANGE_START) + preferences.remove(TIME_RANGE_END) + return@edit + } + preferences[TIME_RANGE_START] = range.startMs + preferences[TIME_RANGE_END] = range.endMs + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt index 99d028149..b578d50bc 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -1,271 +1,113 @@ package com.ai.assistance.operit.data.stats import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -/** - * 统计查询领域模型(阶段 3,供阶段 4 UI/ViewModel 直接复用)。 - * - * unknown 与 0 的约定贯穿全部聚合结果: - * - token/价格/成本为 null 表示“未知”,**不得静默当作 0**; - * - 0 表示 provider 确认该分量为 0(如确认无缓存读取)。 - * 聚合层用 [TokenStatsTokenAggregate]/[TokenStatsCostSummary] 显式携带 - * known/unknown 计数,部分未知可表达,绝不伪装成精确 0。 - */ - -/** 费用口径:事件历史快照(账单价)或当前分层价格重估。 */ -enum class TokenStatsCostMode { - /** 使用事件保存的“发生时”价格快照与原币成本(默认口径)。 */ - HISTORICAL, - - /** 使用当前分层价格(覆盖 > 旧系统价格 > 内置默认价)× 事件用量重算。 */ - REVALUED, -} - -/** - * 聚合参数。汇率只由用户手动设置;未设置时调用方传入默认估算值 - * [TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE] 并标记 [rateIsEstimated] = true - * (界面必须显示估算提示)。修改汇率只改变统一币种换算,不改变历史原币成本。 - */ data class TokenStatsQueryParams( - /** 总计展示目标币种。 */ val targetCurrency: PricingCurrency = PricingCurrency.CNY, - /** 当前手动 USD→CNY 汇率;必须为正。 */ val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, - /** true = 汇率是默认估算值(非用户手动设置),界面必须标记估算。 */ - val rateIsEstimated: Boolean = true, - /** 费用口径:历史快照(默认)或当前价格重估。 */ - val mode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL, - /** 展示模型筛选(identity.displayModelId);null = 全部。 */ - val displayModelIds: Set? = null, - /** 业务分类筛选;null = 全部分类。 */ + val providerModels: Set? = null, val categories: Set? = null, - /** 请求状态筛选(阶段 4);null = 全部状态。 */ val statuses: Set? = null, -) { - init { - require(manualRate.isFinite() && manualRate > 0.0) { - "manual rate must be finite and positive" - } - } -} +) -/** - * 费用合计。已知部分按目标币种汇总(BigDecimal 累加,无 Double 漂移), - * 原币金额按币种分别保存(供 UI 按原币/模型堆叠);无法定价的贡献 - * (unknown)计数保留,[isFullyKnown] = false 表示结果为部分未知。 - */ data class TokenStatsCostSummary( - /** 目标展示币种。 */ val currency: PricingCurrency, - /** 已知贡献合计(目标币种,BigDecimal 累加结果)。 */ val knownAmount: Double, - /** 无法定价(unknown)的贡献条数;不为 0 时结果是 partial,不能当精确账单。 */ val unknownContributionCount: Long, - /** 参与合计的贡献条数(事件数或 baseline 行数)。 */ val totalContributionCount: Long, - /** 本次换算使用的 USD→CNY 汇率。 */ val rateUsed: Double, - /** true = 汇率是默认估算值。 */ - val rateIsEstimated: Boolean, - /** 费用口径。baseline 无论参数如何都按冻结快照(HISTORICAL)展示。 */ - val mode: TokenStatsCostMode, - /** 原币已知合计(仅含 > 0 币种,供堆叠/分币种展示)。 */ val originalCurrencyAmounts: Map, -) { - val isFullyKnown: Boolean - get() = unknownContributionCount == 0L - - val hasAnyContribution: Boolean - get() = totalContributionCount > 0L -} +) -/** - * 单分量 token 合计:已知事件求和(Long 饱和加法,绝不回绕为负), - * 未知事件单独计数,区分“全部已知”与“部分未知”。 - */ data class TokenStatsTokenAggregate( - /** 已知分量的和(饱和加法,上限 Long.MAX_VALUE)。 */ val knownSum: Long, - /** 该分量已知的事件数。 */ val knownEventCount: Long, - /** 该分量未知(provider 未上报)的事件数;0 与未知严格区分。 */ val unknownEventCount: Long, - /** 参与合计的事件总数。 */ val totalEventCount: Long, ) { - val isFullyKnown: Boolean - get() = unknownEventCount == 0L + val isFullyKnown: Boolean get() = unknownEventCount == 0L } -/** - * 时长聚合(首 Token 延迟 / 生成时长)。 - * 无效时长(时间戳缺失、结束早于开始、负数时间戳)一律记为 unknown, - * 不进平均;knownCount == 0 时 [averageMs] 为 0(调用方应展示“无数据”而非均值)。 - */ data class TokenStatsDurationAggregate( val knownCount: Long, val unknownCount: Long, - /** 已知时长总和(毫秒,饱和加法)。 */ val totalMs: Long, - /** 平均时长(毫秒);knownCount == 0 时为 0.0。 */ val averageMs: Double, ) { - val hasData: Boolean - get() = knownCount > 0L + val hasData: Boolean get() = knownCount > 0L } -/** 性能指标:首 Token 延迟(TTFT)与生成时长(首个 token 到结束)。 */ data class TokenStatsPerformance( val ttft: TokenStatsDurationAggregate, val generationDuration: TokenStatsDurationAggregate, ) -/** 一组事件(或一个分组)的完整合计。 */ data class TokenStatsTotals( val requests: Long, + val requestCountUnknownContributionCount: Long, val uncachedInput: TokenStatsTokenAggregate, val cachedInput: TokenStatsTokenAggregate, val cacheWrite: TokenStatsTokenAggregate, val totalInput: TokenStatsTokenAggregate, val output: TokenStatsTokenAggregate, val reasoning: TokenStatsTokenAggregate, - /** - * canonical 总 token(逐事件 [canonicalTotalTokens] 推导:权威 totalInputTokens - * 优先,fallback 按 cacheWriteSeparateBilling 决定输入口径,输出按 - * reasoningIncludedInOutput 决定是否补推理;未知分量保持 unknown)。 - * 是 UI 展示总 Token 的唯一事实来源,Compose 不得再从原始聚合字段重组。 - */ val totalTokens: TokenStatsTokenAggregate, val cost: TokenStatsCostSummary, ) -/** 旧数据迁移 baseline 的生命周期合计(费用按迁移时冻结快照,恒为估算口径)。 */ -data class TokenStatsBaselineTotals( - /** baseline 行数(身份数)。 */ - val identityCount: Long, - val requests: Long, - val inputTokens: Long, - val cachedInputTokens: Long, - val outputTokens: Long, - val cost: TokenStatsCostSummary, - /** 任一行估算标记为 true 即 true(baseline 本身就是估算,正常恒为 true)。 */ - val anyEstimated: Boolean, -) - -/** 生命周期累计总览(独立于时间/模型/分类筛选;事件 + baseline)。 */ data class TokenStatsLifetimeOverview( - val eventTotals: TokenStatsTotals, - val baselineTotals: TokenStatsBaselineTotals, - /** 事件 + baseline 请求数合计(饱和加法)。 */ - val combinedRequests: Long, + val totals: TokenStatsTotals, + val displayModels: List, ) -/** 单个图表桶:半开区间 [bucketStartMs, bucketEndMs),含按展示模型拆分。 */ data class TokenStatsTrendBucket( val bucketStartMs: Long, val bucketEndMs: Long, val totals: TokenStatsTotals, - /** displayModelId -> 桶内该展示模型的合计(费用堆叠按模型/原币)。 */ val byModel: Map, val performance: TokenStatsPerformance, ) -/** 桶内单个展示模型的合计(token 为已知分量和,unknown 事件单独计数)。 */ data class TokenStatsModelBucket( val requests: Long, + val requestCountUnknownContributionCount: Long, val uncachedInput: Long, val cachedInput: Long, val cacheWrite: Long, val output: Long, val reasoning: Long, - /** canonical 总 token 已知和(逐事件推导,口径见 [TokenStatsTotals.totalTokens])。 */ val totalTokens: Long, - /** canonical 总 token 未知的事件数(不把 null 当 0)。 */ val totalTokensUnknownEventCount: Long, - /** 任一核心 token 分量(uncached/cached/output)未知的事件数。 */ val unknownTokenEventCount: Long, val cost: TokenStatsCostSummary, ) -/** 单价的展示信息:历史口径取该身份最近事件的价格快照;重估口径取当前解析。 */ -data class TokenStatsPricingInfo( - val billingMode: BillingMode, - val currency: PricingCurrency, - val inputPricePerMillion: Double?, - val cachedInputPricePerMillion: Double?, - val cacheWritePricePerMillion: Double?, - val outputPricePerMillion: Double?, - val pricePerRequest: Double?, - val source: PricingSource, - val known: Boolean, -) - -/** 单个统计身份(configId+provider+model)的展开明细。 */ data class TokenStatsIdentityBreakdown( - val identityId: String, - val configId: String, + val configId: String?, val provider: String, val model: String, val totals: TokenStatsTotals, - val pricing: TokenStatsPricingInfo?, ) -/** 展示模型分组明细:默认规范化同名归组 + 用户手动别名(identity.displayModelId)。 */ data class TokenStatsDisplayModelBreakdown( val displayModelId: String, val displayName: String, val normalizedModel: String, val totals: TokenStatsTotals, val identities: List, + val providerModels: List, ) -/** - * 展示分组完整元数据(阶段 4 P1 修复):与统计筛选(时间/模型/分类/状态)完全 - * 无关的分组成员/合并目标事实来源。identity.displayModelId 是分组的单一事实 - * 来源;成员 = 全量身份按 displayModelId 分组(事件存在与否不影响成员身份), - * 组名取 display_models 行(缺失时回退 displayModelId)。筛选范围明细 - * ([TokenStatsDisplayModelBreakdown])只包含当前筛选下有事件的身份/分组, - * 不得作为分组操作的成员或目标依据。 - */ -data class TokenStatsGroupModelInfo( - val displayModelId: String, - val displayName: String, - /** 该分组下的全部身份 id(完整归属,非当前筛选范围所见)。 */ - val memberIdentityIds: List, - /** 可供管理界面搜索与辨识的完整身份元数据。 */ - val members: List = emptyList(), -) - -data class TokenStatsGroupMemberInfo( - val identityId: String, - val configId: String, - val provider: String, - val model: String, -) - -/** 业务分类合计。 */ data class TokenStatsCategoryBreakdown( val category: TokenStatCategory, val totals: TokenStatsTotals, ) -/** 请求状态合计。 */ data class TokenStatsStatusBreakdown( val status: TokenStatStatus, val totals: TokenStatsTotals, ) -/** - * 指定时间范围的完整查询结果:范围总计、性能、趋势桶(补齐空桶、 - * 桶合计 == 范围总计)、展示模型/身份、分类、状态明细。 - * baseline 无时间分布,永不进入范围数据。 - */ data class TokenStatsRangeData( val range: TokenStatsTimeRange, val granularity: TokenStatsGranularity, @@ -277,45 +119,3 @@ data class TokenStatsRangeData( val categories: List, val statuses: List, ) - -/** - * 范围查询的同事务只读快照(P1-2):identity/display model/价格覆盖/事件由 - * [com.ai.assistance.operit.data.dao.TokenStatsDao.loadRangeSnapshot] 在**同一个 - * Room 事务**内固定读取(SQLite 事务内快照一致),事务外由聚合器纯函数消费。 - * 并发写入要么整体可见要么整体不可见,杜绝“summary 有事件但模型桶缺失”的 - * 拆分状态;查询期间不重复取 DAO。 - */ -data class TokenStatsQuerySnapshot( - val events: List, - val identitiesById: Map, - val displayModelsById: Map, - val overrides: List, - val baselines: List, -) - -/** - * 生命周期快照的固定小表部分(P1-2/P2-1):identity/display model/价格覆盖/ - * baseline 在同一事务内一次读取;事件不实体化——由 - * [com.ai.assistance.operit.data.dao.TokenStatsDao.loadLifetimeSnapshot] 按 - * `(startedAtMs, eventId)` 键集分页逐页回调增量累加器(每页至多 [pageSize]), - * 避免整表实体化的内存峰值,且分页与事务同界(页面间快照一致)。 - */ -data class TokenStatsLifetimeRead( - val identitiesById: Map, - val displayModelsById: Map, - val overrides: List, - val baselines: List, - val totalEvents: Long, -) - -/** - * 分组元数据快照(阶段 4 P1 修复):全量身份 + 展示模型行在**同一个 Room 事务**内 - * 固定读取([com.ai.assistance.operit.data.dao.TokenStatsDao.loadGroupMetadataSnapshot]), - * 与统计筛选无关;事务外由设置管理器构建 [TokenStatsGroupModelInfo]。 - * 快照一致性原则同 [TokenStatsQuerySnapshot](并发分组变更要么整体可见要么 - * 整体不可见)。 - */ -data class TokenStatsGroupMetadataSnapshot( - val identities: List, - val displayModels: List, -) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 9c21b635f..512bb96d4 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -1,246 +1,383 @@ package com.ai.assistance.operit.data.stats import android.content.Context -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageBreakdownRow +import com.ai.assistance.operit.data.dao.TokenUsageActivityDayRow +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import java.time.LocalDate import java.time.ZoneId -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * 统计查询服务(阶段 3):同事务只读快照 + [TokenStatsAggregator] 单遍聚合。 - * - * 查询策略(防 N+1 / 一致快照): - * - 组成响应的**全部 Room 读取**(identity/display model/价格覆盖/事件/baseline) - * 在**同一个 Room 事务**内固定读取([TokenStatsDao.loadRangeSnapshot] / - * [TokenStatsDao.loadLifetimeSnapshot]),事务外纯聚合:并发写入要么整体可见 - * 要么整体不可见,杜绝“summary 有事件但模型桶缺失”的拆分状态(P1-2); - * - 展示模型筛选在快照事务内走 JOIN 单条 IN 查询,模型数超过 900(SQLite 变量 - * 上限留余量)时在**同一事务**内分块合并(P2-2);null = 全部、空 = 无事件; - * - 生命周期总览不整表实体化:事件按 `(startedAtMs, eventId)` 键集分页 - * (每页 [lifetimeEventPageSize] 条)在同事务内喂给增量累加器(P2-1); - * - 重估口径的旧系统价格(DataStore)**先读一次快照**( - * [ApiPreferences.allLegacyPriceSettings]),再进入 Room 快照事务;Room 事务 - * 不能挂起 DataStore,先后顺序明确——价格只影响重估,不影响事件结构(P1-2)。 - * - * 线程(P2-3):所有公共入口显式 [withContext] 到 [queryDispatcher](默认 - * [Dispatchers.IO],测试可注入),阶段 4 Main 调用不阻塞。 - * - * 汇率与币种:聚合接收当前手动 USD/CNY 汇率(默认 - * [TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE] = 7.0 并标记 estimated), - * 按目标币种换算;事件原币成本永远不变。 - */ -object TokenStatsQueryService { - - internal var databaseProvider: ((Context) -> AppDatabase)? = null - - /** - * 旧系统价格注入缝(一次快照读取整表):生产走 - * [ApiPreferences.allLegacyPriceSettings](单次 DataStore 读取);测试注入桩, - * 避免触碰真实 DataStore。 - */ - internal var legacyPricesProvider: (suspend (Context) -> Map)? = null - - /** Room 查询 + 聚合的执行线程(P2-3):生产默认 IO,测试可注入记录线程的调度器。 */ - internal var queryDispatcher: CoroutineDispatcher = Dispatchers.IO - - /** 生命周期事件分页大小(P2-1):固定批次读取 + 增量聚合,避免整表实体化峰值。 */ - internal var lifetimeEventPageSize: Int = 1_000 - - /** 活动视图事件分页大小:每页读取轻量投影并立即压缩为按日/小时汇总。 */ - internal var activityEventPageSize: Int = 1_000 - // ==== 核心查询(DAO 直连,生产与测试共用) ==== - - /** 生命周期累计总览(事件 + baseline,独立于筛选;事件分页增量聚合,不整表实体化)。 */ +/** SQL-backed statistics queries. Only aggregate rows leave Room. */ +object TokenStatsQueryService { suspend fun lifetimeOverview( - dao: TokenStatsDao, + context: Context, params: TokenStatsQueryParams, - legacyPrices: Map = emptyMap(), ): TokenStatsLifetimeOverview { - val accumulator = TokenStatsAggregator.TokenStatsEventTotalsAccumulator(legacyPrices, params) - val read = - dao.loadLifetimeSnapshot( - includeOverrides = params.mode == TokenStatsCostMode.REVALUED, - pageSize = lifetimeEventPageSize, - onEventsPage = { page, identities, overrides -> - accumulator.addPage(page, identities, overrides) - }, - ) - return TokenStatsAggregator.lifetimeFrom( - eventsTotals = accumulator.totals(), - baselines = read.baselines, - params = params, + val repository = TokenUsageRepository.getInstance(context) + repository.ensureInitialized() + val dao = repository.dao + val requestRows = dao.aggregateRequestModelsForLifetime( + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + return TokenStatsLifetimeOverview( + totals = combineTotals(requestRows.map { it.toTotals(prices, params) }, params), + displayModels = buildDisplayModels(requestRows, prices, params), ) } - /** - * 指定时间范围的完整查询(汇总 + 趋势桶 + 模型/分类/状态明细)。 - * 全部 Room 读取在 [TokenStatsDao.loadRangeSnapshot] 同一事务快照内; - * 粒度按范围时长由 [TokenStatsTimeRanges.granularityFor] 选择。 - */ suspend fun rangeData( - dao: TokenStatsDao, + context: Context, range: TokenStatsTimeRange, params: TokenStatsQueryParams, zone: ZoneId, - legacyPrices: Map = emptyMap(), ): TokenStatsRangeData { - val snapshot = dao.loadRangeSnapshot( + val repository = TokenUsageRepository.getInstance(context) + repository.ensureInitialized() + val dao = repository.dao + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + val modelRows = dao.aggregateModelsInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val displayModels = buildDisplayModels(modelRows, prices, params) + val summary = combineTotals(displayModels.map(TokenStatsDisplayModelBreakdown::totals), params) + val granularity = TokenStatsTimeRanges.granularityFor(range) + val starts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) + val buckets = starts.mapIndexed { index, bucketStart -> + val bucketEnd = minOf( + range.endMs, + TokenStatsTimeRanges.bucketEndMs(starts, index, granularity, zone), + ) + val bucketRows = dao.aggregateModelsInRange( + startMs = maxOf(range.startMs, bucketStart), + endMs = bucketEnd, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val models = buildDisplayModels(bucketRows, prices, params) + TokenStatsTrendBucket( + bucketStartMs = bucketStart, + bucketEndMs = bucketEnd, + totals = combineTotals(models.map(TokenStatsDisplayModelBreakdown::totals), params), + byModel = models.associate { it.displayModelId to it.totals.toModelBucket() }, + performance = performanceOf(bucketRows), + ) + } + val categoryRows = dao.aggregateCategoriesInRange( startMs = range.startMs, endMs = range.endMs, - displayModelIds = params.displayModelIds?.toList(), - includeOverrides = params.mode == TokenStatsCostMode.REVALUED, - ) - return TokenStatsAggregator.rangeData( - events = snapshot.events, - identitiesById = snapshot.identitiesById, - displayModelsById = snapshot.displayModelsById, - overrides = snapshot.overrides, - legacyPrices = legacyPrices, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val statusRows = dao.aggregateStatusesInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + return TokenStatsRangeData( range = range, - granularity = TokenStatsTimeRanges.granularityFor(range), - zone = zone, - params = params, + granularity = granularity, + eventCount = summary.totalTokens.totalEventCount, + summary = summary, + performance = performanceOf(modelRows), + buckets = buckets, + displayModels = displayModels, + categories = categoryRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsCategoryBreakdown( + TokenStatCategory.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, + statuses = statusRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsStatusBreakdown( + TokenStatStatus.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, ) } - /** 时间范围内是否存在事件(初始回退探测,每条都是索引 EXISTS 短路查询)。 */ - suspend fun rangeHasEvents(dao: TokenStatsDao, range: TokenStatsTimeRange): Boolean = - dao.rangeHasEvents(range.startMs, range.endMs) + internal suspend fun activitySnapshot( + context: Context, + range: TokenStatsTimeRange, + params: TokenStatsQueryParams, + zone: ZoneId, + ): TokenActivitySnapshot { + val repository = TokenUsageRepository.getInstance(context) + repository.ensureInitialized() + val days = repository.dao.getActivityDaysInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + return TokenActivitySnapshot( + zone = zone, + dayTotals = + days.groupBy(TokenUsageActivityDayRow::localDate).mapValues { (_, rows) -> + rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } + }.mapKeys { (date, _) -> LocalDate.parse(date) }, + ) + } - suspend fun rangeHasEvents(context: Context, range: TokenStatsTimeRange): Boolean = - withContext(queryDispatcher) { - TokenStatSpool.withStatsDatabaseAccess { - rangeHasEvents(daoOf(context), range) + private fun buildDisplayModels( + rows: List, + prices: TokenPriceSettingsSnapshot, + params: TokenStatsQueryParams, + ): List = + rows.groupBy { row -> displayModelIdFor(row.model) } + .map { (displayModelId, groupRows) -> + val identities = groupRows.map { row -> + TokenStatsIdentityBreakdown( + configId = row.configId, + provider = row.provider, + model = row.model, + totals = row.toTotals(prices, params), + ) + } + TokenStatsDisplayModelBreakdown( + displayModelId = displayModelId, + displayName = groupRows.first().model, + normalizedModel = groupRows.first().model.trim().lowercase(), + totals = combineTotals(identities.map(TokenStatsIdentityBreakdown::totals), params), + identities = identities, + providerModels = groupRows.map(TokenUsageModelAggregateRow::providerModel).distinct(), + ) } - } + .sortedByDescending { it.totals.totalTokens.knownSum } - /** - * 全局 Token 活动快照;不应用统计页筛选,也不包含旧 baseline。Room 在同一事务内 - * keyset 分页,页面逐批压缩为按日 token 与按小时请求数,不保留完整历史事件列表。 - */ - internal suspend fun activitySnapshot(dao: TokenStatsDao, zone: ZoneId): TokenActivitySnapshot { - val accumulator = TokenActivityAccumulator(zone) - dao.loadActivitySnapshot(activityEventPageSize) { page -> accumulator.addPage(page) } - return accumulator.snapshot() + private fun TokenUsageModelAggregateRow.toTotals( + prices: TokenPriceSettingsSnapshot, + params: TokenStatsQueryParams, + ): TokenStatsTotals { + val pricing = TokenPriceResolver.resolve(providerModel, prices.settingFor(providerModel, configId)) + val input = component(uncachedInputTokens, uncachedInputKnown, usageRows) + val cached = component(cachedInputTokens, cachedInputKnown, usageRows) + val cacheWrite = component(cacheWriteTokens, cacheWriteKnown, usageRows) + val totalInput = + if (totalInputKnown > 0L) { + component(totalInputTokens, totalInputKnown, usageRows) + } else { + combineComponents(listOf(input, cached, cacheWrite), usageRows) + } + val output = component(outputTokens, outputKnown, usageRows) + val reasoning = component(reasoningTokens, reasoningKnown, usageRows) + val totalTokens = combineComponents(listOf(totalInput, output), usageRows) + return TokenStatsTotals( + requests = requests, + requestCountUnknownContributionCount = + (usageRows - requestCountKnown).coerceAtLeast(0L), + uncachedInput = input, + cachedInput = cached, + cacheWrite = cacheWrite, + totalInput = totalInput, + output = output, + reasoning = reasoning, + totalTokens = totalTokens, + cost = TokenCostCalculator.currentCost(this, pricing, params.targetCurrency, params.manualRate), + ) } - internal suspend fun activitySnapshot(context: Context, zone: ZoneId): TokenActivitySnapshot = - withContext(queryDispatcher) { - TokenStatSpool.withStatsDatabaseAccess { - activitySnapshot(daoOf(context), zone) - } - } + private fun TokenUsageBreakdownRow.asModelRow() = TokenUsageModelAggregateRow( + provider = provider, + model = model, + configId = configId, + requests = requests, + requestCountKnown = requestCountKnown, + usageRows = usageRows, + uncachedInputTokens = uncachedInputTokens, + uncachedInputKnown = uncachedInputKnown, + cachedInputTokens = cachedInputTokens, + cachedInputKnown = cachedInputKnown, + cacheWriteTokens = cacheWriteTokens, + cacheWriteKnown = cacheWriteKnown, + totalInputTokens = totalInputTokens, + totalInputKnown = totalInputKnown, + outputTokens = outputTokens, + outputKnown = outputKnown, + reasoningTokens = reasoningTokens, + reasoningKnown = reasoningKnown, + ttftTotalMs = ttftTotalMs, + ttftSamples = ttftSamples, + durationTotalMs = durationTotalMs, + durationSamples = durationSamples, + ) - /** - * 进入统计页时的自动回退建议:按 `5h -> 12h -> 24h -> 7d -> 30d` 顺序返回 - * 最近有实际事件的范围;全部为空时返回 5h。 - * “用户手选后不再自动跳转”由调用方(阶段 4 UI/ViewModel)持久化, - * 本函数只计算建议,不改变任何状态。 - */ - suspend fun initialPresetWithData( - dao: TokenStatsDao, - zone: ZoneId, - nowMs: Long, - ): TokenStatsPreset { - for (preset in TokenStatsPreset.INITIAL_FALLBACK_ORDER) { - val range = TokenStatsTimeRanges.rangeFor(preset, nowMs, zone) - if (dao.rangeHasEvents(range.startMs, range.endMs)) return preset - } - return TokenStatsPreset.LAST_5H + private fun combineTotals( + values: List, + params: TokenStatsQueryParams, + ): TokenStatsTotals { + if (values.isEmpty()) return emptyTotals(params) + return TokenStatsTotals( + requests = values.sumLong(TokenStatsTotals::requests), + requestCountUnknownContributionCount = + values.sumLong(TokenStatsTotals::requestCountUnknownContributionCount), + uncachedInput = values.combineComponents(TokenStatsTotals::uncachedInput), + cachedInput = values.combineComponents(TokenStatsTotals::cachedInput), + cacheWrite = values.combineComponents(TokenStatsTotals::cacheWrite), + totalInput = values.combineComponents(TokenStatsTotals::totalInput), + output = values.combineComponents(TokenStatsTotals::output), + reasoning = values.combineComponents(TokenStatsTotals::reasoning), + totalTokens = values.combineComponents(TokenStatsTotals::totalTokens), + cost = TokenStatsCostSummary( + currency = params.targetCurrency, + knownAmount = values.sumOf { it.cost.knownAmount }, + unknownContributionCount = values.sumLong { it.cost.unknownContributionCount }, + totalContributionCount = values.sumLong { it.cost.totalContributionCount }, + rateUsed = params.manualRate, + originalCurrencyAmounts = values + .flatMap { it.cost.originalCurrencyAmounts.entries } + .groupBy({ it.key }, { it.value }) + .mapValues { (_, amounts) -> amounts.sum() }, + ), + ) } - // ==== 生产入口(Context 解析数据库/旧价格;汇率由调用方在 params 中提供) ==== - - suspend fun lifetimeOverview(context: Context, params: TokenStatsQueryParams): TokenStatsLifetimeOverview = - withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> - lifetimeOverview(dao, params, legacyPrices) - } + private fun performanceOf(rows: List): TokenStatsPerformance { + val usageRows = rows.sumLong(TokenUsageModelAggregateRow::usageRows) + val ttftSamples = rows.sumLong(TokenUsageModelAggregateRow::ttftSamples) + val ttftTotal = rows.sumLong(TokenUsageModelAggregateRow::ttftTotalMs) + val durationSamples = rows.sumLong(TokenUsageModelAggregateRow::durationSamples) + val durationTotal = rows.sumLong(TokenUsageModelAggregateRow::durationTotalMs) + return TokenStatsPerformance( + ttft = duration(ttftTotal, ttftSamples, usageRows), + generationDuration = duration(durationTotal, durationSamples, usageRows), + ) + } - suspend fun rangeData( - context: Context, - range: TokenStatsTimeRange, - params: TokenStatsQueryParams, - zone: ZoneId = ZoneId.systemDefault(), - ): TokenStatsRangeData = - withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> - rangeData(dao, range, params, zone, legacyPrices) - } + private fun TokenStatsTotals.toModelBucket() = TokenStatsModelBucket( + requests, + requestCountUnknownContributionCount, + uncachedInput.knownSum, + cachedInput.knownSum, + cacheWrite.knownSum, + output.knownSum, + reasoning.knownSum, + totalTokens.knownSum, + totalTokens.unknownEventCount, + maxOf( + uncachedInput.unknownEventCount, + cachedInput.unknownEventCount, + output.unknownEventCount, + ), + cost, + ) - suspend fun presetRangeData( - context: Context, - preset: TokenStatsPreset, - params: TokenStatsQueryParams, - zone: ZoneId = ZoneId.systemDefault(), - nowMs: Long = System.currentTimeMillis(), - ): TokenStatsRangeData = - withDatabaseAndLegacyPrices(context, params) { dao, legacyPrices -> - rangeData( - dao, - TokenStatsTimeRanges.rangeFor(preset, nowMs, zone), - params, - zone, - legacyPrices, - ) - } + private fun emptyTotals(params: TokenStatsQueryParams): TokenStatsTotals { + val empty = component(0L, 0L, 0L) + return TokenStatsTotals( + 0L, + 0L, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + TokenStatsCostSummary( + params.targetCurrency, + 0.0, + 0L, + 0L, + params.manualRate, + emptyMap(), + ), + ) + } - suspend fun initialPresetWithData( - context: Context, - zone: ZoneId = ZoneId.systemDefault(), - nowMs: Long = System.currentTimeMillis(), - ): TokenStatsPreset = - withContext(queryDispatcher) { - TokenStatSpool.withStatsDatabaseAccess { - initialPresetWithData(daoOf(context), zone, nowMs) - } - } + private fun component(sum: Long, known: Long, total: Long) = + TokenStatsTokenAggregate(sum, known, (total - known).coerceAtLeast(0L), total) - /** - * 生产入口统一骨架(P1-2/P2-3): - * 1. 显式切到 [queryDispatcher] 执行 Room + 聚合(Main 不阻塞); - * 2. **先**读旧价格一次快照(DataStore,Room 事务内不能挂起 DataStore); - * 3. **再**解析数据库一次并进入 Room 快照事务; - * 4. 事务外纯聚合。 - */ - private suspend fun withDatabaseAndLegacyPrices( - context: Context, - params: TokenStatsQueryParams, - block: suspend (TokenStatsDao, Map) -> T, - ): T { - val appContext = context.applicationContext - return withContext(queryDispatcher) { - val legacyPrices = readLegacyPrices(appContext, params) - TokenStatSpool.withStatsDatabaseAccess { - val database = - databaseProvider?.invoke(appContext) ?: AppDatabase.getDatabase(appContext) - block(database.tokenStatsDao(), legacyPrices) - } - } + private fun combineComponents( + components: List, + contributionCount: Long, + ): TokenStatsTokenAggregate { + val known = components.minOfOrNull(TokenStatsTokenAggregate::knownEventCount) ?: 0L + return TokenStatsTokenAggregate( + knownSum = components.sumLong(TokenStatsTokenAggregate::knownSum), + knownEventCount = known, + unknownEventCount = (contributionCount - known).coerceAtLeast(0L), + totalEventCount = contributionCount, + ) } - private suspend fun daoOf(context: Context): TokenStatsDao { - val appContext = context.applicationContext - val injected = databaseProvider - return (injected?.invoke(appContext) ?: AppDatabase.getDatabase(appContext)).tokenStatsDao() + private fun List.combineComponents( + selector: (TokenStatsTotals) -> TokenStatsTokenAggregate, + ): TokenStatsTokenAggregate { + val values = map(selector) + return TokenStatsTokenAggregate( + knownSum = values.sumLong(TokenStatsTokenAggregate::knownSum), + knownEventCount = values.sumLong(TokenStatsTokenAggregate::knownEventCount), + unknownEventCount = values.sumLong(TokenStatsTokenAggregate::unknownEventCount), + totalEventCount = values.sumLong(TokenStatsTokenAggregate::totalEventCount), + ) } - /** - * 重估口径的旧系统价格:**单次快照读取**整表(不逐 identity 反复读 DataStore)。 - * 顺序契约:必须在 Room 快照事务之前读取(见 [withDatabaseAndLegacyPrices])。 - */ - private suspend fun readLegacyPrices( - context: Context, - params: TokenStatsQueryParams, - ): Map { - if (params.mode != TokenStatsCostMode.REVALUED) return emptyMap() - val injected = legacyPricesProvider - return if (injected != null) { - injected(context) - } else { - ApiPreferences.getInstance(context).allLegacyPriceSettings() - } + private fun duration(totalMs: Long, samples: Long, contributionCount: Long) = + TokenStatsDurationAggregate( + knownCount = samples, + unknownCount = (contributionCount - samples).coerceAtLeast(0L), + totalMs = totalMs, + averageMs = if (samples > 0L) totalMs.toDouble() / samples else 0.0, + ) + + private fun Iterable.sumLong(selector: (T) -> Long): Long = + fold(0L) { sum, item -> TokenCostCalculator.saturatedAdd(sum, selector(item)) } + + private fun Set?.queryValues(): List = + if (this == null || isEmpty()) listOf("__none__") else toList() + + private fun > Set?.namesForQuery(): List = + if (this == null || isEmpty()) listOf("__none__") else map { it.name } + + private fun displayModelIdFor(model: String): String = "model:${model.trim().lowercase()}" + + private fun List.toPriceSnapshot(): TokenPriceSettingsSnapshot { + val priceRows = filter(TokenStatsModelEntity::hasPriceSetting) + return TokenPriceSettingsSnapshot( + providerModels = priceRows + .filter { it.configId.isEmpty() } + .associate { row -> "${row.provider}:${row.model}" to row.toModelPriceSettings() }, + configs = priceRows + .filter { it.configId.isNotEmpty() } + .associate { row -> + tokenPriceConfigKey("${row.provider}:${row.model}", row.configId) to + row.toModelPriceSettings() + }, + ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt index a73721f79..e97e13161 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -1,20 +1,18 @@ package com.ai.assistance.operit.data.stats import android.content.Context +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import java.util.UUID +import com.ai.assistance.operit.data.model.TokenStatsModelEntity -/** 价格覆盖编辑草稿(界面 ↔ 管理入口的统一输入形态)。 */ -data class TokenStatsPriceOverrideDraft( - val scope: PriceOverrideScope, +enum class TokenStatsPriceScope { PROVIDER_MODEL, CONFIG } + +data class TokenStatsPriceDraft( + val scope: TokenStatsPriceScope, val provider: String, val model: String, - val configId: String?, + val configId: String? = null, val billingMode: BillingMode, val currency: PricingCurrency, val inputPricePerMillion: Double? = null, @@ -24,271 +22,160 @@ data class TokenStatsPriceOverrideDraft( val pricePerRequest: Double? = null, ) -/** - * 统计页设置管理(阶段 4):价格覆盖与模型别名/分组的**受控**写入入口。 - * - * - 价格覆盖:所有价格值必须为非负有限数(NaN/Infinity/负数直接抛 - * [IllegalArgumentException],不落库);写入走 [TokenStatsDao.upsertPriceOverride] - * 的规范化唯一入口(scope 枚举 + 规范化业务字段),删除走规范化业务组合。 - * - 分组/别名:身份只通过 [TokenStatsDao] 的安全 UPDATE 移动,绝不 REPLACE - * (REPLACE 会经外键级联删除事件);展示模型行用 INSERT IGNORE / UPDATE。 - * - 本类只做校验与编排,事务边界在 DAO(@Transaction)保证。 - */ -class TokenStatsSettingsManager private constructor( - private val injectedDao: TokenStatsDao?, - private val appContext: Context?, -) { - - /** DAO 注入仅用于测试;生产入口必须在访问 token 登记后再解析 Room。 */ - constructor(dao: TokenStatsDao) : this(dao, null) - - constructor(context: Context) : this(null, context.applicationContext) - - companion object { - /** 自定义分组 displayModelId 前缀(与任何规范化模型名天然不冲突)。 */ - const val CUSTOM_GROUP_ID_PREFIX = "custom-group-" - } - - private suspend fun withDao(block: suspend (TokenStatsDao) -> T): T { - val dao = injectedDao - if (dao != null) return block(dao) - val context = checkNotNull(appContext) - return TokenStatSpool.withStatsDatabaseAccess { - block(AppDatabase.getDatabase(context).tokenStatsDao()) - } - } +data class TokenStatsPriceSetting( + val scope: TokenStatsPriceScope, + val providerModel: String, + val provider: String, + val model: String, + val configId: String?, + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double?, + val cachedInputPricePerMillion: Double?, + val cacheWritePricePerMillion: Double?, + val outputPricePerMillion: Double?, + val pricePerRequest: Double?, +) - // ==== 价格覆盖 ==== +class TokenStatsSettingsManager(context: Context) { + private val appContext = context.applicationContext + private val repository = TokenUsageRepository.getInstance(appContext) + private val dao = repository.dao - /** 校验单个价格输入:null 允许(该计费方式不使用),非空必须非负有限。 */ fun validatePriceValue(name: String, value: Double?): Double? { if (value == null) return null - require(value.isFinite() && value >= 0.0) { - "$name must be non-negative and finite, got $value" + require(value.isFinite() && value > 0.0) { + "$name must be positive and finite, got $value" } return value } - /** - * 新增/编辑价格覆盖(provider/model 或 API 配置作用域)。 - * 价格值非法时抛 [IllegalArgumentException],不产生任何写入。 - */ - suspend fun upsertPriceOverride( - scope: PriceOverrideScope, - provider: String, - model: String, - configId: String?, - billingMode: BillingMode, - pricingCurrency: PricingCurrency, - inputPricePerMillion: Double?, - cachedInputPricePerMillion: Double?, - cacheWritePricePerMillion: Double?, - outputPricePerMillion: Double?, - pricePerRequest: Double?, - ) { - withDao { dao -> - dao.upsertPriceOverride( - scope = scope.name, - provider = provider, - model = model, - configId = configId, - billingMode = billingMode.name, - pricingCurrency = pricingCurrency.name, + suspend fun savePrice(draft: TokenStatsPriceDraft) { + repository.ensureInitialized() + val provider = draft.provider.trim() + val model = draft.model.trim() + val configId = draft.configId?.trim().orEmpty() + require(provider.isNotEmpty()) { "provider must not be blank" } + require(model.isNotEmpty()) { "model must not be blank" } + require(draft.scope != TokenStatsPriceScope.CONFIG || configId.isNotEmpty()) { + "configId must not be blank for config pricing" + } + val storageConfigId = + if (draft.scope == TokenStatsPriceScope.PROVIDER_MODEL) "" else configId + val current = + dao.getStatsModel(storageConfigId, provider, model) + ?: TokenStatsModelEntity(storageConfigId, provider, model) + dao.upsertStatsModel( + current.copy( + billingMode = draft.billingMode.name, + currency = draft.currency.name, inputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("inputPrice", inputPricePerMillion) - } else null, + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("inputPrice", draft.inputPricePerMillion) + } else { + null + }, cachedInputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("cachedInputPrice", cachedInputPricePerMillion) - } else null, + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cachedInputPrice", draft.cachedInputPricePerMillion) + } else { + null + }, cacheWritePricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("cacheWritePrice", cacheWritePricePerMillion) - } else null, + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cacheWritePrice", draft.cacheWritePricePerMillion) + } else { + null + }, outputPricePerMillion = - if (billingMode == BillingMode.TOKEN) { - validatePriceValue("outputPrice", outputPricePerMillion) - } else null, + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("outputPrice", draft.outputPricePerMillion) + } else { + null + }, pricePerRequest = - if (billingMode == BillingMode.COUNT) { - validatePriceValue("pricePerRequest", pricePerRequest) - } else null, - ) - } - } - - /** 草稿形态的统一入口(阶段 4 UI 使用)。 */ - suspend fun upsertPriceOverride(draft: TokenStatsPriceOverrideDraft) { - upsertPriceOverride( - scope = draft.scope, - provider = draft.provider, - model = draft.model, - configId = draft.configId, - billingMode = draft.billingMode, - pricingCurrency = draft.currency, - inputPricePerMillion = draft.inputPricePerMillion, - cachedInputPricePerMillion = draft.cachedInputPricePerMillion, - cacheWritePricePerMillion = draft.cacheWritePricePerMillion, - outputPricePerMillion = draft.outputPricePerMillion, - pricePerRequest = draft.pricePerRequest, - ) - } - - /** - * 编辑已有价格覆盖(P1-7):业务键(scope/provider/model/configId)必须与 - * 现有行**规范化后一致**,只允许修改价格/币种/计费方式。防止 UI 之外 - * (或 UI 缺陷)改动业务键产生第二行或误覆盖其他覆盖。 - * 校验通过后按规范化值写回(REPLACE 覆盖同一业务组合,始终只有一行)。 - * @throws IllegalArgumentException 业务键不一致或价格非法。 - */ - suspend fun updatePriceOverride( - existing: TokenStatPriceOverrideEntity, - draft: TokenStatsPriceOverrideDraft, - ) { - val normalized = TokenStatPriceOverrideEntity.normalized( - scope = draft.scope.name, - provider = draft.provider, - model = draft.model, - configId = draft.configId, - billingMode = draft.billingMode.name, - pricingCurrency = draft.currency.name, - ) - require(normalized.scope == existing.scope) { "price override scope must not change on edit" } - require(normalized.provider == existing.provider) { "price override provider must not change on edit" } - require(normalized.model == existing.model) { "price override model must not change on edit" } - require(normalized.configId == existing.configId) { "price override configId must not change on edit" } - upsertPriceOverride(draft) - } - - /** 全部价格覆盖(管理区展示用;小表,一次读取)。 */ - suspend fun allPriceOverrides(): List = - withDao { dao -> - dao.getAllPriceOverrides().map { row -> - val normalized = row.activeBillingFieldsOnly() - if (normalized != row) { - dao.upsertPriceOverride( - scope = normalized.scope, - provider = normalized.provider, - model = normalized.model, - configId = normalized.configId, - billingMode = normalized.billingMode, - pricingCurrency = normalized.pricingCurrency, - inputPricePerMillion = normalized.inputPricePerMillion, - cachedInputPricePerMillion = normalized.cachedInputPricePerMillion, - cacheWritePricePerMillion = normalized.cacheWritePricePerMillion, - outputPricePerMillion = normalized.outputPricePerMillion, - pricePerRequest = normalized.pricePerRequest, - ) - } - normalized - } - } - - private fun TokenStatPriceOverrideEntity.activeBillingFieldsOnly(): TokenStatPriceOverrideEntity = - if (BillingMode.fromString(billingMode) == BillingMode.COUNT) { - copy( - inputPricePerMillion = null, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = null, - ) - } else { - copy(pricePerRequest = null) - } - - /** 删除价格覆盖(按规范化业务组合;不存在的组合静默成功)。 */ - suspend fun deletePriceOverride( - scope: PriceOverrideScope, - provider: String, - model: String, - configId: String?, - ) { - withDao { dao -> - dao.deletePriceOverride( - scope = scope.name, - // 与写入同一规范化:provider trim+小写、model trim+小写+压缩空白 - provider = provider.trim().lowercase(), - model = model.trim().lowercase().replace(Regex("\\s+"), " "), - configId = - if (scope == PriceOverrideScope.PROVIDER_MODEL) { - "" + if (draft.billingMode == BillingMode.COUNT) { + validatePriceValue("pricePerRequest", draft.pricePerRequest) } else { - configId?.trim().orEmpty() + null }, ) - } + ) } - // ==== 展示分组 / 别名 ==== - - /** - * 完整展示分组元数据(阶段 4 P1 修复):与统计筛选(时间/模型/分类/状态) - * 完全无关——分组管理与合并的成员/目标必须来自全量身份/展示模型表,而不是 - * 当前筛选范围所见(范围明细只反映有事件的身份/分组,会把无事件的组成员 - * 漏掉)。返回所有分组(含无身份的空组,空组仍是合法合并目标);组名取 - * display_models 行,缺失时回退 displayModelId;输出顺序确定。 - */ - suspend fun groupModels(): List { - return withDao { dao -> - val snapshot = dao.loadGroupMetadataSnapshot() - val displayNames = snapshot.displayModels.associateBy { it.displayModelId } - val membersByGroup = LinkedHashMap>() - for (identity in snapshot.identities) { - membersByGroup.getOrPut(identity.displayModelId) { mutableListOf() } - .add( - TokenStatsGroupMemberInfo( - identityId = identity.identityId, - configId = identity.configId, - provider = identity.provider, - model = identity.model, - ) - ) - } - // 有展示行但无身份的空分组:仍可作为合并目标,必须保留 - for (display in snapshot.displayModels) { - membersByGroup.putIfAbsent(display.displayModelId, mutableListOf()) - } - membersByGroup.map { (displayModelId, members) -> - val sortedMembers = members.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() }, { it.configId })) - TokenStatsGroupModelInfo( - displayModelId = displayModelId, - displayName = displayNames[displayModelId]?.displayName ?: displayModelId, - memberIdentityIds = sortedMembers.map { it.identityId }, - members = sortedMembers, + suspend fun allPriceSettings(): List { + repository.ensureInitialized() + return dao.getAllStatsModels() + .filter(TokenStatsModelEntity::hasPriceSetting) + .map(TokenStatsModelEntity::toPriceSetting) + .sortedWith( + compareBy( + { it.providerModel.lowercase() }, + { it.scope.ordinal }, + { it.configId.orEmpty().lowercase() }, ) - }.sortedWith(compareBy({ it.displayName.lowercase() }, { it.displayModelId })) - } + ) } - /** 重命名展示分组(只改 displayName,不动身份归属)。 */ - suspend fun renameDisplayGroup(displayModelId: String, displayName: String) { - val trimmed = displayName.trim() - require(trimmed.isNotBlank()) { "display name must not be blank" } - withDao { dao -> dao.updateDisplayModelName(displayModelId, trimmed) } + suspend fun restoreBuiltInPrice(providerModel: String) { + repository.ensureInitialized() + val (provider, model) = splitProviderModel(providerModel) + dao.clearPricing("", provider, model) + dao.deleteEmptyStatsModels() } - /** 把指定身份移动到已有展示分组(分组行不存在时自动创建)。 */ - suspend fun moveIdentitiesToGroup(identityIds: List, displayModelId: String) { - require(identityIds.isNotEmpty()) { "identityIds must not be empty" } - withDao { dao -> dao.moveIdentitiesToDisplayModelTx(identityIds, displayModelId) } + suspend fun resetConfigPrice(providerModel: String, configId: String) { + repository.ensureInitialized() + require(configId.isNotBlank()) { "configId must not be blank" } + val (provider, model) = splitProviderModel(providerModel) + dao.clearPricing(configId, provider, model) + dao.deleteEmptyStatsModels() } - /** - * 新建自定义展示分组并把指定身份移入;返回新分组 displayModelId。 - * 分组 ID 使用随机后缀,避免与规范化模型名冲突。 - */ - suspend fun createGroupAndMove(groupName: String, identityIds: List): String { - val trimmed = groupName.trim() - require(trimmed.isNotBlank()) { "group name must not be blank" } - require(identityIds.isNotEmpty()) { "identityIds must not be empty" } - val groupId = "$CUSTOM_GROUP_ID_PREFIX${UUID.randomUUID()}" - withDao { dao -> dao.createDisplayGroupTx(groupId, trimmed, identityIds) } - return groupId + private fun splitProviderModel(providerModel: String): Pair { + val separator = providerModel.indexOf(':') + require(separator > 0 && separator < providerModel.lastIndex) { + "provider:model is required" + } + return providerModel.substring(0, separator) to providerModel.substring(separator + 1) } +} - /** 恢复默认规范分组:组内每个身份按其自身模型名归回默认组。 */ - suspend fun restoreDefaultGroups(displayModelId: String) { - withDao { dao -> dao.restoreDefaultGroupsTx(displayModelId) } - } +internal fun TokenStatsModelEntity.hasPriceSetting(): Boolean = + billingMode != null || + currency != null || + inputPricePerMillion != null || + cachedInputPricePerMillion != null || + cacheWritePricePerMillion != null || + outputPricePerMillion != null || + pricePerRequest != null + +internal fun TokenStatsModelEntity.toModelPriceSettings(): ModelPriceSettings = + ModelPriceSettings( + billingMode = billingMode?.let { BillingMode.valueOf(it) }, + currency = currency?.let { PricingCurrency.valueOf(it) }, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + +private fun TokenStatsModelEntity.toPriceSetting(): TokenStatsPriceSetting { + val providerModel = "$provider:$model" + val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) + return TokenStatsPriceSetting( + scope = if (configId.isEmpty()) TokenStatsPriceScope.PROVIDER_MODEL else TokenStatsPriceScope.CONFIG, + providerModel = providerModel, + provider = provider, + model = model, + configId = configId.ifEmpty { null }, + billingMode = billingMode?.let { BillingMode.valueOf(it) } ?: defaults.billingMode, + currency = currency?.let { PricingCurrency.valueOf(it) } ?: defaults.currency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt index 59f14b2df..76af029d0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt @@ -2,102 +2,58 @@ package com.ai.assistance.operit.data.stats import android.content.Context import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.preferences.ApiPreferences -/** - * 统计页持久化偏好(阶段 4):汇率、总计币种、费用口径与时间选择。 - * - * - 全部键落在 ApiPreferences 的 `api_settings` DataStore 文件内,由现有 - * 整库/ApiPreferences 备份恢复逻辑整体覆盖,不新增独立存储与凭据。 - * - 汇率**只由用户手动设置**:未设置时 [loadRateWithEstimate] 返回默认估算 - * 7.0([TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE])并标记 estimated, - * 界面必须明显标注“默认估算”;不联网获取汇率。 - * - 时间选择持久化用户是否手动选过;进入页面时,自动选择会重新探测,手动滚动 - * 范围仅在自身无数据时扩展,自定义/日历范围保持用户选择。 - */ interface TokenStatsSettingsStore { - - /** 当前手动汇率 + 是否默认估算(true = 未设置,界面必须标记估算)。 */ suspend fun loadRateWithEstimate(): Pair - /** 保存用户手动汇率;保存后 [loadRateWithEstimate] 的 estimated 为 false。 */ suspend fun saveRate(rate: Double) suspend fun loadTargetCurrency(): PricingCurrency suspend fun saveTargetCurrency(currency: PricingCurrency) - suspend fun loadCostMode(): TokenStatsCostMode - - suspend fun saveCostMode(mode: TokenStatsCostMode) - - /** 是否在生命周期累计中加入迁移的旧版 baseline;默认 true。 */ - suspend fun loadIncludeLegacy(): Boolean - - suspend fun saveIncludeLegacy(include: Boolean) + suspend fun loadTimeRange(): TokenStatsTimeRange? - /** - * 当前时间选择(首次自动回退结果或用户手选);null = 从未有任何选择, - * 进入页面时允许执行首次自动回退并持久化。 - */ - suspend fun loadTimeSelection(): TokenStatsTimeSelection? - - /** 当前时间选择是否由用户手动做出;false = 自动回退(或旧数据迁移)。 */ - suspend fun loadSelectionWasManual(): Boolean - - /** - * 保存/清除时间选择;[manual] = 用户手动选择(true)或首次自动回退 - * (false)。null 表示清除(回到首次自动回退语义)。 - */ - suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) + suspend fun saveTimeRange(range: TokenStatsTimeRange?) } -/** 用户选择的时间范围(持久化形态):预设 + 可选自定义边界(毫秒,设备时区自然日)。 */ -data class TokenStatsTimeSelection( - val preset: TokenStatsPreset, - val customStartMs: Long? = null, - val customEndMs: Long? = null, -) +/** Statistics-only Preferences implementation; structured data remains in Room. */ +class TokenStatsPreferencesStore(context: Context) : TokenStatsSettingsStore { + private val appContext = context.applicationContext + private val repository = TokenUsageRepository.getInstance(appContext) + private val preferences = TokenStatsPreferences(appContext) -/** 生产实现:直接包装 [ApiPreferences](同一 DataStore 文件,备份自动覆盖)。 */ -class ApiPreferencesTokenStatsSettingsStore(context: Context) : TokenStatsSettingsStore { - - private val api = ApiPreferences.getInstance(context) + private suspend fun initialize() { + repository.ensureInitialized() + } - override suspend fun loadRateWithEstimate(): Pair = - api.usdToCnyRateWithEstimate() + override suspend fun loadRateWithEstimate(): Pair { + initialize() + return preferences.loadRateWithEstimate() + } override suspend fun saveRate(rate: Double) { - api.setUsdToCnyExchangeRate(rate) + initialize() + preferences.saveRate(rate) } - override suspend fun loadTargetCurrency(): PricingCurrency = - api.getStatsTargetCurrency() - - override suspend fun saveTargetCurrency(currency: PricingCurrency) { - api.setStatsTargetCurrency(currency) + override suspend fun loadTargetCurrency(): PricingCurrency { + initialize() + return preferences.loadTargetCurrency() } - override suspend fun loadCostMode(): TokenStatsCostMode = - api.getStatsCostMode() - - override suspend fun saveCostMode(mode: TokenStatsCostMode) { - api.setStatsCostMode(mode) + override suspend fun saveTargetCurrency(currency: PricingCurrency) { + initialize() + preferences.saveTargetCurrency(currency) } - override suspend fun loadIncludeLegacy(): Boolean = api.getStatsIncludeLegacy() - - override suspend fun saveIncludeLegacy(include: Boolean) { - api.setStatsIncludeLegacy(include) + override suspend fun loadTimeRange(): TokenStatsTimeRange? { + initialize() + return preferences.loadTimeRange() } - override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = - api.getStatsTimeSelection() - - override suspend fun loadSelectionWasManual(): Boolean = - api.getStatsSelectionWasManual() - - override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { - api.setStatsTimeSelection(selection, manual) + override suspend fun saveTimeRange(range: TokenStatsTimeRange?) { + initialize() + preferences.saveTimeRange(range) } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt deleted file mode 100644 index 70d1a6742..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinator.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import com.ai.assistance.operit.util.AppLogger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.withTimeoutOrNull -import java.util.concurrent.TimeUnit - -/** - * 启动统计 readiness 门控(P1 关键链路)。测试可注入 no-op 或门控实现(见 - * [TokenUsageStatisticsViewModel] 的构造参数)。 - */ -fun interface TokenStatsReadiness { - /** - * 等待初始统计就绪(旧 baseline 导入 + spool 初始重放完成)。返回 true = 就绪; - * false = 超时或失败(调用方可稍后重试,就绪状态不缓存)。 - */ - suspend fun awaitReady(timeoutMs: Long): Boolean -} - -/** - * 启动统计 single-flight 初始化(P1 关键链路):依次 - * [TokenBaselineImportRunner.ensureMigrated] → [TokenBaselineImportRunner.consumePendingRestore] - * → [TokenStatSpool.awaitInitialDrain],保证统计页首次查询看到的是重放完成后的数据, - * 绝不无限展示 pre-replay 快照。 - * - * - **single-flight**:并发调用 join 同一轮初始化([inFlight] 引用只在锁内读写, - * 初始化执行本身在锁外——绝不在持有任何锁时执行 DAO/DataStore/spool 工作)。 - * - **失败不永久缓存**:一轮失败/超时后 [inFlight] 不保留,下一次调用重新执行; - * spool drain 自身另有退避重试。 - * - **无反向依赖**:本协调器只从外部调用各步骤,spool 内部/DAO 事务绝不反向 await - * 本协调器(无死锁环)。 - */ -object TokenStatsStartupCoordinator { - - private const val TAG = "TokenStatsStartupCoordinator" - - /** baseline、pending restore 与初始 drain 共用的端到端初始化预算。 */ - internal const val INITIALIZATION_TIMEOUT_MS = 60_000L - - /** [awaitInitialized] 默认等待上限。 */ - private const val DEFAULT_AWAIT_TIMEOUT_MS = 10_000L - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - /** 单飞 bookkeeping:只保护 [inFlight] 引用,绝不持锁执行初始化(避免反向死锁)。 */ - private val singleFlight = Any() - - /** 进行中的初始化轮;完成后不保留(失败/成功都不缓存,下次调用重新执行)。 */ - private var inFlight: kotlinx.coroutines.Deferred? = null - - // 测试注入缝:生产代码始终为 null,走真实实现。 - internal var ensureMigratedStep: (suspend (Context) -> Boolean)? = null - internal var consumePendingRestoreStep: (suspend (Context) -> Boolean)? = null - internal var initialDrainStep: (suspend (Context, Long) -> Boolean)? = null - internal var initializationTimeoutMsForTest: Long? = null - - /** - * 等待启动统计就绪(single-flight):首次调用触发初始化,并发调用 join 同一轮。 - * 返回 true = 本轮初始化成功(含幂等重放);false = 超时或失败——**不缓存**, - * 后续调用重新执行(失败的 spool drain 有退避重试,成功后即可重试成功)。 - */ - suspend fun awaitInitialized( - context: Context, - timeoutMs: Long = DEFAULT_AWAIT_TIMEOUT_MS, - ): Boolean { - val appContext = context.applicationContext - val job = synchronized(singleFlight) { - inFlight?.takeIf { it.isActive } - ?: scope.async { - val budgetMs = initializationTimeoutMsForTest ?: INITIALIZATION_TIMEOUT_MS - val deadlineNanos = - System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(budgetMs) - withTimeoutOrNull(budgetMs) { - runInitialization(appContext, deadlineNanos) - } ?: false - }.also { inFlight = it } - } - // 超时只停止本次等待(初始化继续,幂等;调用方取消则向上传播)。 - return withTimeoutOrNull(timeoutMs) { job.await() } ?: false - } - - /** 生产 readiness(绑定 applicationContext;测试可注入 no-op 或门控实现)。 */ - fun readiness(context: Context): TokenStatsReadiness = - TokenStatsReadiness { timeoutMs -> awaitInitialized(context, timeoutMs) } - - private suspend fun runInitialization(context: Context, deadlineNanos: Long): Boolean { - return try { - val migrated = ensureMigratedStep - val migrationReady = - if (migrated != null) migrated(context) - else TokenBaselineImportRunner.ensureMigratedStrict(context) - if (!migrationReady) return false - // 审计 P1:崩溃遗留的恢复 REPLACING 标记必须在任何 spool replay 之前消费 - // (清理旧 spool + 删除标记);失败即初始化失败,绝不带不确定状态开始 replay。 - TokenStatSpool.consumeAbandonedRestoreIfAny(context) - val restore = consumePendingRestoreStep - val restoreReady = - if (restore != null) restore(context) - else true - if (!restoreReady) return false - val remainingMs = - TimeUnit.NANOSECONDS.toMillis(deadlineNanos - System.nanoTime()) - if (remainingMs <= 0L) return false - val drain = initialDrainStep - if (drain != null) { - drain(context, remainingMs) - } else { - TokenStatSpool.awaitInitialDrain(context, remainingMs) - } - } catch (e: CancellationException) { - // 取消必须向上传播(scope.async 的任务被外部取消时正常清理) - throw e - } catch (e: Exception) { - // 失败不缓存:记日志并返回 false,下一次调用重新执行 - runCatching { AppLogger.e(TAG, "启动统计初始化失败(不缓存,可重试)", e) } - false - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt index c354bf305..c3a72cc62 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt @@ -4,39 +4,6 @@ import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime -/** - * 统计时间预设(阶段 3 契约,见计划 §2.5): - * - * - 5h/12h/24h 为**滚动窗口**:`[now - 时长, now)`。 - * - 今天/昨天/7d/30d/本月/上月为**自然日/自然月**:按设备当前时区([ZoneId]) - * 的日历边界计算,禁止用固定毫秒加减替代日历运算(DST 与跨月自动正确)。 - * - 7d = 今天往前 7 个自然日(含今天);30d = 今天往前 30 个自然日(含今天), - * 与 token 统计参考实现一致。 - * - 自定义范围显式传 [TokenStatsTimeRanges.customRange]。 - * - * “首次进入自动回退”只选择最近有实际数据的前缀(5h→12h→24h→7d→30d,见 - * [TokenStatsPreset.INITIAL_FALLBACK_ORDER]);用户手动选择后不再自动跳转是 - * 调用方(阶段 4 UI/ViewModel)的持久化职责,本层只提供纯函数建议。 - */ -enum class TokenStatsPreset { - LAST_5H, - LAST_12H, - LAST_24H, - TODAY, - YESTERDAY, - LAST_7D, - LAST_30D, - THIS_MONTH, - LAST_MONTH, - CUSTOM; - - companion object { - /** 首次进入的自动回退顺序:依次选择最近有实际事件的范围。 */ - val INITIAL_FALLBACK_ORDER: List = - listOf(LAST_5H, LAST_12H, LAST_24H, LAST_7D, LAST_30D) - } -} - /** * 时间范围,**半开区间** `[startMs, endMs)`:`startedAtMs == endMs` 的事件 * 不属于该范围;endMs 是下一边界(如次日 0 点),不是包含式终点。 @@ -58,12 +25,12 @@ enum class TokenStatsGranularity { } /** - * 时间预设边界与图表桶对齐计算。 + * 日历范围的图表桶对齐计算。 * * 桶边界在**本地时间**上对齐(10 分钟整点、整点小时、自然日 0 点),并用 * java.time 的 plusMinutes/plusHours/plusDays 在本地时区上推进:跨 DST 的 * 小时/日桶自动得到 23/25 小时的正确 epoch 跨度,且相邻桶起点单调递增、 - * 覆盖无空洞(回退重复的小时也会出现两个不同 epoch 的桶)。 + * 覆盖无空洞(夏令时重复的小时也会出现两个不同 epoch 的桶)。 */ object TokenStatsTimeRanges { @@ -74,24 +41,7 @@ object TokenStatsTimeRanges { /** 防御:自定义范围过大时限制桶数量,避免病态输入拖垮内存/UI。 */ private const val MAX_BUCKETS = 10_000 - fun rangeFor(preset: TokenStatsPreset, nowMs: Long, zone: ZoneId): TokenStatsTimeRange { - require(preset != TokenStatsPreset.CUSTOM) { "CUSTOM preset requires customRange(startMs, endMs)" } - val now = Instant.ofEpochMilli(nowMs).atZone(zone) - return when (preset) { - TokenStatsPreset.LAST_5H -> rollingRange(now, 5L) - TokenStatsPreset.LAST_12H -> rollingRange(now, 12L) - TokenStatsPreset.LAST_24H -> rollingRange(now, 24L) - TokenStatsPreset.TODAY -> naturalDayRange(now, 0L) - TokenStatsPreset.YESTERDAY -> naturalDayRange(now, -1L) - TokenStatsPreset.LAST_7D -> naturalDaysRange(now, 7L) - TokenStatsPreset.LAST_30D -> naturalDaysRange(now, 30L) - TokenStatsPreset.THIS_MONTH -> naturalMonthRange(now, 0L) - TokenStatsPreset.LAST_MONTH -> naturalMonthRange(now, -1L) - TokenStatsPreset.CUSTOM -> error("unreachable") - } - } - - /** 自定义范围:调用方提供显式边界,仍强制半开区间 [startMs, endMs)。 */ + /** 日历选择器提供显式边界,始终使用半开区间 `[startMs, endMs)`。 */ fun customRange(startMs: Long, endMs: Long): TokenStatsTimeRange = TokenStatsTimeRange(startMs, endMs) @@ -161,39 +111,6 @@ object TokenStatsTimeRanges { return floor } - private fun rollingRange(now: ZonedDateTime, hours: Long): TokenStatsTimeRange { - val endMs = now.toInstant().toEpochMilli() - return TokenStatsTimeRange(endMs - hours * HOUR_MS, endMs) - } - - private fun naturalDayRange(now: ZonedDateTime, offsetDays: Long): TokenStatsTimeRange { - val start = now.toLocalDate().atStartOfDay(now.zone).plusDays(offsetDays) - return TokenStatsTimeRange( - start.toInstant().toEpochMilli(), - start.plusDays(1).toInstant().toEpochMilli(), - ) - } - - /** 含今天在内往前 [days] 个自然日:`[今天-(days-1)天 0 点, 明天 0 点)`。 */ - private fun naturalDaysRange(now: ZonedDateTime, days: Long): TokenStatsTimeRange { - val todayStart = now.toLocalDate().atStartOfDay(now.zone) - val start = todayStart.minusDays(days - 1) - return TokenStatsTimeRange( - start.toInstant().toEpochMilli(), - todayStart.plusDays(1).toInstant().toEpochMilli(), - ) - } - - private fun naturalMonthRange(now: ZonedDateTime, offsetMonths: Long): TokenStatsTimeRange { - val month = now.toLocalDate().withDayOfMonth(1).plusMonths(offsetMonths) - val start = month.atStartOfDay(now.zone) - val end = month.plusMonths(1).atStartOfDay(now.zone) - return TokenStatsTimeRange( - start.toInstant().toEpochMilli(), - end.toInstant().toEpochMilli(), - ) - } - private fun truncateToBucket( zdt: ZonedDateTime, granularity: TokenStatsGranularity, diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt new file mode 100644 index 000000000..e0f3f99bc --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt @@ -0,0 +1,109 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.withTransaction +import com.ai.assistance.operit.data.dao.TokenUsageDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageIdentity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordSource +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Room owner for token usage plus the one-time cumulative-counter import. */ +class TokenUsageRepository private constructor(context: Context) { + companion object { + private const val TAG = "TokenUsageRepository" + + @Volatile + private var instance: TokenUsageRepository? = null + + fun getInstance(context: Context): TokenUsageRepository = + instance ?: synchronized(this) { + instance ?: TokenUsageRepository(context.applicationContext).also { instance = it } + } + } + + private val appContext = context.applicationContext + internal val database = AppDatabase.getDatabase(appContext) + internal val dao: TokenUsageDao = database.tokenUsageDao() + private val legacyDataSource = ApiPreferences.getInstance(appContext) + private val statsPreferences = TokenStatsPreferences(appContext) + private val importMutex = Mutex() + + @Volatile + private var initializationComplete = false + + suspend fun ensureInitialized() { + if (initializationComplete) return + importMutex.withLock { + if (initializationComplete) return + if (statsPreferences.importedAtMs() == null) { + val snapshot = legacyDataSource.readTokenStatsMigrationSnapshot() + val importedAtMs = System.currentTimeMillis() + database.withTransaction { + dao.insertRecords(snapshot.totals.map { total -> + TokenUsageRecordEntity( + importKey = TokenUsageIdentity(null, total.provider, total.model).encode(), + occurredAtMs = null, + source = TokenUsageRecordSource.REQUEST, + configId = null, + provider = total.provider, + model = total.model, + category = null, + status = null, + requestCount = total.requestCount, + uncachedInputTokens = + (total.inputTokens - total.cachedInputTokens).coerceAtLeast(0L), + cachedInputTokens = total.cachedInputTokens, + cacheWriteTokens = null, + totalInputTokens = total.inputTokens, + outputTokens = total.outputTokens, + reasoningTokens = null, + ttftMs = null, + durationMs = null, + ) + }) + snapshot.prices.forEach { price -> + val current = + dao.getStatsModel("", price.provider, price.model) + ?: TokenStatsModelEntity("", price.provider, price.model) + dao.upsertStatsModel( + current.copy( + billingMode = price.settings.billingMode?.name, + currency = price.settings.currency?.name, + inputPricePerMillion = price.settings.inputPricePerMillion, + cachedInputPricePerMillion = + price.settings.cachedInputPricePerMillion, + cacheWritePricePerMillion = + price.settings.cacheWritePricePerMillion, + outputPricePerMillion = price.settings.outputPricePerMillion, + pricePerRequest = price.settings.pricePerRequest, + ) + ) + } + } + statsPreferences.completeMigration( + importedAtMs = importedAtMs, + releasedUsdToCnyRate = snapshot.usdToCnyRate, + ) + AppLogger.i( + TAG, + "Imported ${snapshot.totals.size} cumulative totals and " + + "${snapshot.prices.size} price settings", + ) + } + legacyDataSource.clearMigratedTokenStatsData() + initializationComplete = true + } + } + + suspend fun record(record: TokenUsageRecordEntity) { + ensureInitialized() + dao.insertRecord(record) + } + +} diff --git a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt index 79edde76a..63c91d18d 100644 --- a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt +++ b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt @@ -33,6 +33,18 @@ internal object ToolPkgAiProviderRegistry { return providersById.values.sortedBy(ToolPkgAiProviderRegistration::providerId) } + fun releasedTokenProviderAliases(): Map = + buildMap { + list().forEach { registration -> + registration.releasedTokenProviderAliases.forEach { (alias, identity) -> + val previous = put(alias, identity) + require(previous == null || previous == identity) { + "Conflicting ToolPkg token provider alias: $alias" + } + } + } + } + private fun syncToolPkgRegistrations(activeContainers: List) { providersById = activeContainers diff --git a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt index b55ab6eb2..72beb31ce 100644 --- a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt +++ b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt @@ -87,7 +87,22 @@ internal data class ToolPkgAiProviderRegistration( val testConnectionFunctionSource: String? = null, val calculateInputTokensFunctionName: String, val calculateInputTokensFunctionSource: String? = null -) +) { + /** Historical counters use the full provider ID while new requests use the display name. */ + val releasedTokenProviderAliases: Map + get() { + val id = providerId.trim() + require(id.isNotEmpty()) { "ToolPkg AI provider id must not be blank" } + val display = displayName.trim() + require(display.isNotEmpty()) { "ToolPkg AI provider display name must not be blank" } + return mapOf( + id to display, + "TOOLPKG_$id" to display, + "TOOLPKG_${id.lowercase()}" to display, + display to display, + ) + } +} internal fun toolPkgPackageManager(): PackageManager { val application = OperitApplication.instance.applicationContext diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 93604a518..90e792c19 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -55,8 +55,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -77,8 +75,6 @@ import com.ai.assistance.operit.data.backup.RoomDatabaseBackupManager import com.ai.assistance.operit.data.backup.RoomDatabaseBackupPreferences import com.ai.assistance.operit.data.backup.RoomDatabaseBackupScheduler import com.ai.assistance.operit.data.backup.RoomDatabaseRestoreManager -import com.ai.assistance.operit.data.stats.TokenStatSpool -import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.data.preferences.CharacterCardManager import com.ai.assistance.operit.data.preferences.UserPreferencesManager import com.ai.assistance.operit.data.preferences.ModelConfigManager @@ -110,18 +106,11 @@ import com.ai.assistance.operit.ui.features.settings.components.SectionHeader import com.ai.assistance.operit.ui.features.settings.components.CharacterCardOperation import com.ai.assistance.operit.ui.main.MainActivity import java.io.File -import java.io.IOException -import java.nio.file.FileAlreadyExistsException -import java.nio.file.Files import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.UUID import kotlin.system.exitProcess -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -148,65 +137,6 @@ enum class RawSnapshotOperation { FAILED } -private fun formatBytes(bytes: Long): String = when { - bytes >= 1024L * 1024L -> - String.format(Locale.getDefault(), "%.1f MB", bytes / 1024.0 / 1024.0) - bytes >= 1024L -> - String.format(Locale.getDefault(), "%.1f KB", bytes / 1024.0) - else -> "$bytes B" -} - -/** 隔离证据区信息快照(P1-3:含未完成删除事务的计数与字节)。 */ -private data class QuarantineInfoSnapshot( - val evidenceCount: Int, - val evidenceBytes: Long, - val stuckTrashCount: Int, - val stuckTrashBytes: Long, - val summaryRecordCount: Int, - val summaryBytes: Long, -) - -/** - * P2:导出失败/取消时目录清理的调度缝。生产路径在 [ioDispatcher](默认 [Dispatchers.IO]) - * 执行真实递归删除,绝不运行在 Main;测试注入 dispatcher 与删除动作以覆盖失败/取消分支并 - * 断言删除不在 Main。取消分支额外使用 NonCancellable 完成本轮独占目录的有界清理,之后由 - * 调用方重抛取消。 - */ -internal object QuarantineExportCleanup { - internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO - internal var deleteRecursivelyForTest: ((File) -> Boolean?)? = null - - internal suspend fun deleteRecursively(destination: File, nonCancellable: Boolean = false): Boolean = - if (nonCancellable) { - // The outer context prevents prompt cancellation while returning from a different - // dispatcher, so the caller can reliably log the cleanup result before rethrowing. - withContext(NonCancellable) { - withContext(NonCancellable + ioDispatcher) { - deleteRecursivelyForTest?.invoke(destination) ?: destination.deleteRecursively() - } - } - } else { - withContext(ioDispatcher) { - deleteRecursivelyForTest?.invoke(destination) ?: destination.deleteRecursively() - } - } -} - -/** - * P2:导出失败/取消时的目录清理。只删除**本轮成功独占创建**的目录(本应用刚创建、 - * 不含用户文件),绝不触碰他人目录或上一轮残留;返回清理是否成功,调用方据此向用户 - * 反馈清理失败(而非静默假装已清理)。删除在 [QuarantineExportCleanup] 指定的 IO 线程 - * 执行;调用方协程已取消时仍完成有界清理(NonCancellable),随后调用方重抛取消。 - */ -private suspend fun cleanupQuarantineExportDirectory( - destination: File, - createdByThisRun: Boolean, - nonCancellable: Boolean = false, -): Boolean { - if (!createdByThisRun) return true - return QuarantineExportCleanup.deleteRecursively(destination, nonCancellable) -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun ChatBackupSettingsScreen() { @@ -245,64 +175,6 @@ fun ChatBackupSettingsScreen() { var rawSnapshotOperationMessage by remember { mutableStateOf("") } var pendingRawSnapshotRestoreUri by remember { mutableStateOf(null) } var showRawSnapshotRestoreConfirmDialog by remember { mutableStateOf(false) } - var showRawSnapshotRestoreRestartDialog by remember { mutableStateOf(false) } - var quarantineEvidenceCount by remember { mutableIntStateOf(0) } - var quarantineEvidenceBytes by remember { mutableLongStateOf(0L) } - var quarantineSummaryCount by remember { mutableIntStateOf(0) } - var quarantineSummaryBytes by remember { mutableLongStateOf(0L) } - // P1-3:未完成删除事务(非空 ack trash)计数与字节,单独显示并计入总量 - var quarantineStuckTrashCount by remember { mutableIntStateOf(0) } - var quarantineStuckTrashBytes by remember { mutableLongStateOf(0L) } - var quarantineOperationMessage by remember { mutableStateOf("") } - var quarantineOperationFailed by remember { mutableStateOf(false) } - var quarantineBusy by remember { mutableStateOf(false) } - var showQuarantineDeleteConfirmDialog by remember { mutableStateOf(false) } - // 文件扫描/统计全部在 IO 线程执行(P2-2):Main 只更新 state,避免大证据卡死 UI。 - // P1-6:证据枚举失败(如 ack trash 目录不可枚举)→ fail-closed:保留上次显示值并提示 - // 信息不可用,绝不回传“0 证据”误导用户(也不会因此关闭确认删除入口的计数)。 - val refreshQuarantineInfo: suspend () -> Unit = remember { - { - try { - val snapshot = withContext(Dispatchers.IO) { - // P1-3:quarantineEvidence 已含非空 ack trash 目录(目录 length 恒为 0, - // 字节需用 stuckAckTrashBytes 补足) - val evidence = TokenStatSpool.quarantineEvidence(context) - val stuck = TokenStatSpool.stuckAckTrashEvidence(context) - val stuckBytes = TokenStatSpool.stuckAckTrashBytes(context) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - QuarantineInfoSnapshot( - evidenceCount = evidence.size, - evidenceBytes = evidence.sumOf { it.length() } + stuckBytes, - stuckTrashCount = stuck.size, - stuckTrashBytes = stuckBytes, - summaryRecordCount = summary?.recordCount ?: 0, - summaryBytes = summary?.summaryBytes ?: 0L, - ) - } - quarantineEvidenceCount = snapshot.evidenceCount - quarantineEvidenceBytes = snapshot.evidenceBytes - quarantineStuckTrashCount = snapshot.stuckTrashCount - quarantineStuckTrashBytes = snapshot.stuckTrashBytes - quarantineSummaryCount = snapshot.summaryRecordCount - quarantineSummaryBytes = snapshot.summaryBytes - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - AppLogger.e( - "ChatBackupSettings", - "quarantine evidence info refresh failed; keeping previous values", - e, - ) - quarantineOperationFailed = true - quarantineOperationMessage = - context.getString( - R.string.stats_quarantine_info_error, - e.localizedMessage ?: e.toString() - ) - } - } - } - LaunchedEffect(Unit) { refreshQuarantineInfo() } var showDeleteConfirmDialog by remember { mutableStateOf(false) } var showMemoryImportStrategyDialog by remember { mutableStateOf(false) } var pendingMemoryImportUri by remember { mutableStateOf(null) } @@ -1202,272 +1074,6 @@ fun ChatBackupSettingsScreen() { } } } - - item { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - SectionHeader( - title = stringResource(R.string.stats_quarantine_title), - subtitle = stringResource(R.string.stats_quarantine_subtitle), - icon = Icons.Default.Info - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource( - R.string.stats_quarantine_desc, - formatBytes(TokenStatSpool.MAX_QUARANTINE_BYTES) - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - val quarantineInfoText = buildString { - if (quarantineEvidenceCount > 0) { - append( - stringResource( - R.string.stats_quarantine_info, - quarantineEvidenceCount, - formatBytes(quarantineEvidenceBytes) - ) - ) - } - // P1-3:未完成删除事务(stuck ack trash)单独显示,计数已计入总量; - // 确认删除全部证据时会一并清除 - if (quarantineStuckTrashCount > 0) { - if (isNotEmpty()) append("\n") - append( - stringResource( - R.string.stats_quarantine_stuck_trash_info, - quarantineStuckTrashCount, - formatBytes(quarantineStuckTrashBytes) - ) - ) - } - if (quarantineSummaryCount > 0) { - if (isNotEmpty()) append("\n") - append( - stringResource( - R.string.stats_quarantine_summary_info, - quarantineSummaryCount, - formatBytes(quarantineSummaryBytes) - ) - ) - } - if (isEmpty()) { - append(stringResource(R.string.stats_quarantine_info_empty)) - } - } - Text( - modifier = Modifier.fillMaxWidth(), - text = quarantineInfoText, - style = MaterialTheme.typography.bodyMedium - ) - if (quarantineOperationMessage.isNotBlank()) { - Text( - modifier = Modifier.fillMaxWidth(), - text = quarantineOperationMessage, - style = MaterialTheme.typography.bodySmall, - color = - if (quarantineOperationFailed) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - } - ) - } - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - ManagementButton( - text = stringResource(R.string.stats_quarantine_export), - icon = Icons.Default.CloudDownload, - onClick = { - // P2:主线程立即置 busy(防双击,状态写先于协程启动); - // 目录名 = 可读时间戳前缀 + UUID(并发导出各自唯一,毫秒级 - // 碰撞不可能撞目录);目录用 Files.createDirectory 独占创建 - // (已存在即失败,绝不复用旧导出目录);失败/取消只清理本轮 - // 成功创建的独占目录。 - if (quarantineBusy) return@ManagementButton - quarantineBusy = true - quarantineOperationFailed = false - val baseDir = OperitBackupDirs.rawSnapshotDir() - val destination = - File( - baseDir, - "token_stats_quarantine_" + - SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) - .format(Date()) + - "_" + UUID.randomUUID().toString().replace("-", "") - ) - var directoryCreatedByThisRun = false - scope.launch { - try { - try { - Files.createDirectory(destination.toPath()) - } catch (e: FileAlreadyExistsException) { - throw IOException( - "quarantine export directory already exists: " + - destination.absolutePath, - e, - ) - } - directoryCreatedByThisRun = true - // 文件复制/fsync 在 spool 内部切到 IO 线程(P2-2) - TokenStatSpool.exportQuarantineEvidence(context, destination) - quarantineOperationMessage = - context.getString( - R.string.stats_quarantine_export_success, - destination.absolutePath - ) - } catch (e: CancellationException) { - // P2:已取消也要完成有界清理(NonCancellable+IO), - // 清理失败/异常记录日志,随后重抛取消 - val cleaned = try { - cleanupQuarantineExportDirectory( - destination, - directoryCreatedByThisRun, - nonCancellable = true, - ) - } catch (cleanupError: Exception) { - AppLogger.e( - "ChatBackupSettings", - "quarantine export cancelled; cleanup failed: " + - destination.absolutePath, - cleanupError, - ) - false - } - if (!cleaned) { - AppLogger.e( - "ChatBackupSettings", - "quarantine export cancelled; cleanup failed: " + - destination.absolutePath, - ) - } - throw e - } catch (e: Exception) { - quarantineOperationFailed = true - val cleaned = try { - cleanupQuarantineExportDirectory( - destination, - directoryCreatedByThisRun, - ) - } catch (cleanupError: Exception) { - AppLogger.e( - "ChatBackupSettings", - "quarantine export cleanup failed: " + - destination.absolutePath, - cleanupError, - ) - false - } - val reason = e.localizedMessage ?: e.toString() - quarantineOperationMessage = - if (cleaned) { - context.getString( - R.string.stats_quarantine_export_failed, - reason, - ) - } else { - context.getString( - R.string.stats_quarantine_export_failed_cleanup, - reason, - destination.absolutePath, - ) - } - } finally { - quarantineBusy = false - refreshQuarantineInfo() - } - } - }, - modifier = Modifier.weight(1f, fill = false), - enabled = !quarantineBusy - ) - ManagementButton( - text = stringResource(R.string.stats_quarantine_delete), - icon = Icons.Default.Delete, - onClick = { showQuarantineDeleteConfirmDialog = true }, - modifier = Modifier.weight(1f, fill = false), - isDestructive = true, - enabled = - !quarantineBusy && - (quarantineEvidenceCount > 0 || quarantineSummaryCount > 0) - ) - } - } - } - } - } - - if (showQuarantineDeleteConfirmDialog) { - AlertDialog( - onDismissRequest = { showQuarantineDeleteConfirmDialog = false }, - title = { Text(stringResource(R.string.stats_quarantine_delete_confirm_title)) }, - text = { - Text( - stringResource( - R.string.stats_quarantine_delete_confirm_message, - quarantineEvidenceCount, - formatBytes(quarantineEvidenceBytes), - quarantineSummaryCount, - formatBytes(quarantineSummaryBytes), - ) - ) - }, - confirmButton = { - TextButton( - onClick = { - showQuarantineDeleteConfirmDialog = false - scope.launch { - quarantineBusy = true - quarantineOperationFailed = false - try { - // 扫描/删除在 spool 内部切到 IO 线程(P2-2) - val names = - withContext(Dispatchers.IO) { - TokenStatSpool.quarantineEvidence(context).map { it.name }.toSet() - } - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context = context, - names = names, - deleteSummary = quarantineSummaryCount > 0, - ) - quarantineOperationMessage = - context.getString( - R.string.stats_quarantine_delete_success, - names.size, - quarantineSummaryCount, - ) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - quarantineOperationFailed = true - quarantineOperationMessage = - context.getString( - R.string.stats_quarantine_delete_failed, - e.localizedMessage ?: e.toString() - ) - } finally { - quarantineBusy = false - refreshQuarantineInfo() - } - } - } - ) { - Text(stringResource(R.string.stats_quarantine_delete_confirm_action)) - } - }, - dismissButton = { - TextButton(onClick = { showQuarantineDeleteConfirmDialog = false }) { - Text(stringResource(R.string.stats_quarantine_delete_cancel_action)) - } - } - ) } if (showDeleteConfirmDialog) { @@ -1825,20 +1431,6 @@ fun ChatBackupSettingsScreen() { if (showRawSnapshotRestoreConfirmDialog) { val targetName = pendingRawSnapshotRestoreUri?.lastPathSegment ?: "-" - val preparingProgressText = - stringResource(R.string.backup_raw_snapshot_progress_preparing) - val readingZipProgressText = - stringResource(R.string.backup_raw_snapshot_progress_reading_zip) - val extractingProgressText = - stringResource(R.string.backup_raw_snapshot_progress_extracting) - val replacingFilesProgressText = - stringResource(R.string.backup_raw_snapshot_progress_replacing_files) - val replacingExternalFilesProgressText = - stringResource(R.string.backup_raw_snapshot_progress_replacing_external_files) - val replacingSharedPrefsProgressText = - stringResource(R.string.backup_raw_snapshot_progress_replacing_shared_prefs) - val replacingDatastoreProgressText = - stringResource(R.string.backup_raw_snapshot_progress_replacing_datastore) AlertDialog( onDismissRequest = { @@ -1856,7 +1448,7 @@ fun ChatBackupSettingsScreen() { if (uri != null) { scope.launch { rawSnapshotOperationState = RawSnapshotOperation.RESTORING - rawSnapshotOperationMessage = preparingProgressText + rawSnapshotOperationMessage = context.getString(R.string.backup_raw_snapshot_progress_preparing) try { try { context.contentResolver.takePersistableUriPermission( @@ -1871,25 +1463,25 @@ fun ChatBackupSettingsScreen() { onProgress = { progress -> rawSnapshotOperationMessage = when (progress) { RawSnapshotBackupManager.RestoreProgress.PREPARING -> - preparingProgressText + context.getString(R.string.backup_raw_snapshot_progress_preparing) RawSnapshotBackupManager.RestoreProgress.READING_ZIP -> - readingZipProgressText + context.getString(R.string.backup_raw_snapshot_progress_reading_zip) RawSnapshotBackupManager.RestoreProgress.EXTRACTING -> - extractingProgressText + context.getString(R.string.backup_raw_snapshot_progress_extracting) RawSnapshotBackupManager.RestoreProgress.REPLACING_FILES -> - replacingFilesProgressText + context.getString(R.string.backup_raw_snapshot_progress_replacing_files) RawSnapshotBackupManager.RestoreProgress.REPLACING_EXTERNAL_FILES -> - replacingExternalFilesProgressText + context.getString(R.string.backup_raw_snapshot_progress_replacing_external_files) RawSnapshotBackupManager.RestoreProgress.REPLACING_SHARED_PREFS -> - replacingSharedPrefsProgressText + context.getString(R.string.backup_raw_snapshot_progress_replacing_shared_prefs) RawSnapshotBackupManager.RestoreProgress.REPLACING_DATASTORE -> - replacingDatastoreProgressText + context.getString(R.string.backup_raw_snapshot_progress_replacing_datastore) RawSnapshotBackupManager.RestoreProgress.REPLACING_DATABASES -> context.getString(R.string.backup_raw_snapshot_progress_replacing_databases) @@ -2028,3 +1620,4 @@ private suspend fun importMemoriesFromUri( memoryRepository.importMemoriesFromJson(jsonString, strategy) } + diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt index 6ff6b880f..606e9e708 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -1,18 +1,10 @@ package com.ai.assistance.operit.ui.features.tokenstats -import android.content.Context -import android.graphics.Bitmap import android.graphics.Paint -import android.net.Uri -import android.util.AtomicFile -import android.widget.Toast import android.os.SystemClock -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture @@ -31,29 +23,20 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Person -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog +import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -62,259 +45,129 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.Path import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil.compose.AsyncImagePainter -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager import com.ai.assistance.operit.data.stats.TokenActivityDay import com.ai.assistance.operit.data.stats.TokenActivityViewMode -import com.ai.assistance.operit.data.stats.TokenActivityWeek -import com.canhub.cropper.CropImageContract -import com.canhub.cropper.CropImageContractOptions -import com.canhub.cropper.CropImageOptions -import com.canhub.cropper.CropImageView -import java.io.File -import java.io.FileOutputStream -import java.time.DayOfWeek +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import java.time.LocalDate -import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale import kotlin.math.abs -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -internal data class AvatarImportDecision( - val applyAvatar: Boolean, - val avatarPath: String?, - val avatarRevision: Long, -) - -internal fun decideAvatarImport( - globalAvatarUri: String?, - persistedPath: String?, - currentPath: String?, - currentRevision: Long, - nowMs: Long, -): AvatarImportDecision { - if (globalAvatarUri.isNullOrBlank()) { - return AvatarImportDecision(true, null, maxOf(nowMs, currentRevision + 1L)) - } - if (persistedPath == null) { - return AvatarImportDecision(false, currentPath, currentRevision) - } - return AvatarImportDecision(true, persistedPath, maxOf(nowMs, currentRevision + 1L)) -} +import kotlinx.coroutines.withTimeoutOrNull @Composable internal fun TokenActivitySection( state: TokenActivityUiState, - zone: ZoneId, - onSelectRecent: () -> Unit, - onSelectYear: (Int) -> Unit, + dateRange: TokenStatsTimeRange?, + zone: java.time.ZoneId, onSelectMode: (TokenActivityViewMode) -> Unit, + onSelectDateRange: () -> Unit, ) { - val context = LocalContext.current val locale = LocalConfiguration.current.locales[0] - var yearMenuExpanded by remember { mutableStateOf(false) } - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + TokenStatsWhiteCard(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), ) { - Text( - text = stringResource(R.string.token_activity_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Box { - Text( - text = if (state.recentSelected) { - "${stringResource(R.string.token_activity_recent)} ▾" - } else { - "${state.selectedYear} ▾" - }, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .clickable(enabled = state.availableYears.isNotEmpty()) { yearMenuExpanded = true } - .padding(horizontal = 8.dp, vertical = 4.dp), - ) - DropdownMenu( - expanded = yearMenuExpanded, - onDismissRequest = { yearMenuExpanded = false }, + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), ) { - DropdownMenuItem( - text = { - Text( - stringResource(R.string.token_activity_recent), - fontWeight = if (state.recentSelected) FontWeight.Bold else FontWeight.Normal, - ) - }, - onClick = { - yearMenuExpanded = false - onSelectRecent() - }, - ) - state.availableYears.forEach { year -> - DropdownMenuItem( - text = { - Text( - year.toString(), - fontWeight = if (!state.recentSelected && year == state.selectedYear) { - FontWeight.Bold - } else { - FontWeight.Normal - }, - ) - }, - onClick = { - yearMenuExpanded = false - onSelectYear(year) - }, - ) - } - } - } - } - - TokenActivityProfileCard() - - TokenStatsWhiteCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(14.dp)) { - Text( - stringResource(R.string.token_activity_insights), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - ) - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.token_activity_total_requests), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - modifier = Modifier.weight(1f), - ) - Text( - if (state.loading) "–" else formatCount(state.insights.totalRequests), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - } - Spacer(Modifier.height(8.dp)) - Text( - stringResource(R.string.token_activity_peak_hours), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - repeat(3) { index -> - val hour = state.insights.topHours.getOrNull(index) + TokenActivityViewMode.entries.forEach { mode -> + val selected = state.viewMode == mode Text( - text = if (state.loading || hour == null) "–" else stringResource( - R.string.token_activity_hour_range, - hour, - (hour + 1) % 24, + text = stringResource( + when (mode) { + TokenActivityViewMode.DAILY -> R.string.token_activity_daily + TokenActivityViewMode.WEEKLY -> R.string.token_activity_weekly + TokenActivityViewMode.CUMULATIVE -> R.string.token_activity_cumulative + } ), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFFF7F2F4)) - .padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = + if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { onSelectMode(mode) }, ) } } - } - } - - Row(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.padding(horizontal = 4.dp)) { - TokenActivityViewMode.entries.forEach { mode -> - val selected = state.viewMode == mode Text( - text = stringResource( - when (mode) { - TokenActivityViewMode.DAILY -> R.string.token_activity_daily - TokenActivityViewMode.WEEKLY -> R.string.token_activity_weekly - TokenActivityViewMode.CUMULATIVE -> R.string.token_activity_cumulative - } - ), - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - color = if (selected) Color(0xFFE91E63) else TokenStatsCardMuted, - modifier = Modifier.clickable { onSelectMode(mode) }, + text = dateRange?.let { formatCompactDateRangeLabel(it, zone) }.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, ) + IconButton(onClick = onSelectDateRange) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = + dateRange?.let { formatDateRangeLabel(it, zone) } + ?: stringResource(R.string.token_stats_date_range), + ) + } } - } - val stats = state.yearData?.stats - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TokenActivityStat( - stringResource(R.string.token_activity_total_tokens), - if (state.loading || stats == null) "–" else formatCompactCount(stats.totalTokens), - Modifier.weight(1f), - ) - TokenActivityStat( - stringResource(R.string.token_activity_peak_tokens), - if (state.loading || stats == null) "–" else formatCompactCount(stats.peakTokens), - Modifier.weight(1f), - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TokenActivityStat( - stringResource(R.string.token_activity_current_streak), - if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.currentStreak), - Modifier.weight(1f), - ) - TokenActivityStat( - stringResource(R.string.token_activity_longest_streak), - if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.longestStreak), - Modifier.weight(1f), - ) + val stats = state.rangeData?.stats + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenActivityStat( + stringResource(R.string.token_activity_total_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.totalTokens), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_peak_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.peakTokens), + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenActivityStat( + stringResource(R.string.token_activity_current_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.currentStreak), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_longest_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.longestStreak), + Modifier.weight(1f), + ) + } } - } - TokenStatsWhiteCard(Modifier.fillMaxWidth()) { Crossfade( targetState = state.viewMode, animationSpec = tween(150), label = "token_activity_heatmap", ) { mode -> - TokenActivityHeatmap( + TokenActivityVisualization( state = state.copy(viewMode = mode), - zone = zone, locale = locale, - modifier = Modifier.padding(12.dp), + modifier = Modifier.fillMaxWidth(), ) } } @@ -325,335 +178,48 @@ internal fun TokenActivitySection( private fun TokenActivityStat(label: String, value: String, modifier: Modifier = Modifier) { Column( modifier = modifier - .clip(RoundedCornerShape(10.dp)) - .background(Color.White) - .padding(horizontal = 10.dp, vertical = 8.dp), + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(TokenStatsSpacing.content), ) { Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) - Text(label, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted) - } -} - -@Composable -private fun TokenActivityProfileCard() { - val context = LocalContext.current - val resources = LocalResources.current - val prefs = remember { context.getSharedPreferences(PROFILE_PREFS, Context.MODE_PRIVATE) } - var nickname by remember { mutableStateOf(prefs.getString(KEY_NICKNAME, "").orEmpty()) } - var email by remember { mutableStateOf(prefs.getString(KEY_EMAIL, "").orEmpty()) } - val initialAvatarPath = remember { prefs.getString(KEY_AVATAR_PATH, null) } - var avatarPath by remember { mutableStateOf(initialAvatarPath) } - var avatarRevision by remember { - mutableStateOf( - prefs.getLong( - KEY_AVATAR_REVISION, - initialAvatarPath?.let { File(it).lastModified() } ?: 0L, - ) - ) - } - var showEdit by remember { mutableStateOf(false) } - var showAvatarActions by remember { mutableStateOf(false) } - var showImportConfirm by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - suspend fun persistAvatar(uri: Uri?): String? = withContext(Dispatchers.IO) { - val target = File(context.filesDir, AVATAR_FILE) - val atomicFile = AtomicFile(target) - if (uri == null) { - atomicFile.delete() - return@withContext null - } - val input = context.contentResolver.openInputStream(uri) ?: error("Unable to open avatar") - var output: FileOutputStream? = null - try { - input.use { - val stream = atomicFile.startWrite() - output = stream - it.copyTo(stream) - atomicFile.finishWrite(stream) - output = null - } - } catch (e: Exception) { - output?.let(atomicFile::failWrite) - throw e - } - target.absolutePath - } - - val cropLauncher = rememberLauncherForActivityResult(CropImageContract()) { result -> - if (result.isSuccessful) { - result.uriContent?.let { croppedUri -> - scope.launch { - runCatching { persistAvatar(croppedUri) }.onSuccess { path -> - val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) - avatarPath = path - avatarRevision = revision - prefs.edit() - .putString(KEY_AVATAR_PATH, path) - .putLong(KEY_AVATAR_REVISION, revision) - .apply() - } - } - } - } else if (result.error != null) { - Toast.makeText( - context, - resources.getString(R.string.avatar_crop_failed, result.error?.message.orEmpty()), - Toast.LENGTH_LONG, - ).show() - } - } - val picker = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> - if (uri != null) { - cropLauncher.launch( - CropImageContractOptions( - uri, - CropImageOptions().apply { - guidelines = CropImageView.Guidelines.ON - outputCompressFormat = Bitmap.CompressFormat.PNG - outputCompressQuality = 90 - fixAspectRatio = true - aspectRatioX = 1 - aspectRatioY = 1 - cropMenuCropButtonTitle = resources.getString(R.string.theme_crop_done) - activityTitle = resources.getString(R.string.crop_avatar) - toolbarColor = Color.Gray.toArgb() - toolbarTitleColor = Color.White.toArgb() - }, - ) - ) - } - } - - TokenStatsWhiteCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(14.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - stringResource(R.string.token_activity_profile), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - IconButton( - onClick = { showImportConfirm = true }, - ) { - Icon(Icons.Default.Refresh, stringResource(R.string.token_activity_profile_import)) - } - } - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - val painter = rememberAsyncImagePainter( - ImageRequest.Builder(context) - .data(avatarPath?.let(::File)) - .memoryCacheKey("$avatarPath:$avatarRevision") - .diskCacheKey("$avatarPath:$avatarRevision") - .build() - ) - Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(Color(0xFFF7F2F4)) - .clickable { showAvatarActions = true }, - contentAlignment = Alignment.Center, - ) { - if (avatarPath != null && painter.state !is AsyncImagePainter.State.Error) { - Image(painter, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - } else { - val trimmed = nickname.trim() - if (trimmed.isNotEmpty()) { - Text( - String(Character.toChars(trimmed.codePointAt(0))), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = TokenStatsCardMuted, - ) - } else { - Icon(Icons.Default.Person, null, tint = TokenStatsCardMuted) - } - } - } - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - val empty = stringResource(R.string.token_activity_profile_empty) - Text( - nickname.ifBlank { empty }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - email.ifBlank { empty }, - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = { showEdit = true }) { - Icon(Icons.Default.Edit, stringResource(R.string.token_activity_profile_edit)) - } - } - } - } - - if (showImportConfirm) { - var remaining by remember { mutableIntStateOf(PROFILE_IMPORT_COUNTDOWN_SECONDS) } - LaunchedEffect(Unit) { - while (remaining > 0) { - delay(1_000) - remaining-- - } - } - AlertDialog( - onDismissRequest = { showImportConfirm = false }, - title = { Text(stringResource(R.string.token_activity_profile_import_title)) }, - text = { Text(stringResource(R.string.token_activity_profile_import_message)) }, - confirmButton = { - TextButton( - enabled = remaining == 0, - onClick = { - showImportConfirm = false - scope.launch { - val global = DisplayPreferencesManager.getInstance(context) - val importedName = global.globalUserName.first().orEmpty() - val globalAvatarUri = global.globalUserAvatarUri.first()?.takeUnless { it.isBlank() } - val importedPath = if (globalAvatarUri == null) { - persistAvatar(null) - } else { - runCatching { persistAvatar(Uri.parse(globalAvatarUri)) }.getOrNull() - } - val decision = decideAvatarImport( - globalAvatarUri, - importedPath, - avatarPath, - avatarRevision, - System.currentTimeMillis(), - ) - nickname = importedName - val editor = prefs.edit().putString(KEY_NICKNAME, importedName) - if (decision.applyAvatar) { - avatarPath = decision.avatarPath - avatarRevision = decision.avatarRevision - editor.putLong(KEY_AVATAR_REVISION, decision.avatarRevision).apply { - if (decision.avatarPath == null) remove(KEY_AVATAR_PATH) - else putString(KEY_AVATAR_PATH, decision.avatarPath) - } - } else { - Toast.makeText( - context, - resources.getString(R.string.token_activity_profile_avatar_import_failed), - Toast.LENGTH_LONG, - ).show() - } - editor.apply() - } - }, - ) { - Text( - if (remaining > 0) { - stringResource(R.string.token_activity_profile_import_countdown, remaining) - } else { - stringResource(R.string.token_activity_profile_import_confirm) - } - ) - } - }, - dismissButton = { - TextButton(onClick = { showImportConfirm = false }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - - if (showEdit) { - var editingName by remember(nickname) { mutableStateOf(nickname) } - var editingEmail by remember(email) { mutableStateOf(email) } - AlertDialog( - onDismissRequest = { showEdit = false }, - title = { Text(stringResource(R.string.token_activity_profile_edit)) }, - text = { - Column { - OutlinedTextField( - value = editingName, - onValueChange = { editingName = it }, - label = { Text(stringResource(R.string.token_activity_nickname)) }, - singleLine = true, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = editingEmail, - onValueChange = { editingEmail = it }, - label = { Text(stringResource(R.string.token_activity_email)) }, - singleLine = true, - ) - } - }, - confirmButton = { - TextButton(onClick = { - nickname = editingName - email = editingEmail - prefs.edit().putString(KEY_NICKNAME, nickname).putString(KEY_EMAIL, email).apply() - showEdit = false - }) { Text(stringResource(R.string.token_activity_save)) } - }, - dismissButton = { - TextButton(onClick = { showEdit = false }) { Text(stringResource(R.string.settings_cancel)) } - }, - ) - } - - if (showAvatarActions) { - AlertDialog( - onDismissRequest = { showAvatarActions = false }, - title = { Text(stringResource(R.string.token_activity_avatar)) }, - text = { - Column { - TextButton(onClick = { - showAvatarActions = false - picker.launch("image/*") - }) { Text(stringResource(R.string.token_activity_avatar_pick)) } - if (avatarPath != null) { - TextButton(onClick = { - File(avatarPath.orEmpty()).delete() - val revision = maxOf(System.currentTimeMillis(), avatarRevision + 1L) - avatarPath = null - avatarRevision = revision - prefs.edit() - .remove(KEY_AVATAR_PATH) - .putLong(KEY_AVATAR_REVISION, revision) - .apply() - showAvatarActions = false - }) { Text(stringResource(R.string.token_activity_avatar_remove)) } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = { showAvatarActions = false }) { Text(stringResource(R.string.settings_cancel)) } - }, + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @Composable -private fun TokenActivityHeatmap( +private fun TokenActivityVisualization( state: TokenActivityUiState, - zone: ZoneId, locale: Locale, modifier: Modifier = Modifier, ) { - val data = state.yearData - if (state.loading || data == null) { + if (state.loading || state.rangeData == null) { Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } return } + when (state.viewMode) { + TokenActivityViewMode.DAILY -> TokenActivityDailyHeatmap(state, locale, modifier) + TokenActivityViewMode.WEEKLY -> TokenActivityWeeklyChart(state, locale, modifier) + TokenActivityViewMode.CUMULATIVE -> TokenActivityCumulativeChart(state, locale, modifier) + } +} - val days = if (state.viewMode == TokenActivityViewMode.CUMULATIVE) data.cumulative else data.daily +@Composable +private fun TokenActivityDailyHeatmap( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = state.rangeData + checkNotNull(data) + + val days = data.daily val firstDate = days.firstOrNull()?.date val padding = firstDate?.let { it.dayOfWeek.value % 7 } ?: 0 val columns = ((padding + days.size + 6) / 7).coerceAtLeast(1) @@ -677,27 +243,27 @@ private fun TokenActivityHeatmap( val gridHeightPx = with(density) { gridHeight.toPx() } val monthLabelGapPx = with(density) { 4.dp.toPx() } val scroll = rememberScrollState() - var selectedDay by remember(state.recentSelected, state.selectedYear, state.viewMode) { + var selectedDay by remember(days, state.viewMode) { mutableStateOf(null) } - var selectedWeek by remember(state.recentSelected, state.selectedYear, state.viewMode) { - mutableStateOf(null) - } - var indicatorDay by remember(state.recentSelected, state.selectedYear, state.viewMode) { + var indicatorDay by remember(days, state.viewMode) { mutableStateOf(null) } - var indicatorWeek by remember(state.recentSelected, state.selectedYear, state.viewMode) { - mutableStateOf(null) - } - var indicatorColumn by remember(state.recentSelected, state.selectedYear, state.viewMode) { + var indicatorColumn by remember(days, state.viewMode) { mutableIntStateOf(-1) } - var indicatorRow by remember(state.recentSelected, state.selectedYear, state.viewMode) { + var indicatorRow by remember(days, state.viewMode) { mutableIntStateOf(-1) } + val heatmapColor = MaterialTheme.colorScheme.primary + val heatmapLabelColor = MaterialTheme.colorScheme.onSurfaceVariant val colors = listOf( - Color(0xFFEFE0E5), Color(0xFFFFD6E0), Color(0xFFFFB3C6), - Color(0xFFFF85A2), Color(0xFFFF6B8E), Color(0xFFE84973), + heatmapColor.copy(alpha = 0.08f), + heatmapColor.copy(alpha = 0.20f), + heatmapColor.copy(alpha = 0.36f), + heatmapColor.copy(alpha = 0.52f), + heatmapColor.copy(alpha = 0.72f), + heatmapColor, ) val selectionColor = MaterialTheme.colorScheme.primary val selectionStroke = with(density) { 1.5.dp.toPx() } @@ -721,15 +287,15 @@ private fun TokenActivityHeatmap( } } } - val monthPaint = remember(density) { + val monthPaint = remember(density, heatmapLabelColor) { Paint().apply { textSize = with(density) { 12.sp.toPx() } - color = TokenStatsCardMuted.toArgb() + color = heatmapLabelColor.toArgb() isAntiAlias = true } } - LaunchedEffect(columns, state.recentSelected, state.selectedYear, state.viewMode) { + LaunchedEffect(columns, days, state.viewMode) { snapshotFlow { scroll.maxValue }.first { it > 0 } scroll.scrollTo(scroll.maxValue) } @@ -740,26 +306,17 @@ private fun TokenActivityHeatmap( modifier = Modifier .size(width, canvasHeight) // 顺序:查看/滚动仲裁必须先于点击检测收到事件。 - .pointerInput(state.viewMode, grid, data.weekly, stepPx, blockPx) { + .pointerInput(grid, stepPx, blockPx) { val viewSpeedThresholdPxPerMs = with(density) { HEATMAP_VIEW_SPEED_DP_PER_S.dp.toPx() } / 1_000f fun updateIndicator(point: Offset) { val column = (point.x / stepPx).toInt().coerceIn(0, columns - 1) val row = (point.y / stepPx).toInt().coerceIn(0, 6) - if (state.viewMode == TokenActivityViewMode.WEEKLY) { - val week = data.weekly.getOrNull(column) - indicatorWeek = week - indicatorDay = null - indicatorColumn = if (week == null) -1 else column - indicatorRow = if (week == null) -1 else row - } else { - val day = grid.getOrNull(column)?.getOrNull(row) - indicatorDay = day - indicatorWeek = null - indicatorColumn = if (day == null) -1 else column - indicatorRow = if (day == null) -1 else row - } + val day = grid.getOrNull(column)?.getOrNull(row) + indicatorDay = day + indicatorColumn = if (day == null) -1 else column + indicatorRow = if (day == null) -1 else row } awaitEachGesture { @@ -820,55 +377,32 @@ private fun TokenActivityHeatmap( } } else if (mode == HeatmapDragMode.SCROLL) { indicatorDay = null - indicatorWeek = null indicatorColumn = -1 indicatorRow = -1 } } } - .pointerInput(state.viewMode, grid, data.weekly) { + .pointerInput(grid) { detectTapGestures { point -> indicatorDay = null - indicatorWeek = null indicatorColumn = -1 indicatorRow = -1 if (point.x % stepPx >= blockPx || point.y % stepPx >= blockPx) return@detectTapGestures val column = (point.x / stepPx).toInt() val row = (point.y / stepPx).toInt() - if (state.viewMode == TokenActivityViewMode.WEEKLY) { - val week = data.weekly.getOrNull(column) - selectedWeek = if (selectedWeek == week) null else week - selectedDay = null - } else { - val day = grid.getOrNull(column)?.getOrNull(row) - selectedDay = if (selectedDay == day) null else day - selectedWeek = null - } + val day = grid.getOrNull(column)?.getOrNull(row) + selectedDay = if (selectedDay == day) null else day } }, ) { - if (state.viewMode == TokenActivityViewMode.WEEKLY) { - data.weekly.forEachIndexed { column, week -> - repeat(7) { row -> - val inBar = row >= 7 - week.barHeight - drawRoundRect( - color = if (inBar) colors[week.level.coerceIn(0, 5)] else colors[0], - topLeft = Offset(column * stepPx, row * stepPx), - size = Size(blockPx, blockPx), - cornerRadius = CornerRadius(radiusPx), - ) - } - } - } else { - grid.forEachIndexed { column, week -> - week.forEachIndexed { row, day -> - if (day != null) drawRoundRect( - color = colors[day.level.coerceIn(0, 5)], - topLeft = Offset(column * stepPx, row * stepPx), - size = Size(blockPx, blockPx), - cornerRadius = CornerRadius(radiusPx), - ) - } + grid.forEachIndexed { column, week -> + week.forEachIndexed { row, day -> + if (day != null) drawRoundRect( + color = colors[day.level.coerceIn(0, 5)], + topLeft = Offset(column * stepPx, row * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + ) } } @@ -885,7 +419,6 @@ private fun TokenActivityHeatmap( } val indicatorValid = when { - indicatorWeek != null -> data.weekly.getOrNull(indicatorColumn) != null indicatorDay != null -> grid.getOrNull(indicatorColumn)?.getOrNull(indicatorRow) != null else -> false } @@ -908,26 +441,19 @@ private fun TokenActivityHeatmap( indicatorDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), formatCompactCount(indicatorDay!!.tokens), ) - indicatorWeek != null -> stringResource( - R.string.token_activity_week_detail, - indicatorWeek!!.startDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), - indicatorWeek!!.startDate.plusDays(6).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), - formatCompactCount(indicatorWeek!!.tokens), - ) selectedDay != null -> stringResource( R.string.token_activity_day_detail, selectedDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), formatCompactCount(selectedDay!!.tokens), ) - selectedWeek != null -> stringResource( - R.string.token_activity_week_detail, - selectedWeek!!.startDate.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), - selectedWeek!!.startDate.plusDays(6).format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), - formatCompactCount(selectedWeek!!.tokens), - ) else -> stringResource(R.string.token_activity_tap_hint) } - Text(text, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted, maxLines = 1) + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) } Row( @@ -935,27 +461,220 @@ private fun TokenActivityHeatmap( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(R.string.token_activity_less), fontSize = 12.sp, color = TokenStatsCardMuted) + Text( + stringResource(R.string.token_activity_less), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.width(4.dp)) colors.forEach { color -> Box(Modifier.size(block).background(color, RoundedCornerShape(3.dp))) Spacer(Modifier.width(gap)) } - Text(stringResource(R.string.token_activity_more), fontSize = 12.sp, color = TokenStatsCardMuted) + Text( + stringResource(R.string.token_activity_more), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } +@Composable +private fun TokenActivityWeeklyChart( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.weekly.map { week -> + TokenActivitySeriesPoint( + startDate = week.startDate, + endDate = week.startDate.plusDays(6), + tokens = week.tokens, + ) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + ) { point -> + stringResource( + R.string.token_activity_week_detail, + point.startDate.format(localizedDateFormatter(locale)), + point.endDate.format(localizedDateFormatter(locale)), + formatCompactCount(point.tokens), + ) + } +} + +@Composable +private fun TokenActivityCumulativeChart( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.cumulative.map { day -> + TokenActivitySeriesPoint(day.date, day.date, day.tokens) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.LINE, + locale = locale, + modifier = modifier, + ) { point -> + stringResource( + R.string.token_activity_cumulative_detail, + point.startDate.format(localizedDateFormatter(locale)), + formatCompactCount(point.tokens), + ) + } +} + +@Composable +private fun TokenActivityTimeSeriesChart( + points: List, + style: TokenActivitySeriesStyle, + locale: Locale, + modifier: Modifier = Modifier, + detailText: @Composable (TokenActivitySeriesPoint) -> String, +) { + val density = LocalDensity.current + val scroll = rememberScrollState() + val pointWidth = if (style == TokenActivitySeriesStyle.BAR) 18.dp else 14.dp + val chartWidth = (pointWidth * points.size).coerceAtLeast(280.dp) + val plotHeight = 124.dp + val labelHeight = 24.dp + val canvasHeight = plotHeight + labelHeight + val stepPx = with(density) { pointWidth.toPx() } + val plotHeightPx = with(density) { plotHeight.toPx() } + val maxTokens = points.maxOfOrNull(TokenActivitySeriesPoint::tokens)?.coerceAtLeast(1L) ?: 1L + val primary = MaterialTheme.colorScheme.primary + val grid = MaterialTheme.colorScheme.outlineVariant + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val labelPaint = remember(density, labelColor) { + Paint().apply { + textSize = with(density) { 12.sp.toPx() } + color = labelColor.toArgb() + isAntiAlias = true + } + } + val monthLabels = remember(points, locale) { + val formatter = DateTimeFormatter.ofPattern("MMM", locale) + buildList { + var previousMonth = -1 + points.forEachIndexed { index, point -> + if (index == 0 || point.startDate.monthValue != previousMonth) { + add(TokenActivityMonthLabel(index, formatter.format(point.startDate))) + previousMonth = point.startDate.monthValue + } + } + } + } + var selectedPoint by remember(points, style) { mutableStateOf(null) } + + LaunchedEffect(points, style) { + snapshotFlow { scroll.maxValue }.first { it > 0 } + scroll.scrollTo(scroll.maxValue) + } + + Column(modifier) { + Column(Modifier.horizontalScroll(scroll)) { + Canvas( + modifier = Modifier + .size(chartWidth, canvasHeight) + .pointerInput(points, style, stepPx) { + detectTapGestures { point -> + val index = (point.x / stepPx).toInt() + selectedPoint = points.getOrNull(index) + } + }, + ) { + drawLine( + color = grid, + start = Offset(0f, plotHeightPx), + end = Offset(size.width, plotHeightPx), + strokeWidth = with(density) { 1.dp.toPx() }, + ) + if (style == TokenActivitySeriesStyle.BAR) { + points.forEachIndexed { index, point -> + val height = plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + drawRoundRect( + color = primary.copy(alpha = 0.78f), + topLeft = Offset(index * stepPx + stepPx * 0.2f, plotHeightPx - height), + size = Size(stepPx * 0.6f, height), + cornerRadius = CornerRadius(stepPx * 0.2f), + ) + } + } else if (points.isNotEmpty()) { + val path = Path() + points.forEachIndexed { index, point -> + val x = index * stepPx + stepPx / 2f + val y = plotHeightPx - plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath( + path = path, + color = primary, + style = Stroke(width = with(density) { 2.dp.toPx() }), + ) + points.forEachIndexed { index, point -> + val x = index * stepPx + stepPx / 2f + val y = plotHeightPx - plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + drawCircle(primary, radius = with(density) { 2.5.dp.toPx() }, center = Offset(x, y)) + } + } + selectedPoint?.let { point -> + val index = points.indexOf(point) + if (index >= 0) { + drawLine( + color = primary, + start = Offset(index * stepPx + stepPx / 2f, 0f), + end = Offset(index * stepPx + stepPx / 2f, plotHeightPx), + strokeWidth = with(density) { 1.dp.toPx() }, + ) + } + } + drawIntoCanvas { canvas -> + val baseline = plotHeightPx + with(density) { 16.dp.toPx() } + monthLabels.forEach { label -> + canvas.nativeCanvas.drawText(label.text, label.column * stepPx, baseline, labelPaint) + } + } + } + } + + Box(Modifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.CenterStart) { + Text( + text = + if (selectedPoint == null) { + stringResource(R.string.token_activity_tap_hint) + } else { + detailText(selectedPoint!!) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private data class TokenActivitySeriesPoint( + val startDate: LocalDate, + val endDate: LocalDate, + val tokens: Long, +) + +private enum class TokenActivitySeriesStyle { BAR, LINE } + +private fun localizedDateFormatter(locale: Locale): DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + private enum class HeatmapDragMode { VIEW, SCROLL } private data class TokenActivityMonthLabel(val column: Int, val text: String) private const val HEATMAP_VIEW_SPEED_DP_PER_S = 150f -private const val PROFILE_IMPORT_COUNTDOWN_SECONDS = 3 - -private const val PROFILE_PREFS = "token_activity_profile" -private const val KEY_NICKNAME = "nickname" -private const val KEY_EMAIL = "email" -private const val KEY_AVATAR_PATH = "avatar_path" -private const val KEY_AVATAR_REVISION = "avatar_revision" -private const val AVATAR_FILE = "token_activity_avatar" diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt index 3f4d75f84..42781b287 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt @@ -5,131 +5,59 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance /** - * 统计页颜色接口(阶段 4):Token 堆叠/图表配色集中在这里,组件不散落硬编码色值。 - * 后续若增加统计页自定义配色,只需替换 [tokenStatsColors] 的取值来源, - * 组件签名不变。 + * Token statistics uses the application color scheme directly. The semantic palette + * keeps charts distinguishable without introducing a page-specific visual theme. */ data class TokenStatsColors( - /** Token 分类堆叠:未缓存输入。 */ val uncachedInput: Color, - /** Token 分类堆叠:缓存读取。 */ val cachedInput: Color, - /** Token 分类堆叠:缓存写入。 */ val cacheWrite: Color, - /** Token 分类堆叠:输出。 */ val output: Color, - /** Token 分类堆叠:推理。 */ val reasoning: Color, - /** 折线图强调色。 */ val chartAccent: Color, - /** 图表网格参考线。 */ val chartGrid: Color, - /** 图表坐标轴标签。 */ val chartLabel: Color, - /** 图表 tooltip 容器。 */ val tooltipContainer: Color, - /** 图表 tooltip 文字。 */ val tooltipContent: Color, - /** 费用堆叠按模型取色(12 色 Material 色阶,模型多时循环)。 */ val modelPalette: List, - /** unknown 提示色(未知 ≠ 0)。 */ val unknownHint: Color, - /** “默认估算”徽标容器。 */ val estimatedBadgeContainer: Color, - /** “默认估算”徽标文字。 */ - val estimatedBadgeContent: Color, - /** 生命周期累计总览卡片容器。 */ - val summaryCardContainer: Color, - /** 生命周期累计总览卡片文字。 */ - val summaryCardContent: Color, ) -internal val TokenStatsCardContainer = Color.White -internal val TokenStatsCardContent = Color(0xFF202124) -internal val TokenStatsCardMuted = Color(0xFF5F6368) - -// Token 堆叠色板:缓存读取(顶)、未缓存输入(中)、输出(底)。 -internal val TokenStackCacheRead = Color(0xFFFFD1DC) -internal val TokenStackUncachedInput = Color(0xFFFF85A2) -internal val TokenStackOutput = Color(0xFFE91E63) - -/** 组件默认入口:从当前 [MaterialTheme] 派生,明暗自适应。 */ @Composable fun tokenStatsColors(): TokenStatsColors { val scheme = MaterialTheme.colorScheme - // 按实际背景亮度判断明暗(自定义主题/背景图下依然正确) - val dark = scheme.background.luminance() < 0.5f - return if (dark) darkTokenStatsColors(scheme) else lightTokenStatsColors(scheme) -} - -private fun lightTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = - TokenStatsColors( - uncachedInput = TokenStackUncachedInput, - cachedInput = TokenStackCacheRead, - cacheWrite = scheme.secondaryContainer, - output = TokenStackOutput, - reasoning = Color(0xFFF48FB1), + return TokenStatsColors( + uncachedInput = scheme.primary, + cachedInput = scheme.primaryContainer, + cacheWrite = scheme.secondary, + output = scheme.tertiary, + reasoning = scheme.tertiaryContainer, chartAccent = scheme.primary, chartGrid = scheme.outlineVariant, chartLabel = scheme.onSurfaceVariant, tooltipContainer = scheme.surfaceVariant, tooltipContent = scheme.onSurfaceVariant, - modelPalette = MODEL_PALETTE, - unknownHint = scheme.errorContainer, + modelPalette = listOf( + scheme.primary, + scheme.secondary, + scheme.tertiary, + scheme.primaryContainer, + scheme.secondaryContainer, + scheme.tertiaryContainer, + ), + unknownHint = scheme.error, estimatedBadgeContainer = scheme.tertiaryContainer, - estimatedBadgeContent = scheme.onTertiaryContainer, - summaryCardContainer = TokenStatsCardContainer, - summaryCardContent = TokenStatsCardContent, - ) - -private fun darkTokenStatsColors(scheme: androidx.compose.material3.ColorScheme): TokenStatsColors = - TokenStatsColors( - uncachedInput = TokenStackUncachedInput, - cachedInput = TokenStackCacheRead, - cacheWrite = scheme.secondaryContainer, - output = TokenStackOutput, - reasoning = Color(0xFFF8BBD0), - chartAccent = scheme.primary, - chartGrid = scheme.outlineVariant, - chartLabel = scheme.onSurfaceVariant, - tooltipContainer = scheme.surfaceVariant, - tooltipContent = scheme.onSurfaceVariant, - modelPalette = MODEL_PALETTE, - unknownHint = scheme.errorContainer, - estimatedBadgeContainer = scheme.tertiaryContainer, - estimatedBadgeContent = scheme.onTertiaryContainer, - summaryCardContainer = TokenStatsCardContainer, - summaryCardContent = TokenStatsCardContent, - ) - -/** 模型费用堆叠色板(12 色足够区分常见模型数)。 */ -private val MODEL_PALETTE = - listOf( - Color(0xFFF44336), // Red - Color(0xFFE91E63), // Pink - Color(0xFF9C27B0), // Purple - Color(0xFF673AB7), // Deep Purple - Color(0xFF3F51B5), // Indigo - Color(0xFF2196F3), // Blue - Color(0xFF00BCD4), // Cyan - Color(0xFF009688), // Teal - Color(0xFF4CAF50), // Green - Color(0xFFFF9800), // Orange - Color(0xFF795548), // Brown - Color(0xFF607D8B), // Blue Grey ) +} -/** 页面级 CompositionLocal:由 [TokenStatsColorsProvider] 提供。 */ val LocalTokenStatsColors = staticCompositionLocalOf { error("TokenStatsColors not provided") } -/** 在子树内提供统计页颜色。 */ @Composable fun TokenStatsColorsProvider(content: @Composable () -> Unit) { - val colors = tokenStatsColors() - CompositionLocalProvider(LocalTokenStatsColors provides colors, content = content) + CompositionLocalProvider(LocalTokenStatsColors provides tokenStatsColors(), content = content) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt index f5b484a14..b80e31fc2 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -1,6 +1,7 @@ package com.ai.assistance.operit.ui.features.tokenstats import androidx.compose.foundation.clickable +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,6 +9,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -15,13 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Groups -import androidx.compose.material.icons.filled.HelpOutline -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox @@ -34,15 +32,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,19 +50,32 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.TokenPriceResolver import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.data.stats.TokenStatStatus import com.ai.assistance.operit.data.stats.TokenCostCalculator -import com.ai.assistance.operit.data.stats.TokenStatsBaselineTotals -import com.ai.assistance.operit.data.stats.TokenStatsCostMode import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown import com.ai.assistance.operit.data.stats.TokenStatsDurationAggregate import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview -import com.ai.assistance.operit.data.stats.TokenStatsPreset +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsPriceScope +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTokenAggregate import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle import java.util.Locale +import kotlin.math.roundToInt + +/** Shared spatial scale for the token statistics page. */ +internal object TokenStatsSpacing { + val page = 16.dp + val section = 16.dp + val card = 16.dp + val content = 8.dp +} // ==== 通用格式 ==== @@ -73,42 +83,73 @@ import java.util.Locale internal fun formatMoney(amount: Double, currency: PricingCurrency): String = "${currency.symbol}${String.format(Locale.US, "%.4f", amount)}" -/** 每百万 token 单价。 */ -internal fun formatPricePerMillion(price: Double, currency: PricingCurrency): String = - "${currency.symbol}${String.format(Locale.US, "%.4f", price)}/1M" - -/** 按次单价。 */ -internal fun formatPricePerRequest(price: Double, currency: PricingCurrency): String = - "${currency.symbol}${String.format(Locale.US, "%.4f", price)}/次" +/** 累计总览的费用使用紧凑的两位小数,保证三项指标可在一行展示。 */ +private fun formatLifetimeMoney(amount: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.2f", amount)}" internal fun formatCount(value: Long): String = String.format(Locale.US, "%,d", value) -/** 统计页统一白色卡片;局部浅色 scheme 保证深色主题下控件与文字仍清晰。 */ +@Composable +internal fun formatRequestCount(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_minimum, formatCount(value)) + } else { + formatCount(value) + } + +@Composable +internal fun formatRequestCountLabel(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_label_minimum, formatCount(value)) + } else { + stringResource(R.string.settings_request_count_label, value) + } + +@Composable +internal fun formatCompactRequestCountLabel(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_compact_minimum, formatCompactCount(value)) + } else { + stringResource(R.string.token_stats_request_count_compact, formatCompactCount(value)) + } + +/** Statistics cards follow the application surface and content colors. */ @Composable internal fun TokenStatsWhiteCard( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { - val scheme = MaterialTheme.colorScheme - MaterialTheme( - colorScheme = - scheme.copy( - surface = TokenStatsCardContainer, - onSurface = TokenStatsCardContent, - surfaceVariant = Color(0xFFF5F5F5), - onSurfaceVariant = TokenStatsCardMuted, - outline = Color(0xFFBDBDBD), - outlineVariant = Color(0xFFE0E0E0), - ), + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + content = content, + ) +} + +/** Page-level headings stay visually separate from labels inside cards. */ +@Composable +internal fun TokenStatsSectionHeader( + title: String, + modifier: Modifier = Modifier, + trailing: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, ) { - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = TokenStatsCardContainer, - contentColor = TokenStatsCardContent, - ), - content = content, + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), ) + trailing() } } @@ -118,60 +159,19 @@ internal fun TokenStatsWhiteCard( internal fun TokenStatsLifetimeCard( overview: TokenStatsLifetimeOverview, currency: PricingCurrency, - manualRate: Double, - rateIsEstimated: Boolean, - includeLegacy: Boolean, - onIncludeLegacyChange: (Boolean) -> Unit, ) { val colors = LocalTokenStatsColors.current - TokenStatsWhiteCard( + val contentColor = MaterialTheme.colorScheme.onPrimaryContainer + Card( modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = contentColor, + ), ) { - Column(modifier = Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.token_stats_lifetime_total), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = colors.summaryCardContent, - modifier = Modifier.weight(1f), - ) - if (rateIsEstimated) { - EstimatedBadge( - text = stringResource(R.string.token_stats_rate_default_hint, manualRate), - textColor = colors.summaryCardContent, - ) - } - } - - Spacer(Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.token_stats_include_legacy), - style = MaterialTheme.typography.bodyMedium, - color = colors.summaryCardContent, - modifier = Modifier.weight(1f), - ) - Switch( - checked = includeLegacy, - onCheckedChange = onIncludeLegacyChange, - ) - } - - Spacer(Modifier.height(8.dp)) - - val eventTotals = overview.eventTotals - val baseline = overview.baselineTotals - val unknownCostContributions = - includeLegacyValue( - eventTotals.cost.unknownContributionCount, - baseline.cost.unknownContributionCount, - includeLegacy, - ) + Column(modifier = Modifier.padding(TokenStatsSpacing.card)) { + val totals = overview.totals + val unknownCostContributions = totals.cost.unknownContributionCount Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -179,25 +179,25 @@ internal fun TokenStatsLifetimeCard( BigNumber( label = stringResource(R.string.settings_total_requests), value = - formatCount( - includeLegacyValue(eventTotals.requests, baseline.requests, includeLegacy) + formatRequestCount( + totals.requests, + totals.requestCountUnknownContributionCount, ), - color = colors.summaryCardContent, + color = contentColor, ) BigNumber( label = stringResource(R.string.token_stats_tokens_total), - value = formatCompactCount(knownLifetimeTokenSum(overview, includeLegacy)), - color = colors.summaryCardContent, + value = formatCompactCount(knownTokenSum(totals)), + color = contentColor, ) BigNumber( label = stringResource(R.string.settings_total_cost), value = - formatMoney( - eventTotals.cost.knownAmount + - if (includeLegacy) baseline.cost.knownAmount else 0.0, + formatLifetimeMoney( + totals.cost.knownAmount, currency, ), - color = colors.chartAccent, + color = contentColor, alignEnd = true, ) } @@ -211,73 +211,9 @@ internal fun TokenStatsLifetimeCard( color = colors.unknownHint, ) } - if (unknownCostContributions == 0L && - eventTotals.cost.rateIsEstimated - ) { - Text( - text = stringResource(R.string.token_stats_rate_applied_hint, manualRate), - style = MaterialTheme.typography.bodySmall, - color = colors.summaryCardContent.copy(alpha = 0.8f), - ) - } - Spacer(Modifier.height(12.dp)) - TokenComponentLines(totals = eventTotals, textColor = colors.summaryCardContent) - - // 旧数据 baseline(估算口径,明确标注) - if (includeLegacy && baseline.identityCount > 0L) { - Spacer(Modifier.height(12.dp)) - HorizontalDivider(color = colors.summaryCardContent.copy(alpha = 0.2f)) - Spacer(Modifier.height(12.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.token_stats_baseline_estimate), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = colors.summaryCardContent, - modifier = Modifier.weight(1f), - ) - EstimatedBadge( - text = stringResource(R.string.token_stats_baseline_badge), - textColor = colors.summaryCardContent, - ) - } - Spacer(Modifier.height(4.dp)) - Text( - text = stringResource( - R.string.token_stats_baseline_rows, - baseline.identityCount, - ), - style = MaterialTheme.typography.bodySmall, - color = colors.summaryCardContent.copy(alpha = 0.8f), - ) - Spacer(Modifier.height(8.dp)) - BaselineLine( - label = stringResource(R.string.token_stats_tokens_total), - value = formatCount(knownBaselineTokenSum(baseline)), - color = colors.summaryCardContent, - ) - BaselineLine( - label = stringResource(R.string.settings_total_requests), - value = formatCount(baseline.requests), - color = colors.summaryCardContent, - ) - BaselineLine( - label = stringResource(R.string.settings_total_cost), - value = formatMoney(baseline.cost.knownAmount, currency), - color = colors.summaryCardContent, - ) - if (baseline.cost.unknownContributionCount > 0L) { - UnknownHint( - text = stringResource( - R.string.token_stats_unknown_cost, - baseline.cost.unknownContributionCount, - ), - color = colors.unknownHint, - ) - } - } + TokenComponentLines(totals = totals, textColor = contentColor) } } } @@ -300,9 +236,12 @@ private fun androidx.compose.foundation.layout.RowScope.BigNumber( ) Text( text = value, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = color, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, ) } } @@ -338,7 +277,7 @@ private fun TokenComponentLines( totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, textColor: androidx.compose.ui.graphics.Color, ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { TokenLine( label = stringResource(R.string.token_stats_token_uncached), aggregate = totals.uncachedInput, @@ -405,132 +344,209 @@ private fun TokenLine( } } +internal fun knownTokenSum( + totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, +): Long = totals.totalTokens.knownSum + +internal fun saturatedTokenSum(vararg values: Long): Long = + values.fold(0L, TokenCostCalculator::saturatedAdd) + +// ==== 生命周期模型累计 ==== + @Composable -private fun BaselineLine( - label: String, - value: String, - color: androidx.compose.ui.graphics.Color, +internal fun TokenStatsLifetimeModelsSection( + models: List, + currency: PricingCurrency, ) { + val sortedModels = models.sortedByDescending { knownTokenSum(it.totals) } + var showAllModels by rememberSaveable { mutableStateOf(false) } + val visibleModels = + if (showAllModels) { + sortedModels + } else { + sortedModels.take(LIFETIME_MODELS_COLLAPSED_COUNT) + } + val totalTokens = sortedModels.fold(0L) { total, model -> + TokenCostCalculator.saturatedAdd(total, knownTokenSum(model.totals)) + } + + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader( + title = stringResource(R.string.token_stats_lifetime_models), + ) { + Text( + text = stringResource(R.string.token_stats_model_count, sortedModels.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + if (totalTokens > 0L) { + TokenStatsModelDistributionPie( + models = sortedModels, + totalTokens = totalTokens, + ) + } + + visibleModels.forEachIndexed { index, model -> + TokenStatsLifetimeModelRow( + model = model, + totalTokens = totalTokens, + color = LocalTokenStatsColors.current.modelPalette[ + index % LocalTokenStatsColors.current.modelPalette.size + ], + currency = currency, + ) + } + + if (sortedModels.size > LIFETIME_MODELS_COLLAPSED_COUNT) { + TextButton( + onClick = { showAllModels = !showAllModels }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = + if (showAllModels) { + stringResource(R.string.token_stats_model_collapse) + } else { + stringResource( + R.string.token_stats_model_show_all, + sortedModels.size, + ) + }, + ) + } + } + } + } + } +} + +private const val LIFETIME_MODELS_COLLAPSED_COUNT = 5 + +@Composable +private fun TokenStatsModelDistributionPie( + models: List, + totalTokens: Long, +) { + val palette = LocalTokenStatsColors.current.modelPalette + val centerColor = MaterialTheme.colorScheme.surface Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.Center, ) { + Canvas(modifier = Modifier.size(124.dp)) { + var startAngle = -90f + models.forEachIndexed { index, model -> + val sweepAngle = knownTokenSum(model.totals).toFloat() / totalTokens * 360f + if (sweepAngle > 0f) { + drawArc( + color = palette[index % palette.size], + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + ) + startAngle += sweepAngle + } + } + drawCircle( + color = centerColor, + radius = size.minDimension * 0.22f, + ) + } + } +} + +@Composable +private fun TokenStatsLifetimeModelRow( + model: TokenStatsDisplayModelBreakdown, + totalTokens: Long, + color: Color, + currency: PricingCurrency, +) { + val tokens = knownTokenSum(model.totals) + val percentage = + if (totalTokens > 0L) { + (tokens.toDouble() / totalTokens * 100).roundToInt() + } else { + 0 + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(modifier = Modifier.size(10.dp)) { + drawCircle(color = color) + } + Spacer(Modifier.width(TokenStatsSpacing.content)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource( + R.string.token_stats_lifetime_model_value, + formatCompactCount(tokens), + percentage, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Text( - text = label, + text = formatMoney(model.totals.cost.knownAmount, currency), style = MaterialTheme.typography.bodySmall, - color = color.copy(alpha = 0.85f), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, + color = LocalTokenStatsColors.current.chartAccent, fontWeight = FontWeight.Medium, - color = color, ) } } -/** - * 事件 canonical 总 Token(聚合器逐事件推导,口径见 - * [com.ai.assistance.operit.data.stats.TokenStatsTotals.totalTokens]: - * 权威 totalInputTokens 优先,fallback 按 cacheWriteSeparateBilling 决定输入口径, - * 输出按 reasoningIncludedInOutput 决定是否补推理;未知分量保持 unknown 不当作 0)。 - * UI 展示总 Token 的唯一事实来源,不再从原始聚合字段自行重组。 - */ -internal fun knownTokenSum( - totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, -): Long = totals.totalTokens.knownSum - -/** 旧累计值没有额外 token 分类:inputTokens 已含缓存命中,只能按总输入和输出合计。 */ -internal fun knownBaselineTokenSum(totals: TokenStatsBaselineTotals): Long = - saturatedTokenSum(totals.inputTokens, totals.outputTokens) - -/** 生命周期总 Token 必须同时包含新事件与迁移的旧累计 baseline。 */ -internal fun knownLifetimeTokenSum( - overview: TokenStatsLifetimeOverview, - includeLegacy: Boolean = true, -): Long = - includeLegacyValue( - knownTokenSum(overview.eventTotals), - knownBaselineTokenSum(overview.baselineTotals), - includeLegacy, - ) - -internal fun includeLegacyValue(eventValue: Long, baselineValue: Long, includeLegacy: Boolean): Long = - if (includeLegacy) TokenCostCalculator.saturatedAdd(eventValue, baselineValue) else eventValue - -internal fun saturatedTokenSum(vararg values: Long): Long = - values.fold(0L, TokenCostCalculator::saturatedAdd) - // ==== 筛选栏 ==== -/** 时间预设(10 预设 + 自定义)与模型/分类/状态/口径/币种筛选。 */ +/** 当前日期范围内活动、图表和模型明细共用的查询条件。 */ @OptIn(ExperimentalLayoutApi::class) @Composable internal fun TokenStatsFilterBar( - selectedPreset: TokenStatsPreset, selectedModels: Set, availableModels: List, knownModelNames: Map, selectedCategories: Set?, selectedStatuses: Set?, - costMode: TokenStatsCostMode, - targetCurrency: PricingCurrency, - onSelectPreset: (TokenStatsPreset) -> Unit, - onCustomRange: () -> Unit, - onDeleteRange: () -> Unit, onToggleModel: (String) -> Unit, onSelectAllModels: () -> Unit, onToggleCategory: (TokenStatCategory) -> Unit, onClearAllCategories: () -> Unit, onToggleStatus: (TokenStatStatus) -> Unit, onClearAllStatuses: () -> Unit, - onSetCostMode: (TokenStatsCostMode) -> Unit, - onSetCurrency: (PricingCurrency) -> Unit, ) { TokenStatsWhiteCard( modifier = Modifier.fillMaxWidth(), ) { - Column(modifier = Modifier.padding(12.dp)) { - var showCostModeHelp by remember { mutableStateOf(false) } - - // 时间、展示币种和范围删除属于同一层级。 + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + Text( + text = stringResource(R.string.token_stats_filters), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // 用两列呈现,让每个条件都能完整表达自身含义。 Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TimePresetDropdown( - selectedPreset, - onSelectPreset, - onCustomRange, - Modifier.weight(1f), - ) - CurrencyChip( - currency = PricingCurrency.CNY, - selected = targetCurrency == PricingCurrency.CNY, - onClick = { onSetCurrency(PricingCurrency.CNY) }, - ) - CurrencyChip( - currency = PricingCurrency.USD, - selected = targetCurrency == PricingCurrency.USD, - onClick = { onSetCurrency(PricingCurrency.USD) }, - ) - // 删除当前时间范围:只删有时间戳的事件,不触碰 baseline(阶段 5) - IconButton(onClick = onDeleteRange) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.token_stats_delete_range), - tint = MaterialTheme.colorScheme.error, - ) - } - } - - Spacer(Modifier.height(8.dp)) - - // 查询维度固定三列,避免重要筛选藏在横向滚动区域。 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), ) { ModelFilterDropdown( selectedModels, @@ -546,120 +562,59 @@ internal fun TokenStatsFilterBar( onClearAllCategories, Modifier.weight(1f), ) - StatusFilterDropdown( - selectedStatuses, - onToggleStatus, - onClearAllStatuses, - Modifier.weight(1f), - ) } - Spacer(Modifier.height(8.dp)) - - // 计价口径独占一行,帮助入口解释它只影响费用计算。 Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), ) { - FilterChip( - selected = costMode == TokenStatsCostMode.HISTORICAL, - onClick = { onSetCostMode(TokenStatsCostMode.HISTORICAL) }, - label = { Text(stringResource(R.string.token_stats_mode_historical)) }, - modifier = Modifier.weight(1f), - ) - FilterChip( - selected = costMode == TokenStatsCostMode.REVALUED, - onClick = { onSetCostMode(TokenStatsCostMode.REVALUED) }, - label = { Text(stringResource(R.string.token_stats_mode_revalued)) }, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { showCostModeHelp = true }) { - Icon( - imageVector = Icons.Filled.HelpOutline, - contentDescription = stringResource(R.string.token_stats_mode_help_title), - tint = TokenStatsCardMuted, - ) - } - } - - if (showCostModeHelp) { - AlertDialog( - onDismissRequest = { showCostModeHelp = false }, - title = { Text(stringResource(R.string.token_stats_mode_help_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.token_stats_mode_historical_help)) - Text(stringResource(R.string.token_stats_mode_revalued_help)) - Text( - text = stringResource(R.string.token_stats_mode_rate_help), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - }, - confirmButton = { - TextButton(onClick = { showCostModeHelp = false }) { - Text(stringResource(R.string.token_stats_help_got_it)) - } - }, + StatusFilterDropdown( + selectedStatuses, + onToggleStatus, + onClearAllStatuses, + Modifier.fillMaxWidth(), ) } } } } -@Composable -private fun CurrencyChip( - currency: PricingCurrency, - selected: Boolean, - onClick: () -> Unit, -) { - FilterChip( - selected = selected, - onClick = onClick, - label = { - Text(currency.code) - }, - ) +internal fun formatDateRangeLabel(range: TokenStatsTimeRange, zone: ZoneId): String { + val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(zone).toLocalDate() + return if (start == end) start.format(formatter) else "${start.format(formatter)} - ${end.format(formatter)}" } -private fun TokenStatsPreset.labelRes(): Int = - when (this) { - TokenStatsPreset.LAST_5H -> R.string.token_stats_preset_5h - TokenStatsPreset.LAST_12H -> R.string.token_stats_preset_12h - TokenStatsPreset.LAST_24H -> R.string.token_stats_preset_24h - TokenStatsPreset.TODAY -> R.string.token_stats_preset_today - TokenStatsPreset.YESTERDAY -> R.string.token_stats_preset_yesterday - TokenStatsPreset.LAST_7D -> R.string.token_stats_preset_7d - TokenStatsPreset.LAST_30D -> R.string.token_stats_preset_30d - TokenStatsPreset.THIS_MONTH -> R.string.token_stats_preset_this_month - TokenStatsPreset.LAST_MONTH -> R.string.token_stats_preset_last_month - TokenStatsPreset.CUSTOM -> R.string.token_stats_custom_range - } +internal fun formatCompactDateRangeLabel(range: TokenStatsTimeRange, zone: ZoneId): String { + val formatter = DateTimeFormatter.ofPattern("M/d", Locale.getDefault()) + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(zone).toLocalDate() + return if (start == end) start.format(formatter) else "${start.format(formatter)}-${end.format(formatter)}" +} @Composable -private fun TimePresetDropdown( - selected: TokenStatsPreset, - onSelect: (TokenStatsPreset) -> Unit, - onCustomRange: () -> Unit, +internal fun TokenStatsCurrencyDropdown( + selected: PricingCurrency, + onSelect: (PricingCurrency) -> Unit, modifier: Modifier = Modifier, ) { FilterDropdown( - label = stringResource(selected.labelRes()), + label = selected.code, modifier = modifier, ) { dismiss -> - TokenStatsPreset.entries.forEach { preset -> + PricingCurrency.entries.forEach { currency -> DropdownMenuItem( text = { Text( - text = stringResource(preset.labelRes()), - fontWeight = if (preset == selected) FontWeight.Bold else FontWeight.Normal, + text = currency.code, + fontWeight = if (currency == selected) FontWeight.Bold else FontWeight.Normal, ) }, onClick = { dismiss() - if (preset == TokenStatsPreset.CUSTOM) onCustomRange() else onSelect(preset) + onSelect(currency) }, ) } @@ -688,9 +643,15 @@ private fun ModelFilterDropdown( FilterDropdown( modifier = modifier, label = if (selectedModels.isEmpty()) { - stringResource(R.string.token_stats_filter_all_models) + stringResource( + R.string.token_stats_filter_model_label, + stringResource(R.string.token_stats_filter_all_models), + ) } else { - stringResource(R.string.token_stats_filter_models_count, selectedModels.size) + stringResource( + R.string.token_stats_filter_model_label, + stringResource(R.string.token_stats_filter_models_count, selectedModels.size), + ) }, ) { dismiss -> DropdownMenuItem( @@ -737,9 +698,15 @@ private fun CategoryFilterDropdown( FilterDropdown( modifier = modifier, label = if (selected == null) { - stringResource(R.string.token_stats_filter_all_categories) + stringResource( + R.string.token_stats_filter_category_label, + stringResource(R.string.token_stats_filter_all_categories), + ) } else { - stringResource(R.string.token_stats_filter_categories_count, selected.size) + stringResource( + R.string.token_stats_filter_category_label, + stringResource(R.string.token_stats_filter_categories_count, selected.size), + ) }, ) { dismiss -> DropdownMenuItem( @@ -784,9 +751,15 @@ private fun StatusFilterDropdown( FilterDropdown( modifier = modifier, label = if (selected == null) { - stringResource(R.string.token_stats_filter_all_statuses) + stringResource( + R.string.token_stats_filter_status_label, + stringResource(R.string.token_stats_filter_all_statuses), + ) } else { - stringResource(R.string.token_stats_filter_statuses_count, selected.size) + stringResource( + R.string.token_stats_filter_status_label, + stringResource(R.string.token_stats_filter_statuses_count, selected.size), + ) }, ) { dismiss -> DropdownMenuItem( @@ -878,270 +851,221 @@ internal fun TokenStatsChartCard( headerExtra: @Composable () -> Unit = {}, content: @Composable () -> Unit, ) { - val chartColors = - LocalTokenStatsColors.current.copy( - chartGrid = Color(0xFFE0E0E0), - chartLabel = Color(0xFF5F6368), - tooltipContainer = TokenStatsCardContainer, - tooltipContent = Color(0xFF202124), - unknownHint = Color(0xFF8A4B00), - ) - CompositionLocalProvider(LocalTokenStatsColors provides chartColors) { - TokenStatsWhiteCard( - modifier = modifier.fillMaxWidth(), - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Text( - text = summary, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = chartColors.chartAccent, - modifier = if (onSummaryClick != null) { - Modifier.clickable(onClick = onSummaryClick) - } else { - Modifier - }, - ) - } - Spacer(Modifier.height(8.dp)) - headerExtra() - content() + val colors = LocalTokenStatsColors.current + TokenStatsWhiteCard( + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(TokenStatsSpacing.card)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = summary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = colors.chartAccent, + modifier = if (onSummaryClick != null) { + Modifier.clickable(onClick = onSummaryClick) + } else { + Modifier + }, + ) } + Spacer(Modifier.height(8.dp)) + headerExtra() + content() } } } -// ==== 模型卡片 ==== +// ==== 配置详情 ==== @Composable -internal fun TokenStatsModelCardsSection( - models: List, +internal fun TokenStatsConfigurationCardsSection( + configurations: List, currency: PricingCurrency, - costMode: TokenStatsCostMode, - zone: ZoneId, - onGroupManage: (TokenStatsDisplayModelBreakdown) -> Unit, - onDelete: (TokenStatsDisplayModelBreakdown) -> Unit, + configurationNames: Map, + priceSettings: List, + onEditPrice: (TokenStatsPriceSetting?, TokenStatsPriceDraft, String?) -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - models.forEach { model -> - TokenStatsModelCard( - model = model, - currency = currency, - costMode = costMode, - zone = zone, - onGroupManage = { onGroupManage(model) }, - onDelete = { onDelete(model) }, - ) - } - } -} - -@Composable -internal fun TokenStatsModelCard( - model: TokenStatsDisplayModelBreakdown, - currency: PricingCurrency, - costMode: TokenStatsCostMode, - zone: ZoneId, - onGroupManage: () -> Unit, - onDelete: () -> Unit, -) { - val colors = LocalTokenStatsColors.current - var expanded by remember(model.displayModelId) { mutableStateOf(false) } - TokenStatsWhiteCard( modifier = Modifier.fillMaxWidth(), ) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { expanded = !expanded }) { - Icon( - imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = stringResource(R.string.token_stats_model_expand), - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = model.displayName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource( - R.string.token_stats_model_identities_count, - model.identities.size, - ), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - Column(horizontalAlignment = Alignment.End) { - Text( - text = formatMoney(model.totals.cost.knownAmount, currency), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = colors.chartAccent, - ) - Text( - text = stringResource(R.string.settings_request_count_label, model.totals.requests), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - // 阶段 5:删除对完整展示分组生效(可跨 provider/模型合并组), - // 不再限制单 provider:model;危险操作在对话框两步确认。 - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(R.string.token_stats_delete_model), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp), - ) - } - IconButton(onClick = onGroupManage) { - Icon( - imageVector = Icons.Filled.Groups, - contentDescription = stringResource(R.string.token_stats_group_manage), - tint = TokenStatsCardMuted, - modifier = Modifier.size(20.dp), + Column { + configurations + .sortedByDescending { it.totals.totalTokens.knownSum } + .forEachIndexed { index, identity -> + if (index > 0) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + val configurationName = + identity.configId?.let { configId -> + configurationNames[configId] + ?: stringResource(R.string.token_stats_config_deleted) + } ?: stringResource(R.string.token_stats_legacy_configuration) + TokenStatsConfigurationRow( + identity = identity, + configurationName = configurationName, + currency = currency, + priceSettings = priceSettings, + onEditPrice = onEditPrice, ) } - } - - if (expanded) { - Spacer(Modifier.height(8.dp)) - model.identities.forEach { identity -> - TokenStatsIdentityRow(identity = identity, currency = currency, costMode = costMode) - Spacer(Modifier.height(6.dp)) - } - } } } } @Composable -private fun TokenStatsIdentityRow( +private fun TokenStatsConfigurationRow( identity: com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown, + configurationName: String, currency: PricingCurrency, - costMode: TokenStatsCostMode, + priceSettings: List, + onEditPrice: (TokenStatsPriceSetting?, TokenStatsPriceDraft, String?) -> Unit, ) { val colors = LocalTokenStatsColors.current + var expanded by remember(identity.configId, identity.provider, identity.model) { mutableStateOf(false) } Column( modifier = Modifier .fillMaxWidth() - .padding(start = 8.dp), + .clickable { expanded = !expanded } + .padding(horizontal = TokenStatsSpacing.card, vertical = 10.dp), ) { - Text( - text = "${identity.provider} · ${identity.model}", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - if (identity.configId.isNotEmpty()) { - Text( - text = stringResource(R.string.token_stats_config_id, identity.configId), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - val totals = identity.totals Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "${stringResource(R.string.token_stats_token_uncached)} ${formatCompactCount(totals.uncachedInput.knownSum)}" + - " · ${stringResource(R.string.token_stats_token_cached)} ${formatCompactCount(totals.cachedInput.knownSum)}" + - " · ${stringResource(R.string.token_stats_token_output)} ${formatCompactCount(totals.output.knownSum)}", - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - Text( - text = formatMoney(totals.cost.knownAmount, currency), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - } - if (totals.uncachedInput.unknownEventCount > 0L || - totals.cachedInput.unknownEventCount > 0L || - totals.output.unknownEventCount > 0L - ) { - Text( - text = stringResource( - R.string.token_stats_unknown_parts, - totals.uncachedInput.unknownEventCount + - totals.cachedInput.unknownEventCount + - totals.output.unknownEventCount, - ), - style = MaterialTheme.typography.bodySmall, - color = colors.unknownHint, - ) - } - if (totals.cost.unknownContributionCount > 0L) { - Text( - text = stringResource(R.string.token_stats_unknown_cost, totals.cost.unknownContributionCount), - style = MaterialTheme.typography.bodySmall, - color = colors.unknownHint, + Column(modifier = Modifier.weight(1f)) { + Text( + text = configurationName, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${identity.provider} · ${identity.model}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatMoney(totals.cost.knownAmount, currency), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = colors.chartAccent, + maxLines = 1, + ) + Text( + text = formatCompactRequestCountLabel( + totals.requests, + totals.requestCountUnknownContributionCount, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.token_stats_model_expand), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - // 单价:历史口径 = 事件快照;重估口径 = 当前解析价格 - identity.pricing?.let { pricing -> - val priceText = - if (!pricing.known) { - stringResource(R.string.token_stats_unknown_pricing) + if (expanded) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "${stringResource(R.string.token_stats_token_uncached)} ${formatCompactCount(totals.uncachedInput.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${stringResource(R.string.token_stats_token_cached)} ${formatCompactCount(totals.cachedInput.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${stringResource(R.string.token_stats_token_output)} ${formatCompactCount(totals.output.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (totals.uncachedInput.unknownEventCount > 0L || + totals.cachedInput.unknownEventCount > 0L || + totals.output.unknownEventCount > 0L + ) { + Text( + text = stringResource( + R.string.token_stats_unknown_parts, + totals.uncachedInput.unknownEventCount + + totals.cachedInput.unknownEventCount + + totals.output.unknownEventCount, + ), + style = MaterialTheme.typography.labelSmall, + color = colors.unknownHint, + modifier = Modifier.weight(1f), + ) } else { - buildPricingText(pricing, currency) + Spacer(Modifier.weight(1f)) + } + IconButton( + onClick = { + val scope = + if (identity.configId.isNullOrEmpty()) { + TokenStatsPriceScope.PROVIDER_MODEL + } else { + TokenStatsPriceScope.CONFIG + } + val providerModel = "${identity.provider}:${identity.model}" + val existing = + priceSettings.firstOrNull { + it.scope == scope && + it.providerModel.equals(providerModel, ignoreCase = true) && + (scope == TokenStatsPriceScope.PROVIDER_MODEL || + it.configId == identity.configId) + } + onEditPrice( + existing, + priceDraftForConfiguration(identity, priceSettings), + identity.configId?.let { configurationName }, + ) + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.token_stats_pricing_edit), + modifier = Modifier.size(18.dp), + ) } - Text( - text = "${stringResource(R.string.token_stats_price_label)} $priceText", - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - } -} - -/** 单价的展示文本(含计费方式与来源标签)。 */ -@Composable -private fun buildPricingText( - pricing: com.ai.assistance.operit.data.stats.TokenStatsPricingInfo, - currency: PricingCurrency, -): String { - val displayCurrency = pricing.currency - val modeText = - if (pricing.billingMode == com.ai.assistance.operit.data.model.BillingMode.TOKEN) { - val parts = buildList { - pricing.inputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } - pricing.cachedInputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } - pricing.cacheWritePricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } - pricing.outputPricePerMillion?.let { add(formatPricePerMillion(it, displayCurrency)) } } - if (parts.isEmpty()) stringResource(R.string.token_stats_unknown_pricing) else parts.joinToString(" · ") - } else { - pricing.pricePerRequest?.let { formatPricePerRequest(it, displayCurrency) } - ?: stringResource(R.string.token_stats_unknown_pricing) - } - val sourceText = - when (pricing.source) { - com.ai.assistance.operit.data.stats.PricingSource.DEFAULT -> - stringResource(R.string.token_stats_pricing_source_builtin) - com.ai.assistance.operit.data.stats.PricingSource.PROVIDER_MODEL_OVERRIDE -> - stringResource(R.string.token_stats_pricing_source_override) - com.ai.assistance.operit.data.stats.PricingSource.CONFIG_OVERRIDE -> - stringResource(R.string.token_stats_pricing_source_config) - com.ai.assistance.operit.data.stats.PricingSource.LEGACY_OVERRIDE -> - stringResource(R.string.token_stats_pricing_source_legacy) - com.ai.assistance.operit.data.stats.PricingSource.UNKNOWN -> - stringResource(R.string.token_stats_unknown_pricing) + if (totals.cost.unknownContributionCount > 0L) { + Text( + text = stringResource(R.string.token_stats_unknown_cost, totals.cost.unknownContributionCount), + style = MaterialTheme.typography.labelSmall, + color = colors.unknownHint, + ) + } } - return "$modeText($sourceText)" + } } // ==== 汇率与币种设置卡 ==== @@ -1163,8 +1087,8 @@ internal fun TokenStatsRateCard( modifier = Modifier.fillMaxWidth(), ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -1176,14 +1100,14 @@ internal fun TokenStatsRateCard( if (rateIsEstimated) { EstimatedBadge( text = stringResource(R.string.token_stats_rate_default_badge), - textColor = TokenStatsCardContent, + textColor = MaterialTheme.colorScheme.onSurface, ) } } Text( text = stringResource(R.string.settings_exchange_rate_subtitle), style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Row( @@ -1224,6 +1148,76 @@ internal fun TokenStatsRateCard( } } +private fun priceDraftForConfiguration( + identity: com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown, + priceSettings: List, +): TokenStatsPriceDraft { + val providerModel = "${identity.provider}:${identity.model}" + val providerSettings = + priceSettings.firstOrNull { + it.scope == TokenStatsPriceScope.PROVIDER_MODEL && + it.providerModel.equals(providerModel, ignoreCase = true) + }?.toModelPriceSettings() + val configurationSettings = + identity.configId?.let { configId -> + priceSettings.firstOrNull { + it.scope == TokenStatsPriceScope.CONFIG && + it.providerModel.equals(providerModel, ignoreCase = true) && + it.configId == configId + } + }?.toModelPriceSettings() + val resolved = + TokenPriceResolver.resolve( + providerModel, + mergePriceSettings(providerSettings, configurationSettings), + ) + return TokenStatsPriceDraft( + scope = + if (identity.configId.isNullOrEmpty()) { + TokenStatsPriceScope.PROVIDER_MODEL + } else { + TokenStatsPriceScope.CONFIG + }, + provider = identity.provider, + model = identity.model, + configId = identity.configId, + billingMode = resolved.billingMode, + currency = resolved.currency, + inputPricePerMillion = resolved.inputPricePerMillion, + cachedInputPricePerMillion = resolved.cachedInputPricePerMillion, + cacheWritePricePerMillion = resolved.cacheWritePricePerMillion, + outputPricePerMillion = resolved.outputPricePerMillion, + pricePerRequest = resolved.pricePerRequest, + ) +} + +private fun mergePriceSettings( + provider: com.ai.assistance.operit.data.stats.ModelPriceSettings?, + configuration: com.ai.assistance.operit.data.stats.ModelPriceSettings?, +) = + com.ai.assistance.operit.data.stats.ModelPriceSettings( + billingMode = configuration?.billingMode ?: provider?.billingMode, + currency = configuration?.currency ?: provider?.currency, + inputPricePerMillion = configuration?.inputPricePerMillion ?: provider?.inputPricePerMillion, + cachedInputPricePerMillion = + configuration?.cachedInputPricePerMillion ?: provider?.cachedInputPricePerMillion, + cacheWritePricePerMillion = + configuration?.cacheWritePricePerMillion ?: provider?.cacheWritePricePerMillion, + outputPricePerMillion = configuration?.outputPricePerMillion ?: provider?.outputPricePerMillion, + pricePerRequest = configuration?.pricePerRequest ?: provider?.pricePerRequest, + ) + +private fun TokenStatsPriceSetting.toModelPriceSettings() = + com.ai.assistance.operit.data.stats.ModelPriceSettings( + billingMode = billingMode, + currency = currency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + private fun formatRateInput(rate: Double): String = String.format(Locale.US, "%.4f", rate).trimEnd('0').trimEnd('.') diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt index bb97d1cf9..424a6fd34 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -1,24 +1,18 @@ package com.ai.assistance.operit.ui.features.tokenstats import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.CurrencyYen +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog -import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DateRangePicker import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider @@ -27,11 +21,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberDateRangePickerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -40,36 +32,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect import com.ai.assistance.operit.data.collects.PricingCurrency import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo -import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsPriceScope +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle import java.util.Locale -// ==== 自定义时间范围(两步日期选择,设备时区自然日边界) ==== - -/** - * DatePicker 返回所选日期当日的 **UTC 0 点**;按 UTC 日历解析出日期本身 - * (P1-6:若用设备时区解析,西半球(如 New York)会因 UTC 日期尚在前一日 - * 20:00 而回退一天,导致选择 8/7 实际落在 8/6)。 - */ internal fun datePickerMillisToLocalDate(utcMidnightMs: Long): java.time.LocalDate = Instant.ofEpochMilli(utcMidnightMs).atZone(java.time.ZoneOffset.UTC).toLocalDate() -/** - * 由“开始日 + 结束日(**包含**结束日当天)”构造半开区间范围: - * `[startDay 0 点, endDay+1 天 0 点)`。同日合法(一天范围)。 - * 结束日早于开始日抛 [IllegalArgumentException]。 - */ internal fun customRangeInclusiveEnd( startDate: java.time.LocalDate, endDate: java.time.LocalDate, @@ -83,124 +61,105 @@ internal fun customRangeInclusiveEnd( @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun CustomRangeDialog( +internal fun TokenStatsDateRangeDialog( zone: ZoneId, maxRangeDays: Long, + initialRange: com.ai.assistance.operit.data.stats.TokenStatsTimeRange?, onConfirm: (startMs: Long, endMs: Long) -> Boolean, onDismiss: () -> Unit, ) { - var step by remember { mutableIntStateOf(0) } - var startDate by remember { mutableStateOf(null) } var inlineError by remember { mutableStateOf(null) } - - // 步骤切换时重建 picker(rememberDatePickerState 只取首帧初始值); - // DatePicker 的毫秒语义是“UTC 当日 0 点”,初始值同样按 UTC 日历生成。 - val pickerState = - androidx.compose.runtime.key(step, startDate) { - rememberDatePickerState( - initialSelectedDateMillis = startDate?.atStartOfDay(java.time.ZoneOffset.UTC) - ?.toInstant()?.toEpochMilli() - ) - } - - val dateFormatter = - remember { - DateTimeFormatter - .ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(Locale.getDefault()) - } - - // 已选日期回显:开始日跨步骤保留;结束日实时显示当前日历选中项。 - // 每次选中变化即清除上次的错误提示(用户已尝试修正)。 - androidx.compose.runtime.LaunchedEffect(pickerState.selectedDateMillis) { + val initialStartDateMillis = initialRange + ?.startMs + ?.let { Instant.ofEpochMilli(it).atZone(zone).toLocalDate() } + ?.atStartOfDay(java.time.ZoneOffset.UTC) + ?.toInstant() + ?.toEpochMilli() + val initialEndDateMillis = initialRange + ?.endMs + ?.minus(1L) + ?.let { Instant.ofEpochMilli(it).atZone(zone).toLocalDate() } + ?.atStartOfDay(java.time.ZoneOffset.UTC) + ?.toInstant() + ?.toEpochMilli() + val pickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = initialStartDateMillis, + initialSelectedEndDateMillis = initialEndDateMillis, + ) + androidx.compose.runtime.LaunchedEffect( + pickerState.selectedStartDateMillis, + pickerState.selectedEndDateMillis, + ) { inlineError = null } - val notSelected = stringResource(R.string.token_stats_custom_range_not_selected) val invalidRangeText = stringResource(R.string.token_stats_custom_range_invalid) val rangeTooLongText = stringResource(R.string.token_stats_custom_range_too_long) - val startText = startDate?.format(dateFormatter) ?: notSelected - val endText = - if (step == 1) { - pickerState.selectedDateMillis?.let(::datePickerMillisToLocalDate)?.format(dateFormatter) - } else { - null - } ?: notSelected - - val title = - if (step == 0) { - stringResource(R.string.token_stats_custom_range_pick_start) - } else { - stringResource(R.string.token_stats_custom_range_pick_end) - } DatePickerDialog( onDismissRequest = onDismiss, + modifier = Modifier.widthIn(max = 360.dp), confirmButton = { TextButton( - enabled = pickerState.selectedDateMillis != null, + enabled = + pickerState.selectedStartDateMillis != null && + pickerState.selectedEndDateMillis != null, onClick = { - val selected = pickerState.selectedDateMillis ?: return@TextButton - val date = datePickerMillisToLocalDate(selected) - if (step == 0) { - startDate = date - inlineError = null - step = 1 - return@TextButton - } - val start = startDate ?: return@TextButton - // 结束日包含当天:+1 天 0 点作为半开区间终点(P1-6),同日合法; - // 非法边界在对话框内就地提示(不静默停留)。 - val startMs = start.atStartOfDay(zone).toInstant().toEpochMilli() - val endMs = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - inlineError = when (validateCustomRange(startMs, endMs, zone, maxRangeDays)) { - CustomRangeValidation.INVALID_BOUNDS -> invalidRangeText - CustomRangeValidation.TOO_LONG -> rangeTooLongText - CustomRangeValidation.VALID -> null - } - if (inlineError == null && onConfirm(startMs, endMs)) { - onDismiss() - } + val start = pickerState.selectedStartDateMillis ?: return@TextButton + val end = pickerState.selectedEndDateMillis ?: return@TextButton + val range = customRangeInclusiveEnd( + datePickerMillisToLocalDate(start), + datePickerMillisToLocalDate(end), + zone, + ) + inlineError = + when (validateCustomRange(range.startMs, range.endMs, zone, maxRangeDays)) { + CustomRangeValidation.INVALID_BOUNDS -> invalidRangeText + CustomRangeValidation.TOO_LONG -> rangeTooLongText + CustomRangeValidation.VALID -> null + } + if (inlineError == null && onConfirm(range.startMs, range.endMs)) onDismiss() }, ) { Text(stringResource(R.string.token_stats_custom_range_confirm)) } }, dismissButton = { - TextButton(onClick = { if (step == 0) onDismiss() else step = 0 }) { - Text( - stringResource( - if (step == 0) R.string.settings_cancel - else R.string.token_stats_custom_range_back - ) - ) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) } }, ) { Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.token_stats_custom_range_summary_start, startText), - style = MaterialTheme.typography.bodySmall, - color = if (startDate == null) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurface, - fontWeight = if (startDate != null) FontWeight.Medium else FontWeight.Normal, - ) - Text( - text = stringResource(R.string.token_stats_custom_range_summary_end, endText), - style = MaterialTheme.typography.bodySmall, - color = if (step == 1 && pickerState.selectedDateMillis != null) - MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = if (step == 1 && pickerState.selectedDateMillis != null) - FontWeight.Medium - else FontWeight.Normal, - ) - } - DatePicker(state = pickerState) + DateRangePicker( + state = pickerState, + title = { + Text( + text = stringResource(R.string.token_stats_date_range), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 24.dp, top = 16.dp), + ) + }, + headline = { + Text( + text = formatDatePickerSelection( + pickerState.selectedStartDateMillis, + pickerState.selectedEndDateMillis, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 12.dp), + ) + }, + showModeToggle = false, + ) inlineError?.let { Text( text = it, @@ -212,178 +171,97 @@ internal fun CustomRangeDialog( } } -// ==== 价格覆盖新增/编辑 ==== +private fun formatDatePickerSelection(startMillis: Long?, endMillis: Long?): String { + if (startMillis == null) return "" + val start = datePickerMillisToLocalDate(startMillis).format(datePickerSelectionFormatter) + if (endMillis == null) return start + val end = datePickerMillisToLocalDate(endMillis).format(datePickerSelectionFormatter) + return "$start - $end" +} + +private val datePickerSelectionFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd", Locale.getDefault()) @Composable -internal fun PriceOverrideDialog( - existing: TokenStatPriceOverrideEntity?, - initialDraft: TokenStatsPriceOverrideDraft? = null, - onSave: (TokenStatsPriceOverrideDraft) -> Unit, - onDelete: (() -> Unit)?, +internal fun PriceSettingsDialog( + existing: TokenStatsPriceSetting?, + initialDraft: TokenStatsPriceDraft, + configurationName: String?, + onSave: (TokenStatsPriceDraft) -> Unit, + onDelete: (() -> Unit)? = null, onDismiss: () -> Unit, ) { - val storedBillingMode = - existing?.let { BillingMode.fromString(it.billingMode) } - ?: initialDraft?.billingMode - ?: BillingMode.TOKEN - var scope by remember(existing, initialDraft) { - mutableStateOf( - existing?.let { PriceOverrideScope.fromNameOrNull(it.scope) } - ?: initialDraft?.scope - ?: PriceOverrideScope.PROVIDER_MODEL - ) - } - var provider by remember(existing, initialDraft) { - mutableStateOf(existing?.provider ?: initialDraft?.provider.orEmpty()) - } - var model by remember(existing, initialDraft) { - mutableStateOf(existing?.model ?: initialDraft?.model.orEmpty()) - } - var configId by remember(existing, initialDraft) { - mutableStateOf(existing?.configId ?: initialDraft?.configId.orEmpty()) - } + val scope = initialDraft.scope + val provider = initialDraft.provider + val model = initialDraft.model + val configId = initialDraft.configId.orEmpty() var billingMode by remember(existing, initialDraft) { - mutableStateOf( - storedBillingMode - ) + mutableStateOf(existing?.billingMode ?: initialDraft.billingMode) } var currency by remember(existing, initialDraft) { - mutableStateOf( - existing?.let { - if (it.pricingCurrency.equals("CNY", ignoreCase = true)) PricingCurrency.CNY else PricingCurrency.USD - } ?: initialDraft?.currency ?: PricingCurrency.CNY - ) + mutableStateOf(existing?.currency ?: initialDraft.currency) } var inputPrice by remember(existing, initialDraft) { mutableStateOf( - formatEditablePrice( - if (storedBillingMode == BillingMode.TOKEN) { - existing?.inputPricePerMillion ?: initialDraft?.inputPricePerMillion - } else null - ) + formatEditablePrice(existing?.inputPricePerMillion ?: initialDraft.inputPricePerMillion) ) } var cachedInputPrice by remember(existing, initialDraft) { mutableStateOf( formatEditablePrice( - if (storedBillingMode == BillingMode.TOKEN) { - existing?.cachedInputPricePerMillion ?: initialDraft?.cachedInputPricePerMillion - } else null + existing?.cachedInputPricePerMillion ?: initialDraft.cachedInputPricePerMillion ) ) } var cacheWritePrice by remember(existing, initialDraft) { mutableStateOf( formatEditablePrice( - if (storedBillingMode == BillingMode.TOKEN) { - existing?.cacheWritePricePerMillion ?: initialDraft?.cacheWritePricePerMillion - } else null + existing?.cacheWritePricePerMillion ?: initialDraft.cacheWritePricePerMillion ) ) } var outputPrice by remember(existing, initialDraft) { mutableStateOf( - formatEditablePrice( - if (storedBillingMode == BillingMode.TOKEN) { - existing?.outputPricePerMillion ?: initialDraft?.outputPricePerMillion - } else null - ) + formatEditablePrice(existing?.outputPricePerMillion ?: initialDraft.outputPricePerMillion) ) } var pricePerRequest by remember(existing, initialDraft) { mutableStateOf( - formatEditablePrice( - if (storedBillingMode == BillingMode.COUNT) { - existing?.pricePerRequest ?: initialDraft?.pricePerRequest - } else null - ) + formatEditablePrice(existing?.pricePerRequest ?: initialDraft.pricePerRequest) ) } - var inlineError by remember { mutableStateOf(null) } - val pricingInvalidText = stringResource(R.string.token_stats_pricing_invalid) - // P1-7:编辑已有覆盖时业务键(scope/provider/model/configId)只读, - // 只允许修改价格/币种/计费方式,防止键被改掉产生第二行或误覆盖。 - val editing = existing != null - val targetLocked = editing || initialDraft != null - val priceFields = if (billingMode == BillingMode.TOKEN) { listOf(inputPrice, cachedInputPrice, cacheWritePrice, outputPrice) } else { listOf(pricePerRequest) } - val allParsed = priceFields.all { - it.isBlank() || it.toDoubleOrNull() != null - } - // CONFIG 作用域必须填写配置 ID(P1-7);新增时同样强制 - val configIdValid = scope != PriceOverrideScope.CONFIG || configId.isNotBlank() - - val builtinReference = - if (scope == PriceOverrideScope.PROVIDER_MODEL && provider.isNotBlank() && model.isNotBlank()) { - DefaultModelPricingCollect.getDefaultPricing("$provider:$model") - } else { - null + val allPricesValid = + priceFields.all { raw -> + raw.isBlank() || + raw.toDoubleOrNull()?.let { it.isFinite() && it > 0.0 } == true } - + val targetValid = scope != TokenStatsPriceScope.CONFIG || configId.isNotBlank() AlertDialog( onDismissRequest = onDismiss, - title = { - Text( - stringResource( - if (existing == null) R.string.token_stats_pricing_add - else R.string.token_stats_pricing_edit - ) - ) - }, + title = { Text(stringResource(R.string.token_stats_pricing_edit)) }, text = { Column( modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = scope == PriceOverrideScope.PROVIDER_MODEL, - onClick = { if (!targetLocked) scope = PriceOverrideScope.PROVIDER_MODEL }, - enabled = !targetLocked, - label = { Text(stringResource(R.string.token_stats_pricing_scope_provider)) }, - modifier = Modifier.weight(1f), - ) - FilterChip( - selected = scope == PriceOverrideScope.CONFIG, - onClick = { if (!targetLocked) scope = PriceOverrideScope.CONFIG }, - enabled = !targetLocked, - label = { Text(stringResource(R.string.token_stats_pricing_scope_config)) }, - modifier = Modifier.weight(1f), - ) - } - - OutlinedTextField( - value = provider, - onValueChange = { if (!targetLocked) provider = it }, - label = { Text(stringResource(R.string.token_stats_pricing_provider_label)) }, - singleLine = true, - enabled = !targetLocked, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = model, - onValueChange = { if (!targetLocked) model = it }, - label = { Text(stringResource(R.string.token_stats_pricing_model_label)) }, - singleLine = true, - enabled = !targetLocked, - modifier = Modifier.fillMaxWidth(), + Text( + text = "$provider · $model", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, ) - if (scope == PriceOverrideScope.CONFIG) { - OutlinedTextField( - value = configId, - onValueChange = { if (!targetLocked) configId = it }, - label = { Text(stringResource(R.string.token_stats_pricing_config_label)) }, - singleLine = true, - enabled = !targetLocked, - modifier = Modifier.fillMaxWidth(), + if (scope == TokenStatsPriceScope.CONFIG) { + Text( + text = configurationName.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - + HorizontalDivider() Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = billingMode == BillingMode.TOKEN, @@ -411,34 +289,21 @@ internal fun PriceOverrideDialog( modifier = Modifier.weight(1f), ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip( selected = currency == PricingCurrency.CNY, onClick = { currency = PricingCurrency.CNY }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.CurrencyYen, null, modifier = Modifier.padding(end = 2.dp)) - Text(stringResource(R.string.token_stats_currency_cny)) - } - }, + label = { Text(stringResource(R.string.token_stats_currency_cny)) }, modifier = Modifier.weight(1f), ) FilterChip( selected = currency == PricingCurrency.USD, onClick = { currency = PricingCurrency.USD }, - label = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.AttachMoney, null, modifier = Modifier.padding(end = 2.dp)) - Text(stringResource(R.string.token_stats_currency_usd)) - } - }, + label = { Text(stringResource(R.string.token_stats_currency_usd)) }, modifier = Modifier.weight(1f), ) } - HorizontalDivider() - if (billingMode == BillingMode.TOKEN) { PriceField( label = stringResource(R.string.token_stats_pricing_input), @@ -467,60 +332,46 @@ internal fun PriceOverrideDialog( onChange = { pricePerRequest = it }, ) } - - builtinReference?.let { defaults -> - val referenceText = - if (billingMode == BillingMode.COUNT) { - "${defaults.currency.symbol}${defaults.pricePerRequest}/${stringResource(R.string.settings_billing_mode_count)}" - } else { - "${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M · " + - "${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M" - } - Text( - text = stringResource(R.string.token_stats_pricing_reference, referenceText), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - inlineError?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } } }, confirmButton = { TextButton( - enabled = provider.isNotBlank() && model.isNotBlank() && allParsed && configIdValid, + enabled = targetValid && allPricesValid, onClick = { val parse = { raw: String -> raw.trim().toDoubleOrNull() } - val draft = - TokenStatsPriceOverrideDraft( + onSave( + TokenStatsPriceDraft( scope = scope, provider = provider, model = model, - configId = configId.ifBlank { null }, + configId = configId.takeIf { scope == TokenStatsPriceScope.CONFIG }, billingMode = billingMode, currency = currency, inputPricePerMillion = if (billingMode == BillingMode.TOKEN) parse(inputPrice) else null, cachedInputPricePerMillion = - if (billingMode == BillingMode.TOKEN) parse(cachedInputPrice) else null, + if (billingMode == BillingMode.TOKEN) { + parse(cachedInputPrice) + } else { + null + }, cacheWritePricePerMillion = - if (billingMode == BillingMode.TOKEN) parse(cacheWritePrice) else null, + if (billingMode == BillingMode.TOKEN) { + parse(cacheWritePrice) + } else { + null + }, outputPricePerMillion = if (billingMode == BillingMode.TOKEN) parse(outputPrice) else null, pricePerRequest = - if (billingMode == BillingMode.COUNT) parse(pricePerRequest) else null, + if (billingMode == BillingMode.COUNT) { + parse(pricePerRequest) + } else { + null + }, ) - runCatching { onSave(draft) } - .onSuccess { onDismiss() } - .onFailure { - inlineError = pricingInvalidText - } + ) + onDismiss() }, ) { Text(stringResource(R.string.settings_save)) @@ -533,7 +384,7 @@ internal fun PriceOverrideDialog( onClick = { onDelete() onDismiss() - }, + } ) { Text( stringResource(R.string.token_stats_pricing_delete), @@ -551,11 +402,7 @@ internal fun PriceOverrideDialog( } @Composable -private fun PriceField( - label: String, - value: String, - onChange: (String) -> Unit, -) { +private fun PriceField(label: String, value: String, onChange: (String) -> Unit) { OutlinedTextField( value = value, onValueChange = onChange, @@ -567,181 +414,4 @@ private fun PriceField( } private fun formatEditablePrice(value: Double?): String = - value?.let { - String.format(Locale.US, "%.6f", it).trimEnd('0').trimEnd('.') - } ?: "" - -// ==== 分组管理(别名/合并) ==== - -/** - * 分组管理对话框(阶段 4 P1 修复):[groupInfo] 与 [otherGroups] 必须来自 - * 独立于统计筛选的完整分组元数据([TokenStatsGroupModelInfo])——当前筛选范围 - * 的明细只包含有事件的身份/分组,作为成员或目标会把无事件组成员漏掉。 - */ -@Composable -internal fun GroupManageDialog( - groupInfo: TokenStatsGroupModelInfo, - otherGroups: List, - onRename: (String) -> Unit, - onCreateAndMerge: (String) -> Unit, - onMergeInto: (String) -> Unit, - onRestoreDefault: () -> Unit, - onDismiss: () -> Unit, -) { - var renameInput by remember(groupInfo.displayModelId) { mutableStateOf(groupInfo.displayName) } - var newGroupInput by remember { mutableStateOf("") } - var mergeTarget by remember { mutableStateOf(null) } - var confirmRestore by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.token_stats_group_manage)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = groupInfo.displayName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource( - R.string.token_stats_group_members, - groupInfo.memberIdentityIds.size, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - HorizontalDivider() - - // 重命名 - Text( - text = stringResource(R.string.token_stats_group_rename), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = renameInput, - onValueChange = { renameInput = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - TextButton( - enabled = renameInput.isNotBlank(), - onClick = { - onRename(renameInput.trim()) - onDismiss() - }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.settings_save)) - } - - HorizontalDivider() - - // 新建分组并合并 - Text( - text = stringResource(R.string.token_stats_group_new), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = newGroupInput, - onValueChange = { newGroupInput = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - TextButton( - enabled = newGroupInput.isNotBlank(), - onClick = { - onCreateAndMerge(newGroupInput.trim()) - onDismiss() - }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.token_stats_group_create)) - } - - HorizontalDivider() - - // 合并到其他分组 - Text( - text = stringResource(R.string.token_stats_group_merge), - style = MaterialTheme.typography.titleSmall, - ) - var targetExpanded by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = mergeTarget?.let { id -> - otherGroups.firstOrNull { it.displayModelId == id }?.displayName ?: id - } ?: "", - onValueChange = {}, - readOnly = true, - singleLine = true, - label = { Text(stringResource(R.string.token_stats_group_merge_into)) }, - modifier = Modifier.weight(1f), - ) - Box { - TextButton(onClick = { targetExpanded = true }) { - Text(stringResource(R.string.token_stats_group_pick)) - } - DropdownMenu(expanded = targetExpanded, onDismissRequest = { targetExpanded = false }) { - otherGroups.forEach { group -> - DropdownMenuItem( - text = { - Text( - text = group.displayName, - maxLines = 1, - ) - }, - onClick = { - mergeTarget = group.displayModelId - targetExpanded = false - }, - ) - } - } - } - } - TextButton( - enabled = mergeTarget != null, - onClick = { - onMergeInto(mergeTarget!!) - onDismiss() - }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringResource(R.string.token_stats_group_merge_confirm)) - } - - HorizontalDivider() - - // 恢复默认分组(两次点击确认) - TextButton( - onClick = { - if (confirmRestore) { - onRestoreDefault() - onDismiss() - } else { - confirmRestore = true - } - }, - modifier = Modifier.align(Alignment.End), - ) { - Text( - text = stringResource( - if (confirmRestore) R.string.token_stats_group_restore_confirm - else R.string.token_stats_group_restore - ), - color = if (confirmRestore) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary, - ) - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) -} + value?.let { String.format(Locale.US, "%.6f", it).trimEnd('0').trimEnd('.') } ?: "" diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt deleted file mode 100644 index b42389dd4..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementScreen.kt +++ /dev/null @@ -1,839 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AttachMoney -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.Restore -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo -import com.ai.assistance.operit.data.stats.TokenStatsGroupMemberInfo -import com.ai.assistance.operit.data.stats.LegacyPriceSettings -import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft -import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager - -private enum class ManagementTab { GROUPS, PRICING } - -private data class PricingEditor( - val existing: TokenStatPriceOverrideEntity?, - val draft: TokenStatsPriceOverrideDraft, -) - -@Composable -fun TokenStatsManagementScreen(initialPricingTab: Boolean = false) { - val context = LocalContext.current - val viewModel: TokenStatsManagementViewModel = - viewModel(factory = TokenStatsManagementViewModel.Factory(context)) - val state by viewModel.state.collectAsState() - var selectedTab by rememberSaveable { - mutableStateOf(if (initialPricingTab) ManagementTab.PRICING else ManagementTab.GROUPS) - } - - TokenStatsColorsProvider { - Column(modifier = Modifier.fillMaxSize()) { - TabRow(selectedTabIndex = selectedTab.ordinal) { - Tab( - selected = selectedTab == ManagementTab.GROUPS, - onClick = { selectedTab = ManagementTab.GROUPS }, - text = { Text(stringResource(R.string.token_stats_management_groups_tab)) }, - ) - Tab( - selected = selectedTab == ManagementTab.PRICING, - onClick = { selectedTab = ManagementTab.PRICING }, - text = { Text(stringResource(R.string.token_stats_management_pricing_tab)) }, - ) - } - state.errorMessage?.let { message -> - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - when { - state.loading && state.groups.isEmpty() -> Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { CircularProgressIndicator() } - selectedTab == ManagementTab.GROUPS -> GroupManagementTab( - groups = state.groups, - configs = state.configs, - pricingModels = state.pricingModels, - overrides = state.overrides, - onRename = viewModel::renameGroup, - onCreate = viewModel::createGroup, - onMove = viewModel::moveToGroup, - onRestore = viewModel::restoreDefaultGroup, - onSavePrice = viewModel::savePriceOverride, - ) - else -> PricingManagementTab( - models = state.pricingModels, - overrides = state.overrides, - onSave = viewModel::savePriceOverride, - onResetConfig = viewModel::resetPrice, - onRestoreBuiltIn = viewModel::restoreBuiltInPrice, - ) - } - } - } -} - -@Composable -private fun GroupManagementTab( - groups: List, - configs: List, - pricingModels: List, - overrides: List, - onRename: (String, String) -> Unit, - onCreate: (String, List) -> Unit, - onMove: (List, String) -> Unit, - onRestore: (String) -> Unit, - onSavePrice: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, -) { - var query by rememberSaveable { mutableStateOf("") } - var selectedIds by remember { mutableStateOf(emptySet()) } - var renameTarget by remember { mutableStateOf(null) } - var showCreate by remember { mutableStateOf(false) } - var showMove by remember { mutableStateOf(false) } - var restoreTarget by remember { mutableStateOf(null) } - var pricingEditor by remember { mutableStateOf(null) } - val configNames = remember(configs) { configs.associate { it.id to it.name } } - val normalizedQuery = query.trim().lowercase() - val visibleGroups = remember(groups, normalizedQuery, configNames) { - if (normalizedQuery.isEmpty()) groups else groups.filter { group -> - group.displayName.contains(normalizedQuery, ignoreCase = true) || - group.members.any { member -> - member.model.contains(normalizedQuery, ignoreCase = true) || - member.provider.contains(normalizedQuery, ignoreCase = true) || - configNames[member.configId].orEmpty().contains(normalizedQuery, ignoreCase = true) - } - } - } - - Column(modifier = Modifier.fillMaxSize()) { - SearchField( - value = query, - onValueChange = { query = it }, - placeholder = stringResource(R.string.token_stats_group_search_hint), - ) - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(visibleGroups, key = { it.displayModelId }) { group -> - GroupCard( - group = group, - configNames = configNames, - selectedIds = selectedIds, - onToggleMember = { id -> - selectedIds = selectedIds.toMutableSet().apply { - if (!add(id)) remove(id) - } - }, - onRename = { renameTarget = group }, - onRestore = { restoreTarget = group }, - onEditPricing = { member -> - pricingEditor = pricingEditorForMember(member, pricingModels, overrides) - }, - ) - } - } - if (selectedIds.isNotEmpty()) { - TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.token_stats_group_selected_count, selectedIds.size), - modifier = Modifier.weight(1f), - fontWeight = FontWeight.Medium, - ) - TextButton(onClick = { showCreate = true }) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(4.dp)) - Text(stringResource(R.string.token_stats_group_new_short)) - } - Button(onClick = { showMove = true }) { - Text(stringResource(R.string.token_stats_group_move)) - } - } - } - } - } - - renameTarget?.let { group -> - TextInputDialog( - title = stringResource(R.string.token_stats_group_rename), - initialValue = group.displayName, - confirmLabel = stringResource(R.string.settings_save), - onConfirm = { onRename(group.displayModelId, it) }, - onDismiss = { renameTarget = null }, - ) - } - if (showCreate) { - TextInputDialog( - title = stringResource(R.string.token_stats_group_new), - initialValue = "", - confirmLabel = stringResource(R.string.token_stats_group_create), - onConfirm = { - onCreate(it, selectedIds.toList()) - selectedIds = emptySet() - }, - onDismiss = { showCreate = false }, - ) - } - if (showMove) { - ChoiceDialog( - title = stringResource(R.string.token_stats_group_move_title), - groups = groups, - onSelect = { - onMove(selectedIds.toList(), it.displayModelId) - selectedIds = emptySet() - showMove = false - }, - onDismiss = { showMove = false }, - ) - } - restoreTarget?.let { group -> - AlertDialog( - onDismissRequest = { restoreTarget = null }, - title = { Text(stringResource(R.string.token_stats_group_restore)) }, - text = { Text(stringResource(R.string.token_stats_group_restore_message, group.displayName)) }, - confirmButton = { - TextButton(onClick = { - onRestore(group.displayModelId) - restoreTarget = null - }) { Text(stringResource(R.string.token_stats_group_restore_confirm_short)) } - }, - dismissButton = { - TextButton(onClick = { restoreTarget = null }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - pricingEditor?.let { target -> - PriceOverrideDialog( - existing = target.existing, - initialDraft = if (target.existing == null) target.draft else null, - onSave = { onSavePrice(target.existing, it) }, - onDelete = null, - onDismiss = { pricingEditor = null }, - ) - } -} - -@Composable -private fun GroupCard( - group: TokenStatsGroupModelInfo, - configNames: Map, - selectedIds: Set, - onToggleMember: (String) -> Unit, - onRename: () -> Unit, - onRestore: () -> Unit, - onEditPricing: (TokenStatsGroupMemberInfo) -> Unit, -) { - var expanded by rememberSaveable(group.displayModelId) { mutableStateOf(false) } - val custom = group.displayModelId.startsWith(TokenStatsSettingsManager.CUSTOM_GROUP_ID_PREFIX) - TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { expanded = !expanded }) { - Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, null) - } - Column(modifier = Modifier.weight(1f)) { - Text(group.displayName, fontWeight = FontWeight.Bold) - Text( - stringResource( - if (custom) R.string.token_stats_group_custom_summary - else R.string.token_stats_group_default_summary, - group.members.size, - ), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - IconButton(onClick = onRename) { - Icon(Icons.Default.Edit, stringResource(R.string.token_stats_group_rename)) - } - IconButton(onClick = onRestore) { - Icon(Icons.Default.Restore, stringResource(R.string.token_stats_group_restore)) - } - } - if (expanded) { - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) - if (group.members.isEmpty()) { - Text( - stringResource(R.string.token_stats_group_empty), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - group.members.forEach { member -> - GroupMemberRow( - member = member, - configName = configNames[member.configId], - checked = member.identityId in selectedIds, - onToggle = { onToggleMember(member.identityId) }, - onEditPricing = { onEditPricing(member) }, - ) - } - } - } - } -} - -@Composable -private fun GroupMemberRow( - member: TokenStatsGroupMemberInfo, - configName: String?, - checked: Boolean, - onToggle: () -> Unit, - onEditPricing: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox(checked = checked, onCheckedChange = { onToggle() }) - Column(modifier = Modifier.weight(1f)) { - Text(member.model, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Text( - text = buildString { - append(member.provider) - if (member.configId.isNotEmpty()) { - append(" · ") - append(configName ?: stringResource(R.string.token_stats_config_deleted)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onEditPricing) { - Icon( - Icons.Default.AttachMoney, - stringResource(R.string.token_stats_group_edit_pricing, member.model), - ) - } - } -} - -@Composable -private fun PricingManagementTab( - models: List, - overrides: List, - onSave: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, - onResetConfig: (TokenStatPriceOverrideEntity) -> Unit, - onRestoreBuiltIn: (TokenStatPriceOverrideEntity?, String?) -> Unit, -) { - var query by rememberSaveable { mutableStateOf("") } - var selectedProvider by rememberSaveable { mutableStateOf(null) } - var editor by remember { mutableStateOf(null) } - var resetConfigTarget by remember { mutableStateOf(null) } - var restoreBuiltInTarget by remember { - mutableStateOf?>(null) - } - val providers = remember(models) { models.map { it.provider }.distinct().sorted() } - val visible = remember(models, query, selectedProvider) { - models.filter { option -> - (selectedProvider == null || option.provider == selectedProvider) && - (query.isBlank() || option.model.contains(query, true) || - option.provider.contains(query, true) || - option.configs.any { - it.name.contains(query, true) || it.endpoint.contains(query, true) - }) - } - } - - Column(modifier = Modifier.fillMaxSize()) { - SearchField( - value = query, - onValueChange = { query = it }, - placeholder = stringResource(R.string.token_stats_pricing_search_hint), - ) - ProviderDropdown( - providers = providers, - selected = selectedProvider, - onSelect = { selectedProvider = it }, - ) - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - items(visible, key = { "${it.provider}:${it.model}" }) { option -> - PricingModelCard( - option = option, - overrides = overrides, - onEdit = { existing, draft -> editor = PricingEditor(existing, draft) }, - onResetConfig = { resetConfigTarget = it }, - onRestoreBuiltIn = { override, legacyKey -> - restoreBuiltInTarget = override to legacyKey - }, - ) - } - } - } - - editor?.let { target -> - PriceOverrideDialog( - existing = target.existing, - initialDraft = if (target.existing == null) target.draft else null, - onSave = { onSave(target.existing, it) }, - onDelete = null, - onDismiss = { editor = null }, - ) - } - restoreBuiltInTarget?.let { (override, legacyKey) -> - AlertDialog( - onDismissRequest = { restoreBuiltInTarget = null }, - title = { Text(stringResource(R.string.token_stats_pricing_restore_builtin)) }, - text = { Text(stringResource(R.string.token_stats_pricing_restore_message)) }, - confirmButton = { - TextButton(onClick = { - onRestoreBuiltIn(override, legacyKey) - restoreBuiltInTarget = null - }) { Text(stringResource(R.string.token_stats_pricing_restore_confirm)) } - }, - dismissButton = { - TextButton(onClick = { restoreBuiltInTarget = null }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - resetConfigTarget?.let { override -> - AlertDialog( - onDismissRequest = { resetConfigTarget = null }, - title = { Text(stringResource(R.string.token_stats_pricing_restore_model)) }, - text = { Text(stringResource(R.string.token_stats_pricing_restore_message)) }, - confirmButton = { - TextButton(onClick = { - onResetConfig(override) - resetConfigTarget = null - }) { Text(stringResource(R.string.token_stats_pricing_restore_confirm)) } - }, - dismissButton = { - TextButton(onClick = { resetConfigTarget = null }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } -} - -@Composable -private fun PricingModelCard( - option: TokenStatsPricingModelOption, - overrides: List, - onEdit: (TokenStatPriceOverrideEntity?, TokenStatsPriceOverrideDraft) -> Unit, - onResetConfig: (TokenStatPriceOverrideEntity) -> Unit, - onRestoreBuiltIn: (TokenStatPriceOverrideEntity?, String?) -> Unit, -) { - val providerOverride = overrides.firstOrNull { - it.scope == PriceOverrideScope.PROVIDER_MODEL.name && - it.provider.equals(option.provider, true) && it.model.equals(option.model, true) - } - val providerDraft = providerOverride?.toDraft() - ?: option.legacyPricing?.let { legacyDraft(option.provider, option.model, it) } - ?: builtinDraft(option.provider, option.model) - val providerSourceRes = when { - providerOverride != null -> R.string.token_stats_pricing_source_override - option.legacyPricing != null -> R.string.token_stats_pricing_source_legacy - else -> R.string.token_stats_pricing_source_builtin - } - TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(option.model, fontWeight = FontWeight.Bold) - Text(option.provider, style = MaterialTheme.typography.bodySmall, color = TokenStatsCardMuted) - } - } - - HorizontalDivider() - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource(R.string.token_stats_pricing_model_default), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - stringResource(providerSourceRes), - style = MaterialTheme.typography.labelSmall, - color = LocalTokenStatsColors.current.chartAccent, - ) - Text( - priceSummary(providerDraft), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - IconButton(onClick = { onEdit(providerOverride, providerDraft) }) { - Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit)) - } - } - if (providerOverride != null || option.legacyPricing != null) { - TextButton(onClick = { - onRestoreBuiltIn(providerOverride, option.legacyProviderModel) - }) { - Text(stringResource(R.string.token_stats_pricing_restore_builtin)) - } - } - - if (option.configs.isNotEmpty()) { - HorizontalDivider() - Text( - stringResource(R.string.token_stats_pricing_config_prices), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - ) - Text( - stringResource(R.string.token_stats_pricing_config_prices_hint), - style = MaterialTheme.typography.labelSmall, - color = TokenStatsCardMuted, - ) - option.configs.forEach { config -> - val configOverride = overrides.firstOrNull { - it.scope == PriceOverrideScope.CONFIG.name && - it.provider.equals(option.provider, true) && - it.model.equals(option.model, true) && it.configId == config.id - } - val draft = configOverride?.toDraft() ?: providerDraft.copy( - scope = PriceOverrideScope.CONFIG, - configId = config.id, - ) - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - if (config.available) config.name - else stringResource(R.string.token_stats_config_deleted_with_id, config.id), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - if (config.endpoint.isNotBlank()) { - Text( - config.endpoint, - style = MaterialTheme.typography.labelSmall, - color = TokenStatsCardMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Text( - stringResource( - if (configOverride == null) R.string.token_stats_pricing_inherits_model - else R.string.token_stats_pricing_source_config - ), - style = MaterialTheme.typography.labelSmall, - color = LocalTokenStatsColors.current.chartAccent, - ) - Text( - priceSummary(draft), - style = MaterialTheme.typography.labelSmall, - color = TokenStatsCardMuted, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (configOverride != null) { - IconButton(onClick = { onResetConfig(configOverride) }) { - Icon(Icons.Default.Restore, stringResource(R.string.token_stats_pricing_restore_model)) - } - } - IconButton(onClick = { onEdit(configOverride, draft) }) { - Icon(Icons.Default.Edit, stringResource(R.string.token_stats_pricing_edit_config, config.name)) - } - } - } - } - } - } - } -} - -@Composable -private fun ProviderDropdown( - providers: List, - selected: String?, - onSelect: (String?) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { - FilterChip( - selected = selected != null, - onClick = { expanded = true }, - label = { Text(selected ?: stringResource(R.string.token_stats_filter_all_providers)) }, - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.token_stats_filter_all_providers)) }, - onClick = { onSelect(null); expanded = false }, - ) - providers.forEach { provider -> - DropdownMenuItem( - text = { Text(provider) }, - onClick = { onSelect(provider); expanded = false }, - ) - } - } - } -} - -@Composable -private fun SearchField(value: String, onValueChange: (String) -> Unit, placeholder: String) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - placeholder = { Text(placeholder) }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), - ) -} - -@Composable -private fun TextInputDialog( - title: String, - initialValue: String, - confirmLabel: String, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit, -) { - var value by remember(initialValue) { mutableStateOf(initialValue) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - OutlinedTextField( - value = value, - onValueChange = { value = it }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - enabled = value.isNotBlank(), - onClick = { onConfirm(value.trim()); onDismiss() }, - ) { Text(confirmLabel) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } - }, - ) -} - -@Composable -private fun ChoiceDialog( - title: String, - groups: List, - onSelect: (TokenStatsGroupModelInfo) -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - items(groups, key = { it.displayModelId }) { group -> - Row( - modifier = Modifier.fillMaxWidth().clickable { onSelect(group) }.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(group.displayName, fontWeight = FontWeight.Medium) - Text( - stringResource(R.string.token_stats_group_members, group.members.size), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - } - } - } - }, - confirmButton = {}, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.settings_cancel)) } - }, - ) -} - -private fun builtinDraft(provider: String, model: String): TokenStatsPriceOverrideDraft { - val defaults = DefaultModelPricingCollect.getDefaultPricing("$provider:$model") - return TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = provider, - model = model, - configId = null, - billingMode = defaults.billingMode, - currency = defaults.currency, - inputPricePerMillion = defaults.inputPricePerMillion, - cachedInputPricePerMillion = defaults.cachedInputPricePerMillion, - outputPricePerMillion = defaults.outputPricePerMillion, - pricePerRequest = defaults.pricePerRequest, - ) -} - -private fun pricingEditorForMember( - member: TokenStatsGroupMemberInfo, - pricingModels: List, - overrides: List, -): PricingEditor { - val option = pricingModels.firstOrNull { - it.provider.equals(member.provider, true) && it.model.equals(member.model, true) - } - val providerOverride = overrides.firstOrNull { - it.scope == PriceOverrideScope.PROVIDER_MODEL.name && - it.provider.equals(member.provider, true) && it.model.equals(member.model, true) - } - val inherited = providerOverride?.toDraft() - ?: option?.legacyPricing?.let { legacyDraft(member.provider, member.model, it) } - ?: builtinDraft(member.provider, member.model) - val scope = if (member.configId.isBlank()) { - PriceOverrideScope.PROVIDER_MODEL - } else { - PriceOverrideScope.CONFIG - } - val existing = overrides.firstOrNull { - it.scope == scope.name && - it.provider.equals(member.provider, true) && - it.model.equals(member.model, true) && - (scope == PriceOverrideScope.PROVIDER_MODEL || it.configId == member.configId) - } - return PricingEditor( - existing = existing, - draft = existing?.toDraft() ?: inherited.copy( - scope = scope, - configId = member.configId.ifBlank { null }, - ), - ) -} - -private fun legacyDraft( - provider: String, - model: String, - legacy: LegacyPriceSettings, -): TokenStatsPriceOverrideDraft { - val defaults = DefaultModelPricingCollect.getDefaultPricing("$provider:$model") - val billingMode = legacy.billingMode ?: defaults.billingMode - return TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = provider, - model = model, - configId = null, - billingMode = billingMode, - currency = defaults.currency, - inputPricePerMillion = legacy.inputPricePerMillion ?: defaults.inputPricePerMillion, - cachedInputPricePerMillion = legacy.cachedInputPricePerMillion - ?: defaults.cachedInputPricePerMillion, - outputPricePerMillion = legacy.outputPricePerMillion ?: defaults.outputPricePerMillion, - pricePerRequest = legacy.pricePerRequest ?: defaults.pricePerRequest, - ) -} - -private fun TokenStatPriceOverrideEntity.toDraft() = TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.fromNameOrNull(scope) ?: PriceOverrideScope.PROVIDER_MODEL, - provider = provider, - model = model, - configId = configId.ifBlank { null }, - billingMode = BillingMode.fromString(billingMode), - currency = if (pricingCurrency.equals("CNY", true)) PricingCurrency.CNY else PricingCurrency.USD, - inputPricePerMillion = inputPricePerMillion, - cachedInputPricePerMillion = cachedInputPricePerMillion, - cacheWritePricePerMillion = cacheWritePricePerMillion, - outputPricePerMillion = outputPricePerMillion, - pricePerRequest = pricePerRequest, -) - -@Composable -private fun priceSummary(draft: TokenStatsPriceOverrideDraft): String { - if (draft.billingMode == BillingMode.COUNT) { - return draft.pricePerRequest?.let { - "${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, draft.currency)}" - } ?: "-" - } - return listOfNotNull( - draft.inputPricePerMillion?.let { - "${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, draft.currency)}" - }, - draft.cachedInputPricePerMillion?.let { - "${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, draft.currency)}" - }, - draft.outputPricePerMillion?.let { - "${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, draft.currency)}" - }, - ).joinToString(" · ").ifEmpty { "-" } -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt deleted file mode 100644 index f07b474a6..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModel.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.model.ModelConfigSummary -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.model.getModelList -import com.ai.assistance.operit.data.preferences.ModelConfigManager -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.stats.LegacyPriceSettings -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo -import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft -import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager -import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistry -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -data class TokenStatsConfigOption( - val id: String, - val name: String, - val provider: String, - val models: List, - val endpoint: String = "", - val available: Boolean = true, -) - -data class TokenStatsPricingModelOption( - val provider: String, - val model: String, - val configs: List, - val legacyProviderModel: String? = null, - val legacyPricing: LegacyPriceSettings? = null, -) - -data class TokenStatsManagementState( - val loading: Boolean = true, - val errorMessage: String? = null, - val groups: List = emptyList(), - val overrides: List = emptyList(), - val configs: List = emptyList(), - val pricingModels: List = emptyList(), -) - -class TokenStatsManagementViewModel( - context: Context, - dao: TokenStatsDao? = null, -) : ViewModel() { - private val appContext = context.applicationContext - private val manager = - dao?.let(::TokenStatsSettingsManager) ?: TokenStatsSettingsManager(appContext) - private val configManager = ModelConfigManager(appContext) - private val apiPreferences = ApiPreferences.getInstance(appContext) - private val _state = MutableStateFlow(TokenStatsManagementState()) - val state: StateFlow = _state.asStateFlow() - - init { - load() - } - - fun load() { - viewModelScope.launch { - _state.update { it.copy(loading = true, errorMessage = null) } - try { - val groups = manager.groupModels() - val overrides = manager.allPriceOverrides() - val legacyPrices = apiPreferences.allLegacyPriceSettings().mapNotNull { (key, value) -> - value?.let { key to it } - }.toMap() - val configs = configManager.getAllConfigSummaries().map(ModelConfigSummary::toTokenStatsOption) - _state.value = TokenStatsManagementState( - loading = false, - groups = groups, - overrides = overrides, - configs = configs, - pricingModels = buildPricingModels(groups, configs, overrides, legacyPrices), - ) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - _state.update { - it.copy( - loading = false, - errorMessage = appContext.getString(R.string.token_stats_management_load_failed), - ) - } - } - } - } - - fun renameGroup(groupId: String, name: String) = mutate { - manager.renameDisplayGroup(groupId, name) - } - - fun createGroup(name: String, identityIds: List) = mutate { - manager.createGroupAndMove(name, identityIds) - } - - fun moveToGroup(identityIds: List, targetGroupId: String) = mutate { - manager.moveIdentitiesToGroup(identityIds, targetGroupId) - } - - fun restoreDefaultGroup(groupId: String) = mutate { - manager.restoreDefaultGroups(groupId) - } - - fun savePriceOverride( - existing: TokenStatPriceOverrideEntity?, - draft: TokenStatsPriceOverrideDraft, - ) = mutate { - if (existing == null) manager.upsertPriceOverride(draft) - else manager.updatePriceOverride(existing, draft) - } - - fun resetPrice(override: TokenStatPriceOverrideEntity) = mutate { - val scope = PriceOverrideScope.fromNameOrNull(override.scope) ?: return@mutate - manager.deletePriceOverride(scope, override.provider, override.model, override.configId) - } - - fun restoreBuiltInPrice( - providerOverride: TokenStatPriceOverrideEntity?, - legacyProviderModel: String?, - ) = mutate { - if (providerOverride != null) { - manager.deletePriceOverride( - PriceOverrideScope.PROVIDER_MODEL, - providerOverride.provider, - providerOverride.model, - providerOverride.configId, - ) - } - if (legacyProviderModel != null) { - apiPreferences.clearLegacyPriceSettings(legacyProviderModel) - } - } - - private fun mutate(block: suspend () -> Unit) { - viewModelScope.launch { - try { - block() - load() - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - _state.update { - it.copy(errorMessage = appContext.getString(R.string.token_stats_management_save_failed)) - } - } - } - } - - class Factory(context: Context) : ViewModelProvider.Factory { - private val appContext = context.applicationContext - - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = - TokenStatsManagementViewModel(appContext) as T - } -} - -private fun ModelConfigSummary.toTokenStatsOption() = TokenStatsConfigOption( - id = id, - name = name, - // ToolPkg 事件的 provider 记录为 displayName;未产生事件时也按同一名称解析, - // 否则价格覆盖保存后 TokenPriceResolver 按 displayName 查不到。 - provider = ToolPkgAiProviderRegistry.get(apiProviderTypeId)?.displayName ?: apiProviderTypeId, - models = getModelList(modelName), - endpoint = apiEndpoint, -) - -internal fun buildPricingModels( - groups: List, - configs: List, - overrides: List, - legacyPrices: Map = emptyMap(), -): List { - val observedMembers = groups.flatMap { it.members } - fun observedProvider(configId: String, model: String): String? = - observedMembers.firstOrNull { member -> - member.configId == configId && member.model.equals(model, ignoreCase = true) - }?.provider - - val keys = linkedMapOf>() - fun add(provider: String, model: String) { - val key = "${provider.trim().lowercase()}\u0000${model.trim().lowercase()}" - keys.putIfAbsent(key, provider to model) - } - observedMembers.forEach { add(it.provider, it.model) } - configs.forEach { config -> - config.models.forEach { model -> - add(observedProvider(config.id, model) ?: config.provider, model) - } - } - overrides.forEach { add(it.provider, it.model) } - legacyPrices.keys.forEach { providerModel -> - val separator = providerModel.indexOf(':') - if (separator > 0) add(providerModel.substring(0, separator), providerModel.substring(separator + 1)) - } - return keys.values.map { (provider, model) -> - val matchingConfigs = configs.filter { config -> - config.models.any { it.equals(model, ignoreCase = true) } && - (observedProvider(config.id, model) ?: config.provider) - .equals(provider, ignoreCase = true) - } - val missingConfigIds = overrides.asSequence().filter { - it.scope == PriceOverrideScope.CONFIG.name && - it.provider.equals(provider, true) && it.model.equals(model, true) - }.map { it.configId }.filter { id -> matchingConfigs.none { it.id == id } }.distinct() - TokenStatsPricingModelOption( - provider = provider, - model = model, - configs = matchingConfigs + missingConfigIds.map { id -> - TokenStatsConfigOption( - id = id, - name = id, - provider = provider, - models = listOf(model), - available = false, - ) - }, - legacyProviderModel = legacyPrices.keys.firstOrNull { key -> - val separator = key.indexOf(':') - separator > 0 && key.substring(0, separator).equals(provider, true) && - key.substring(separator + 1).equals(model, true) - }, - legacyPricing = legacyPrices.entries.firstOrNull { (key, _) -> - val separator = key.indexOf(':') - separator > 0 && key.substring(0, separator).equals(provider, true) && - key.substring(separator + 1).equals(model, true) - }?.value, - ) - }.sortedWith(compareBy({ it.model.lowercase() }, { it.provider.lowercase() })) -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt index cfd6e4226..7fd7c5dfe 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -12,16 +12,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,7 +30,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -41,20 +38,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft import com.ai.assistance.operit.data.stats.TokenStatsRangeData import com.ai.assistance.operit.ui.components.CustomScaffold import java.time.ZoneId -import kotlinx.coroutines.delay /** 性能卡指标切换。 */ internal enum class PerfMetric { TTFT, GENERATION } @@ -64,13 +57,11 @@ private enum class ChartDetailMetric { COST, REQUESTS, TOKENS } * Token 统计完整页面(阶段 4)。 * 沿用 Operit 设置入口与页面框架(Settings → Token使用统计), * 升级旧累计页面为账本统计:生命周期总览 + 时间/模型/分类/状态筛选 + - * 四张图表卡 + 模型明细 + 汇率/币种/价格覆盖/分组管理设置。 + * 四张图表卡 + 配置详情 + 汇率设置。 */ @Composable fun TokenUsageStatisticsScreen( onBackPressed: () -> Unit, - onOpenGroupManagement: () -> Unit, - onOpenPricingManagement: () -> Unit, ) { val context = LocalContext.current // P1-3:VM 由路由级 ViewModelStore 管理(AppContent 为该 route 提供 @@ -84,14 +75,7 @@ fun TokenUsageStatisticsScreen( // 瞬态 UI 状态:可存 rememberSaveable 的在配置变化后保留(P1-3); // 筛选已在 VM state 中,天然跨配置变化保留。 - var showCustomRange by rememberSaveable { mutableStateOf(false) } - var showDeleteRangeDialog by rememberSaveable { mutableStateOf(false) } - // 全部删除两步确认:第一步危险确认,第二步 baseline 选择(阶段 5) - var showDeleteAllConfirm by rememberSaveable { mutableStateOf(false) } - var showDeleteAllBaseline by rememberSaveable { mutableStateOf(false) } - // 模型删除两步确认:目标模型 + baseline 选择 - var deleteModel by remember { mutableStateOf(null) } - var showDeleteModelBaseline by rememberSaveable { mutableStateOf(false) } + var showDateRange by rememberSaveable { mutableStateOf(false) } var perfMetric by rememberSaveable { mutableStateOf(PerfMetric.TTFT) } LaunchedEffect(actionMessage) { @@ -103,20 +87,7 @@ fun TokenUsageStatisticsScreen( LaunchedEffect(Unit) { viewModel.loadForEntry() } TokenStatsColorsProvider { - CustomScaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { showDeleteAllConfirm = true }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(id = R.string.token_stats_delete_all_title), - ) - } - }, - ) { paddingValues -> + CustomScaffold { paddingValues -> val content: @Composable () -> Unit = { when { state.loading && (state.range == null || state.lifetime == null) -> { @@ -135,11 +106,7 @@ fun TokenUsageStatisticsScreen( zone = viewModel.zone, perfMetric = perfMetric, onTogglePerfMetric = { perfMetric = it }, - onCustomRange = { showCustomRange = true }, - onDeleteRange = { showDeleteRangeDialog = true }, - onDeleteModel = { deleteModel = it }, - onOpenGroupManagement = onOpenGroupManagement, - onOpenPricingManagement = onOpenPricingManagement, + onSelectDateRange = { showDateRange = true }, ) } } @@ -155,225 +122,16 @@ fun TokenUsageStatisticsScreen( } } - if (showCustomRange) { - CustomRangeDialog( + if (showDateRange) { + TokenStatsDateRangeDialog( zone = viewModel.zone, maxRangeDays = TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS, + initialRange = state.currentRange, onConfirm = { start, end -> viewModel.setCustomRange(start, end) }, - onDismiss = { showCustomRange = false }, - ) - } - - // ==== 阶段 5 删除对话框 ==== - // 危险操作明确确认:范围删除单步确认(绝不触碰 baseline); - // 模型/全部删除两步确认(第一步危险确认 → 第二步选择是否同时删除 baseline)。 - - if (showDeleteRangeDialog) { - AlertDialog( - onDismissRequest = { showDeleteRangeDialog = false }, - title = { Text(stringResource(R.string.token_stats_delete_range_title)) }, - text = { Text(stringResource(R.string.token_stats_delete_range_message)) }, - confirmButton = { - CountdownDeleteButton( - onClick = { - viewModel.deleteRangeEvents() - showDeleteRangeDialog = false - }, - ) - }, - dismissButton = { - TextButton(onClick = { showDeleteRangeDialog = false }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - - if (showDeleteAllConfirm) { - AlertDialog( - onDismissRequest = { showDeleteAllConfirm = false }, - title = { Text(stringResource(R.string.token_stats_delete_all_title)) }, - text = { Text(stringResource(R.string.token_stats_delete_all_message)) }, - confirmButton = { - TextButton( - onClick = { - showDeleteAllConfirm = false - showDeleteAllBaseline = true - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.token_stats_delete_continue)) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteAllConfirm = false }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - - if (showDeleteAllBaseline) { - val baselineRows = state.lifetime?.baselineTotals?.identityCount ?: 0L - DeleteBaselineDialog( - title = stringResource(R.string.token_stats_delete_baseline_title), - message = stringResource(R.string.token_stats_delete_baseline_message_all, baselineRows), - onEventsOnly = { - showDeleteAllBaseline = false - viewModel.deleteAllStatistics(deleteBaselines = false) - }, - onEventsAndBaseline = { - showDeleteAllBaseline = false - viewModel.deleteAllStatistics(deleteBaselines = true) - }, - onDismiss = { showDeleteAllBaseline = false }, + onDismiss = { showDateRange = false }, ) } - deleteModel?.let { model -> - // 成员数取完整分组元数据(state.groupModels),与统计筛选无关(P1 修复) - val groupMembers = - state.groupModels.firstOrNull { it.displayModelId == model.displayModelId } - ?.memberIdentityIds?.size ?: model.identities.size - if (showDeleteModelBaseline) { - DeleteBaselineDialog( - title = stringResource(R.string.token_stats_delete_baseline_title), - message = stringResource( - R.string.token_stats_delete_baseline_message_model, - model.displayName, - ), - onEventsOnly = { - showDeleteModelBaseline = false - viewModel.deleteDisplayModel(model.displayModelId, deleteBaselines = false) - deleteModel = null - }, - onEventsAndBaseline = { - showDeleteModelBaseline = false - viewModel.deleteDisplayModel(model.displayModelId, deleteBaselines = true) - deleteModel = null - }, - onDismiss = { showDeleteModelBaseline = false }, - ) - } else { - AlertDialog( - onDismissRequest = { deleteModel = null }, - title = { Text(stringResource(R.string.token_stats_delete_model_title)) }, - text = { - Text( - stringResource( - R.string.token_stats_delete_model_message, - model.displayName, - groupMembers, - ) - ) - }, - confirmButton = { - TextButton( - onClick = { - showDeleteModelBaseline = true - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text(stringResource(R.string.token_stats_delete_continue)) - } - }, - dismissButton = { - TextButton(onClick = { deleteModel = null }) { - Text(stringResource(R.string.settings_cancel)) - } - }, - ) - } - } -} - -/** - * 删除的第二步:是否同时删除迁移的旧统计 baseline(阶段 5)。 - * 选择“仅删除事件”只删事件并保留 baseline;选择“删除事件与 baseline” - * 才删对应/全部 baseline;取消不做任何删除。 - */ -@Composable -private fun DeleteBaselineDialog( - title: String, - message: String, - onEventsOnly: () -> Unit, - onEventsAndBaseline: () -> Unit, - onDismiss: () -> Unit, -) { - var remainingSeconds by rememberSaveable { mutableIntStateOf(DELETE_COUNTDOWN_SECONDS) } - LaunchedEffect(Unit) { - while (remainingSeconds > 0) { - delay(1_000) - remainingSeconds-- - } - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { Text(message) }, - confirmButton = { - CountdownDeleteButton( - onClick = onEventsAndBaseline, - remainingSeconds = remainingSeconds, - readyLabel = stringResource(R.string.token_stats_delete_events_and_baseline), - ) - }, - dismissButton = { - Row { - CountdownDeleteButton( - onClick = onEventsOnly, - remainingSeconds = remainingSeconds, - readyLabel = stringResource(R.string.token_stats_delete_events_only), - ) - Spacer(Modifier.weight(1f)) - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.settings_cancel)) - } - } - }, - ) -} - -private const val DELETE_COUNTDOWN_SECONDS = 5 - -/** 最终危险操作在对话框出现后等待五秒;取消始终保持可用。 */ -@Composable -private fun CountdownDeleteButton( - onClick: () -> Unit, - remainingSeconds: Int? = null, - readyLabel: String = stringResource(R.string.token_stats_delete_confirm), -) { - var localRemaining by rememberSaveable { mutableIntStateOf(DELETE_COUNTDOWN_SECONDS) } - val remaining = remainingSeconds ?: localRemaining - if (remainingSeconds == null) { - LaunchedEffect(Unit) { - while (localRemaining > 0) { - delay(1_000) - localRemaining-- - } - } - } - TextButton( - enabled = remaining == 0, - onClick = onClick, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error, - disabledContentColor = MaterialTheme.colorScheme.error.copy(alpha = 0.5f), - ), - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) { - Text( - if (remaining > 0) { - stringResource(R.string.token_stats_delete_countdown, readyLabel, remaining) - } else { - readyLabel - } - ) - } } @Composable @@ -407,81 +165,84 @@ private fun TokenStatsPageContent( zone: ZoneId, perfMetric: PerfMetric, onTogglePerfMetric: (PerfMetric) -> Unit, - onCustomRange: () -> Unit, - onDeleteRange: () -> Unit, - onDeleteModel: (TokenStatsDisplayModelBreakdown) -> Unit, - onOpenGroupManagement: () -> Unit, - onOpenPricingManagement: () -> Unit, + onSelectDateRange: () -> Unit, ) { val lifetime = state.lifetime ?: return - val hasAnyData = lifetime.eventTotals.requests > 0L || lifetime.baselineTotals.identityCount > 0L + val hasAnyData = + lifetime.totals.requests > 0L || lifetime.totals.totalTokens.totalEventCount > 0L val context = LocalContext.current + val range = state.range LazyColumn( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(TokenStatsSpacing.page), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), ) { item { - TokenActivitySection( - state = state.activity, - zone = zone, - onSelectRecent = viewModel::setActivityRecent, - onSelectYear = viewModel::setActivityYear, - onSelectMode = viewModel::setActivityViewMode, - ) + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader( + title = stringResource(R.string.token_stats_lifetime_total), + ) { + TokenStatsCurrencyDropdown( + selected = state.targetCurrency, + onSelect = viewModel::setTargetCurrency, + modifier = Modifier.width(88.dp), + ) + } + TokenStatsLifetimeCard( + overview = lifetime, + currency = state.targetCurrency, + ) + } } - item { - TokenStatsLifetimeCard( - overview = lifetime, - currency = state.targetCurrency, - manualRate = state.manualRate, - rateIsEstimated = state.rateIsEstimated, - includeLegacy = state.includeLegacy, - onIncludeLegacyChange = viewModel::setIncludeLegacy, - ) + if (lifetime.displayModels.isNotEmpty()) { + item { + TokenStatsLifetimeModelsSection( + models = lifetime.displayModels, + currency = state.targetCurrency, + ) + } } item { - TokenStatsFilterBar( - selectedPreset = state.selectedPreset, - selectedModels = state.selectedModels, - availableModels = state.availableDisplayModels, - knownModelNames = state.knownModelNames, - selectedCategories = state.selectedCategories, - selectedStatuses = state.selectedStatuses, - costMode = state.costMode, - targetCurrency = state.targetCurrency, - onSelectPreset = viewModel::selectPreset, - onCustomRange = onCustomRange, - onDeleteRange = onDeleteRange, - onToggleModel = viewModel::toggleModel, - onSelectAllModels = viewModel::selectAllModels, - onToggleCategory = viewModel::toggleCategory, - onClearAllCategories = viewModel::clearCategories, - onToggleStatus = viewModel::toggleStatus, - onClearAllStatuses = viewModel::clearStatuses, - onSetCostMode = viewModel::setCostMode, - onSetCurrency = viewModel::setTargetCurrency, - ) + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_range_analysis)) + TokenStatsFilterBar( + selectedModels = state.selectedModels, + availableModels = state.availableDisplayModels, + knownModelNames = state.knownModelNames, + selectedCategories = state.selectedCategories, + selectedStatuses = state.selectedStatuses, + onToggleModel = viewModel::toggleModel, + onSelectAllModels = viewModel::selectAllModels, + onToggleCategory = viewModel::toggleCategory, + onClearAllCategories = viewModel::clearCategories, + onToggleStatus = viewModel::toggleStatus, + onClearAllStatuses = viewModel::clearStatuses, + ) + TokenActivitySection( + state = state.activity, + dateRange = state.currentRange, + zone = zone, + onSelectMode = viewModel::setActivityViewMode, + onSelectDateRange = onSelectDateRange, + ) + when { + range == null -> NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) + !hasAnyData -> EmptyStateCard() + range.eventCount == 0L -> { + NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) + } + } + } } - val range = state.range - if (range == null) { - item { NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) } - } else if (!hasAnyData) { + if (range != null && hasAnyData && range.eventCount > 0L) { item { - EmptyStateCard() - } - } else { - if (range.eventCount == 0L) { - item { - NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) - } - } else { - item { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_trends)) TokenStatsChartsSection( range = range, currency = state.targetCurrency, @@ -490,71 +251,104 @@ private fun TokenStatsPageContent( onTogglePerfMetric = onTogglePerfMetric, ) } + } - item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.settings_model_details), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - Text( - text = stringResource(R.string.token_stats_model_count, range.displayModels.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - TextButton(onClick = onOpenGroupManagement) { - Text(stringResource(R.string.token_stats_group_manage)) - } - } - TokenStatsModelCardsSection( - models = range.displayModels, - currency = state.targetCurrency, - costMode = state.costMode, - zone = zone, - onGroupManage = { onOpenGroupManagement() }, - onDelete = onDeleteModel, - ) - } - } + item { + TokenStatsModelDetailsSection( + title = stringResource(R.string.settings_model_details), + models = range.displayModels, + currency = state.targetCurrency, + configurationNames = state.configurationNames, + priceSettings = state.priceSettings, + onSavePrice = viewModel::savePrice, + onDeletePrice = viewModel::deletePrice, + ) } } item { - val rateInvalidText = stringResource(R.string.token_stats_rate_invalid) - TokenStatsRateCard( - manualRate = state.manualRate, - rateIsEstimated = state.rateIsEstimated, - onSaveRate = { rate -> - val ok = viewModel.setManualRate(rate) - if (!ok) { - Toast.makeText(context, rateInvalidText, Toast.LENGTH_SHORT).show() - } - ok - }, - ) + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_settings)) + val rateInvalidText = stringResource(R.string.token_stats_rate_invalid) + TokenStatsRateCard( + manualRate = state.manualRate, + rateIsEstimated = state.rateIsEstimated, + onSaveRate = { rate -> + val ok = viewModel.setManualRate(rate) + if (!ok) { + Toast.makeText(context, rateInvalidText, Toast.LENGTH_SHORT).show() + } + ok + }, + ) + } } item { - TokenStatsPricingSection( - range = range, - overrides = state.overrides, - onManage = onOpenPricingManagement, + Spacer(Modifier.height(96.dp)) + } + } +} + +@Composable +private fun TokenStatsModelDetailsSection( + title: String, + models: List, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + configurationNames: Map, + priceSettings: List, + onSavePrice: (TokenStatsPriceDraft) -> Unit, + onDeletePrice: (com.ai.assistance.operit.data.stats.TokenStatsPriceSetting) -> Unit, + subtitle: String? = null, +) { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = title) { + Text( + text = stringResource( + R.string.token_stats_configuration_count, + models.sumOf { it.identities.size }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - item { - Spacer(Modifier.height(96.dp)) + var priceEditor by remember { mutableStateOf(null) } + TokenStatsConfigurationCardsSection( + configurations = models.flatMap(TokenStatsDisplayModelBreakdown::identities), + currency = currency, + configurationNames = configurationNames, + priceSettings = priceSettings, + onEditPrice = { existing, draft, configurationName -> + priceEditor = PriceEditorTarget(existing, draft, configurationName) + }, + ) + priceEditor?.let { target -> + PriceSettingsDialog( + existing = target.existing, + initialDraft = target.draft, + configurationName = target.configurationName, + onSave = onSavePrice, + onDelete = target.existing?.let { setting -> { onDeletePrice(setting) } }, + onDismiss = { priceEditor = null }, + ) } } } +private data class PriceEditorTarget( + val existing: com.ai.assistance.operit.data.stats.TokenStatsPriceSetting?, + val draft: TokenStatsPriceDraft, + val configurationName: String?, +) + @Composable private fun EmptyStateCard() { TokenStatsWhiteCard( @@ -570,19 +364,19 @@ private fun EmptyStateCard() { imageVector = Icons.Default.Analytics, contentDescription = null, modifier = Modifier.size(48.dp), - tint = TokenStatsCardMuted, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.token_stats_empty), style = MaterialTheme.typography.bodyMedium, - color = TokenStatsCardMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(4.dp)) Text( text = stringResource(R.string.token_stats_empty_hint), style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted.copy(alpha = 0.8f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), ) } } @@ -596,7 +390,7 @@ private fun NoDataCard(text: String) { Text( text = text, style = MaterialTheme.typography.bodyMedium, - color = TokenStatsCardMuted, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(24.dp), ) } @@ -618,11 +412,11 @@ private fun TokenStatsChartsSection( if (wide) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), ) { Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), ) { CostChartCard(range = range, currency = currency, zone = zone) { detailMetric = ChartDetailMetric.COST @@ -633,7 +427,7 @@ private fun TokenStatsChartsSection( } Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), ) { RequestChartCard(range = range, currency = currency, zone = zone) { detailMetric = ChartDetailMetric.REQUESTS @@ -647,7 +441,7 @@ private fun TokenStatsChartsSection( } } } else { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section)) { CostChartCard(range = range, currency = currency, zone = zone) { detailMetric = ChartDetailMetric.COST } @@ -704,7 +498,13 @@ private fun TokenStatsChartDetailDialog( } ChartDetailMetric.REQUESTS -> range.displayModels.forEach { model -> if (model.totals.requests > 0L) { - TokenStatsDetailRow(model.displayName, formatCount(model.totals.requests)) + TokenStatsDetailRow( + model.displayName, + formatRequestCount( + model.totals.requests, + model.totals.requestCountUnknownContributionCount, + ), + ) } } ChartDetailMetric.TOKENS -> { @@ -816,9 +616,13 @@ private fun RequestChartCard( onSummaryClick: () -> Unit, ) { val chartTitle = stringResource(R.string.token_stats_chart_requests) + val unknownRequestTemplate = stringResource(R.string.token_stats_request_count_unknown) TokenStatsChartCard( title = chartTitle, - summary = formatCount(range.summary.requests), + summary = formatRequestCount( + range.summary.requests, + range.summary.requestCountUnknownContributionCount, + ), onSummaryClick = onSummaryClick, ) { TokenStatsLineChart( @@ -829,6 +633,10 @@ private fun RequestChartCard( emptyText = stringResource(R.string.token_stats_no_data_in_range), chartLabel = chartTitle, valueSelector = { it.totals.requests.toDouble() }, + unknownNote = { bucket -> + val unknown = bucket.totals.requestCountUnknownContributionCount + if (unknown > 0L) String.format(unknownRequestTemplate, unknown) else null + }, ) } } @@ -852,7 +660,7 @@ private fun TokenChartCard( TokenStatsChartCard( title = chartTitle, - // canonical 总 Token(聚合器逐事件推导,口径与 headline/detail 一致) + // Canonical total tokens come from the same SQL records as the headline and details. summary = formatCompactCount(range.summary.totalTokens.knownSum), onSummaryClick = onSummaryClick, ) { @@ -912,7 +720,7 @@ private fun PerformanceChartCard( title = chartTitle, summary = durationSummaryText(aggregate), ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { FilterChip( selected = perfMetric == PerfMetric.TTFT, onClick = { onTogglePerfMetric(PerfMetric.TTFT) }, @@ -924,7 +732,7 @@ private fun PerformanceChartCard( label = { Text(stringResource(R.string.token_stats_perf_generation)) }, ) } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(TokenStatsSpacing.content)) TokenStatsLineChart( buckets = range.buckets, granularity = range.granularity, @@ -961,180 +769,3 @@ private fun RangeUnknownHint(text: String) { modifier = Modifier.padding(bottom = 4.dp), ) } - -// ==== 价格覆盖管理区 ==== - -@Composable -private fun TokenStatsPricingSection( - range: TokenStatsRangeData?, - overrides: List, - onManage: () -> Unit, -) { - var showBuiltin by remember { mutableStateOf(false) } - - TokenStatsWhiteCard( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stringResource(R.string.token_stats_pricing_manage), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = stringResource(R.string.token_stats_pricing_subtitle), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - IconButton(onClick = onManage) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(R.string.token_stats_management_open), - ) - } - } - - // 内置默认(只读) - val countBillingText = stringResource(R.string.settings_billing_mode_count) - val providerModels = - range?.displayModels - ?.flatMap { it.identities } - ?.map { "${it.provider}:${it.model}" } - ?.distinct() - .orEmpty() - if (providerModels.isNotEmpty()) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.token_stats_pricing_builtin), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { showBuiltin = !showBuiltin }) { - Icon( - imageVector = if (showBuiltin) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = stringResource(R.string.token_stats_model_expand), - modifier = Modifier.size(20.dp), - ) - } - } - if (showBuiltin) { - providerModels.take(12).forEach { providerModel -> - val defaults = - com.ai.assistance.operit.data.collects.DefaultModelPricingCollect - .getDefaultPricing(providerModel) - Text( - text = buildString { - append(providerModel) - append(" ") - append("${defaults.currency.symbol}${defaults.inputPricePerMillion}/1M") - append(" · ") - append("${defaults.currency.symbol}${defaults.outputPricePerMillion}/1M") - if (defaults.billingMode == com.ai.assistance.operit.data.model.BillingMode.COUNT) { - append(" · $countBillingText") - } - }, - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - if (providerModels.size > 12) { - Text( - text = stringResource(R.string.token_stats_more_count, providerModels.size - 12), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - } - } - - if (overrides.isEmpty()) { - Text( - text = stringResource(R.string.token_stats_pricing_none), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } else { - overrides.forEach { override -> - PriceOverrideRow(override = override) - } - } - } - } -} - -@Composable -private fun PriceOverrideRow( - override: TokenStatPriceOverrideEntity, -) { - val scopeText = - if (override.scope == PriceOverrideScope.CONFIG.name) { - stringResource(R.string.token_stats_pricing_scope_config) - } else { - stringResource(R.string.token_stats_pricing_scope_provider) - } - val currency = - if (override.pricingCurrency.equals("CNY", ignoreCase = true)) { - com.ai.assistance.operit.data.collects.PricingCurrency.CNY - } else { - com.ai.assistance.operit.data.collects.PricingCurrency.USD - } - val prices = - if (com.ai.assistance.operit.data.model.BillingMode.fromString(override.billingMode) == - com.ai.assistance.operit.data.model.BillingMode.COUNT - ) { - listOfNotNull( - override.pricePerRequest?.let { - "${stringResource(R.string.settings_billing_mode_count)} ${formatPricePerRequest(it, currency)}" - } - ) - } else { - buildList { - override.inputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_uncached)} ${formatPricePerMillion(it, currency)}") } - override.cachedInputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cached)} ${formatPricePerMillion(it, currency)}") } - override.cacheWritePricePerMillion?.let { add("${stringResource(R.string.token_stats_token_cache_write)} ${formatPricePerMillion(it, currency)}") } - override.outputPricePerMillion?.let { add("${stringResource(R.string.token_stats_token_output)} ${formatPricePerMillion(it, currency)}") } - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "$scopeText · ${override.provider}:${override.model}", - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) - if (override.configId.isNotEmpty()) { - Text( - text = stringResource(R.string.token_stats_config_id, override.configId), - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - Text( - text = if (prices.isEmpty()) { - stringResource(R.string.token_stats_unknown_pricing) - } else { - prices.joinToString(" · ") - }, - style = MaterialTheme.typography.bodySmall, - color = TokenStatsCardMuted, - ) - } - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index 6b78fd3e4..1a3067cb1 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -6,24 +6,18 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.ai.assistance.operit.R import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.preferences.usdToCnyStorageValue -import com.ai.assistance.operit.data.stats.ApiPreferencesTokenStatsSettingsStore +import com.ai.assistance.operit.data.preferences.ModelConfigManager +import com.ai.assistance.operit.data.stats.TokenStatsPreferencesStore import com.ai.assistance.operit.data.stats.TokenActivityAggregator -import com.ai.assistance.operit.data.stats.TokenActivityInsights import com.ai.assistance.operit.data.stats.TokenActivityViewMode -import com.ai.assistance.operit.data.stats.TokenActivityYearData +import com.ai.assistance.operit.data.stats.TokenActivityRangeData import com.ai.assistance.operit.data.stats.TokenCostCurrency import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.data.stats.TokenStatStatus -import com.ai.assistance.operit.data.stats.TokenStatsCostMode import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview -import com.ai.assistance.operit.data.stats.TokenStatsPreset +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting import com.ai.assistance.operit.data.stats.TokenStatsQueryParams import com.ai.assistance.operit.data.stats.TokenStatsQueryService import com.ai.assistance.operit.data.stats.TokenStatsRangeData @@ -31,11 +25,7 @@ import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges -import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection -import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft -import com.ai.assistance.operit.data.stats.TokenStatsReadiness -import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator -import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.time.ZoneId import kotlinx.coroutines.CancellationException @@ -49,369 +39,159 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext data class TokenActivityUiState( val loading: Boolean = true, val viewMode: TokenActivityViewMode = TokenActivityViewMode.DAILY, - val recentSelected: Boolean = true, - val selectedYear: Int = 0, - val availableYears: List = emptyList(), - val yearData: TokenActivityYearData? = null, - val insights: TokenActivityInsights = TokenActivityInsights(), + val rangeData: TokenActivityRangeData? = null, ) -/** 页面 UI 状态(阶段 4)。 */ data class TokenStatsUiState( val loading: Boolean = true, val errorMessage: String? = null, - /** - * 每次查询完成的单调版本号:UI/测试可用它等待“这次操作触发的查询已落定” - * (Room 在后台线程恢复协程,loading 标志本身有竞态窗口)。 - */ - val refreshVersion: Long = 0, - /** 生命周期累计总览(不受筛选影响)。 */ + val refreshVersion: Long = 0L, val lifetime: TokenStatsLifetimeOverview? = null, - /** 当前时间范围的完整查询结果(受筛选影响)。 */ val range: TokenStatsRangeData? = null, - /** 当前时间预设(首次自动回退后为回退结果)。 */ - val selectedPreset: TokenStatsPreset = TokenStatsPreset.LAST_5H, - /** 自定义范围的显式边界;非 CUSTOM 预设时为 null。 */ - val customRange: TokenStatsTimeRange? = null, - /** - * 当前查询实际使用的时间范围(阶段 5 删除入口):与展示/查询完全同界—— - * CUSTOM 用自定义边界,其余预设用 [TokenStatsTimeRanges.rangeFor] 实时计算。 - * 删除当前范围必须与用户所见范围一致,不能在 UI 侧另行计算。 - */ val currentRange: TokenStatsTimeRange? = null, - /** true = 用户手动选择过时间(不再自动回退)。 */ - val userChoseTime: Boolean = false, val targetCurrency: PricingCurrency = PricingCurrency.CNY, - val costMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL, - /** true = 生命周期累计包含迁移的旧版 baseline;关闭只影响展示,不删除数据。 */ - val includeLegacy: Boolean = true, val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, - /** true = 汇率是默认估算值(用户未设置),界面必须明显标注。 */ val rateIsEstimated: Boolean = true, - /** 展示模型筛选;empty = 全部。 */ val selectedModels: Set = emptySet(), - /** 业务分类筛选;null = 全部。 */ val selectedCategories: Set? = null, - /** 请求状态筛选;null = 全部。 */ val selectedStatuses: Set? = null, - /** - * 模型筛选下拉的可选项(P1-5):与当前范围同时间/分类/状态/口径筛选但 - * **不应用模型筛选**,因此选中某模型后其他模型仍可选。 - */ val availableDisplayModels: List = emptyList(), - /** 已知展示模型 id → 名称(P1-5:被筛选出当前结果但仍选中的模型可显示)。 */ val knownModelNames: Map = emptyMap(), - /** 全部价格覆盖(供管理区展示)。 */ - val overrides: List = emptyList(), - /** - * 完整展示分组元数据(阶段 4 P1 修复):与统计筛选无关的分组成员/合并目标 - * 来源——范围明细只含当前筛选下有事件的身份/分组,分组操作(合并成员、 - * 目标组列表)必须用完整归属,否则无事件成员被漏移、无事件目标组不可选。 - */ - val groupModels: List = emptyList(), - /** 全局历史活动;独立于下方时间、模型、分类和状态筛选。 */ + val configurationNames: Map = emptyMap(), + val priceSettings: List = emptyList(), val activity: TokenActivityUiState = TokenActivityUiState(), ) -/** 一次性操作结果消息(Toast):错误或成功提示,消费后清除。 */ data class TokenStatsActionMessage( val text: String, val isError: Boolean = false, ) -/** - * 统计页 ViewModel(阶段 4)。 - * - * - 时间选择:`settings` 中**从未选择**时,每次进入按 5h→12h→24h→7d→30d - * 自动回退到最近有数据的预设;用户手动选择后持久化,此后不再自动跳转。 - * - 筛选/币种/费用口径/汇率变更都触发重新查询;查询走 - * [TokenStatsQueryService](同事务快照 + IO 线程)。 - * - 依赖注入缝(测试):[settings] 替换为内存假实现、[dao] 传入测试 Room - * DAO、[nowMs]/[zone] 固定时间;生产默认全部使用真实实现。 - */ @android.annotation.SuppressLint("StaticFieldLeak") class TokenUsageStatisticsViewModel( - private val context: Context, - private val settings: TokenStatsSettingsStore = ApiPreferencesTokenStatsSettingsStore(context), - /** 页面时区(图表时间标签与自定义范围边界),生产 = 系统默认。 */ + context: Context, + private val settings: TokenStatsSettingsStore = TokenStatsPreferencesStore(context), val zone: ZoneId = ZoneId.systemDefault(), private val nowMs: () -> Long = { System.currentTimeMillis() }, - dao: TokenStatsDao? = null, - /** - * 错误文案解析(测试注入,避免 JVM 上不可 mock 的 Context.getString); - * 生产默认走真实 Context。 - */ private val stringResolver: (Int) -> String = { context.applicationContext.getString(it) }, - /** - * 协程调度器(测试注入非 Main 调度器,避免 JVM 上 Room 后台恢复与 - * TestMainDispatcher 冲突);生产默认 = Main.immediate(与 viewModelScope 一致)。 - */ private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, - /** - * 启动统计 readiness 门控(P1 关键链路):首次数据查询等待初始 spool 重放完成, - * 避免无限展示 pre-replay 快照。测试注入 no-op 或门控实现;生产默认绑定 - * [TokenStatsStartupCoordinator](single-flight,失败不缓存可重试)。 - */ - private val readiness: TokenStatsReadiness = TokenStatsStartupCoordinator.readiness(context), - private val readinessInitialWaitMs: Long = READINESS_WAIT_MS, - private val readinessRefreshWaitMs: Long = READINESS_REFRESH_WAIT_MS, ) : ViewModel() { - - // 只保存 applicationContext(进程级单例,无泄漏风险;与 CustomEmojiViewModel 同模式) - private val appContext: Context = context.applicationContext + private val appContext = context.applicationContext + private val repository = TokenUsageRepository.getInstance(appContext) + private val manager = TokenStatsSettingsManager(appContext) + private val modelConfigManager = ModelConfigManager(appContext) private val tag = "TokenUsageStatisticsViewModel" - private val manager = - dao?.let(::TokenStatsSettingsManager) ?: TokenStatsSettingsManager(appContext) - private val _state = MutableStateFlow(TokenStatsUiState()) val state: StateFlow = _state.asStateFlow() private val _actionMessage = MutableStateFlow(null) val actionMessage: StateFlow = _actionMessage.asStateFlow() - /** 丢弃过期加载结果(generation 防竞态)。 */ private var loadGeneration = 0 - - /** 当前加载任务:新一轮 [load] 先取消旧任务,旧任务不得写 state(P1-4)。 */ private var loadJob: Job? = null - - private var activityLoadJob: Job? = null - private var activityLoadGeneration = 0 - - /** - * 首次查询 readiness 门控(P1 关键链路):单 VM 生命周期只等待一次。置位先于等待, - * 即使等待被 loadJob 取消也不会重复等待(取消时另行安排后台刷新兜底)。 - */ - private var readinessGateDone = false - - /** Once true, all later loads can skip readiness retry scheduling. */ - private var readinessReady = false - - /** 首次 readiness 未就绪时安排的后台“就绪后自动刷新”任务(独立于 loadJob)。 */ - private var readinessRefreshJob: Job? = null - - /** 已知展示模型 id → 最近一次查询所见名称(P1-5,永不清除,只增补)。 */ - private val knownModelNames = mutableMapOf() + private val knownModelNames = linkedMapOf() fun consumeActionMessage() { _actionMessage.value = null } - // ==== 查询 ==== + fun load() = loadInternal() - fun load() { - loadInternal(reconsiderAutomaticTime = false) - } - - /** 进入/返回统计页时重新探测自动时间范围;用户手选范围始终保持不变。 */ - fun loadForEntry() { - loadActivity() - loadInternal(reconsiderAutomaticTime = true) - } - - private fun loadActivity(requestedRecent: Boolean = true, requestedYear: Int? = null) { - activityLoadJob?.cancel() - val generation = ++activityLoadGeneration - _state.update { - it.copy( - activity = it.activity.copy( - loading = true, - recentSelected = requestedRecent, - selectedYear = requestedYear ?: it.activity.selectedYear, - ) - ) - } - activityLoadJob = viewModelScope.launch(dispatcher) { - try { - val snapshot = TokenStatsQueryService.activitySnapshot(appContext, zone) - val result = withContext(Dispatchers.Default) { - val years = TokenActivityAggregator.availableYears(snapshot, nowMs()) - val recent = requestedRecent || requestedYear !in years - val year = requestedYear?.takeIf { it in years } ?: years.first() - ActivityLoadResult( - years = years, - year = year, - recent = recent, - data = if (recent) { - TokenActivityAggregator.recentData(snapshot, nowMs()) - } else { - TokenActivityAggregator.yearData(snapshot, year, nowMs()) - }, - insights = TokenActivityAggregator.insights(snapshot), - ) - } - if (generation != activityLoadGeneration) return@launch - _state.update { - it.copy( - activity = it.activity.copy( - loading = false, - recentSelected = result.recent, - selectedYear = result.year, - availableYears = result.years, - yearData = result.data, - insights = result.insights, - ) - ) - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - if (generation == activityLoadGeneration) { - _state.update { it.copy(activity = it.activity.copy(loading = false)) } - } - runCatching { AppLogger.e(tag, "Token 活动加载失败", e) } - } - } - } + fun loadForEntry() = loadInternal() fun setActivityViewMode(mode: TokenActivityViewMode) { _state.update { it.copy(activity = it.activity.copy(viewMode = mode)) } } - fun setActivityYear(year: Int) { - val activity = _state.value.activity - if ((!activity.recentSelected && year == activity.selectedYear) || year !in activity.availableYears) return - loadActivity(requestedRecent = false, requestedYear = year) - } - - fun setActivityRecent() { - if (_state.value.activity.recentSelected) return - loadActivity(requestedRecent = true) - } - - private fun loadInternal(reconsiderAutomaticTime: Boolean) { + private fun loadInternal() { loadJob?.cancel() val generation = ++loadGeneration - // 筛选状态同步快照:偏好读取挂起期间用户可能已改筛选并触发新 load, - // 旧任务一律用本快照构造 params,不混入新状态(P1-4)。 val filterSnapshot = _state.value loadJob = viewModelScope.launch(dispatcher) { try { - // 首次数据查询 readiness 门控(P1 关键链路):等待初始 spool 重放完成, - // 首次渲染即包含 pre-replay 事件。等待有界——超时/失败先按现状查询, - // 并由 [scheduleRefreshAfterReadiness] 在就绪后自动刷新,绝不无限展示 - // pre-replay 快照。 - if (!readinessGateDone) { - readinessGateDone = true - val ready = try { - readiness.awaitReady(readinessInitialWaitMs) - } catch (e: CancellationException) { - // loadJob 被新操作取消:数据可能仍是 pre-replay 快照,后台兜底刷新 - scheduleRefreshAfterReadiness() - throw e - } - readinessReady = ready - if (!ready) scheduleRefreshAfterReadiness() - } else if (!readinessReady) { - scheduleRefreshAfterReadiness() - } - // 偏好全部读取为不可变本地快照:任何 _state.update 之前先核对 - // generation,旧 load 即使恢复也不污染共享 state(P1-4)。 val rateInfo = settings.loadRateWithEstimate() val currency = settings.loadTargetCurrency() - val mode = settings.loadCostMode() - val includeLegacy = settings.loadIncludeLegacy() - val savedSelection = settings.loadTimeSelection() - val selectionWasManual = settings.loadSelectionWasManual() - - val preset: TokenStatsPreset - val customRange: TokenStatsTimeRange? - val userChoseTime: Boolean - val savedRange = savedSelection?.let { selection -> - selection.customRangeOrNull() - ?: selection.preset.takeIf { it != TokenStatsPreset.CUSTOM }?.let { - TokenStatsTimeRanges.rangeFor(it, nowMs(), zone) - } - } - val emptyManualRollingSelection = - reconsiderAutomaticTime && - selectionWasManual && - savedSelection?.preset in TokenStatsPreset.INITIAL_FALLBACK_ORDER && - savedRange != null && - !TokenStatsQueryService.rangeHasEvents(appContext, savedRange) - val shouldProbeAutomaticRange = - reconsiderAutomaticTime && - (savedSelection == null || !selectionWasManual || emptyManualRollingSelection) - - if (savedSelection != null && !shouldProbeAutomaticRange) { - // 普通刷新直接复用;手选范围有数据时也保持用户选择。 - preset = savedSelection.preset - customRange = savedSelection.customRangeOrNull() - userChoseTime = selectionWasManual - } else { - // 自动模式每次进入重探测;手选滚动范围为空时才扩展到更大窗口。 - val suggested = - TokenStatsQueryService.initialPresetWithData(appContext, zone, nowMs()) - if (savedSelection?.preset != suggested || - savedSelection.customRangeOrNull() != null || - selectionWasManual - ) { - settings.saveTimeSelection(TokenStatsTimeSelection(suggested), manual = false) - } - preset = suggested - customRange = null - userChoseTime = false - } - - val params = TokenStatsQueryParams( - targetCurrency = currency, - manualRate = rateInfo.first, - rateIsEstimated = rateInfo.second, - mode = mode, - displayModelIds = filterSnapshot.selectedModels.ifEmpty { null }, - categories = filterSnapshot.selectedCategories, - statuses = filterSnapshot.selectedStatuses, - ) - val range: TokenStatsTimeRange? = - when { - preset == TokenStatsPreset.CUSTOM && customRange != null -> customRange - preset == TokenStatsPreset.CUSTOM -> null - else -> TokenStatsTimeRanges.rangeFor(preset, nowMs(), zone) - } + val range = settings.loadTimeRange() ?: defaultDateRange(nowMs(), zone) if (generation != loadGeneration) return@launch - _state.update { it.copy(loading = true, errorMessage = null) } + _state.update { it.copy(loading = true, errorMessage = null, activity = it.activity.copy(loading = true)) } - // 并发查询(P1-5):范围结果 + 模型菜单可用项同范围但不应用模型 - // 筛选;无模型筛选时可用项直接复用主结果,不产生重复查询。 val result = coroutineScope { - val lifetimeD = async(dispatcher) { - TokenStatsQueryService.lifetimeOverview(appContext, params) + val pricesDeferred = async(Dispatchers.IO) { manager.allPriceSettings() } + val selectedProviderModels = + filterSnapshot.selectedModels + .takeIf { it.isNotEmpty() } + ?.let { selected -> + filterSnapshot.availableDisplayModels + .asSequence() + .filter { it.displayModelId in selected } + .flatMap { it.providerModels.asSequence() } + .toSet() + } + val rangeParams = TokenStatsQueryParams( + targetCurrency = currency, + manualRate = rateInfo.first, + providerModels = selectedProviderModels, + categories = filterSnapshot.selectedCategories, + statuses = filterSnapshot.selectedStatuses, + ) + val availableParams = rangeParams.copy(providerModels = null) + val lifetimeDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.lifetimeOverview( + appContext, + TokenStatsQueryParams( + targetCurrency = currency, + manualRate = rateInfo.first, + ), + ) } - val rangeD = async(dispatcher) { - range?.let { - TokenStatsQueryService.rangeData(appContext, it, params, zone) - } + val rangeDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.rangeData(appContext, range, rangeParams, zone) } - val availableD = async(dispatcher) { - if (params.displayModelIds == null || range == null) { + val availableDeferred = async(Dispatchers.IO) { + if (selectedProviderModels == null) { null } else { TokenStatsQueryService.rangeData( appContext, range, - params.copy(displayModelIds = null), + availableParams, zone, ) } } - val overridesD = async(dispatcher) { manager.allPriceOverrides() } - // 分组元数据与统计筛选无关(P1 修复):并发读取完整分组归属, - // 供分组管理对话框的成员与目标列表使用 - val groupsD = async(dispatcher) { manager.groupModels() } - val rangeData = rangeD.await() + val activityDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.activitySnapshot(appContext, range, rangeParams, zone) + } + val rangeData = rangeDeferred.await() + val configurationIds = + rangeData + ?.displayModels + .orEmpty() + .flatMap { it.identities } + .mapNotNull { it.configId } + .distinct() + val configurationNamesDeferred = async(Dispatchers.IO) { + buildMap { + configurationIds.forEach { configId -> + modelConfigManager.getModelConfig(configId)?.let { config -> + put(configId, config.name) + } + } + } + } QueryLoadResult( - lifetime = lifetimeD.await(), + lifetime = lifetimeDeferred.await(), range = rangeData, - available = availableD.await() ?: rangeData, - overrides = overridesD.await(), - groups = groupsD.await(), + available = availableDeferred.await() ?: rangeData, + prices = pricesDeferred.await(), + configurationNames = configurationNamesDeferred.await(), + activity = TokenActivityAggregator.rangeData(activityDeferred.await(), range), ) } @@ -424,20 +204,16 @@ class TokenUsageStatisticsViewModel( errorMessage = null, lifetime = result.lifetime, range = result.range, - availableDisplayModels = result.available?.displayModels.orEmpty(), - knownModelNames = knownModelNames.toMap(), + currentRange = range, targetCurrency = currency, manualRate = rateInfo.first, rateIsEstimated = rateInfo.second, - costMode = mode, - includeLegacy = includeLegacy, - selectedPreset = preset, - customRange = customRange, - currentRange = range, - userChoseTime = userChoseTime, - overrides = result.overrides, - groupModels = result.groups, - refreshVersion = it.refreshVersion + 1, + availableDisplayModels = result.available?.displayModels.orEmpty(), + knownModelNames = knownModelNames.toMap(), + configurationNames = result.configurationNames, + priceSettings = result.prices, + activity = it.activity.copy(loading = false, rangeData = result.activity), + refreshVersion = it.refreshVersion + 1L, ) } } catch (e: CancellationException) { @@ -448,98 +224,49 @@ class TokenUsageStatisticsViewModel( it.copy( loading = false, errorMessage = stringResolver(R.string.token_stats_load_failed), + activity = it.activity.copy(loading = false), ) } } - runCatching { AppLogger.e(tag, "统计页加载失败", e) } + AppLogger.e(tag, "Token statistics load failed", e) } } } - /** 记录最近一次查询所见模型名(供被筛选出当前结果但仍选中的模型显示)。 */ private fun rememberModelNames(models: List) { models.forEach { knownModelNames[it.displayModelId] = it.displayName } } - /** - * 首次 readiness 未就绪(超时/失败)时的兜底(P1 关键链路):后台等待就绪(含 - * 协调器后续重试成功),完成后触发一次 [load] 自动刷新,绝不无限展示 pre-replay - * 快照。独立于 loadJob(不被用户操作取消);同一时刻只调度一次。等待 coordinator - * 的完整有界生命周期;失败后由下一次 load/进入页面重新尝试。 - */ - private fun scheduleRefreshAfterReadiness() { - if (readinessRefreshJob?.isActive == true) return - readinessRefreshJob = viewModelScope.launch(dispatcher) { - try { - val ready = readiness.awaitReady(readinessRefreshWaitMs) - if (ready) { - readinessReady = true - load() - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - runCatching { AppLogger.e(tag, "统计就绪后自动刷新失败", e) } - } - } - } - - // ==== 时间选择 ==== - - /** 用户手动选择预设:持久化(manual=true)后锁定(不再自动回退)。 */ - fun selectPreset(preset: TokenStatsPreset) { - if (preset == TokenStatsPreset.CUSTOM) return - viewModelScope.launch(dispatcher) { - settings.saveTimeSelection(TokenStatsTimeSelection(preset), manual = true) - load() - } - } - - /** - * 设置自定义范围(半开区间 [startMs, endMs))。 - * 校验:end > start 且设备时区自然日数不超过 [MAX_CUSTOM_RANGE_DAYS] 天; - * 非法时不持久化、不触发查询,返回 false 并由 [actionMessage] 说明原因。 - */ fun setCustomRange(startMs: Long, endMs: Long): Boolean { when (validateCustomRange(startMs, endMs, zone, MAX_CUSTOM_RANGE_DAYS)) { CustomRangeValidation.INVALID_BOUNDS -> { _actionMessage.value = TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_custom_range_invalid), + stringResolver(R.string.token_stats_custom_range_invalid), isError = true, ) return false } CustomRangeValidation.TOO_LONG -> { _actionMessage.value = TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_custom_range_too_long), + stringResolver(R.string.token_stats_custom_range_too_long), isError = true, ) return false } CustomRangeValidation.VALID -> Unit } - val range = TokenStatsTimeRanges.customRange(startMs, endMs) viewModelScope.launch(dispatcher) { - settings.saveTimeSelection( - TokenStatsTimeSelection( - preset = TokenStatsPreset.CUSTOM, - customStartMs = range.startMs, - customEndMs = range.endMs, - ), - manual = true, - ) + settings.saveTimeRange(TokenStatsTimeRanges.customRange(startMs, endMs)) load() } return true } - // ==== 筛选 ==== - fun toggleModel(displayModelId: String) { _state.update { state -> - val newSet = state.selectedModels.toMutableSet() - if (!newSet.add(displayModelId)) newSet.remove(displayModelId) - state.copy(selectedModels = newSet) + val selected = state.selectedModels.toMutableSet() + if (!selected.add(displayModelId)) selected.remove(displayModelId) + state.copy(selectedModels = selected) } load() } @@ -551,52 +278,32 @@ class TokenUsageStatisticsViewModel( fun toggleCategory(category: TokenStatCategory) { _state.update { state -> - val current = state.selectedCategories - val newSet = (current?.toMutableSet() ?: mutableSetOf()) - if (!newSet.add(category)) newSet.remove(category) - state.copy(selectedCategories = newSet.ifEmpty { null }) + val selected = state.selectedCategories?.toMutableSet() ?: mutableSetOf() + if (!selected.add(category)) selected.remove(category) + state.copy(selectedCategories = selected.ifEmpty { null }) } load() } - fun toggleStatus(status: TokenStatStatus) { - _state.update { state -> - val current = state.selectedStatuses - val newSet = (current?.toMutableSet() ?: mutableSetOf()) - if (!newSet.add(status)) newSet.remove(status) - state.copy(selectedStatuses = newSet.ifEmpty { null }) - } + fun clearCategories() { + _state.update { it.copy(selectedCategories = null) } load() } - /** “全部分类”:一次置空并只触发一次查询(P2,避免逐项 toggle 的多次 load)。 */ - fun clearCategories() { - _state.update { it.copy(selectedCategories = null) } + fun toggleStatus(status: TokenStatStatus) { + _state.update { state -> + val selected = state.selectedStatuses?.toMutableSet() ?: mutableSetOf() + if (!selected.add(status)) selected.remove(status) + state.copy(selectedStatuses = selected.ifEmpty { null }) + } load() } - /** “全部状态”:一次置空并只触发一次查询(P2,避免逐项 toggle 的多次 load)。 */ fun clearStatuses() { _state.update { it.copy(selectedStatuses = null) } load() } - // ==== 口径/币种/汇率 ==== - - fun setIncludeLegacy(include: Boolean) { - viewModelScope.launch(dispatcher) { - settings.saveIncludeLegacy(include) - _state.update { it.copy(includeLegacy = include) } - } - } - - fun setCostMode(mode: TokenStatsCostMode) { - viewModelScope.launch(dispatcher) { - settings.saveCostMode(mode) - load() - } - } - fun setTargetCurrency(currency: PricingCurrency) { viewModelScope.launch(dispatcher) { settings.saveTargetCurrency(currency) @@ -604,9 +311,8 @@ class TokenUsageStatisticsViewModel( } } - /** 手动汇率:非正或非有限值拒绝(不改持久化、不重查),返回 false。 */ fun setManualRate(rate: Double): Boolean { - if (usdToCnyStorageValue(rate) == null) return false + if (!rate.isFinite() || rate <= 0.0) return false viewModelScope.launch(dispatcher) { settings.saveRate(rate) load() @@ -614,238 +320,44 @@ class TokenUsageStatisticsViewModel( return true } - // ==== 价格覆盖 ==== - - fun upsertPriceOverride(draft: TokenStatsPriceOverrideDraft) { - viewModelScope.launch(dispatcher) { - runCatching { manager.upsertPriceOverride(draft) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_pricing_save_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "保存价格覆盖失败", e) } - } - } - } - - /** 编辑已有价格覆盖:业务键只读,仅更新价格/币种/计费方式(P1-7)。 */ - fun updatePriceOverride(existing: TokenStatPriceOverrideEntity, draft: TokenStatsPriceOverrideDraft) { - viewModelScope.launch(dispatcher) { - runCatching { manager.updatePriceOverride(existing, draft) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_pricing_save_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "更新价格覆盖失败", e) } - } - } - } - - fun deletePriceOverride( - scope: PriceOverrideScope, - provider: String, - model: String, - configId: String?, - ) { - viewModelScope.launch(dispatcher) { - runCatching { manager.deletePriceOverride(scope, provider, model, configId) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_pricing_delete_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "删除价格覆盖失败", e) } - } - } - } - - // ==== 分组 / 别名 ==== - - fun renameDisplayGroup(displayModelId: String, displayName: String) { - viewModelScope.launch(dispatcher) { - runCatching { manager.renameDisplayGroup(displayModelId, displayName) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_group_rename_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "重命名分组失败", e) } - } - } - } - - /** 新建分组并把指定身份移入;成功返回新分组 id,失败返回 null。 */ - fun createGroupAndMerge(groupName: String, identityIds: List) { - viewModelScope.launch(dispatcher) { - runCatching { manager.createGroupAndMove(groupName, identityIds) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_group_create_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "创建分组失败", e) } - } - } - } - - /** 把指定身份合并到已有分组。 */ - fun mergeIntoGroup(identityIds: List, targetDisplayModelId: String) { - viewModelScope.launch(dispatcher) { - runCatching { manager.moveIdentitiesToGroup(identityIds, targetDisplayModelId) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_group_merge_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "合并分组失败", e) } - } - } - } - - /** 恢复默认规范分组:组内每个身份按其自身模型名归回默认组。 */ - fun restoreDefaultGroup(displayModelId: String) { - viewModelScope.launch(dispatcher) { - runCatching { manager.restoreDefaultGroups(displayModelId) } - .onSuccess { load() } - .onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_group_restore_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "恢复默认分组失败", e) } - } - } - } - - // ==== 阶段 5:删除(范围/模型/全部,危险操作由 UI 两步确认) ==== - // 删除后统一 load() 全量重查:生命周期、范围、图表与模型明细全部刷新, - // 不留任何缓存旧数据。删除语义(baseline 只随“全部/模型 + 用户确认”删除): - // - 范围删除:只删有时间戳的事件(RANGE tombstone),绝不触碰 baseline; - // - 模型删除:完整展示分组(identity 全表解析成员,不依赖当前筛选), - // baseline 是否删除由 UI 第二步确认;确认删除时经 outbox 清理旧 DataStore - // 累计键(否则下次启动迁移会按旧快照把已删 baseline 重新导入); - // - 全部删除:FULL tombstone + 全部事件;baseline 是否删除由 UI 第二步 - // 确认,确认时走 resetAllProviderModelTokenCounts(Room 先删 + ALL cleanup - // operation 排空旧计数,保持已确认语义)。 - // 所有删 baseline 的路径都满足 P1 闭环:删除事务是唯一线性化点,operation - // 持久化在同一事务,DataStore 清理在事务外排空且 marker 幂等。 - - /** - * 删除当前时间范围的事件([TokenStatsUiState.currentRange],与显示同界)。 - * 只删事件,绝不删除 baseline;失败时通过 [actionMessage] 提示。 - */ - fun deleteRangeEvents() { - val range = _state.value.currentRange ?: return - viewModelScope.launch(dispatcher) { - runCatching { - TokenStatsResetCoordinator.deleteEventsInRange(appContext, range.startMs, range.endMs) - }.onSuccess { - loadActivity() - load() - }.onFailure { e -> - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_delete_range_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "删除时间范围统计失败", e) } - } - } - } - - /** - * 删除指定展示分组的全部事件(完整组成员,DAO 事务内从 identity 全表解析)。 - * [deleteBaselines] 为 true 时同时删除该组成员的 baseline,并清理这些成员 - * 中**确实对应 legacy 身份**(configId 为空串)的 provider:model 旧 DataStore - * 累计键(防迁移重导复活);为 false 时 baseline 与旧键一律保留。身份行/分组/ - * 价格覆盖不删除。 - * - * P1 闭环:成员解析、tombstone、删除与 cleanup operation 持久化全部在 DAO - * **同一事务**内线性化(不再 VM 事务外预读),事务提交后由 - * [TokenStatsResetCoordinator] 立即排空 DataStore 累计键(marker 幂等, - * 失败保持 PENDING 由下次启动重试并向上报错)。 - */ - fun deleteDisplayModel(displayModelId: String, deleteBaselines: Boolean) { + fun savePrice(draft: TokenStatsPriceDraft) { viewModelScope.launch(dispatcher) { try { - TokenStatsResetCoordinator.deleteDisplayModel(appContext, displayModelId, deleteBaselines) - loadActivity() + manager.savePrice(draft) load() } catch (e: CancellationException) { throw e } catch (e: Exception) { - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_delete_model_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "删除模型统计失败", e) } + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_pricing_save_failed), + isError = true, + ) } } } - /** - * 删除全部统计事件。 [deleteBaselines] 为 true 时走既有 - * [ApiPreferences.resetAllProviderModelTokenCounts](旧 DataStore 累计键 + - * 新账本事件与 baseline 一并清空);为 false 时只删新账本事件,baseline 与 - * 旧累计键保留。失败时通过 [actionMessage] 提示。 - */ - fun deleteAllStatistics(deleteBaselines: Boolean) { + fun deletePrice(setting: TokenStatsPriceSetting) { viewModelScope.launch(dispatcher) { try { - val ok = - if (deleteBaselines) { - ApiPreferences.getInstance(appContext).resetAllProviderModelTokenCounts() - } else { - TokenStatsResetCoordinator.deleteAllEvents(appContext, deleteBaselines = false) - true - } - if (ok) { - loadActivity() - load() + if (setting.scope == com.ai.assistance.operit.data.stats.TokenStatsPriceScope.CONFIG) { + manager.resetConfigPrice(setting.providerModel, requireNotNull(setting.configId)) } else { - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.settings_token_stats_reset_failed), - isError = true, - ) + manager.restoreBuiltInPrice(setting.providerModel) } + load() } catch (e: CancellationException) { throw e } catch (e: Exception) { - _actionMessage.value = - TokenStatsActionMessage( - text = stringResolver(R.string.token_stats_delete_all_failed), - isError = true, - ) - runCatching { AppLogger.e(tag, "删除全部统计失败", e) } + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_pricing_delete_failed), + isError = true, + ) } } } - /** - * 生产构造(P1-3):只持有 applicationContext;VM 由路由级 ViewModelStore - * 管理(AppContent 按 screenKey 提供 owner)——配置变化保留实例, - * 路由出栈/替换/清栈时 store.clear() 触发 onCleared,viewModelScope - * 取消,正在进行的 load 一并取消。 - */ class Factory(context: Context) : ViewModelProvider.Factory { - private val appContext: Context = context.applicationContext + private val appContext = context.applicationContext @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = @@ -853,41 +365,22 @@ class TokenUsageStatisticsViewModel( } companion object { - /** 自定义范围时长上限(天):与聚合器 10k 桶上限留出余量。 */ const val MAX_CUSTOM_RANGE_DAYS = 3 * 366L - - /** 首次查询等待 readiness 的时长上限;超时先按现状查询并安排就绪后自动刷新。 */ - private const val READINESS_WAIT_MS = 5_000L - - /** Covers a newly started end-to-end coordinator flight without racing its boundary. */ - private const val READINESS_REFRESH_WAIT_MS = - TokenStatsStartupCoordinator.INITIALIZATION_TIMEOUT_MS + READINESS_WAIT_MS - } } -/** 一次 load 的并发查询结果(P1-5):模型菜单可用项可能复用主范围结果。 */ private data class QueryLoadResult( val lifetime: TokenStatsLifetimeOverview, val range: TokenStatsRangeData?, val available: TokenStatsRangeData?, - val overrides: List, - val groups: List, -) - -private data class ActivityLoadResult( - val years: List, - val year: Int, - val recent: Boolean, - val data: TokenActivityYearData, - val insights: TokenActivityInsights, + val prices: List, + val configurationNames: Map, + val activity: TokenActivityRangeData, ) -/** 保存范围时用:无效自定义边界返回 null(防御损坏状态)。 */ -internal fun TokenStatsTimeSelection.customRangeOrNull(): TokenStatsTimeRange? { - if (preset != TokenStatsPreset.CUSTOM) return null - val start = customStartMs ?: return null - val end = customEndMs ?: return null - if (end <= start) return null +private fun defaultDateRange(nowMs: Long, zone: ZoneId): TokenStatsTimeRange { + val today = java.time.Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + val start = today.minusDays(29L).atStartOfDay(zone).toInstant().toEpochMilli() + val end = today.plusDays(1L).atStartOfDay(zone).toInstant().toEpochMilli() return TokenStatsTimeRanges.customRange(start, end) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index b306519cc..da8da0177 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -66,7 +66,6 @@ import com.ai.assistance.operit.ui.features.settings.screens.ToolPermissionSetti import com.ai.assistance.operit.ui.features.settings.screens.MnnModelDownloadScreen import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesSettingsScreen import com.ai.assistance.operit.ui.features.tokenstats.TokenUsageStatisticsScreen -import com.ai.assistance.operit.ui.features.tokenstats.TokenStatsManagementScreen import com.ai.assistance.operit.ui.features.token.TokenConfigWebViewScreen import com.ai.assistance.operit.ui.features.toolbox.screens.AppPermissionsToolScreen import com.ai.assistance.operit.ui.features.toolbox.screens.FileManagerToolScreen @@ -1133,52 +1132,10 @@ sealed class Screen( ) { TokenUsageStatisticsScreen( onBackPressed = onGoBack, - onOpenGroupManagement = { navigateTo(TokenStatsManagement) }, - onOpenPricingManagement = { navigateTo(TokenStatsPricingManagement) } ) } } - data object TokenStatsManagement : - Screen( - navItem = NavItem.Settings, - titleRes = R.string.token_stats_management_title, - usesRouteViewModelStore = true - ) { - @Composable - override fun Content( - navController: NavController, - navigateTo: ScreenNavigationHandler, - onGoBack: () -> Unit, - hasBackgroundImage: Boolean, - onLoading: (Boolean) -> Unit, - onError: (String) -> Unit, - onGestureConsumed: (Boolean) -> Unit - ) { - TokenStatsManagementScreen() - } - } - - data object TokenStatsPricingManagement : - Screen( - navItem = NavItem.Settings, - titleRes = R.string.token_stats_management_title, - usesRouteViewModelStore = true - ) { - @Composable - override fun Content( - navController: NavController, - navigateTo: ScreenNavigationHandler, - onGoBack: () -> Unit, - hasBackgroundImage: Boolean, - onLoading: (Boolean) -> Unit, - onError: (String) -> Unit, - onGestureConsumed: (Boolean) -> Unit - ) { - TokenStatsManagementScreen(initialPricingTab = true) - } - } - data object ContextSummarySettings : Screen(navItem = NavItem.Settings, titleRes = R.string.screen_title_context_summary_settings) { @Composable diff --git a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt index 505be3c92..06a2b5f96 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt @@ -24,17 +24,8 @@ object OperitPaths { const val MEDIA_POOL_DIR_NAME = "media_pool" const val SKILL_REPO_ZIP_POOL_DIR_NAME = "skill_repo_zip_pool" - /** - * JVM 单元测试注入点:覆盖 [Environment.getExternalStoragePublicDirectory](纯 JVM - * 无该桩,且 thread-local 的 mockStatic 无法覆盖 Dispatchers.IO 线程)。测试设置、 - * tearDown 置回 null。 - */ - @Volatile - internal var downloadsDirOverrideForTest: File? = null - fun downloadsDir(): File { - return downloadsDirOverrideForTest - ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) } fun operitRootDir(): File { @@ -124,9 +115,6 @@ object OperitPaths { IMAGE_POOL_DIR_NAME, MEDIA_POOL_DIR_NAME, SKILL_REPO_ZIP_POOL_DIR_NAME, - // Raw snapshot 在全局门闩内先排空并 checkpoint,因此不重复打包 spool; - // restore 在同一门闩内验证清理,避免旧事件污染恢复 DB。 - com.ai.assistance.operit.data.stats.TokenStatSpool.SPOOL_DIR_NAME, ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a26981c36..d5b982b28 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3584,6 +3584,9 @@ Model Usage Distribution Request Count Requests: %1$d + At least %1$s + Requests: at least %1$s + %1$d historical record(s) have an unknown exact request count Billing Mode Token-based Billing Per-request Billing @@ -3592,7 +3595,7 @@ ¥%1$.4f per request %1$s%2$.4f per request Switch Billing Mode - Model Details + Configuration Details Click to edit pricing Edit Model Pricing - %1$s Set RMB price per million tokens @@ -3621,56 +3624,41 @@ Cached Tokens $%.2f/1M Cost - Lifetime Total (all time) + Cumulative Usage + Cumulative Models + %1$s tokens · %2$d%% Total Tokens Default estimate Default estimate: 1 USD = %1$s CNY (not set manually) Converted at 1 USD = %1$s CNY Rate must be a positive finite number - Legacy data estimate - Estimated - Include legacy data in totals - %1$d legacy rows (no time distribution; excluded from trends) %1$d request(s) with unknown cost %1$d request(s) with partially unknown data + %1$s requests + At least %1$s requests unknown %1$d Uncached input Cache read Cache write Output Reasoning - 5h - 12h - 24h - Today - Yesterday - 7d - 30d - This month - Last month - Custom - Pick start date - Pick end date + Date range + Range analysis + Filters + Trends + Statistics settings OK End date must be after start date Custom range must not exceed 3 years - Start date: %1$s - End date: %1$s - Not selected - Back All models %1$d models All categories %1$d categories All statuses %1$d statuses - Price at request time - Recalculate now - Cost calculation - Price at request time: uses the price saved when each request occurred. Later pricing changes do not alter this cost. - Recalculate now: recalculates the selected range with current built-in and custom prices. This is useful for estimates and is not a provider bill. - Changing the exchange rate only changes converted display totals. It does not alter the original-currency cost saved with an event. - Got it + Model: %1$s + Type: %1$s + Result: %1$s Total currency CNY USD @@ -3701,101 +3689,22 @@ Failed to load statistics. Please retry. Retry %1$d models - %1$d identities - Expand/Collapse - Config: %1$s + %1$d configurations + Expand or collapse details + Historical record Price Pricing unknown - Built-in default - Provider/Model override - Config override - Legacy price - Price Overrides - Model and pricing management - Manage models and pricing - Model groups - Model pricing - Could not load model and pricing information. Try again. - The operation failed. Existing settings were not changed. - Search groups, models, providers, or configs - %1$d identities selected - New group - Move - Edit pricing for %1$s - Move to group - Custom group · %1$d identities - Default group · %1$d identities - No identities in this group - Restore identities in “%1$s” to default groups based on their model names. - Restore Deleted configuration - Search models, providers, or API configs - All providers - Uses model price - Model default price - Configuration-specific prices - Different API configurations can have separate prices even when they use the same provider and model. - Edit the specific price for “%1$s” - Deleted configuration (%1$s) - Restore built-in price - Restore model price - The effective price will fall back to the next level. - Restore - Built-in default → Provider/Model → Config, layered - Built-in defaults (read-only) - No custom price overrides yet - Add override Edit override Delete - Provider/Model - API Config - Provider - Model - API config ID Input price (per million) Cache read price (per million) Cache write price (per million) Output price (per million) Price per request - Built-in default: %1$s Prices must be non-negative finite numbers Failed to save price override Failed to delete price override - %1$d more - Manage group - %1$d identities in group - Rename group - Create group and merge - Create - Merge into another group - Target group - Pick - Merge - Restore default group - Tap again to confirm restore - Failed to rename group - Failed to create group - Failed to merge group - Failed to restore default group - Delete current time range - Delete Range Statistics - This will delete all events in the current time range (all models and categories). The migrated legacy baseline is not affected. This action cannot be undone. - Failed to delete range statistics. Please try again. - Delete this model - Delete Model Statistics - This will delete all events of %1$s (%2$d identities, all configs). This action cannot be undone. - Failed to delete model statistics. Please try again. - Delete All Statistics - This will delete statistics events of all models and categories. This action cannot be undone. - Failed to delete statistics. Please try again. - Delete Legacy Baseline? - Also delete the migrated legacy baseline (%1$d rows)? Choosing \"Events only\" keeps the baseline. - Also delete the migrated legacy baseline of %1$s? Choosing \"Events only\" keeps the baseline. - Events only - Events and baseline - Delete - Continue - %1$s (%2$d) Previous time bucket Next time bucket Bucket %1$d of %2$d @@ -5399,26 +5308,6 @@ This will overwrite current app data with the following backup:\n%1$s\n\nThis action cannot be undone. It is recommended to perform a backup before restoring. Confirm restore Cancel - Statistics quarantine evidence - Preserve and maintain corrupt statistics evidence - Event segments that cannot be parsed are preserved as quarantine evidence and never block healthy statistics recording. Full evidence is retained up to the %1$s cap; beyond it only count, hash, byte and time summaries are kept, never content. Export the evidence first, then acknowledge deletion explicitly. - No quarantine evidence - %1$d file(s) quarantined, %2$s total - %1$d incomplete deletion transaction(s) (%2$s), cleared together on delete confirmation - %1$d summary record(s) (%2$s) for the over-cap portion, without content - Export evidence - Evidence exported to: %1$s - Evidence export failed: %1$s - Evidence export failed: %1$s (export directory cleanup also failed, please remove it manually: %2$s) - Delete evidence - Delete statistics quarantine evidence? - This permanently deletes %1$d quarantine evidence file(s) (%2$s total) and %3$d quarantine summary record(s) (%4$s total). This cannot be undone; exporting the evidence first is recommended. - Delete - Cancel - Deleted %1$d quarantine evidence file(s) and %2$d quarantine summary record(s) - Deletion failed: %1$s - Quarantine evidence info unavailable: %1$s (keeping the last shown values) - Chat History Cross-format backup, export and recovery Currently %1$d chat record(s). @@ -8308,27 +8197,6 @@ Guardian denied several tool calls in this turn. The denied calls and the remaining tools were not run, so the turn was stopped. What you should do Review the audit record and check which operation was denied and why. If it is still needed, return to the main chat and clearly state the exact operation, target, and scope before asking the AI to try again. Do not authorize an operation you do not understand. - Token Activity - Recent - Profile - Not set - Edit profile - Reset and import global name and avatar - Reset profile - This replaces the independent Token Activity nickname and avatar with Operit’s global name and avatar. The independent email is preserved. - Confirm in %1$d s - Confirm reset - Could not import the global avatar. The current avatar was kept. - Nickname - Email - Save - Avatar - Choose from gallery - Remove avatar - Activity Insights - Total requests - Peak hours - %1$d-%2$d Daily Weekly Cumulative @@ -8339,6 +8207,7 @@ %1$d days %1$s used %2$s tokens %1$s - %2$s used %3$s tokens + %2$s tokens through %1$s Tap a cell to view details Less More diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c2bddefc..7c3e13aa6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3495,7 +3495,7 @@ 总费用 总请求次数 模型使用分布 - 模型详情 + 配置详情 点击编辑定价和计费方式 编辑模型定价 - %1$s 设置每百万Token的人民币价格 @@ -3511,6 +3511,9 @@ 缓存输入价格(每百万Token) 请求次数 请求次数: %1$d + 至少 %1$s + 请求次数: 至少 %1$s + %1$d 条历史记录的精确请求次数未知 计费方式 按Token计费 按次计费 @@ -3536,56 +3539,41 @@ 费用 - 生命周期累计(全部时间) + 累计消耗 + 模型累计 + %1$s Token · %2$d%% 总Token 默认估算 默认估算:1 USD = %1$s CNY(未手动设置) 按 1 USD = %1$s CNY 换算 汇率必须为正的有限数值 - 旧数据估算 - 估算 - 累计中包含旧版数据 - %1$d 行旧版累计数据(无时间分布,不进趋势图) 其中 %1$d 个请求费用未知 %1$d 个请求部分数据未知 + %1$s 次请求 + 至少 %1$s 次请求 未知 %1$d 未缓存输入 缓存读取 缓存写入 输出 推理 - 近5小时 - 近12小时 - 近24小时 - 今天 - 昨天 - 近7天 - 近30天 - 本月 - 上月 - 自定义 - 选择开始日期 - 选择结束日期 + 日期范围 + 范围分析 + 筛选条件 + 趋势 + 统计设置 确定 结束日期需晚于开始日期 自定义范围不能超过 3 年 - 开始日期:%1$s - 结束日期:%1$s - 未选择 - 上一步 全部模型 %1$d 个模型 全部分类 %1$d 个分类 全部状态 %1$d 个状态 - 发生时价格 - 按当前价格重算 - 费用计算方式 - 发生时价格:使用请求发生时保存的价格。之后修改模型定价,不会改变这部分费用。 - 按当前价格重算:使用现在的内置价格和自定义价格重新计算所选时间范围,适合预算模拟,不代表供应商实际账单。 - 修改汇率只会改变统一币种后的展示结果,不会改变事件保存的原币费用。 - 知道了 + 模型:%1$s + 调用类型:%1$s + 结果:%1$s 总计币种 CNY USD @@ -3616,101 +3604,24 @@ 统计加载失败,请重试 重试 %1$d 个模型 - %1$d 个身份 - 展开/收起 - 配置:%1$s + %1$d 个配置 + 展开或收起详情 + 展开全部(%1$d) + 收起 + 历史记录 单价 定价未知 - 内置默认 - Provider/模型覆盖 - API 配置覆盖 - 旧系统价格 - 价格覆盖 - 模型与定价管理 - 管理模型与定价 - 模型分组 - 模型定价 - 无法加载模型与定价信息,请重试。 - 操作失败,现有设置未改变。 - 搜索分组、模型、Provider 或配置 - 已选择 %1$d 个身份 - 新建分组 - 移动 - 修改 %1$s 的定价 - 移动到分组 - 自定义分组 · %1$d 个身份 - 默认分组 · %1$d 个身份 - 该分组暂无身份 - 将“%1$s”内的身份按各自模型名称恢复到默认分组。 - 恢复 已删除的配置 - 搜索模型、Provider 或 API 配置 - 全部 Provider - 使用模型价格 - 模型默认价格 - 模型配置专属价格 - 相同 Provider 和模型的不同 API 配置可分别设置价格。 - 编辑“%1$s”的专属价格 - 已删除的配置(%1$s) - 恢复内置价格 - 恢复模型价格 - 恢复后将自动使用上一级有效价格。 - 确认恢复 - 内置默认 → Provider/模型 → API 配置,逐层覆盖 - 内置默认(只读) - 暂无自定义价格覆盖 - 新增覆盖 编辑覆盖 删除 - 按 Provider/模型 - 按 API 配置 - Provider - 模型 - API 配置 ID 输入价格(每百万) 缓存读取价格(每百万) 缓存写入价格(每百万) 输出价格(每百万) 每次请求价格 - 内置默认:%1$s 价格必须为非负有限数值 价格覆盖保存失败 价格覆盖删除失败 - 其他 %1$d 个 - 分组管理 - 组内 %1$d 个身份 - 重命名分组 - 新建分组并合并 - 创建 - 合并到其他分组 - 目标分组 - 选择 - 合并 - 恢复默认分组 - 再次点击确认恢复 - 重命名分组失败 - 创建分组失败 - 合并分组失败 - 恢复默认分组失败 - 删除当前时间范围 - 删除时间范围统计 - 将删除当前时间范围内的全部事件(所有模型与分类),迁移的旧统计 baseline 不受影响。此操作不可恢复。 - 删除时间范围统计失败,请重试 - 删除该模型 - 删除模型统计 - 将删除 %1$s 的全部事件(%2$d 个身份,含所有配置),此操作不可恢复。 - 删除模型统计失败,请重试 - 删除全部统计 - 将删除全部模型与分类的统计事件,此操作不可恢复。 - 删除全部统计失败,请重试 - 删除旧统计 baseline? - 是否同时删除迁移的旧统计 baseline(%1$d 行)?选择“仅删除事件”会保留 baseline。 - 是否同时删除 %1$s 迁移的旧统计 baseline?选择“仅删除事件”会保留 baseline。 - 仅删除事件 - 删除事件与 baseline - 确认删除 - 继续 - %1$s(%2$d) 上一个时间桶 下一个时间桶 第 %1$d / %2$d 桶 @@ -5846,26 +5757,6 @@ 将使用以下备份文件覆盖当前应用数据:\n%1$s\n\n此操作不可撤销。建议先执行一次备份再恢复。 确认恢复 取消 - 统计隔离证据 - 损坏的统计事件证据保留与维护 - 统计落盘中无法解析的事件段会保留为隔离证据,不会阻断后续正常事件的记录。完整证据保留到 %1$s 上限;超出上限的部分只保留计数、哈希、字节与时间摘要,不保留正文。请先导出证据,再明确确认删除。 - 暂无隔离证据 - 已隔离 %1$d 个文件,共 %2$s - 其中未完成删除事务 %1$d 个(共 %2$s),确认删除时一并清除 - 其中超出上限的部分仅保留 %1$d 条摘要(%2$s),不含正文 - 导出证据 - 证据已导出到:%1$s - 证据导出失败:%1$s - 证据导出失败:%1$s(导出目录清理也失败,请手动删除:%2$s) - 确认删除 - 确认删除统计隔离证据 - 将永久删除 %1$d 个隔离证据文件(共 %2$s)及 %3$d 条隔离摘要(共 %4$s)。此操作不可撤销,建议先导出证据。 - 确认删除 - 取消 - 已删除 %1$d 个隔离证据文件及 %2$d 条隔离摘要 - 删除失败:%1$s - 隔离证据信息读取失败:%1$s(保留上次显示,请稍后重试) - 聊天记录 跨格式备份、导出与恢复 当前共有 %1$d 条聊天记录。 @@ -8298,27 +8189,6 @@ Guardian 连续拒绝了本轮中的多个工具调用。被拒绝的调用和本轮剩余工具都没有执行,因此系统停止了本轮。 你应该怎么做 先查看审核记录,确认被拒绝的操作和原因。若仍需执行,请回到主对话,明确说明具体操作、目标和范围后再让 AI 重试;不要授权你不理解的操作。 - Token 活动 - 最近 - 个人资料 - 未设置 - 编辑个人资料 - 重置并导入全局用户名和头像 - 重置个人资料 - 将用 Operit 的全局用户名和头像覆盖 Token 活动中的独立昵称和头像。独立邮箱不会改变。 - %1$d 秒后确认 - 确认重置 - 全局头像导入失败,已保留当前头像 - 昵称 - 邮箱 - 保存 - 头像 - 从相册选择 - 移除头像 - 活动洞察 - 总请求次数 - 请求高峰时段 - %1$d~%2$d 点 每日 每周 累计 @@ -8329,6 +8199,7 @@ %1$d 天 %1$s使用了 %2$s Token %1$s - %2$s 使用了 %3$s Token + 截至 %1$s 累计使用 %2$s Token 点击方格查看详细数据 diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt deleted file mode 100644 index 0fa644ab3..000000000 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIStreamUsageOptionTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.ai.assistance.operit.api.chat.llmprovider - -import com.ai.assistance.operit.data.model.ApiProviderType -import org.json.JSONObject -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class OpenAIStreamUsageOptionTest { - - @Test - fun `known chat completions providers request streamed usage`() { - listOf(ApiProviderType.OPENAI, ApiProviderType.DEEPSEEK, ApiProviderType.MOONSHOT).forEach { provider -> - val body = JSONObject() - body.applyChatCompletionsStreamUsageOption(true, provider, useResponsesApi = false) - assertTrue(body.getJSONObject("stream_options").getBoolean("include_usage")) - } - } - - @Test - fun `generic and local compatible providers omit stream options`() { - listOf( - ApiProviderType.OPENAI_GENERIC, - ApiProviderType.OPENAI_LOCAL, - ApiProviderType.LMSTUDIO, - ApiProviderType.OLLAMA, - ApiProviderType.OTHER, - ).forEach { provider -> - val body = JSONObject() - body.applyChatCompletionsStreamUsageOption(true, provider, useResponsesApi = false) - assertFalse("$provider must not receive stream_options", body.has("stream_options")) - } - } - - @Test - fun `responses and non streaming requests omit stream options`() { - val responsesBody = JSONObject() - responsesBody.applyChatCompletionsStreamUsageOption(true, ApiProviderType.OPENAI, useResponsesApi = true) - assertFalse(responsesBody.has("stream_options")) - - val nonStreamingBody = JSONObject() - nonStreamingBody.applyChatCompletionsStreamUsageOption(false, ApiProviderType.OPENAI, useResponsesApi = false) - assertFalse(nonStreamingBody.has("stream_options")) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt deleted file mode 100644 index ed473bdf8..000000000 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIServiceTest.kt +++ /dev/null @@ -1,2506 +0,0 @@ -package com.ai.assistance.operit.api.chat.llmprovider - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome -import com.ai.assistance.operit.core.chat.hooks.PromptTurn -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.ModelOption -import com.ai.assistance.operit.data.model.ModelParameter -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.ToolPrompt -import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver -import com.ai.assistance.operit.data.stats.PricingSource -import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot -import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer -import com.ai.assistance.operit.data.stats.TokenPriceResolver -import com.ai.assistance.operit.data.stats.TokenStatCategory -import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver -import com.ai.assistance.operit.data.stats.TokenStatSpool -import com.ai.assistance.operit.data.stats.TokenStatStatus -import com.ai.assistance.operit.data.stats.TokenStatsLedger -import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator -import com.ai.assistance.operit.data.stats.TokenStatRequestContext -import com.ai.assistance.operit.util.exceptions.UserCancellationException -import com.ai.assistance.operit.util.stream.MutableSharedStream -import com.ai.assistance.operit.util.stream.Stream -import com.ai.assistance.operit.util.stream.TextStreamEvent -import com.ai.assistance.operit.util.stream.TextStreamEventCarrier -import com.ai.assistance.operit.util.stream.TextStreamEventType -import com.ai.assistance.operit.util.stream.stream -import com.ai.assistance.operit.util.stream.streamOf -import com.ai.assistance.operit.util.stream.timeout -import com.ai.assistance.operit.util.stream.withEventChannel -import java.io.File -import java.io.IOException -import java.io.InterruptedIOException -import java.net.SocketTimeoutException -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import org.json.JSONObject -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * 统一记录边界(TokenTrackingAIService)测试: - * 正常/流式/取消/超时/失败、真实 Job 取消与 withTimeout、有 usage/无 usage、 - * 防重、首 token(含仅空白 chunk)、内部重试的 attempt 聚合、调用者 callback 转发、 - * 业务分类上下文、修订流接口保持、连接测试 usage 与取消传播、 - * 有界落账(存储挂起不阻塞业务)。 - */ -class TokenTrackingAIServiceTest { - - private lateinit var tempDir: File - private lateinit var database: AppDatabase - private lateinit var context: Context - - @Before - fun setUp() { - // 流框架日志走 android.util.Log,JVM 测试不可用:关闭避免 Stub! 异常 - com.ai.assistance.operit.util.stream.StreamLogger.setEnabled(false) - com.ai.assistance.operit.util.stream.StreamLogger.setVerboseEnabled(false) - // 收尾日志跑在 Dispatchers.IO(P1-3),thread-local 的 mockStatic(AppLogger) - // 无法覆盖后台线程;统一关闭 android.util.Log 调用,避免 "not mocked"。 - com.ai.assistance.operit.util.AppLogger.enableSystemLog = false - tempDir = kotlin.io.path.createTempDirectory("tracking-test").toFile() - context = mockContext(tempDir) - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - TokenStatsLedger.databaseProvider = { database } - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - // 每个测试使用独立的 spool 目录(context.filesDir 指向独立 tempDir), - // 落账 writer 是生产默认单例:清空调度状态并递增会话代次,使任何残留 - // drain/resolver 立即中止,避免跨测试污染(评审 P2-4) - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.segmentDeleteForTest = null - // P1 终审:测试夹具默认“目录 fsync 支持且成功”(平台无关)——本类断言 seal 排空后 - // spool 无残留文件;Windows 生产会走原地排空模式(active 保留为空文件),与夹具无关 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenTrackingAIService.resetPricingExecutorForTest() - } - - @After - fun tearDown() { - com.ai.assistance.operit.util.AppLogger.enableSystemLog = true - runBlocking { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } - } - TokenStatsLedger.databaseProvider = null - TokenStatsLedger.legacyPriceProvider = null - TokenTrackingAIService.recordTimeoutMs = 5_000L - TokenStatSpool.insertTimeoutMs = 5_000L - TokenStatSpool.dirSyncForTest = null - database.close() - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - /** - * 可编程的假 provider:按给定行为产出流,并像真实 provider 一样上报 usage - * (携带 attempt 序号);testConnection 支持上报 usage / 抛取消,模拟真实 - * provider 内部经 sendMessage 发起探测的形态。 - */ - private class FakeAiService( - var testConnectionResult: Result = Result.success("ok"), - val eventCarrier: Boolean = false, - var behavior: suspend ( - onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, - ) -> Stream = { _ -> streamOf("hello") }, - ) : AIService { - val reportCount = AtomicInteger(0) - - /** 非空时 testConnection 会像真实 provider 一样上报 usage。 */ - var testConnectionUsage: ProviderUsageSnapshot? = null - - /** 非空时 testConnection 抛出的取消(模拟 provider 内部取消传播)。 */ - var testConnectionCancellation: CancellationException? = null - - /** 非空时 testConnection 直接抛出该异常(模拟 provider 内部超时/失败)。 */ - var testConnectionThrowable: Throwable? = null - - /** cancelStreaming 的行为(模拟本地 provider 的 isCancelled 通知)。 */ - var cancelHandler: (() -> Unit)? = null - - override val inputTokenCount: Long = 100L - override val cachedInputTokenCount: Long = 0L - override val outputTokenCount: Long = 50L - override val providerModel: String = "DEEPSEEK:deepseek-chat" - - override fun resetTokenCounts() {} - override fun cancelStreaming() { - cancelHandler?.invoke() - } - - override suspend fun getModelsList(context: Context): Result> = - Result.success(emptyList()) - - override suspend fun sendMessage( - context: Context, - chatHistory: List, - modelParameters: List>, - enableThinking: Boolean, - stream: Boolean, - availableTools: List?, - preserveThinkInHistory: Boolean, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, - onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean, - statsCategory: TokenStatCategory?, - ): Stream { - val inner = behavior(onUsageReported) - if (!eventCarrier) return inner - val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) - return inner.withEventChannel(eventChannel) - } - - override suspend fun testConnection( - context: Context, - onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, - ): Result { - testConnectionCancellation?.let { throw it } - testConnectionThrowable?.let { throw it } - testConnectionUsage?.let { onUsageReported?.invoke(it, 1) } - return testConnectionResult - } - - override suspend fun calculateInputTokens( - chatHistory: List, - availableTools: List?, - ): Long = 100L - } - - private fun tracked(fake: FakeAiService): TokenTrackingAIService = - TokenTrackingAIService(delegate = fake, context = context, configId = "cfg-1") - - private fun tracked(fake: FakeAiService, configId: String): TokenTrackingAIService = - TokenTrackingAIService(delegate = fake, context = context, configId = configId) - - private fun usage(): ProviderUsageSnapshot = - ProviderUsageSnapshot( - uncachedInputTokens = 800L, - cachedInputTokens = 200L, - outputTokens = 500L, - reasoningIncludedInOutput = true, - source = ProviderUsageNormalizer.SOURCE_OPENAI_CHAT_COMPLETIONS, - ) - - /** 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 - * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 */ - private fun gateIgnoringInterrupts(gate: CountDownLatch) { - while (true) { - try { - if (gate.await(1, TimeUnit.SECONDS)) return - } catch (_: InterruptedException) { - } - } - } - - /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ - private fun awaitNoSpoolWorkerThreads() { - fun live(): List = - Thread.getAllStackTraces().entries - .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } - .map { it.key.name } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) - while (System.nanoTime() < deadline) { - if (live().isEmpty()) return - Thread.sleep(20) - } - fail("spool worker threads leaked: ${live()}") - } - - @Test - fun `restore waiter invalidation never overrides model failure or cancellation`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousInsert = TokenStatSpool.insertTimeoutMs - val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs - val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs - TokenStatSpool.insertTimeoutMs = 50 - // caller 的可见性等待也缩短:restore 有界失败后由 caller 自己的超时返回 - TokenTrackingAIService.recordTimeoutMs = 100 - TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 - try { - // insert 永久挂起(忽略中断但可释放):append 已 durable 但 Room 可见性永远等不到。 - // mock 的 suspend 方法默认返回 null,必须显式 stub 新请求会读取的查询 - val release = CountDownLatch(1) - val hangingDao = mock() - whenever(hangingDao.currentResetGeneration()).thenReturn(0L) - // P1-1:请求接受边界在同一事务内建身份+取 generation——本测试聚焦 restore - // 对 wedged insert 的有界失败,边界直接返回 generation,不触碰挂起门闩 - whenever(hangingDao.ensureIdentityAndCaptureGenerationTx(any(), any())).thenReturn(0L) - whenever(hangingDao.getAllPriceOverrides()).thenReturn(emptyList()) - whenever(hangingDao.insertIdentityIfAbsent(any())).thenAnswer { - gateIgnoringInterrupts(release) - true - } - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(hangingDao) - TokenStatsLedger.databaseProvider = { proxy } - - // 固定模型异常:restore 门闩遇到仍存活的 wedged insert 必须有界失败(P1-2), - // 绝不作废 waiter、绝不覆盖 primary;caller 自己的可见性等待按超时返回 - val modelFailure = IOException("model failed") - var primary: Throwable? = null - val failureJob = launch { - try { - tracked(FakeAiService { _ -> stream { emit("partial"); throw modelFailure } }) - .sendMessage(context = context).collect { } - fail("model failure must propagate") - } catch (e: Throwable) { - primary = e - if (e is CancellationException) throw e - } - } - val firstDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.activeInsertCountForTest() == 0 && System.nanoTime() < firstDeadline) { - delay(10) - } - assertTrue( - "the first Room insert must be active before restore starts", - TokenStatSpool.activeInsertCountForTest() > 0, - ) - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } - fail("restore must fail bounded while the caller's insert is still live") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - failureJob.join() - assertTrue("primary must be the model exception, was: $primary", primary === modelFailure) - assertEquals(0, primary!!.suppressed.size) - - // 模型取消同样原样传播,不被 restore 门闩/waiter 覆盖 - val modelCancellation = CancellationException("user cancelled") - var primaryCancel: Throwable? = null - val cancelJob = launch { - try { - tracked(FakeAiService { _ -> stream { throw modelCancellation } }) - .sendMessage(context = context).collect { } - fail("model cancellation must propagate") - } catch (e: Throwable) { - primaryCancel = e - if (e is CancellationException) throw e - } - } - val secondDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.activeInsertCountForTest() == 0 && System.nanoTime() < secondDeadline) { - delay(10) - } - assertTrue( - "the second Room insert must be active before restore starts", - TokenStatSpool.activeInsertCountForTest() > 0, - ) - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } - fail("restore must fail bounded while the caller's insert is still live") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - cancelJob.join() - assertTrue( - "primary must be the model cancellation, was: $primaryCancel", - primaryCancel === modelCancellation, - ) - - // 模拟重启前必须释放旧 insert 并确认 registry 真正清空:释放门闩 → 任务 - // 完成 → 旧 worker shutdown 后真实终止,绝不遗留线程 - release.countDown() - val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { - delay(10) - } - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.insertTimeoutMs = previousInsert - TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout - TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce - } - } - } - - @Test - fun `pricing timeouts stay bounded without leaking threads and events stay unknown`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousPrepare = TokenStatSpool.prepareTimeoutMs - TokenStatSpool.prepareTimeoutMs = 50 - try { - // 价格解析永久挂起且忽略中断(可释放):每次调用都必须在有界时间内返回 UNKNOWN - val release = CountDownLatch(1) - TokenStatsLedger.legacyPriceProvider = { _, _ -> - gateIgnoringInterrupts(release) - null - } - val startedAt = System.nanoTime() - repeat(8) { index -> - val request = - TokenStatRequestContext( - eventId = "evt-price-hang-$index", - category = TokenStatCategory.OTHER, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.finish(TokenStatStatus.COMPLETED) - TokenTrackingAIService.recordSafely(context, request) - } - val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 - assertTrue("every record must be bounded: $elapsedMs ms", elapsedMs < 10_000) - - // 反复超时后定价线程数固定在上限内(1 个执行线程 + 1 个队列位) - val priceThreads = Thread.getAllStackTraces().keys.count { - it.isAlive && it.name.startsWith("operit-token-stats-price") - } - assertTrue("pricing worker must stay bounded: $priceThreads", priceThreads <= 2) - - // 事件全部 durable 且价格为明确的 UNKNOWN(绝不静默用默认价冒充) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().countEvents() < 8 && System.nanoTime() < deadline) { - delay(50) - } - assertEquals(8, database.tokenStatsDao().countEvents()) - assertTrue( - "all events must be durable with UNKNOWN pricing", - database.tokenStatsDao().getAllEvents().all { it.pricingSource == PricingSource.UNKNOWN.name }, - ) - - // 释放挂起的定价 worker 并确认其真实终止,绝不遗留线程 - release.countDown() - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - } finally { - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.prepareTimeoutMs = previousPrepare - } - } - } - - @Test - fun `normal stream records one completed event with first token and usage`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("first ") - emit("chunk") - onUsage?.invoke(usage(), 1) - } - } - val collected = StringBuilder() - tracked(fake).sendMessage( - context = context, - chatHistory = emptyList(), - statsCategory = TokenStatCategory.CHAT, - ).collect { collected.append(it) } - - assertEquals("first chunk", collected.toString()) - val events = database.tokenStatsDao().getAllEvents() - assertEquals(1, events.size) - val event = events[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(TokenStatCategory.CHAT.name, event.category) - assertNotNull("first token must be set on real first chunk", event.firstTokenAtMs) - assertTrue(event.firstTokenAtMs!! >= event.startedAtMs) - assertEquals(800L, event.uncachedInputTokens) - assertEquals(200L, event.cachedInputTokens) - assertEquals(500L, event.outputTokens) - assertTrue("endedAt after startedAt", event.endedAtMs >= event.startedAtMs) - } - - @Test - fun `category defaults to OTHER when caller does not declare it`() = runBlocking { - val fake = FakeAiService() - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatCategory.OTHER.name, event.category) - } - - @Test - fun `subagent category is propagated`() = runBlocking { - val fake = FakeAiService() - tracked(fake).sendMessage( - context = context, - statsCategory = TokenStatCategory.SUBAGENT, - ).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatCategory.SUBAGENT.name, event.category) - } - - @Test - fun `real job cancellation records cancelled event with usage and propagates`() = - runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("partial") - onUsage?.invoke(usage(), 1) - delay(60_000) - } - } - var propagated: CancellationException? = null - val job = - launch { - try { - tracked(fake).sendMessage(context = context).collect { } - fail("cancellation must propagate") - } catch (e: CancellationException) { - propagated = e - throw e - } - } - // 等流开始并已上报 usage 后再真实取消 Job - delay(50) - job.cancelAndJoin() - assertNotNull("original cancellation must propagate", propagated) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.CANCELLED.name, event.status) - // 真实取消前拿到的 usage 必须落账 - assertEquals(800L, event.uncachedInputTokens) - assertEquals(500L, event.outputTokens) - } - - @Test - fun `cancellation records cancelled event and rethrows`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("partial") - onUsage?.invoke(usage(), 1) - throw CancellationException("user cancelled") - } - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("cancellation must propagate") - } catch (e: CancellationException) { - assertEquals("user cancelled", e.message) - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.CANCELLED.name, event.status) - // 取消时已拿到的 usage 也要落账 - assertEquals(800L, event.uncachedInputTokens) - } - - @Test - fun `failure records failed event and rethrows`() = runBlocking { - val fake = - FakeAiService { _ -> - stream { throw IOException("boom") } - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("failure must propagate") - } catch (e: IOException) { - assertEquals("boom", e.message) - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.FAILED.name, event.status) - assertNull("no usage on failure", event.uncachedInputTokens) - } - - @Test - fun `local provider fatal error emits user visible text then records failed`() = runBlocking { - // 与 Llama/MNN 修复后的契约一致:致命错误先保留用户可见错误文本, - // 再以异常终止 → 统计边界记为 FAILED,错误文本仍完整送达 - val fake = - FakeAiService { onUsage -> - stream { - emit("[error] 模型文件不存在") - onUsage?.invoke( - usage().copy(uncachedInputTokens = 300L, outputTokens = 12L), - 1, - ) - throw IOException("llama_error_inference_failed") - } - } - val collected = StringBuilder() - try { - tracked(fake).sendMessage(context = context).collect { collected.append(it) } - fail("fatal error must propagate") - } catch (e: IOException) { - assertEquals("llama_error_inference_failed", e.message) - } - assertEquals("[error] 模型文件不存在", collected.toString()) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.FAILED.name, event.status) - // 失败前已实测的 usage 也要落账 - assertEquals(300L, event.uncachedInputTokens) - assertEquals(12L, event.outputTokens) - } - - @Test - fun `withTimeout classifies as timeout and records event with usage`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("partial") - onUsage?.invoke(usage(), 1) - delay(60_000) - } - } - var timeout: TimeoutCancellationException? = null - try { - withTimeout(100) { - tracked(fake).sendMessage(context = context).collect { } - } - fail("withTimeout must fire") - } catch (e: TimeoutCancellationException) { - timeout = e - } - assertNotNull(timeout) - val event = database.tokenStatsDao().getAllEvents()[0] - // coroutine 超时(CancellationException 子类)必须记为 TIMEOUT 而非 CANCELLED - assertEquals(TokenStatStatus.TIMEOUT.name, event.status) - assertEquals(800L, event.uncachedInputTokens) - } - - @Test - fun `stream timeout operator classifies as timeout`() = runBlocking { - val fake = - FakeAiService { _ -> - stream { - emit("a") - delay(200) - emit("b") - }.timeout(50.milliseconds) - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("stream timeout must fire") - } catch (e: com.ai.assistance.operit.util.stream.TimeoutException) { - // expected - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.TIMEOUT.name, event.status) - } - - @Test - fun `socket timeout records timeout event`() = runBlocking { - val fake = - FakeAiService { _ -> - stream { throw SocketTimeoutException("connect timed out") } - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("timeout must propagate") - } catch (e: SocketTimeoutException) { - // expected - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.TIMEOUT.name, event.status) - } - - @Test - fun `network retry exhaustion preserves timeout classification through cause chain`() = - runBlocking { - val fake = - FakeAiService { _ -> - // provider 重试耗尽时把超时包成 IOException:cause chain 必须可识别 - stream { - throw IOException("exhausted", SocketTimeoutException("connect timed out")) - } - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("must throw") - } catch (e: IOException) { - // expected - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.TIMEOUT.name, event.status) - } - - @Test - fun `classify maps timeout cancellation and failure precisely`() = runBlocking { - val coroutineTimeout = captureCoroutineTimeout() - assertEquals(TokenStatStatus.TIMEOUT, TokenTrackingAIService.classify(coroutineTimeout)) - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(java.util.concurrent.TimeoutException("t")), - ) - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(com.ai.assistance.operit.util.stream.TimeoutException("t")), - ) - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(SocketTimeoutException("t")), - ) - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(IOException("wrapped", SocketTimeoutException("t"))), - ) - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(IOException("wrapped", coroutineTimeout)), - ) - assertEquals( - TokenStatStatus.CANCELLED, - TokenTrackingAIService.classify(CancellationException("c")), - ) - assertEquals(TokenStatStatus.FAILED, TokenTrackingAIService.classify(IOException("f"))) - assertEquals( - TokenStatStatus.CANCELLED, - TokenTrackingAIService.classify(UserCancellationException("manual stream cancellation")), - ) - // 明确非超时取消优先于 cause 链:UserCancellationException(cause=InterruptedIOException) - // 必须 CANCELLED,不能被 cause 里的 InterruptedIOException 误判为超时 - assertEquals( - TokenStatStatus.CANCELLED, - TokenTrackingAIService.classify( - UserCancellationException("c", InterruptedIOException("Interrupted")) - ), - ) - // 线程中断等普通 InterruptedIOException 不是超时 - assertEquals( - TokenStatStatus.FAILED, - TokenTrackingAIService.classify( - IOException("wrapped", InterruptedIOException("Interrupted")) - ), - ) - // OkHttp 整调用超时(消息明确 "timeout")仍识别为超时 - assertEquals( - TokenStatStatus.TIMEOUT, - TokenTrackingAIService.classify(IOException("wrapped", InterruptedIOException("timeout"))), - ) - } - - /** TimeoutCancellationException 构造器是 internal:用真实 withTimeout 捕获一个实例。 */ - private suspend fun captureCoroutineTimeout(): TimeoutCancellationException = - try { - withTimeout(1) { delay(10_000) } - error("withTimeout must fire") - } catch (e: TimeoutCancellationException) { - e - } - - @Test - fun `no usage keeps unknown fields null`() = runBlocking { - val fake = FakeAiService { _ -> streamOf("plain answer") } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertNull(event.uncachedInputTokens) - assertNull(event.outputTokens) - assertNull(event.costInPricingCurrency) - assertTrue(event.diagnosticsJson!!.contains("\"usageObserved\":false")) - } - - @Test - fun `internal retry usage accumulates across attempts without double counting`() = - runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - // 模拟 provider 内部重试:attempt 1 上报 usage 后 I/O 失败被 - // provider 内部捕获,attempt 2 上报后成功;同 attempt 的重复 - // 上报只取最后一次。 - onUsage?.invoke( - usage().copy(uncachedInputTokens = 300L, outputTokens = 100L), - 1, - ) - try { - throw IOException("transient failure") - } catch (e: IOException) { - onUsage?.invoke( - usage().copy(uncachedInputTokens = 500L, outputTokens = 400L), - 2, - ) - // 同 attempt 重复上报:不得重复累加,取最后一次 - onUsage?.invoke( - usage().copy(uncachedInputTokens = 999L, outputTokens = 400L), - 2, - ) - } - emit("final answer") - } - } - val collected = StringBuilder() - tracked(fake) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { collected.append(it) } - assertEquals("final answer", collected.toString()) - - // 一次逻辑请求只落一个事件 - val events = database.tokenStatsDao().getAllEvents() - assertEquals(1, events.size) - val event = events[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - // attempt1(300) + attempt2 最后一次(999) = 1299;attempt2 重复上报不累加 - assertEquals(1299L, event.uncachedInputTokens) - // attempt1(100) + attempt2 最后一次(400) = 500 - assertEquals(500L, event.outputTokens) - assertTrue(event.diagnosticsJson!!.contains("\"usageReportCount\":3")) - assertTrue(event.diagnosticsJson!!.contains("\"attemptCount\":2")) - } - - @Test - fun `duplicate usage callbacks never duplicate the event`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("a") - onUsage?.invoke(usage(), 1) - emit("b") - onUsage?.invoke(usage(), 1) - } - } - tracked(fake).sendMessage(context = context).collect { } - val events = database.tokenStatsDao().getAllEvents() - assertEquals(1, events.size) - } - - @Test - fun `caller usage callback is forwarded for every report with attempt`() = runBlocking { - val forwarded = mutableListOf>() - val fake = - FakeAiService { onUsage -> - stream { - emit("a") - onUsage?.invoke(usage(), 1) - emit("b") - onUsage?.invoke(usage().copy(outputTokens = 777L), 1) - } - } - tracked(fake) - .sendMessage( - context = context, - onUsageReported = { u, attempt -> forwarded.add(u to attempt) }, - ) - .collect { } - // 调用者 callback 每次上报都转发(含 attempt) - assertEquals(2, forwarded.size) - assertEquals(1, forwarded[0].second) - assertEquals(777L, forwarded[1].first.outputTokens) - // 内部账本按同 attempt 最后一次记账,不重复 - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(777L, event.outputTokens) - } - - @Test - fun `whitespace-only first chunk records first token`() = runBlocking { - val fake = - FakeAiService { _ -> - stream { emit(" "); emit("\n"); emit("x") } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertNotNull("whitespace chunk is real output and must set first token", event.firstTokenAtMs) - } - - @Test - fun `empty string chunks do not set first token before real content`() = runBlocking { - val fake = - FakeAiService { _ -> - stream { emit(""); emit("x") } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - // 空字符串不记首 token,首个非空 chunk 才记 - assertNotNull(event.firstTokenAtMs) - } - - @Test - fun `test connection records connection test events with result status`() = runBlocking { - val ok = FakeAiService(testConnectionResult = Result.success("ok")) - assertEquals(true, tracked(ok).testConnection(context).isSuccess) - val okEvent = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatCategory.CONNECTION_TEST.name, okEvent.category) - assertEquals(TokenStatStatus.COMPLETED.name, okEvent.status) - - val bad = - FakeAiService(testConnectionResult = Result.failure(IOException("denied"))) - assertEquals(false, tracked(bad).testConnection(context).isSuccess) - val badEvent = database.tokenStatsDao().getAllEvents()[1] - assertEquals(TokenStatCategory.CONNECTION_TEST.name, badEvent.category) - assertEquals(TokenStatStatus.FAILED.name, badEvent.status) - } - - @Test - fun `test connection forwards provider usage into connection test event`() = runBlocking { - val fake = FakeAiService(testConnectionResult = Result.success("ok")) - fake.testConnectionUsage = usage() - assertEquals(true, tracked(fake).testConnection(context).isSuccess) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatCategory.CONNECTION_TEST.name, event.category) - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - // 探测调用拿到的 provider usage 必须进入 CONNECTION_TEST 事件 - assertEquals(800L, event.uncachedInputTokens) - assertEquals(500L, event.outputTokens) - } - - @Test - fun `test connection propagates cancellation and records cancelled`() = runBlocking { - val fake = FakeAiService() - fake.testConnectionCancellation = CancellationException("test cancelled") - try { - tracked(fake).testConnection(context) - fail("cancellation must propagate") - } catch (e: CancellationException) { - assertEquals("test cancelled", e.message) - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatCategory.CONNECTION_TEST.name, event.category) - assertEquals(TokenStatStatus.CANCELLED.name, event.status) - } - - @Test - fun `revision stream interface is preserved for downstream rollback handling`() = runBlocking { - val fake = FakeAiService(eventCarrier = true) - val result = tracked(fake).sendMessage(context = context) - assertTrue("tracked stream must stay a revision carrier", result is TextStreamEventCarrier) - val collected = StringBuilder() - result.collect { collected.append(it) } - assertEquals("hello", collected.toString()) - // 保存点/回滚事件通道仍然可访问(replayCache 是 SharedStream 的公开只读面) - assertNotNull((result as TextStreamEventCarrier).eventChannel.replayCache) - val events = database.tokenStatsDao().getAllEvents() - assertEquals(1, events.size) - } - - @Test - fun `generation read failure degrades tracking and still invokes the model`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - var invoked = false - val fake = - FakeAiService { onUsage -> - invoked = true - stream { - emit("still delivered") - onUsage?.invoke(usage(), 1) - } - } - // P1-2:统计数据库/身份不可用时降级跟踪——请求必须照常开始与完成 - TokenStatsLedger.databaseProvider = { throw IOException("generation unavailable") } - try { - val collected = StringBuilder() - tracked(fake).sendMessage(context = context).collect { collected.append(it) } - assertEquals("still delivered", collected.toString()) - } finally { - TokenStatsLedger.databaseProvider = { database } - } - assertTrue("model must be invoked despite statistics degradation", invoked) - // 降级上下文的事件经 spool 保留:排空重试在数据库恢复后落账(身份 INSERT IGNORE 补齐) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) - while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { - delay(100) - } - assertEquals(1, database.tokenStatsDao().countEvents()) - val event = database.tokenStatsDao().getAllEvents().single() - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(800L, event.uncachedInputTokens) - } - } - - @Test - fun `first request identity cannot bypass group deletion tombstone`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val dao = database.tokenStatsDao() - // cfg-a 已在默认展示组 deepseek-chat(FakeAiService 的 provider:model) - val identityA = TokenStatIdentityResolver.identityId("cfg-a", "DEEPSEEK", "deepseek-chat") - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityA, - configId = "cfg-a", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - ) - // cfg-b 首次请求:sendMessage 的接受边界原子创建身份并捕获 generation 0 - val fake = - FakeAiService { onUsage -> - stream { emit("hello"); onUsage?.invoke(usage(), 1) } - } - val stream = tracked(fake, configId = "cfg-b").sendMessage(context = context) - - // 请求进行中删除默认展示组:成员解析必须看见边界已创建的身份并写 tombstone - dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = false) - assertEquals(1L, dao.currentResetGeneration()) - - stream.collect { } - // 事件接受于删除前:排空被 IDENTITY tombstone 跳过,绝不复活 - assertEquals("old event must not resurrect", 0, dao.countEvents()) - assertNotNull( - "identity must exist (created atomically at the request boundary)", - dao.getIdentityByTriple("cfg-b", "DEEPSEEK", "deepseek-chat"), - ) - } - } - - @Test - fun `request after group deletion records normally with newer generation`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val dao = database.tokenStatsDao() - val identityA = TokenStatIdentityResolver.identityId("cfg-a", "DEEPSEEK", "deepseek-chat") - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityA, - configId = "cfg-a", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - ) - dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = false) - assertEquals(1L, dao.currentResetGeneration()) - - // 删除后的新请求:边界捕获 ≥ tombstone 的新 generation - val fake = - FakeAiService { onUsage -> - stream { emit("hello"); onUsage?.invoke(usage(), 1) } - } - val stream = tracked(fake, configId = "cfg-b").sendMessage(context = context) - stream.collect { } - - val events = dao.getAllEvents() - assertEquals(1, events.size) - val event = events.single() - assertEquals( - TokenStatIdentityResolver.identityId("cfg-b", "DEEPSEEK", "deepseek-chat"), - event.statIdentityId, - ) - assertEquals("post-deletion request must carry the new generation", 1L, event.acceptedGeneration) - } - } - - - @Test - fun `claude same attempt incremental usage keeps full snapshot and cost`() = runBlocking { - database.tokenStatsDao().upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 3.0, - outputPricePerMillion = 5.0, - ) - val fake = - FakeAiService { onUsage -> - stream { - emit("answer") - // Anthropic 流式:message_start 携带完整 input/cache/cacheWrite - onUsage?.invoke( - ProviderUsageNormalizer.anthropic( - JSONObject( - """ - {"input_tokens": 100, "cache_read_input_tokens": 50, - "cache_creation_input_tokens": 10, "output_tokens": 0} - """.trimIndent() - ) - )!!, - 1, - ) - // message_delta 只携带累计 output:同 attempt 按最新非空字段合并, - // 不得覆盖掉 input/cache(output 等累计字段取最新值,不相加) - onUsage?.invoke( - ProviderUsageNormalizer.anthropic( - JSONObject("""{"output_tokens": 300}""") - )!!, - 1, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(100L, event.uncachedInputTokens) - assertEquals(50L, event.cachedInputTokens) - assertEquals(10L, event.cacheWriteTokens) - assertEquals(300L, event.outputTokens) - // 费用完整:100*2 + 50*0.5 + 10*3 + 300*5 = 1755(每百万) - assertEquals(1755.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) - } - - @Test - fun `complete snapshot revocation overwrites previously known fields`() = runBlocking { - // 评审 P2-2:完整快照(completeSnapshot=true)的 null = 明确未知, - // 必须覆盖旧值(撤销),协议因此可表达“省略”与“明确未知”的区别 - val fake = - FakeAiService { onUsage -> - stream { - emit("a") - onUsage?.invoke( - usage().copy(completeSnapshot = true), - 1, - ) - // 完整快照撤销 output(后续消息确认该分量未知) - onUsage?.invoke( - usage().copy( - uncachedInputTokens = 800L, - cachedInputTokens = 200L, - outputTokens = null, - completeSnapshot = true, - ), - 1, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(800L, event.uncachedInputTokens) - assertNull("complete snapshot null must revoke output", event.outputTokens) - } - - @Test - fun `partial update omission keeps previously known fields`() = runBlocking { - // 评审 P2-2:部分更新(completeSnapshot=false)省略字段保留旧值 - val fake = - FakeAiService { onUsage -> - stream { - emit("a") - onUsage?.invoke( - usage().copy(completeSnapshot = false), - 1, - ) - // 部分更新只带 output,input 省略必须保留 - onUsage?.invoke( - usage().copy( - uncachedInputTokens = null, - cachedInputTokens = null, - outputTokens = 777L, - completeSnapshot = false, - ), - 1, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(800L, event.uncachedInputTokens) - assertEquals(200L, event.cachedInputTokens) - assertEquals(777L, event.outputTokens) - } - - @Test - fun `attempt aggregation never overflows int`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - // 两个 attempt 各 Int.MAX_VALUE:Int 加法必溢出为负, - // Long 聚合必须得到正确的 4294967294 - onUsage?.invoke( - usage().copy( - uncachedInputTokens = Int.MAX_VALUE.toLong(), - outputTokens = 100L, - ), - 1, - ) - onUsage?.invoke( - usage().copy( - uncachedInputTokens = Int.MAX_VALUE.toLong(), - outputTokens = 200L, - ), - 2, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals("no int overflow", 4294967294L, event.uncachedInputTokens) - assertEquals(300L, event.outputTokens) - } - - @Test - fun `negative provider component is rejected as unknown not silently recorded`() = - runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - onUsage?.invoke( - usage().copy(uncachedInputTokens = 500L, outputTokens = 100L), - 1, - ) - // 负值分量(异常 provider 数据)必须拒绝为未知 - onUsage?.invoke( - usage().copy(uncachedInputTokens = -5L, outputTokens = 300L), - 2, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - // attempt2 的 uncached 为负被拒绝为未知 → 该分量整体未知, - // 绝不静默落负数或把未知当作 0 - assertNull("negative must not be recorded", event.uncachedInputTokens) - // 其他分量不受影响:100+300=400 - assertEquals(400L, event.outputTokens) - } - - @Test - fun `cancel streaming only ends with cancelled event preserved usage and propagation`() = - runBlocking { - val cancelled = java.util.concurrent.atomic.AtomicBoolean(false) - val fake = - FakeAiService { onUsage -> - stream { - emit("partial") - onUsage?.invoke(usage(), 1) - // 模拟本地 provider 修复后的契约:cancelStreaming(isCancelled) - // 让 native 停止后,以 UserCancellationException 结束流, - // 不取消 collector Job、不 emit 错误文本 - while (!cancelled.get()) { - delay(10) - } - throw UserCancellationException("cancelled by user") - } - } - fake.cancelHandler = { cancelled.set(true) } - var propagated: CancellationException? = null - val job = - launch { - try { - tracked(fake).sendMessage(context = context).collect { } - fail("cancellation must propagate") - } catch (e: CancellationException) { - propagated = e - throw e - } - } - delay(100) // 等待流开始并已上报 usage - // 只调用 cancelStreaming,不取消 collector Job - tracked(fake).cancelStreaming() - job.join() - assertNotNull("cancelStreaming must end the stream with cancellation", propagated) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.CANCELLED.name, event.status) - // 取消前已实测的 usage 必须保留 - assertEquals(800L, event.uncachedInputTokens) - assertEquals(500L, event.outputTokens) - } - - @Test - fun `test connection forwards external usage callback`() = runBlocking { - val fake = FakeAiService(testConnectionResult = Result.success("ok")) - fake.testConnectionUsage = usage() - val forwarded = mutableListOf>() - tracked(fake).testConnection(context) { u, attempt -> forwarded.add(u to attempt) } - assertEquals(1, forwarded.size) - assertEquals(1, forwarded[0].second) - assertEquals(800L, forwarded[0].first.uncachedInputTokens) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(800L, event.uncachedInputTokens) - } - - @Test - fun `test connection failure result classifies timeout through cause chain`() = runBlocking { - val fake = - FakeAiService( - testConnectionResult = - Result.failure( - IOException("exhausted", SocketTimeoutException("connect timed out")) - ) - ) - assertEquals(false, tracked(fake).testConnection(context).isSuccess) - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.TIMEOUT.name, event.status) - } - - @Test - fun `test connection thrown exception classifies timeout and plain failure`() = runBlocking { - val timeout = FakeAiService() - timeout.testConnectionThrowable = IOException("wrapped", SocketTimeoutException("t")) - assertEquals(false, tracked(timeout).testConnection(context).isSuccess) - assertEquals( - TokenStatStatus.TIMEOUT.name, - database.tokenStatsDao().getAllEvents()[0].status, - ) - - val plain = FakeAiService() - plain.testConnectionThrowable = IOException("denied") - assertEquals(false, tracked(plain).testConnection(context).isSuccess) - assertEquals( - TokenStatStatus.FAILED.name, - database.tokenStatsDao().getAllEvents()[1].status, - ) - } - - @Test - fun `user cancellation with interrupted io cause stays cancelled and interrupt is not timeout`() = - runBlocking { - val fake = - FakeAiService { _ -> - stream { - throw UserCancellationException( - "user cancelled", - InterruptedIOException("Interrupted"), - ) - } - } - try { - tracked(fake).sendMessage(context = context).collect { } - fail("cancellation must propagate") - } catch (e: CancellationException) { - // expected - } - assertEquals( - TokenStatStatus.CANCELLED.name, - database.tokenStatsDao().getAllEvents()[0].status, - ) - - // 线程中断等普通 InterruptedIOException 不是超时 - val interrupted = - FakeAiService { _ -> - stream { throw IOException("interrupted", InterruptedIOException("Interrupted")) } - } - try { - tracked(interrupted).sendMessage(context = context).collect { } - fail("must throw") - } catch (e: IOException) { - // expected - } - assertEquals( - TokenStatStatus.FAILED.name, - database.tokenStatsDao().getAllEvents()[1].status, - ) - - // OkHttp 整调用超时(消息明确 "timeout")仍识别为超时 - val okhttp = - FakeAiService { _ -> - stream { throw IOException("call timeout", InterruptedIOException("timeout")) } - } - try { - tracked(okhttp).sendMessage(context = context).collect { } - fail("must throw") - } catch (e: IOException) { - // expected - } - assertEquals( - TokenStatStatus.TIMEOUT.name, - database.tokenStatsDao().getAllEvents()[2].status, - ) - } - - @Test - fun `caller usage observer non-cancel exception is isolated from request and ledger`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val fake = - FakeAiService { onUsage -> - stream { - emit("still delivered") - onUsage?.invoke(usage(), 1) - } - } - val collected = StringBuilder() - tracked(fake) - .sendMessage( - context = context, - onUsageReported = { _, _ -> throw IllegalStateException("observer bug") }, - ) - .collect { collected.append(it) } - assertEquals("still delivered", collected.toString()) - // 调用者异常不得改变账本/请求结果 - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(800L, event.uncachedInputTokens) - assertEquals(500L, event.outputTokens) - } - } - - @Test - fun `caller usage observer cancellation propagates as request cancellation`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("partial") - onUsage?.invoke(usage(), 1) - } - } - try { - tracked(fake) - .sendMessage( - context = context, - onUsageReported = { _, _ -> throw CancellationException("observer cancel") }, - ) - .collect { } - fail("observer cancellation must propagate") - } catch (e: CancellationException) { - assertEquals("observer cancel", e.message) - } - val event = database.tokenStatsDao().getAllEvents()[0] - assertEquals(TokenStatStatus.CANCELLED.name, event.status) - // 取消前已记录的 usage 仍完整落账 - assertEquals(800L, event.uncachedInputTokens) - } - - @Test - fun `blocked writer never loses events and all are eventually written`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs - TokenTrackingAIService.recordTimeoutMs = 100 - val previousInsertTimeout = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 500 - val previousPrepareTimeout = TokenStatSpool.prepareTimeoutMs - TokenStatSpool.prepareTimeoutMs = 100 - // 可控阻塞:数据库访问是纯同步等待(runBlocking),withTimeout 无法 - // 抢占——必须由独立 resolver/writer + 持久 spool 隔离,业务只做有界等待 - val blocker = CompletableDeferred() - try { - TokenStatsLedger.databaseProvider = { - runBlocking { blocker.await() } - database - } - val startedAt = System.nanoTime() - repeat(20) { index -> - val request = - TokenStatRequestContext( - eventId = "evt-blocked-$index", - category = TokenStatCategory.OTHER, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.finish(TokenStatStatus.COMPLETED) - TokenTrackingAIService.recordSafely(context, request) - } - val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 - // 每次调用都在有界时间内返回(等待窗口 100ms),业务不被数据库阻塞 - assertTrue("bounded per-call: $elapsedMs ms", elapsedMs < 10_000) - // 未解除阻塞前:任何事件都不应已落账(resolver 全部被阻塞) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 解除阻塞:全部事件必须最终写入,一个都不能丢(评审 P1/P1-4) - blocker.complete(Unit) - TokenStatsLedger.databaseProvider = { database } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20) - while (database.tokenStatsDao().countEvents() < 20 && System.nanoTime() < deadline) { - delay(50) - } - assertEquals("all 20 events must be recorded", 20, database.tokenStatsDao().countEvents()) - val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() - assertEquals("no event may be dropped or duplicated", 20, ids.size) - assertTrue(ids.containsAll((0 until 20).map { "evt-blocked-$it" })) - // 排空完成后 spool 必须为空 - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - if (spoolDir.isDirectory) { - val remaining = - spoolDir.listFiles().orEmpty().filter { it.isFile } - assertEquals("spool must be drained", 0, remaining.size) - } - } finally { - TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout - TokenStatSpool.insertTimeoutMs = previousInsertTimeout - TokenStatSpool.prepareTimeoutMs = previousPrepareTimeout - TokenStatsLedger.databaseProvider = { database } - } - } - } - - @Test - fun `task failure is isolated and subsequent events keep recording`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs - TokenTrackingAIService.recordTimeoutMs = 500 - try { - // 让落账失败(数据库不可用):事件保留在 spool,业务不受影响 - TokenStatsLedger.databaseProvider = { throw RuntimeException("db unavailable") } - val first = - TokenStatRequestContext( - eventId = "evt-fail-1", - category = TokenStatCategory.OTHER, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), - ) - first.finish(TokenStatStatus.COMPLETED) - TokenTrackingAIService.recordSafely(context, first) - - // 恢复数据库:后续事件触发排空,失败事件一并重放(IGNORE 幂等) - TokenStatsLedger.databaseProvider = { database } - val second = - TokenStatRequestContext( - eventId = "evt-fail-2", - category = TokenStatCategory.OTHER, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), - ) - second.finish(TokenStatStatus.COMPLETED) - TokenTrackingAIService.recordSafely(context, second) - - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getAllEvents().size < 2 && System.nanoTime() < deadline) { - delay(50) - } - val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() - // 失败任务不阻断后续;失败事件在数据库恢复后成功重放 - assertEquals(setOf("evt-fail-1", "evt-fail-2"), ids) - // P2-4:等待结束/超时后 waiter 引用必须全部解除(大量失败不累积) - assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) - } finally { - TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout - TokenStatsLedger.databaseProvider = { database } - } - } - } - - @Test - fun `spool survives process death and replays on next process start`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousInsertTimeout = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 200 - try { - // 模拟上一进程写入的 spool:直接构造持久化 v2 行(含发生时价格 - // 快照,等价于 enqueue 落盘内容;P1-1:重放只用快照) - val previousProcess = TokenStatRequestContext( - eventId = "evt-old-process-1", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - previousProcess.onUsage( - com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( - uncachedInputTokens = 300L, - cachedInputTokens = 100L, - outputTokens = 50L, - reasoningIncludedInOutput = true, - source = "test", - ), - 1, - ) - previousProcess.finish(TokenStatStatus.COMPLETED) - val line = TokenStatsLedger.prepareEventLine(context, previousProcess, previousProcess.toSpoolBaseJson()) - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") - - // “重启”:清空本进程内存状态,不依赖新 append 触发恢复 - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-old-process-1") == null && - System.nanoTime() < deadline - ) { - delay(50) - } - val event = database.tokenStatsDao().getEvent("evt-old-process-1") - assertNotNull("stale spool must be replayed after process restart", event) - assertEquals(300L, event!!.uncachedInputTokens) - assertEquals(50L, event.outputTokens) - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - // 价格快照来自行内冻结(无覆盖时内置默认价) - assertEquals(PricingSource.DEFAULT.name, event.pricingSource) - } finally { - TokenStatSpool.insertTimeoutMs = previousInsertTimeout - } - } - } - - @Test - fun `shutdown writer self heals and keeps recording`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // 关闭默认 writer(模拟执行器生命周期结束),下一次落账必须自愈重建 - TokenStatSpool.shutdownWriterForTest() - val fake = FakeAiService { _ -> streamOf("still works") } - tracked(fake).sendMessage(context = context).collect { } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getAllEvents().isEmpty() && System.nanoTime() < deadline) { - delay(50) - } - assertEquals(1, database.tokenStatsDao().getAllEvents().size) - } - } - - @Test - fun `hanging record write is bounded and never blocks completion`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs - TokenTrackingAIService.recordTimeoutMs = 300 - val previousInsertTimeout = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 100 - val previousPrepareTimeout = TokenStatSpool.prepareTimeoutMs - TokenStatSpool.prepareTimeoutMs = 100 - try { - // 让价格解析(legacy 价格读取)永久挂起(可释放):suspend 卡死由 - // prepareTimeout 截断 → 回退默认价快照 → 事件仍持久落账(不丢); - // 业务线程只做有界等待,绝不被阻塞 - val release = CountDownLatch(1) - TokenStatsLedger.legacyPriceProvider = { _, _ -> - gateIgnoringInterrupts(release) - null - } - val fake = FakeAiService { _ -> streamOf("still delivered") } - val collected = StringBuilder() - val done = - withTimeoutOrNull(5_000) { - tracked(fake).sendMessage(context = context).collect { - collected.append(it) - } - true - } - assertNotNull("record hang must not block completion", done) - assertEquals("still delivered", collected.toString()) - // 价格解析挂起被截断后:事件带默认价快照持久化并最终落账,不丢失 - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getAllEvents().isEmpty() && - System.nanoTime() < deadline - ) { - delay(50) - } - assertEquals(1, database.tokenStatsDao().getAllEvents().size) - - // 释放挂起的定价 worker 并确认其真实终止,绝不遗留线程 - release.countDown() - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - } finally { - TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout - TokenStatSpool.insertTimeoutMs = previousInsertTimeout - TokenStatSpool.prepareTimeoutMs = previousPrepareTimeout - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - } - } - } - - // ==== P1-3:统计收尾绝不执行同步文件 I/O / Future.get() 在调用方 Main 线程 ==== - - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - @Test - fun `statistics finalization never performs sync io on the caller main thread`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // 模拟 UI 入口:rememberCoroutineScope(Main)→ 连接测试 / 收集统计包装流。 - // recordSafely 进入 IO 上下文后触发探针记录实际执行线程——收尾的 - // FutureTask.get() 与 append 的 FileOutputStream+fd.sync() 绝不允许 - // 跑在 Main 上(ANR 风险,P1-3)。 - val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } - Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) - val finalizationThreads = ConcurrentHashMap.newKeySet() - TokenTrackingAIService.recordIoThreadProbeForTest = { - finalizationThreads += Thread.currentThread().name - } - try { - withContext(Dispatchers.Main) { - // 成功流收尾(recordSafely) - tracked(FakeAiService()).sendMessage(context = context).collect { } - // 模型失败流的收尾(persistAndCapture,同样 fail-open 且跑在 IO) - try { - tracked(FakeAiService { _ -> stream { throw IOException("model failed") } }) - .sendMessage(context = context).collect { } - } catch (_: IOException) { - // expected - } - // 连接测试(ModelConfigScreen 的 Main scope 入口形态) - tracked(FakeAiService(testConnectionResult = Result.success("ok"))) - .testConnection(context) - } - assertTrue( - "statistics finalization must actually dispatch to IO", - finalizationThreads.isNotEmpty(), - ) - assertFalse( - "statistics finalization must never run sync io on the main thread: $finalizationThreads", - finalizationThreads.any { it == "test-main-thread" }, - ) - } finally { - TokenTrackingAIService.recordIoThreadProbeForTest = null - Dispatchers.resetMain() - mainExecutor.shutdown() - } - } - } - - // ==== P1-1:发生时价格快照 ==== - - @Test - fun `price snapshot is frozen at append and replay never re-reads current prices`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val dao = database.tokenStatsDao() - // 价格 A - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 5.0, - ) - val request = - TokenStatRequestContext( - eventId = "evt-frozen-price", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis() - 1_000, - ) - request.onUsage(usage().copy(cacheWriteTokens = 0L), 1) - request.finish(TokenStatStatus.COMPLETED) - // 请求收尾:解析并冻结价 A(durable append 前) - val line = - TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) - - // writer 阻塞/重启期间用户改价为 B - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 9.0, - cachedInputPricePerMillion = 8.0, - outputPricePerMillion = 7.0, - ) - - // 重放:只用行内快照,绝不重读当前价格 - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-frozen-price") == null && - System.nanoTime() < deadline - ) { - delay(50) - } - val event = database.tokenStatsDao().getEvent("evt-frozen-price")!! - // 历史仍 A(发生时快照) - assertEquals(2.0, event.inputPricePerMillion!!, 1e-12) - assertEquals(0.5, event.cachedInputPricePerMillion!!, 1e-12) - assertEquals(5.0, event.outputPricePerMillion!!, 1e-12) - assertEquals(PricingSource.CONFIG_OVERRIDE.name, event.pricingSource) - // 800*2 + 200*0.5 + 500*5 = 4200(每百万) - assertEquals(4200.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) - - // 新请求(当前重估路径)用改价后的 B - val current = - TokenStatRequestContext( - eventId = "evt-current-price", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - current.onUsage(usage().copy(cacheWriteTokens = 0L), 1) - current.finish(TokenStatStatus.COMPLETED) - TokenStatsLedger.record(context, current) - val currentEvent = database.tokenStatsDao().getEvent("evt-current-price")!! - assertEquals(9.0, currentEvent.inputPricePerMillion!!, 1e-12) - } - } - - // ==== P1-2:失败段退避重试(不依赖新请求) ==== - - @Test - fun `failed drain retries with backoff without any new request`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousInsertTimeout = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 200 - try { - // 预置一个事件到 spool(模拟冷启动遗留的失败段) - val request = - TokenStatRequestContext( - eventId = "evt-retry-1", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.onUsage(usage(), 1) - request.finish(TokenStatStatus.COMPLETED) - val line = - TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") - - // 数据库故障:replay 后落账失败 - TokenStatsLedger.databaseProvider = { throw RuntimeException("db down") } - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - delay(300) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复数据库:**不产生任何新请求**,退避定时重试必须自行恢复 - TokenStatsLedger.databaseProvider = { database } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) - while (database.tokenStatsDao().getEvent("evt-retry-1") == null && - System.nanoTime() < deadline - ) { - delay(100) - } - assertNotNull("backoff retry must recover without new requests", database.tokenStatsDao().getEvent("evt-retry-1")) - assertEquals(1, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.insertTimeoutMs = previousInsertTimeout - TokenStatsLedger.databaseProvider = { database } - } - } - } - - // ==== P1-3:reset 与 spool 一致性(durable tombstone) ==== - - @Test - fun `full reset tombstone prevents spool resurrection`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // 预置“已接受但未入 Room”的事件:直接写 sealed 段(startedAtMs 在 reset 前) - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - val pre = - TokenStatRequestContext( - eventId = "evt-pre-reset", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis() - 60_000, - ) - pre.onUsage(usage(), 1) - pre.finish(TokenStatStatus.COMPLETED) - val line = TokenStatsLedger.prepareEventLine(context, pre, pre.toSpoolBaseJson()) - File(spoolDir, "sealed_1.jsonl").writeText(line + "\n") - - TokenStatsResetCoordinator.daoProvider = { database.tokenStatsDao() } - try { - // 全量重置:tombstone 与删除同事务;随后排空丢弃被覆盖的行 - TokenStatsResetCoordinator.resetAllStatistics(context) - delay(500) - assertEquals( - "pre-reset spool event must never resurrect", - 0, - database.tokenStatsDao().countEvents(), - ) - // reset 后的新请求正常记录(tombstone 只覆盖 reset 前开始的事件) - val post = - TokenStatRequestContext( - eventId = "evt-post-reset", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - acceptedGeneration = database.tokenStatsDao().currentResetGeneration(), - ) - post.onUsage(usage(), 1) - post.finish(TokenStatStatus.COMPLETED) - TokenTrackingAIService.recordSafely(context, post) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-post-reset") == null && - System.nanoTime() < deadline - ) { - delay(50) - } - assertEquals(1, database.tokenStatsDao().countEvents()) - assertNotNull(database.tokenStatsDao().getEvent("evt-post-reset")) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - - @Test - fun `model reset only clears matching identity and keeps other model spool events`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - val deep = - TokenStatRequestContext( - eventId = "evt-pre-deep", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis() - 60_000, - ) - deep.onUsage(usage(), 1) - deep.finish(TokenStatStatus.COMPLETED) - val open = - TokenStatRequestContext( - eventId = "evt-pre-open", - category = TokenStatCategory.CHAT, - configId = "cfg-2", - provider = "OPENAI", - model = "gpt-4o", - startedAtMs = System.currentTimeMillis() - 60_000, - ) - open.onUsage(usage(), 1) - open.finish(TokenStatStatus.COMPLETED) - File(spoolDir, "sealed_1.jsonl").writeText( - TokenStatsLedger.prepareEventLine(context, deep, deep.toSpoolBaseJson()) + "\n" + - TokenStatsLedger.prepareEventLine(context, open, open.toSpoolBaseJson()) + "\n" - ) - - TokenStatsResetCoordinator.daoProvider = { database.tokenStatsDao() } - try { - TokenStatsResetCoordinator.resetStatisticsForProviderModel( - context, - "DEEPSEEK:deepseek-chat", - ) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-pre-open") == null && - System.nanoTime() < deadline - ) { - delay(50) - } - // 被模型 tombstone 覆盖:不复活;其他模型保留 - assertNull(database.tokenStatsDao().getEvent("evt-pre-deep")) - assertNotNull(database.tokenStatsDao().getEvent("evt-pre-open")) - assertEquals(1, database.tokenStatsDao().countEvents()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - - // ==== P1-4:append 故障不伪装 durable(fail-open:只记日志,绝不影响业务) ==== - - @Test - fun `append failure returns lost without pretending durability and recovers cleanly`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val previousRecordTimeout = TokenTrackingAIService.recordTimeoutMs - TokenTrackingAIService.recordTimeoutMs = 1_000 - try { - // 让 spool 目录不可创建:filesDir 下同名文件占位 - val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolPath.writeText("i am a file, not a directory") - var lost = 0 - (0 until 5).forEach { index -> - val request = - TokenStatRequestContext( - eventId = "evt-emergency-$index", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.onUsage(usage(), 1) - request.finish(TokenStatStatus.COMPLETED) - // P1-2:统计收尾 fail-open——append 失败明确返回 LOST,不再抛出 - if (TokenTrackingAIService.recordSafely(context, request) == - RecordOutcome.LOST - ) { - lost++ - } - } - // 全部明确 LOST;无内存队列冒充 durable 副本,绝无伪落账 - assertEquals(5, lost) - assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // P2-4:deferred 事件不登记 waiter(latch 已直接完成) - assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) - - // 修复磁盘后,失败调用不会凭空出现未确认事件 - spoolPath.delete() - TokenStatSpool.replay(context) - delay(100) - assertEquals(0, database.tokenStatsDao().countEvents()) - assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) - } finally { - TokenTrackingAIService.recordTimeoutMs = previousRecordTimeout - } - } - } - - @Test - fun `append failure does not fail a successful model result and stays suppressed on model failure`() = - runBlocking { - val spoolPath = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolPath.writeText("not a directory") - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // P1-2:成功流收集正常完成——统计收尾失败不得改写成功的模型结果 - val collected = StringBuilder() - tracked(FakeAiService()).sendMessage(context = context).collect { collected.append(it) } - assertEquals("hello", collected.toString()) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 模型失败路径保持 fail-open:原始模型异常为主异常,统计失败为 suppressed - val modelFailure = IOException("model failed") - val failing = FakeAiService { _ -> stream { throw modelFailure } } - try { - tracked(failing).sendMessage(context = context).collect { } - fail("model failure must propagate") - } catch (e: IOException) { - assertTrue("original model exception stays primary", e === modelFailure) - assertEquals(1, e.suppressed.size) - assertTrue(e.suppressed[0] is TokenStatsPersistenceException) - } - } - } - - // ==== P2-2:损坏行整段隔离(保留证据) ==== - - @Test - fun `corrupt line quarantines the whole segment with evidence and does not re-block`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - val valid1 = - TokenStatRequestContext( - eventId = "evt-q-1", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - valid1.onUsage(usage(), 1) - valid1.finish(TokenStatStatus.COMPLETED) - val valid2 = - TokenStatRequestContext( - eventId = "evt-q-2", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - valid2.onUsage(usage(), 1) - valid2.finish(TokenStatStatus.COMPLETED) - val segment = File(spoolDir, "sealed_1.jsonl") - segment.writeText( - TokenStatsLedger.prepareEventLine(context, valid1, valid1.toSpoolBaseJson()) + - "\n{corrupt raw evidence line\n" + - TokenStatsLedger.prepareEventLine(context, valid2, valid2.toSpoolBaseJson()) + "\n" - ) - - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-q-2") == null && - System.nanoTime() < deadline - ) { - delay(50) - } - // 有效行全部落账 - assertEquals(2, database.tokenStatsDao().countEvents()) - // 证据保留:quarantine 文件存在且含损坏原文;原段已移除 - val quarantined = - spoolDir.listFiles().orEmpty().single { it.name.startsWith("quarantine_") } - assertTrue("quarantine evidence must exist", quarantined.isFile) - assertTrue(quarantined.readText().contains("corrupt raw evidence line")) - assertFalse("original segment must be gone", segment.exists()) - - // 再次 replay:quarantine 被忽略,不重复插入、不重新阻塞 - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - delay(300) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertTrue(quarantined.isFile) - } - } - - // ==== P2-3:段删除失败 → 视为未完成,退避重试 ==== - - @Test - fun `segment delete failure keeps segment and backs off until recovery`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - val request = - TokenStatRequestContext( - eventId = "evt-del-1", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - request.onUsage(usage(), 1) - request.finish(TokenStatStatus.COMPLETED) - val segment = File(spoolDir, "sealed_1.jsonl") - segment.writeText( - TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) + "\n" - ) - // 强制段删除失败(确定性):事件已插入(IGNORE 幂等),但段保留 - val deleteAllowed = java.util.concurrent.atomic.AtomicBoolean(false) - TokenStatSpool.segmentDeleteForTest = { seg -> - if (deleteAllowed.get()) seg.delete() else false - } - try { - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.replay(context) - val insertedDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent("evt-del-1") == null && - System.nanoTime() < insertedDeadline - ) { - delay(50) - } - // 事件已插入,但段删除失败 → 段保留,进入退避(不忙循环) - assertTrue("segment must survive delete failure", segment.exists()) - - // 恢复删除能力:退避重试最终删除段,不重复插入 - deleteAllowed.set(true) - val deleteDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15) - while (segment.exists() && System.nanoTime() < deleteDeadline) { - delay(100) - } - assertFalse("segment must be removed after delete recovery", segment.exists()) - assertEquals(1, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.segmentDeleteForTest = null - } - } - } - - // ==== P1-5:显式全零 usage 也是已观察到的 usage ==== - - @Test - fun `explicit zero usage payload records zero fields with usageObserved true`() = runBlocking { - val fake = - FakeAiService { onUsage -> - stream { - emit("answer") - onUsage?.invoke( - com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( - uncachedInputTokens = 0L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - totalInputTokens = 0L, - outputTokens = 0L, - reasoningTokens = 0L, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - source = "test", - ), - 1, - ) - } - } - tracked(fake).sendMessage(context = context).collect { } - val event = database.tokenStatsDao().getAllEvents()[0] - // 字段存在且为 0L:真实 0,不是未知 - assertEquals(0L, event.uncachedInputTokens) - assertEquals(0L, event.cachedInputTokens) - assertEquals(0L, event.outputTokens) - assertEquals(0L, event.reasoningTokens) - assertTrue( - "zero payload is still observed usage", - event.diagnosticsJson!!.contains("\"usageObserved\":true"), - ) - } - - // ==== P1 终审:恢复屏障对 in-flight provider/stream 请求的 request/session fencing ==== - - @Test - fun `restore barrier drops in-flight statistics without failing the model and rejects same-process requests until restart`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // 真实 TokenTracking + fake provider:请求停在 provider 流阶段(未收尾)时 - // 执行完整 restore(block 模拟恢复替换数据库 + clearAfter 删除旧 spool)→ - // 旧请求释放后收尾 append 被请求 fence 明确拒绝——P1-2:统计收尾失败不得 - // 改写成功的模型结果(事件丢弃并记日志,绝不写新 DB);同进程新请求 fail-open - // (统计跳过跟踪、直调 delegate,模型功能不受影响);模拟进程重启(reset - // 状态)后新请求可正常写入。 - val entered = CompletableDeferred() - val release = CompletableDeferred() - val fake = - FakeAiService { _ -> - stream { - emit("partial answer") - entered.complete(Unit) - release.await() - emit("tail") - } - } - var completedSuccessfully = false - var primary: Throwable? = null - val requestJob = - launch { - try { - tracked(fake) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - completedSuccessfully = true - } catch (e: Throwable) { - primary = e - if (e is CancellationException) throw e - } - } - assertTrue( - "request must be paused in the provider stage", - withTimeoutOrNull(10.seconds) { entered.await() } != null, - ) - // 请求进行中执行恢复:epoch 在屏障开始原子递增(旧请求失效),替换开始后 - // 本进程不再接受任何事件(accepting=false,UI“稍后重启”窗口语义) - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - database.tokenStatsDao().deleteAllEvents() - } - release.complete(Unit) - requestJob.join() - assertTrue( - "statistics fence must never fail a successful model result", - completedSuccessfully, - ) - assertNull("fence rejection is logged and dropped, not thrown: $primary", primary) - // 恢复后的 spool/Room 无旧事件:旧请求从未写入(fence 在写 spool 前拒绝) - assertEquals(0, database.tokenStatsDao().countEvents()) - val spoolDir = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME) - assertFalse( - "restored spool must contain no old events", - spoolDir.exists() && - spoolDir.listFiles().orEmpty().any { it.isFile && it.length() > 0L }, - ) - // 同进程新请求(UI"稍后重启"窗口):P1-2 修复后 fail-open——统计跳过跟踪, - // 直调 delegate,模型功能完全不受影响,且不向新 DB 写入任何事件 - val delegated = AtomicInteger(0) - tracked( - FakeAiService { _ -> - stream { - delegated.incrementAndGet() - emit("fail-open answer") - } - }, - ).sendMessage(context = context, statsCategory = TokenStatCategory.CHAT).collect { } - assertEquals( - "restored-process request must reach the delegate", - 1, - delegated.get(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 模拟进程重启:reset 状态后新请求可写 - TokenStatSpool.clearPendingStateForTest() - tracked(FakeAiService()) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { - delay(50) - } - assertEquals(1, database.tokenStatsDao().countEvents()) - } - } - - @Test - fun `restore failure before replacement drops in-flight statistics without failing the model`() = - runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - // 旧请求停在 provider 阶段;restore 在替换前失败(drain 阶段失败——epoch 已 - // 递增但 accepting 保持 true)→ 旧请求释放后收尾被 fence 拒绝——P1-2:统计 - // 收尾失败不得改写成功的模型结果(事件丢弃并记日志);同进程新请求 - // (新 epoch)照常落账——替换前失败可继续。 - val spool = File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val pre = - TokenStatRequestContext( - eventId = "evt-pre-restore-old", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - pre.onUsage(usage(), 1) - pre.finish(TokenStatStatus.COMPLETED) - File(spool, "sealed_1.jsonl").writeText( - TokenStatsLedger.prepareEventLine(context, pre, pre.toSpoolBaseJson()) + "\n", - ) - val entered = CompletableDeferred() - val release = CompletableDeferred() - val fake = - FakeAiService { _ -> - stream { - emit("partial") - entered.complete(Unit) - release.await() - } - } - var completedSuccessfully = false - var primary: Throwable? = null - val requestJob = - launch { - try { - tracked(fake) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - completedSuccessfully = true - } catch (e: Throwable) { - primary = e - if (e is CancellationException) throw e - } - } - assertTrue( - "request must be paused in the provider stage", - withTimeoutOrNull(10.seconds) { entered.await() } != null, - ) - // 替换前失败:restore barrier 的 drain 阶段失败(段读取故障),block 绝不执行 - TokenStatSpool.segmentReadErrorForTest = { true } - try { - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = true, - clearAfter = true, - ) { - fail("replacement must never run") - } - fail("restore must fail in the drain phase") - } catch (e: IOException) { - assertTrue("restore must report the drain failure", e.message!!.contains("drained")) - } - } finally { - TokenStatSpool.segmentReadErrorForTest = null - } - // 旧请求释放:epoch 不匹配 → fence 拒绝(不写 spool/DB),模型结果不受影响 - release.complete(Unit) - requestJob.join() - assertTrue( - "statistics fence must never fail a successful model result", - completedSuccessfully, - ) - assertNull("fence rejection is logged and dropped, not thrown: $primary", primary) - // 同进程新请求(新 epoch):替换前失败可继续,正常落账;旧 spool 段(restore - // 失败未替换/未清理)一并排空到未被替换的旧 DB - tracked(FakeAiService()) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().countEvents() < 2 && System.nanoTime() < deadline) { - delay(50) - } - // 只有旧 spool 段 + 新请求两个事件:in-flight 旧请求被 fence 丢弃, - // 其事件(第 3 个)绝不出现 - val ids = database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet() - assertEquals(2, ids.size) - assertTrue(ids.contains("evt-pre-restore-old")) - } - } - - // ==== reviewer P1-1/P1-2 修复:统计数据库访问门控(立即拒绝)+ 恢复后 fail-open ==== - - @Test - fun `restore barrier rejects concurrent new request immediately without touching Room and fail-opens to the delegate`() = - runBlocking { - // 审查复现时序:恢复在 prepareBeforeCommit(closeDatabase 后、持久化标记前) - // 暂停;并发新请求被门控**立即拒绝**——不重建 Room、不等待屏障,直调 - // delegate(模型功能不受影响);恢复完成后(accepting=false)继续 fail-open。 - val entered = CompletableDeferred() - val release = CompletableDeferred() - val restoreJob = - launch { - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = { - entered.complete(Unit) - release.await() - }, - commitReplacement = {}, - block = {}, - ) - } - assertTrue( - "restore must be paused inside prepareBeforeCommit", - withTimeoutOrNull(10.seconds) { entered.await() } != null, - ) - val delegated = AtomicInteger(0) - val requestJob = - launch { - tracked( - FakeAiService { _ -> - stream { - delegated.incrementAndGet() - emit("answer") - } - }, - ).sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - } - // 立即拒绝:不等屏障退出,provider 立刻被调用,Room 身份事务绝不执行 - withTimeoutOrNull(10.seconds) { requestJob.join() } - ?: fail("fail-open request must not wait for the barrier to exit") - assertEquals("fail-open request must reach the delegate", 1, delegated.get()) - assertEquals(0, database.tokenStatsDao().countEvents()) - release.complete(Unit) - restoreJob.join() - // 恢复替换完成(accepting=false)后新请求依然 fail-open:直调 delegate 正常返回 - val conn = tracked(FakeAiService(testConnectionResult = Result.success("pong"))) - .testConnection(context) - assertTrue("testConnection must succeed after restore", conn.isSuccess) - assertEquals("pong", conn.getOrNull()) - assertEquals(0, database.tokenStatsDao().countEvents()) - TokenStatSpool.clearPendingStateForTest() - } - - @Test - fun `snapshot barrier rejects identity writes immediately so zip packaging sees a frozen database`() = - runBlocking { - // 审查复现时序:备份在 checkpoint 之后(block 内,ZIP 打包前)暂停;并发请求 - // 的身份事务被门控**立即拒绝**(不进 provider 前不开 Room)——打包期间绝不产生 - // 新 WAL 或身份写入;快照不设 accepting=false:屏障退出后新请求正常落账 - // (事件恰 1 条)。 - // drainBefore=true 的待处理判定 fail-closed:spool 目录需存在(空目录)。 - File(context.filesDir, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val entered = CompletableDeferred() - val release = CompletableDeferred() - val snapshotJob = - launch { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - entered.complete(Unit) - release.await() - } - } - assertTrue( - "snapshot must be paused inside its exclusive block", - withTimeoutOrNull(10.seconds) { entered.await() } != null, - ) - val delegated = AtomicInteger(0) - val requestJob = - launch { - tracked( - FakeAiService { _ -> - stream { - delegated.incrementAndGet() - emit("answer") - } - }, - ) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - } - // 立即拒绝:打包期间请求直调 delegate(不等待、不写身份) - withTimeoutOrNull(10.seconds) { requestJob.join() } - ?: fail("fail-open request must not wait for the snapshot to exit") - assertEquals(1, delegated.get()) - assertEquals(0, database.tokenStatsDao().countEvents()) - release.complete(Unit) - snapshotJob.join() - // 快照后进程仍接受事件:新请求正常落账 - tracked(FakeAiService { _ -> stream { emit("answer") } }) - .sendMessage(context = context, statsCategory = TokenStatCategory.CHAT) - .collect { } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().countEvents() == 0 && System.nanoTime() < deadline) { - delay(50) - } - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `restore barrier rejects request-finalization pricing reads immediately`() = - runBlocking { - // 审查复现时序:请求收尾的价格解析(直接 Room 读取)同样走门控—— - // 屏障排他期间被**立即拒绝**(TokenStatsBarrierActiveException),绝不与 - // 数据库文件替换竞争;屏障退出后正常完成。 - val entered = CompletableDeferred() - val release = CompletableDeferred() - val restoreJob = - launch { - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = { - entered.complete(Unit) - release.await() - }, - commitReplacement = {}, - block = {}, - ) - } - assertTrue( - "restore must be paused inside prepareBeforeCommit", - withTimeoutOrNull(10.seconds) { entered.await() } != null, - ) - val ctx = - TokenStatRequestContext( - eventId = "evt-pricing-gate", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = System.currentTimeMillis(), - ) - var barrierRejected = false - val pricingJob = - launch { - try { - TokenStatsLedger.prepareEventLineDetached(context, ctx) - } catch (e: com.ai.assistance.operit.data.stats.TokenStatsBarrierActiveException) { - barrierRejected = true - } - } - withTimeoutOrNull(10.seconds) { pricingJob.join() } - ?: fail("pricing read must fail immediately while the barrier is active") - assertTrue("pricing read must be rejected while the barrier is active", barrierRejected) - release.complete(Unit) - restoreJob.join() - // 屏障退出后价格解析正常完成 - var pricingResolved = false - launch { - TokenStatsLedger.prepareEventLineDetached(context, ctx) - pricingResolved = true - }.join() - assertTrue("pricing read must complete after the barrier exits", pricingResolved) - TokenStatSpool.clearPendingStateForTest() - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt deleted file mode 100644 index d2d759e0f..000000000 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderServiceTest.kt +++ /dev/null @@ -1,522 +0,0 @@ -package com.ai.assistance.operit.api.chat.llmprovider - -import android.content.Context -import com.ai.assistance.operit.data.model.ModelConfigData -import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistration -import com.ai.assistance.operit.util.stream.StreamLogger -import kotlinx.coroutines.runBlocking -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.mockito.Mockito.mock - -/** - * ToolPkg JS provider usage 协议测试(P1-5 + 评审 P2-1): - * - usage 对象携带 attempt 序号(新协议):同 attempt 流式更新部分合并, - * 不同 attempt 分别入账; - * - 不携带 attempt(旧协议):整个逻辑请求的累计完整快照,后报覆盖先报, - * 绝不猜测 attempt; - * - 真实 hook 层测试:通过 [ToolPkgMainHookRunner] 注入假 runner,驱动 - * sendMessage 的真实编排(intermediate channel、解码、usage 提取、chunk - * 发射、attempt 语义),不只是 JSON parser。 - */ -class ToolPkgJsAiProviderServiceTest { - - private fun service(): ToolPkgJsAiProviderService { - val config = ModelConfigData(id = "cfg-1", name = "cfg-1") - val registration = - ToolPkgAiProviderRegistration( - containerPackageName = "com.example.testpkg", - providerId = "test-provider", - displayName = "Test Provider", - description = "", - listModelsFunctionName = "", - sendMessageFunctionName = "", - testConnectionFunctionName = "", - calculateInputTokensFunctionName = "", - ) - return ToolPkgJsAiProviderService(config, registration) - } - - /** 假 runner:按给定 intermediate/final JSON 驱动真实 hook 编排层。 */ - private fun runnerWith( - intermediates: List, - final: String, - ): ToolPkgMainHookRunner = - ToolPkgMainHookRunner { _, _, _, _, _, _, _, _, onIntermediateResult -> - intermediates.forEach { raw -> - onIntermediateResult?.invoke(raw) - } - Result.success(final) - } - - private class ReportedUsage( - val input: Long, - val output: Long, - val attempt: Int, - val completeSnapshot: Boolean, - ) - - private fun report(usage: com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) = - ReportedUsage( - input = usage.totalInputTokens ?: -1L, - output = usage.outputTokens ?: -1L, - attempt = attempt, - completeSnapshot = usage.completeSnapshot, - ) - - private fun runSendMessage( - svc: ToolPkgJsAiProviderService, - runner: ToolPkgMainHookRunner, - onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, - ): String = runBlocking { - svc.mainHookRunnerOverride = runner - val collected = StringBuilder() - svc.sendMessage( - context = mock(Context::class.java), - onUsageReported = onUsageReported, - ).collect { collected.append(it) } - collected.toString() - } - - /** 期望失败的 sendMessage 运行:返回已收集文本与 collect 传播出的异常。 */ - private fun runSendMessageExpectFailure( - svc: ToolPkgJsAiProviderService, - runner: ToolPkgMainHookRunner, - onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, - ): Pair = runBlocking { - svc.mainHookRunnerOverride = runner - val collected = StringBuilder() - // JVM 测试环境没有可用的 android.util.Log:stream 构建器捕获异常后 - // StreamLogger.e → AppLogger.e → Log.e 会抛 "not mocked" 掩盖原始错误, - // 关闭日志使真实异常原样传播出来 - StreamLogger.setEnabled(false) - val failure = - try { - svc.sendMessage( - context = mock(Context::class.java), - onUsageReported = onUsageReported, - ).collect { collected.append(it) } - null - } catch (e: Throwable) { - e - } finally { - StreamLogger.setEnabled(true) - } - collected.toString() to failure - } - - @Test - fun `usage protocol carries attempt number from usage object`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject( - """ - {"usage": {"input": 100, "cachedInput": 20, "output": 50, "attempt": 2}} - """.trimIndent() - ) - ) - val usage = svc.extractUsage(decoded)!! - assertEquals(2, usage.attempt) - assertTrue("attempt present must be tracked", usage.attemptPresent) - assertEquals(100L, usage.input) - assertEquals(20L, usage.cachedInput) - assertEquals(50L, usage.output) - } - - @Test - fun `attemptNumber alias is supported`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject( - """ - {"usage": {"input": 10, "output": 5, "attemptNumber": 5}} - """.trimIndent() - ) - ) - val usage = svc.extractUsage(decoded)!! - assertEquals(5, usage.attempt) - assertTrue(usage.attemptPresent) - } - - @Test - fun `attempt defaults to 1 when provider omits the field and marks old protocol`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject("""{"usage": {"input": 10, "output": 5}}""") - ) - val usage = svc.extractUsage(decoded)!! - assertEquals(1, usage.attempt) - // 评审 P2-1:缺 attempt 的旧协议上报必须被显式标记,绝不猜测 attempt - assertFalse("attempt absent must not be guessed", usage.attemptPresent) - } - - @Test - fun `attempt is coerced to at least 1`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject("""{"usage": {"input": 10, "output": 5, "attempt": 0}}""") - ) - assertEquals(1, svc.extractUsage(decoded)!!.attempt) - } - - @Test - fun `top-level usage without usage object is accepted`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject("""{"input": 30, "output": 9, "attempt": 3}""") - ) - val usage = svc.extractUsage(decoded)!! - assertEquals(3, usage.attempt) - assertEquals(30L, usage.input) - assertEquals(9L, usage.output) - } - - @Test - fun `no usage fields returns null`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue(JSONObject("""{"chunk": "text"}""")) - assertNull(svc.extractUsage(decoded)) - assertNull(svc.extractUsage(ToolPkgJsAiProviderService.ProviderHookValue.NullValue)) - } - - // ==== 评审 P2-1:账本路径全程 Long,负值拒绝为未知 ==== - - @Test - fun `usage values beyond int range are preserved as longs`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject( - """{"usage": {"input": 5000000000, "cachedInput": 2000000000, "output": 3000000000, "attempt": 1}}""" - ) - ) - val usage = svc.extractUsage(decoded)!! - assertEquals(5_000_000_000L, usage.input) - assertEquals(2_000_000_000L, usage.cachedInput) - assertEquals(3_000_000_000L, usage.output) - } - - @Test - fun `negative usage values are rejected as unknown`() { - val svc = service() - val decoded = - ToolPkgJsAiProviderService.ProviderHookValue.ObjectValue( - JSONObject("""{"usage": {"input": -5, "output": 10}}""") - ) - val usage = svc.extractUsage(decoded)!! - assertNull("negative input must be unknown", usage.input) - assertEquals(10L, usage.output) - } - - // ==== 真实 hook 层(评审 P2-1)==== - - @Test - fun `old protocol without attempt is one cumulative complete snapshot per report`() { - val svc = service() - val reports = mutableListOf() - val text = - runSendMessage( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 300, "output": 100}}""", - ), - final = """{"usage": {"input": 500, "output": 400}, "chunk": "done"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertEquals("done", text) - // 旧协议:两次上报都是请求级累计完整快照,attempt 固定 1,绝不猜测递增 - assertEquals(2, reports.size) - reports.forEach { r -> - assertEquals(1, r.attempt) - assertTrue( - "old protocol report must be complete snapshot, was $r", - r.completeSnapshot, - ) - } - assertEquals(300L, reports[0].input) - assertEquals(500L, reports[1].input) - } - - @Test - fun `new protocol attempt numbers are forwarded and merged per attempt`() { - val svc = service() - val reports = mutableListOf() - val text = - runSendMessage( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 300, "output": 100, "attempt": 1}}""", - """{"usage": {"input": 500, "output": 400, "attempt": 2}}""", - ), - final = """{"chunk": "final"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertEquals("final", text) - // 新协议:attempt 序号原样转发,不猜测、不覆盖 - assertEquals(2, reports.size) - assertEquals(1, reports[0].attempt) - assertEquals(2, reports[1].attempt) - assertFalse("new protocol report is a partial update", reports[0].completeSnapshot) - assertFalse(reports[1].completeSnapshot) - assertEquals(300L, reports[0].input) - assertEquals(500L, reports[1].input) - } - - @Test - fun `new protocol same attempt streaming updates stay on the same attempt`() { - val svc = service() - val reports = mutableListOf() - runSendMessage( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 300, "output": 100, "attempt": 1}}""", - // 同 attempt 流式更新(只带 output):不分配新 attempt - """{"usage": {"output": 150, "attempt": 1}}""", - ), - final = """{"chunk": "ok"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertEquals(2, reports.size) - assertEquals(1, reports[0].attempt) - assertEquals(1, reports[1].attempt) - // 同 attempt 流式更新为部分快照:省略的 input 保留旧值(由上下文合并) - assertFalse(reports[1].completeSnapshot) - } - - @Test - fun `final result usage is reported after intermediate usage`() { - val svc = service() - val reports = mutableListOf() - runSendMessage( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", - ), - final = """{"usage": {"input": 120, "output": 25, "attempt": 1}}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertEquals(2, reports.size) - // 最终结果 payload 的 usage 也必须上报(intermediate + final 都走同一通道) - assertEquals(120L, reports[1].input) - assertEquals(25L, reports[1].output) - assertEquals(1, reports[1].attempt) - } - - // ==== 评审 P1-6:新协议跨 attempt 不得继承全局 current 计数 ==== - - @Test - fun `new protocol attempt missing input does not inherit global counters`() { - val svc = service() - val reports = mutableListOf() - val text = - runSendMessage( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 100, "output": 50, "attempt": 1}}""", - // attempt 2 首次只上报 output:input 必须保持未知, - // 绝不能填入全局 currentInput(100)造成虚假累计 - """{"usage": {"output": 10, "attempt": 2}}""", - ), - final = """{"chunk": "done"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertEquals("done", text) - assertEquals(2, reports.size) - assertEquals(1, reports[0].attempt) - assertEquals(100L, reports[0].input) - assertEquals(2, reports[1].attempt) - assertEquals("input must be unknown for attempt 2", -1L, reports[1].input) - - // 账本聚合:attempt1 input 已知 + attempt2 input 未知 → 该分量保持未知, - // 绝不把 100 继承为 200 - val ctx = - com.ai.assistance.operit.data.stats.TokenStatRequestContext( - eventId = "evt-toolpkg-attempt-gap", - category = com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "TEST", - model = "toolpkg-model", - startedAtMs = 1000L, - ) - reports.forEach { r -> - ctx.onUsage( - com.ai.assistance.operit.data.stats.ProviderUsageSnapshot( - uncachedInputTokens = if (r.input >= 0) r.input else null, - outputTokens = r.output, - cacheWriteSeparateBilling = false, - completeSnapshot = r.completeSnapshot, - source = "toolpkg_js", - ), - r.attempt, - ) - } - val aggregated = ctx.aggregatedUsage()!! - assertNull("aggregated input must stay unknown, not fabricated", aggregated.uncachedInputTokens) - assertEquals(60L, aggregated.outputTokens) - } - - // ==== 聚焦修复:final 致命失败结果 ==== - - @Test - fun `final failure with usage reports usage once, propagates error, and emits no final text`() { - val svc = service() - val reports = mutableListOf() - val (text, failure) = - runSendMessageExpectFailure( - svc, - runnerWith( - intermediates = emptyList(), - final = - """{"usage": {"input": 80, "output": 9, "attempt": 1}, "success": false, "error": "denied"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - // 致命错误必须传播(stream collect 抛出,不吞成空结果) - assertTrue( - "fatal result must propagate error, failure=$failure text=<$text>", - failure is IllegalStateException, - ) - assertEquals("denied", failure?.message) - // 最终失败结果里的 usage 先于致命检查被转发,且只解析/上报一次 - assertEquals(1, reports.size) - assertEquals(80L, reports[0].input) - assertEquals(9L, reports[0].output) - assertEquals(1, reports[0].attempt) - // fatal 结果不得发射最终文本 - assertEquals("", text) - } - - @Test - fun `final failure without usage emits no final text and propagates error`() { - val svc = service() - val (text, failure) = - runSendMessageExpectFailure( - svc, - runnerWith( - intermediates = emptyList(), - final = """{"success": false, "error": "boom"}""", - ), - ) { _, _ -> - error("usage callback must not fire without usage") - } - assertTrue("fatal result must propagate error, failure=$failure text=<$text>", failure is IllegalStateException) - assertEquals("boom", failure?.message) - assertEquals("", text) - } - - // ==== 评审 P1-7:testConnection 的 usage 提取与 attempt 转发 ==== - - private fun runTestConnection( - svc: ToolPkgJsAiProviderService, - runner: ToolPkgMainHookRunner, - onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, - ): Result = runBlocking { - svc.mainHookRunnerOverride = runner - svc.testConnection(context = mock(Context::class.java), onUsageReported = onUsageReported) - } - - @Test - fun `test connection forwards intermediate and final usage like a normal request`() { - val svc = service() - val reports = mutableListOf() - val result = - runTestConnection( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", - """{"usage": {"output": 25, "attempt": 1}}""", - ), - final = """{"usage": {"input": 120, "output": 25, "attempt": 1}, "success": true, "message": "ok"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertTrue(result.isSuccess) - // 中间 + 最终结果都走同一 usage 提取/attempt 转发(P1-7) - assertEquals(3, reports.size) - assertEquals(1, reports[0].attempt) - assertEquals(100L, reports[0].input) - assertEquals(25L, reports[1].output) - assertEquals(120L, reports[2].input) - assertEquals(1, reports[2].attempt) - assertFalse("new protocol report is a partial update", reports[2].completeSnapshot) - } - - @Test - fun `test connection failure still forwards usage before failing`() { - val svc = service() - val reports = mutableListOf() - val result = - runTestConnection( - svc, - runnerWith( - intermediates = listOf("""{"usage": {"input": 50, "output": 5, "attempt": 1}}"""), - final = """{"usage": {"input": 80, "output": 9, "attempt": 1}, "success": false, "error": "denied"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertTrue("connection must fail", result.isFailure) - // 失败结果里的 usage 同样被转发(不丢) - assertEquals(2, reports.size) - assertEquals(80L, reports[1].input) - } - - @Test - fun `test connection forwards multiple attempts`() { - val svc = service() - val reports = mutableListOf() - val result = - runTestConnection( - svc, - runnerWith( - intermediates = - listOf( - """{"usage": {"input": 100, "output": 10, "attempt": 1}}""", - """{"usage": {"input": 200, "output": 20, "attempt": 2}}""", - ), - final = """{"success": true, "message": "ok"}""", - ), - ) { usage, attempt -> - reports.add(report(usage, attempt)) - } - assertTrue(result.isSuccess) - assertEquals(2, reports.size) - assertEquals(1, reports[0].attempt) - assertEquals(2, reports[1].attempt) - assertEquals(200L, reports[1].input) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt b/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt deleted file mode 100644 index e245672d1..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/backup/BackupRestoreBarrierTest.kt +++ /dev/null @@ -1,460 +0,0 @@ -package com.ai.assistance.operit.data.backup - -import android.content.ContentResolver -import android.net.Uri -import android.os.Looper -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver -import com.ai.assistance.operit.data.stats.TokenStatReliabilityTestBase -import com.ai.assistance.operit.data.stats.TokenStatSpool -import com.ai.assistance.operit.data.stats.TokenStatsLedger -import com.ai.assistance.operit.util.AppLogger -import com.ai.assistance.operit.util.OperitPaths -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.sql.SQLException -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.zip.ZipEntry -import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.MockedStatic -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * P1 终审:生产备份/恢复 Manager 的屏障接线测试(真实 Manager + 真实 spool/Room 文件, - * 纯 JVM 基建与 [TokenStatReliabilityTestBase] 一致)。 - * - * 覆盖:备份排空 spool 且事件在备份中恰一次;恢复清除旧 spool 绝不 replay;恢复前开始 - * 的旧 epoch 请求不写入恢复后的数据库;替换前失败可继续、替换开始后失败拒绝新事件。 - */ -@OptIn(ExperimentalCoroutinesApi::class) -internal class BackupRestoreBarrierTest : TokenStatReliabilityTestBase() { - - private val filesDir get() = File(root, "files") - private val dataDir get() = File(root, "data") - private val cacheDir get() = File(root, "cache") - private val databasesDir get() = File(dataDir, "databases") - private val spoolDir get() = File(filesDir, TokenStatSpool.SPOOL_DIR_NAME) - - private var mainExecutor: ExecutorService? = null - private var looperStatic: MockedStatic? = null - - @Before - fun setUpBarrierMocks() { - // RawSnapshotBackupManager 的 object 初始化会构造 Handler(Looper.getMainLooper()); - // 纯 JVM 没有 Looper,静态 mock 提供非空实例(onProgress 一律传 null, - // mainHandler.post 永远不会被调用)。 - looperStatic = Mockito.mockStatic(Looper::class.java).also { - it.`when` { Looper.getMainLooper() }.thenReturn(mock()) - } - // Manager 用 Dispatchers.Main 汇报进度:安装真实单线程 Main(与 - // CleanupReliabilityTest 相同的模式)。 - mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } - Dispatchers.setMain(mainExecutor!!.asCoroutineDispatcher()) - - // 生产目录布局:filesDir 内是 spool,Room 数据库在 dataDir/databases 下, - // 与 RawSnapshotBackupManager 打包的 payload/databases 一致。 - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.dataDir).thenReturn(dataDir) - whenever(context.cacheDir).thenReturn(cacheDir) - whenever(context.getExternalFilesDir(null)).thenReturn(File(root, "external_files")) - whenever(context.getDatabasePath(any())).thenAnswer { File(databasesDir, it.getArgument(0)) } - cacheDir.mkdirs() - // RestoreReplacingMarker.persist 写 filesDir/restore_replacing.flag; - // 生产 filesDir 恒存在,JVM 测试需显式创建。 - filesDir.mkdirs() - // RoomDatabaseRestoreManager 把恢复目标写 room_restore_target 下的 tmp 文件, - // 目标目录需存在(生产由 getDatabasePath 保证)。 - File(root, "room_restore_target").mkdirs() - } - - @After - fun tearDownBarrierMocks() { - Dispatchers.resetMain() - mainExecutor?.shutdown() - looperStatic?.close() - } - - @Test - fun `export drains spool only event into the backup database exactly once`() = runBlocking { - val spool = spoolDir.apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-in-backup")) + "\n") - // spool 内非段文件:验证 spool 目录本身被排除出备份 zip(OperitPaths 排除名单) - File(spool, "stray.txt").writeText("not a segment") - - OperitPaths.downloadsDirOverrideForTest = File(root, "sdcard") - try { - Mockito.mockStatic(AppLogger::class.java).use { - val out = RawSnapshotBackupManager.exportToBackupDir(context, onProgress = null) - assertTrue("backup zip must exist", out.isFile) - - // 屏障 drain 后:spool 无残留段、事件已在 Room 中 - assertTrue( - "spool must be drained by the snapshot barrier", - spool.listFiles().orEmpty().none { - it.name.startsWith("sealed_") || it.name == TokenStatSpool.ACTIVE_FILE_NAME - }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - - val exported = unzipTo(out, File(root, "unzipped-backup")) - // 事件在备份中恰出现一次(经 Room 数据库文件进入备份) - val restoredDb = File(exported, "payload/databases/app_database") - assertTrue("backup must contain the drained database", restoredDb.isFile) - val count = JdbcSQLiteDriver().open(restoredDb.absolutePath).use { connection -> - connection.prepare("SELECT count(*) FROM token_stat_events WHERE eventId = 'spool-only-in-backup'") - .use { statement -> - if (statement.step()) statement.getLong(0) else -1L - } - } - assertEquals(1L, count) - // spool 目录被排除出备份,源文件保留 - assertFalse( - "spool directory must be excluded from the backup zip", - File(exported, "payload/files/${TokenStatSpool.SPOOL_DIR_NAME}/stray.txt").exists(), - ) - assertTrue("source spool file must stay untouched", File(spool, "stray.txt").exists()) - } - } finally { - OperitPaths.downloadsDirOverrideForTest = null - } - } - - @Test - fun `raw restore clears the pre restore spool so replay cannot inject old events`() = runBlocking { - val spool = spoolDir.apply { mkdirs() } - // 仅存在于 spool(未入 Room)的旧事件:恢复后绝不能 replay 进新数据库 - File(spool, "sealed_1.jsonl").writeText(line(request("old-pre-restore")) + "\n") - val zip = rawSnapshotZip(emptyMap()) - val uri = mock() - val resolver = mock() - whenever(context.contentResolver).thenReturn(resolver) - whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) - - val epochBefore = TokenStatSpool.captureRestoreEpoch() - // 测试 Room 实例打开着 dataDir/databases/app_database,restore 会替换该目录 - // (Windows 文件锁);先关闭释放,restore 完成后重建用于断言。 - database.close() - TokenStatsLedger.databaseProvider = null - try { - Mockito.mockStatic(AppLogger::class.java).use { - RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) - } - } finally { - reopenDatabase() - } - - // 备份 zip 不含 spool → files 替换按排除名单保留 spool 目录 → 屏障清理删除它 - assertFalse("old spool must be cleared by the restore barrier", spool.exists()) - assertFalse( - "REPLACING marker must be removed after a successful restore", - File(filesDir, RestoreReplacingMarker.FILE_NAME).exists(), - ) - assertTrue( - "restore must pass through the epoch fencing barrier", - TokenStatSpool.captureRestoreEpoch() > epochBefore, - ) - - // replay(模拟重启后的重放):旧事件绝不进入(已替换的)数据库 - TokenStatSpool.replay(context) - delay(300) - assertFalse(spool.exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - - private fun reopenDatabase() { - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - TokenStatsLedger.databaseProvider = { database } - } - - @Test - fun `room restore rejects pre restore epoch requests and replaces the database`() = runBlocking { - // 先让测试库在默认路径(dataDir/databases)打开,再把恢复目标改指独立路径, - // 避免 JVM 下替换打开中的 Room 文件(Windows 文件锁)。 - database.tokenStatsDao().deleteAllEvents() - val restoredDb = File(File(root, "room_restore_target"), "app_database") - whenever(context.getDatabasePath(any())).thenAnswer { - File(File(root, "room_restore_target"), it.getArgument(0)) - } - - // 恢复前开始的请求:捕获旧 epoch - val oldRequest = request("old-epoch-room-restore") - val oldEpoch = oldRequest.sessionEpoch - - val zip = File(cacheDir, "room-restore-test.zip").apply { - ZipOutputStream(FileOutputStream(this)).use { zos -> - zos.putNextEntry(ZipEntry("app_database")) - zos.write(ByteArray(0)) - zos.closeEntry() - } - } - Mockito.mockStatic(AppLogger::class.java).use { - RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) - } - - // 屏障生效:epoch 递增、替换完成后 REPLACING 标记已删除、新事件被拒绝 - assertTrue( - "restore must bump the restore epoch after the REPLACING commit", - TokenStatSpool.captureRestoreEpoch() > oldEpoch, - ) - assertFalse(TokenStatSpool.isAcceptingEvents()) - assertFalse(File(filesDir, RestoreReplacingMarker.FILE_NAME).exists()) - assertEquals( - "restored database must not contain any old events", - 0L, - eventCountIn(restoredDb), - ) - - // 恢复前开始的请求(旧 epoch)收尾时被明确拒绝,绝不写入恢复后的数据库/spool - assertEquals( - RecordOutcome.LOST, - TokenTrackingAIService.recordSafely(context, oldRequest), - ) - assertFalse(File(spoolDir, TokenStatSpool.ACTIVE_FILE_NAME).exists()) - assertEquals(0L, eventCountIn(restoredDb)) - } - - @Test - fun `restore failure before replacement keeps accepting events and new requests land`() = runBlocking { - val epochBefore = TokenStatSpool.captureRestoreEpoch() - // 损坏备份:缺少 manifest → 在 prepareBeforeCommit(替换前)明确失败 - val zip = rawSnapshotZip(emptyMap(), manifest = null) - val uri = mock() - val resolver = mock() - whenever(context.contentResolver).thenReturn(resolver) - whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) - - Mockito.mockStatic(AppLogger::class.java).use { - try { - RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) - fail("restore must fail on a corrupt backup") - } catch (e: IllegalArgumentException) { - // 替换前的失败:epoch 不变、进程仍接受事件 - } - } - assertEquals(epochBefore, TokenStatSpool.captureRestoreEpoch()) - assertTrue("pre-replacement failure must keep accepting events", TokenStatSpool.isAcceptingEvents()) - - // 新请求照常入账 - TokenTrackingAIService.recordSafely(context, request("after-failed-restore")) - awaitEvent("after-failed-restore") - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `restore failure after replacement started rejects all new events`() = runBlocking { - database.tokenStatsDao().deleteAllEvents() - val epochBefore = TokenStatSpool.captureRestoreEpoch() - // payload 含文件:替换(block)开始后必然失败(JVM 下 AtomicFile 复制抛错) - val zip = rawSnapshotZip(mapOf("payload/files/some-file.txt" to "x".toByteArray())) - val uri = mock() - val resolver = mock() - whenever(context.contentResolver).thenReturn(resolver) - whenever(resolver.openInputStream(uri)).thenReturn(FileInputStream(zip)) - - Mockito.mockStatic(AppLogger::class.java).use { - try { - RawSnapshotBackupManager.restoreFromBackupUri(context, uri, onProgress = null) - fail("restore must fail after replacement started") - } catch (e: Exception) { - // 替换开始后的失败:epoch 已递增、本进程拒绝一切新事件直至重启 - } - } - assertTrue( - "restore epoch must bump before replacement starts", - TokenStatSpool.captureRestoreEpoch() > epochBefore, - ) - assertFalse( - "replacement-started failure must reject new events", - TokenStatSpool.isAcceptingEvents(), - ) - assertEquals( - RecordOutcome.LOST, - TokenTrackingAIService.recordSafely(context, request("post-replacement")), - ) - assertFalse(File(spoolDir, TokenStatSpool.ACTIVE_FILE_NAME).exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - - @Test - fun `startup consumes abandoned restore marker and discards pre restore spool`() = runBlocking { - // 模拟崩溃于"替换已开始(REPLACING 已持久化)但未成功完成(标记未删除)": - // 重启后启动路径必须先消费标记、清理旧 spool,再开始 replay——旧事件绝不进数据库。 - val spool = spoolDir.apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("abandoned-pre-restore")) + "\n") - val marker = File(filesDir, RestoreReplacingMarker.FILE_NAME) - marker.writeText("REPLACING\n") - - val consumed = TokenStatSpool.consumeAbandonedRestoreIfAny(context) - assertTrue("marker must be consumed at startup", consumed) - assertFalse("old spool must be discarded", spool.exists()) - assertFalse("marker must be removed", marker.exists()) - - // 之后正常启动 replay:数据库仍是本机旧库,但旧 spool 已清空,无旧事件注入 - TokenStatSpool.replay(context) - delay(300) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 新请求照常落账 - TokenTrackingAIService.recordSafely(context, request("post-abandoned-restore")) - awaitEvent("post-abandoned-restore") - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `startup without marker does not touch the spool`() = runBlocking { - val spool = spoolDir.apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("normal-startup")) + "\n") - assertFalse("no marker must mean normal startup", TokenStatSpool.consumeAbandonedRestoreIfAny(context)) - TokenStatSpool.replay(context) - awaitEvent("normal-startup") - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `room restore corrupt zip fails before commit and keeps accepting events`() = runBlocking { - val epochBefore = TokenStatSpool.captureRestoreEpoch() - // 缺 app_database 条目的 ZIP:prepareBeforeCommit 解压验证失败 → 不 commit、 - // epoch 不变、marker 不写、进程仍接受事件(绝不用错误备份锁死当前进程)。 - val zip = File(cacheDir, "room-restore-corrupt.zip").apply { - ZipOutputStream(FileOutputStream(this)).use { zos -> - zos.putNextEntry(ZipEntry("other-file")) - zos.write(ByteArray(0)) - zos.closeEntry() - } - } - try { - RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) - fail("restore must fail on a corrupt zip") - } catch (e: IllegalArgumentException) { - // expected - } - assertEquals(epochBefore, TokenStatSpool.captureRestoreEpoch()) - assertTrue("pre-commit failure must keep accepting events", TokenStatSpool.isAcceptingEvents()) - assertFalse(File(filesDir, RestoreReplacingMarker.FILE_NAME).exists()) - - // 新请求照常落账 - TokenTrackingAIService.recordSafely(context, request("after-corrupt-room-restore")) - awaitEvent("after-corrupt-room-restore") - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `room restore atomic replacement failure preserves the existing database`() = runBlocking { - val restoreDir = File(root, "room_restore_target") - val existingDatabase = File(restoreDir, "app_database").apply { - writeText("existing-database") - } - whenever(context.getDatabasePath(any())).thenAnswer { - File(restoreDir, it.getArgument(0)) - } - val zip = File(cacheDir, "room-restore-atomic-move-failure.zip").apply { - ZipOutputStream(FileOutputStream(this)).use { zos -> - zos.putNextEntry(ZipEntry("app_database")) - zos.write("restored-database".toByteArray()) - zos.closeEntry() - } - } - - RoomDatabaseRestoreManager.atomicMoveForTest = { _, _ -> - throw IOException("injected atomic move failure") - } - try { - try { - RoomDatabaseRestoreManager.restoreFromBackupFile(context, zip) - fail("restore must fail when atomic replacement fails") - } catch (e: IOException) { - assertEquals("injected atomic move failure", e.message) - } - - assertEquals("existing-database", existingDatabase.readText()) - assertTrue( - "replacement-started failure must retain the replacing marker", - File(filesDir, RestoreReplacingMarker.FILE_NAME).exists(), - ) - } finally { - RoomDatabaseRestoreManager.atomicMoveForTest = null - } - } - - // ── helpers ───────────────────────────────────────────────────────────── - - private val validManifest = - """ - {"formatVersion":1,"packageName":"com.ai.assistance.operit","createdAt":1, - "includes":["payload/files/","payload/external_files/","payload/shared_prefs/", - "payload/datastore/","payload/databases/"],"includeTerminalData":true} - """.trimIndent() - - /** 生成 RawSnapshotBackupManager 可识别的备份 zip;manifest=null 时缺 manifest(损坏)。 */ - private fun rawSnapshotZip(entries: Map, manifest: String? = validManifest): File { - val zip = File(cacheDir, "raw-restore-test.zip") - ZipOutputStream(FileOutputStream(zip)).use { zos -> - if (manifest != null) { - zos.putNextEntry(ZipEntry("manifest.json")) - zos.write(manifest.toByteArray(Charsets.UTF_8)) - zos.closeEntry() - } - entries.forEach { (name, bytes) -> - zos.putNextEntry(ZipEntry(name)) - zos.write(bytes) - zos.closeEntry() - } - } - return zip - } - - private fun unzipTo(zip: File, targetDir: File): File { - ZipInputStream(java.io.BufferedInputStream(FileInputStream(zip))).use { zis -> - while (true) { - val entry = zis.nextEntry ?: break - if (!entry.isDirectory) { - val out = File(targetDir, entry.name) - out.parentFile?.mkdirs() - FileOutputStream(out).use { output -> zis.copyTo(output) } - } - zis.closeEntry() - } - } - return targetDir - } - - /** 直连 sqlite 统计事件表;表缺失(恢复后的库为空)视为 0 条旧事件。 */ - private fun eventCountIn(dbFile: File): Long = - JdbcSQLiteDriver().open(dbFile.absolutePath).use { connection -> - try { - connection.prepare("SELECT count(*) FROM token_stat_events").use { statement -> - if (statement.step()) statement.getLong(0) else -1L - } - } catch (e: SQLException) { - 0L - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt deleted file mode 100644 index a566c7d6d..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/preferences/ApiPreferencesResetFailureTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.ai.assistance.operit.data.preferences - -import android.content.Context -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -/** - * 重置失败语义:旧 DataStore 计数清零后,新账本(事件 + baseline)清理失败时 - * 必须返回失败(false)并记录日志,不能假装成功;成功时返回 true。 - * - * 隔离说明:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建一个 - * DataStore 实例(绑定首个访问它的 Context),后续测试共享同一文件,Windows - * 上对同一文件重复写入会失败。因此每个测试在 [Before] 中通过反射清空该单例, - * 使每次测试都绑定到自己的临时目录,每个测试最多一次真实写入。 - */ -class ApiPreferencesResetFailureTest { - - @Before - fun isolateDataStoreSingleton() { - clearApiDataStoreSingleton() - ApiPreferences.toolPkgProviderNamesProvider = { emptyList() } - } - - @After - fun clearTestProviders() { - ApiPreferences.toolPkgProviderNamesProvider = null - } - - private fun contextWithFiles(tempDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.filesDir).thenReturn(tempDir) - return context - } - - private fun constructApiPreferences(context: Context): ApiPreferences { - val constructor = - ApiPreferences::class.java - .getDeclaredConstructor(Context::class.java) - .apply { isAccessible = true } - return constructor.newInstance(context) - } - - /** - * 清空 `Context.apiDataStore` 委托缓存的数据存储单例,使下一个测试能绑定 - * 到自己的临时目录。委托与单例字段均为 Kotlin 生成物,字段名随编译固定; - * 若未来布局变化导致失败,测试类会直接报错而非静默跳过。 - */ - private fun clearApiDataStoreSingleton() { - val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") - val delegateField = facade.getDeclaredField("apiDataStore\$delegate") - delegateField.isAccessible = true - val delegate = delegateField.get(null) - val instanceField = - delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } - instanceField.set(delegate, null) - } - - @Test - fun `reset all returns false when new ledger cleanup fails`() { - Mockito.mockStatic(AppLogger::class.java).use { - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - - TokenStatsResetCoordinator.daoProvider = - { throw RuntimeException("db unavailable") } - try { - assertFalse(prefs.resetAllProviderModelTokenCounts()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - } - - @Test - fun `reset model returns false when new ledger cleanup fails`() { - Mockito.mockStatic(AppLogger::class.java).use { - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - - TokenStatsResetCoordinator.daoProvider = - { throw RuntimeException("db unavailable") } - try { - assertFalse(prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat")) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - } - - @Test - fun `reset all returns true and clears ledger when cleanup succeeds`() = runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - val dao = mock() - // P1 闭环:coordinator 删除后立即排空 pending cleanup——mock 无 operation - whenever(dao.getPendingCleanupOperations()).thenReturn(emptyList()) - - TokenStatsResetCoordinator.daoProvider = { dao } - try { - assertTrue(prefs.resetAllProviderModelTokenCounts()) - // 全量重置:tombstone 与删除在同一事务(resetAllStatisticsTx) - verify(dao).resetAllStatisticsTx() - Unit - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - - @Test - fun `reset model returns true and clears ledger when cleanup succeeds`() = runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - val dao = mock() - whenever(dao.getPendingCleanupOperations()).thenReturn(emptyList()) - - TokenStatsResetCoordinator.daoProvider = { dao } - try { - assertTrue(prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat")) - verify(dao).resetModelTx( - org.mockito.ArgumentMatchers.anyString(), - org.mockito.ArgumentMatchers.anyString(), - ) - Unit - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - - @Test - fun `apply legacy cleanup is a safe single write and marker is readable`() = runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - // Windows DataStore 约束(本 JVM 环境同一文件只能“首写”一次):本测试的唯一 - // 真实写入。apply 的键级精准语义由 TokenStatsCleanupOutboxTest 的纯变更函数 - // 与真实排空测试覆盖;这里验证 edit 薄壳 + marker 读取(导入 fence 数据源)。 - prefs.applyLegacyCleanup("op-1", listOf("OPENAI:gpt-4o")) - assertEquals(setOf("op-1"), prefs.appliedLegacyCleanupMarkerIds()) - assertEquals(0L, prefs.getInputTokensForProviderModel("OPENAI:gpt-4o")) - assertTrue(prefs.legacyStatsSnapshotWithMarkers().cleanupMarkerIds == setOf("op-1")) - assertTrue(prefs.legacyStatsSnapshotWithMarkers().snapshot.providerModels.isEmpty()) - } - - @Test - fun `reset all propagates cancellation instead of swallowing it`() { - Mockito.mockStatic(AppLogger::class.java).use { - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - - TokenStatsResetCoordinator.daoProvider = - { throw CancellationException("reset all cancelled") } - try { - prefs.resetAllProviderModelTokenCounts() - fail("expected CancellationException to propagate") - } catch (e: CancellationException) { - assertEquals("reset all cancelled", e.message) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - } - - @Test - fun `reset model propagates cancellation instead of swallowing it`() { - Mockito.mockStatic(AppLogger::class.java).use { - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("apiprefs-reset").toFile() - val prefs = constructApiPreferences(contextWithFiles(tempDir)) - - TokenStatsResetCoordinator.daoProvider = - { throw CancellationException("reset model cancelled") } - try { - prefs.resetProviderModelTokenCounts("DEEPSEEK:deepseek-chat") - fail("expected CancellationException to propagate") - } catch (e: CancellationException) { - assertEquals("reset model cancelled", e.message) - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - } - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt deleted file mode 100644 index 24089f1c4..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/preferences/UsdToCnyRateValidationTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.ai.assistance.operit.data.preferences - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class UsdToCnyRateValidationTest { - - @Test - fun `double must remain finite and positive after Float conversion`() { - assertNull(usdToCnyStorageValue(1e-50)) - assertNull(usdToCnyStorageValue(1e50)) - assertNull(usdToCnyStorageValue(Double.NaN)) - assertNull(usdToCnyStorageValue(Double.POSITIVE_INFINITY)) - assertNull(usdToCnyStorageValue(Double.NEGATIVE_INFINITY)) - assertEquals(7.35f, usdToCnyStorageValue(7.35)) - } - - @Test - fun `historical invalid Float values are treated as missing`() { - assertNull(validStoredUsdToCnyRate(0f)) - assertNull(validStoredUsdToCnyRate(-1f)) - assertNull(validStoredUsdToCnyRate(Float.NaN)) - assertNull(validStoredUsdToCnyRate(Float.POSITIVE_INFINITY)) - assertEquals(7.25, validStoredUsdToCnyRate(7.25f)!!, 0.0) - assertTrue(validStoredUsdToCnyRate(Float.MIN_VALUE)!! > 0.0) - - listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, 0f, -1f).forEach { stored -> - assertEquals(7.2, resolveUsdToCnyExchangeRate(stored), 0.0) - assertEquals(7.0 to true, resolveUsdToCnyRateWithEstimate(stored)) - } - assertEquals(7.25, resolveUsdToCnyExchangeRate(7.25f), 0.0) - assertEquals(7.25 to false, resolveUsdToCnyRateWithEstimate(7.25f)) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt deleted file mode 100644 index 1da5dff58..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/CleanupReliabilityTest.kt +++ /dev/null @@ -1,2539 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class CleanupReliabilityTest : TokenStatReliabilityTestBase() { - @Test - fun `quarantine export and delete file work never runs on the caller main thread`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 超过 16MiB 的证据:满上限 + 额外段(复制/fsync 足够大,能卡住 Main) - RandomAccessFile(File(spool, "quarantine_existing_sealed_1.jsonl"), "rw").use { - it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) - } - File(spool, "quarantine_existing_sealed_2.jsonl").writeText("legacy-over-cap\n") - - val mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } - Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) - val ioThreads = ConcurrentHashMap.newKeySet() - val previousIo = TokenStatSpool.ioDispatcher - TokenStatSpool.ioDispatcher = object : CoroutineDispatcher() { - override fun dispatch(context: CoroutineContext, block: Runnable) { - Dispatchers.IO.dispatch(context) { - ioThreads += Thread.currentThread().name - block.run() - } - } - } - try { - withContext(Dispatchers.Main) { - val exported = - TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertTrue(exported.size >= 2) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exported.map { it.name }.toSet()) - } - assertTrue("file I/O must actually dispatch", ioThreads.isNotEmpty()) - assertFalse( - "evidence file I/O must never run on the main thread: $ioThreads", - ioThreads.any { it == "test-main-thread" } - ) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - } finally { - TokenStatSpool.ioDispatcher = previousIo - Dispatchers.resetMain() - mainExecutor.shutdown() - } - } - - @Test - fun `quarantine summary publishes atomically via fallback when atomic move unsupported`() = - runBlocking { - val previous = TokenStatSpool.quarantineAtomicMoveForTest - // 强制 ATOMIC_MOVE 不支持(P1-1):必须走 old/new/backup 回退且结果完整 - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - // 目标已存在(旧摘要)时回退协议必须保留旧值直到新值就绪 - File(spool, "quarantine_summary.jsonl").writeText("{\"old\":\"value\"}\n") - File(spool, "sealed_2.jsonl").writeText("{forced-fallback-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertEquals(2, summary.recordCount) - val text = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest record must survive the fallback publish", text.contains("sealed_2.jsonl")) - assertTrue("old record must be preserved in the rebuilt summary", text.contains("\"old\":\"value\"")) - assertTrue(text.contains("sha256")) - assertFalse("fallback must not leave staged sidecars", File(spool, "quarantine_summary.jsonl.new").exists()) - assertFalse("fallback must not leave backup sidecars", File(spool, "quarantine_summary.jsonl.bak").exists()) - } finally { - TokenStatSpool.quarantineAtomicMoveForTest = previous - } - } - - @Test - fun `twice-rename-failure original sealed is managed evidence exportable and ack-deleted`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{twice-rename-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-evidence")) + "\n") - // P1-3:两次重命名都失败(进证据区 + pending-delete 都失败)→ tombstone 原段 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-evidence") - // 等 drain 完成损坏段处置:tombstone 记录落盘(原段保留在磁盘上, - // 不能等它消失——受管失败段本就不消失) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "tombstone must be recorded for the twice-rename-failed segment", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - assertEquals(1, database.tokenStatsDao().countEvents()) - // tombstoned 原 sealed 必须作为 managed evidence 参与计数/字节 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "original sealed must appear as managed evidence", - evidence.any { it.name == "sealed_1.jsonl" }, - ) - // 导出包含原文件(原文件名,身份可追溯)并附 manifest - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertTrue(exported.any { it.name == "sealed_1.jsonl" }) - assertTrue(exported.any { it.name == "quarantine_skip_manifest.jsonl" }) - assertTrue( - "exported managed evidence must retain the corrupt content", - exported.single { it.name == "sealed_1.jsonl" }.readText().contains("twice-rename-bad"), - ) - // ack 确认删除:按 identity 删除原文件并移除对应 manifest 记录 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) - assertFalse("acked managed evidence must be deleted", File(spool, "sealed_1.jsonl").exists()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertFalse( - "manifest entry must be removed after ack", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - assertTrue(TokenStatSpool.quarantineEvidence(context).isEmpty()) - // 健康继续 - TokenTrackingAIService.recordSafely(context, request("after-evidence-ack")) - awaitEvent("after-evidence-ack") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - } - - @Test - fun `export recovers canonical summary and manifest when only sidecars remain`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P2-2:崩溃窗口——canonical 缺失,内容只在 .new sidecar(完整、已 fsync) - val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" - File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) - val manifestContent = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":2,\"sha256\":\"def\",\"overCap\":true}\n" - File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals( - summaryContent, - exported.single { it.name == "quarantine_summary.jsonl" }.readText(), - ) - assertEquals( - manifestContent, - exported.single { it.name == "quarantine_skip_manifest.jsonl" }.readText(), - ) - // canonical 也已被恢复,后续信息/ack 不再依赖 sidecar - assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) - assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) - } - } - - @Test - fun `evidence info and ack recover manifest and summary from new sidecar without export`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val oldBody = "{sidecar-new-only\n" - File(spool, "sealed_1.jsonl").writeText(oldBody) - val sha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestContent = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha\",\"overCap\":false}\n" - // 崩溃窗口:canonical 缺失,内容只在 .new(完整、已 fsync) - File(spool, "quarantine_skip_manifest.jsonl.new").writeText(manifestContent) - val summaryContent = "{\"ts\":1,\"file\":\"sealed_9.jsonl\",\"bytes\":1,\"sha256\":\"abc\"}\n" - File(spool, "quarantine_summary.jsonl.new").writeText(summaryContent) - - // 不先 export:直接调用 evidence/info/ack - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "managed evidence must be visible after sidecar recovery", - evidence.any { it.name == "sealed_1.jsonl" }, - ) - val info = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("summary info must recover from sidecar", info) - assertEquals(1, info!!.recordCount) - // canonical 已恢复且 sidecar 身份被清理 - assertEquals(manifestContent, File(spool, "quarantine_skip_manifest.jsonl").readText()) - assertEquals(summaryContent, File(spool, "quarantine_summary.jsonl").readText()) - assertFalse(File(spool, "quarantine_skip_manifest.jsonl.new").exists()) - assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) - // ack 按身份删除 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "manifest entry must be removed after ack", - File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_1.jsonl"), - ) - } - } - - @Test - fun `each export uses its own empty directory and stale exports never leak`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_first_sealed_1.jsonl").writeText("first-bad\n") - val exportA = File(root, "token_stats_quarantine_A") - val exportedA = TokenStatSpool.exportQuarantineEvidence(context, exportA) - assertTrue(exportedA.any { it.name.startsWith("quarantine_first_") }) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, exportedA.map { it.name }.toSet()) - // 第二次导出到新目录:只含本次证据,上一次的残留绝不混入/冒充 - File(spool, "quarantine_second_sealed_2.jsonl").writeText("second-bad\n") - val exportB = File(root, "token_stats_quarantine_B") - val exportedB = TokenStatSpool.exportQuarantineEvidence(context, exportB) - assertTrue(exportedB.any { it.name.startsWith("quarantine_second_") }) - assertFalse( - "a previous export must never leak into the new export directory", - exportB.listFiles().orEmpty().any { it.name.startsWith("quarantine_first_") }, - ) - assertFalse( - "previous export must never be reported as this run's result", - exportedB.any { it.name.startsWith("quarantine_first_") }, - ) - } - - @Test - fun `export into a non-empty destination is refused without touching its content`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_x_sealed_1.jsonl").writeText("bad\n") - val dest = File(root, "token_stats_quarantine_existing").apply { mkdirs() } - val userFile = File(dest, "user-notes.txt").apply { writeText("do not touch") } - try { - TokenStatSpool.exportQuarantineEvidence(context, dest) - fail("export into a non-empty destination must be refused") - } catch (e: IOException) { - assertTrue("refusal must name the reason", e.message!!.contains("not empty")) - } - assertEquals("do not touch", userFile.readText()) - assertFalse( - "no evidence may be written into a refused destination", - dest.listFiles().orEmpty().any { it.name.startsWith("quarantine_") }, - ) - } - - @Test - fun `segment read failure keeps managed entries and ack refuses until identity is readable`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{unreadable-bad\n") - // 正常建立受管失败段(重命名失败 → tombstone) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - - // 段原始字节读取失败(身份校验 UNREADABLE):受管段不处理、entry 保留 - TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_1.jsonl" } - TokenStatSpool.replay(context) - delay(700) - assertTrue( - "unreadable managed segment must be skipped, never processed", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue( - "manifest entry must be retained for the unreadable segment", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // evidence 列表不暴露身份不可校验的受管段(不可安全导出/ack) - assertTrue( - TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, - ) - // ack 不能成功 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail while the segment identity is unreadable") - } catch (e: IOException) { - assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) - } - assertTrue(File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // 恢复后正常:ack 按身份删除并移除记录 - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "entry must be removed after a successful ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } finally { - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack manifest read failure preserves quarantine evidence managed evidence and manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // quarantine area 完整证据(无 manifest 记录)+ 受管失败段(重命名失败 → tombstone) - File(spool, "quarantine_area_sealed_1.jsonl").writeText("area-bad\n") - File(spool, "sealed_2.jsonl").writeText("{managed-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // manifest 不可读 → 整个 ack 失败:quarantine + managed + manifest 全部保留 - TokenStatSpool.metadataReadErrorForTest = { file -> - file.name == "quarantine_skip_manifest.jsonl" - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("quarantine_area_sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail while the manifest is unreadable") - } catch (e: IOException) { - } - assertTrue( - "quarantine evidence must survive a failed ack", - File(spool, "quarantine_area_sealed_1.jsonl").exists(), - ) - assertTrue( - "managed evidence must survive a failed ack", - File(spool, "sealed_2.jsonl").exists(), - ) - assertTrue( - "manifest entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - assertTrue( - "no ack trash directory may be left behind", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } finally { - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack with later unreadable managed identity keeps earlier match and all entries`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{first-bad\n") - File(spool, "sealed_2.jsonl").writeText("{second-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 后一个段身份不可校验(UNREADABLE)→ 整个 ack 失败:前一个 MATCH 也不删除 - TokenStatSpool.segmentReadErrorForTest = { file -> file.name == "sealed_2.jsonl" } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail when any managed identity is unreadable") - } catch (e: IOException) { - assertTrue("ack must name the unverifiable identity", e.message!!.contains("identity")) - } - assertTrue( - "earlier matched segment must not be deleted on a partial failure", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue(File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "both entries must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // 恢复后一次 ack 按身份删除两个段并移除两条记录 - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_2.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - } finally { - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack staging rename failure rolls back staged renames and keeps manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rollback-a\n") - File(spool, "sealed_2.jsonl").writeText("{rollback-b\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 第 2 个文件的 stage rename 失败 → 第 1 个已 stage 的文件必须回滚, - // manifest 不改;回滚 rename 的目标是 spool 根目录,不受注入影响 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == "sealed_2.jsonl" -> false - else -> null - } - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must fail when staging a rename fails") - } catch (e: IOException) { - assertTrue("ack must report the staging failure", e.message!!.contains("stage")) - } - assertTrue( - "staged file must be rolled back after a failed rename", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue(File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "no trash directory may remain after rollback", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - assertTrue( - "both entries must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true && - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // 恢复真实 rename 后 ack 成功:按身份删除两个段并移除两条记录 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_2.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertFalse(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ack manifest write failure rolls back all staged files and keeps old manifest`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val managed = File(spool, "sealed_1.jsonl").apply { writeText("{managed-bad\n") } - val quarantine = - File(spool, "quarantine_area_sealed_2.jsonl").apply { writeText("area-bad\n") } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val oldManifest = - "{\"file\":\"${managed.name}\",\"bytes\":${managed.length()}," + - "\"sha256\":\"${sha256Hex(managed.readBytes())}\",\"overCap\":false}\n" - manifest.writeText(oldManifest) - TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(managed.name, quarantine.name), - ) - fail("ack must fail when the manifest cannot be published") - } catch (e: IOException) { - assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) - } finally { - TokenStatSpool.metadataWriteErrorForTest = null - } - assertTrue("managed evidence must be restored", managed.isFile) - assertTrue("quarantine evidence must be restored", quarantine.isFile) - assertEquals("old manifest must remain byte-for-byte intact", oldManifest, manifest.readText()) - assertTrue( - "no trash directory may remain after a successful rollback", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } - } - - @Test - fun `ack rollback move with not durable dir sync keeps uncommitted trash and maintenance recovers it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText("{rollback-sync-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - var calls = 0 - try { - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - TokenStatSpool.segmentRenameForTest = null - // 阶段 2:manifest 重写失败触发回滚;回滚 move 的目录项 sync(第 7 次: - // 1 次 manifest 严格读取 + 1 次 trash 创建 + 2 次暂存 + 2 次状态写入) - // 失败 → trash 保留 UNCOMMITTED 状态、上层失败,绝不静默(P2) - TokenStatSpool.metadataWriteErrorForTest = { it.name == manifest.name } - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 7) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail when the rollback dir sync is not OK") - } catch (e: IOException) { - assertTrue("ack must report the manifest failure", e.message!!.contains("manifest")) - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained after a not-durable rollback", 1, trashDirs.size) - val state = File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue( - "state must remain UNCOMMITTED for maintenance rollback", - state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - // 回滚 move 已可见(证据回到原路径)但目录项未确认:mapping 仍持有身份, - // 维护按状态机幂等完成 - assertTrue("evidence is back at its original path", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 3:恢复后维护按 UNCOMMITTED + mapping 完成回滚并删除 trash;损坏 - // sealed 随后被扫描器重新隔离为完整证据(与 ack 崩溃窗口协议一致) - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be resolved by maintenance after recovery", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val body = "{rollback-sync-bad\n" - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < evidenceDeadline && - TokenStatSpool.quarantineEvidence(context).none { it.readText() == body } - ) { - delay(20) - } - assertEquals( - "evidence must be re-quarantined exactly once after the rollback", - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `committed ack trash residue counts into the total cap and maintenance cleans it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousDelete = TokenStatSpool.spoolDeleteForTest - try { - // 大证据文件:ack 后留在 trash(删除被强制失败),必须计入总容量 - val evidence = File(spool, "quarantine_trash_cap_sealed_1.jsonl") - RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB,28KiB 残留 - // 证据已让每次 append 的递归投影超限——旧实现只数顶层会放行到实际 36KiB - TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 - TokenStatSpool.spoolDeleteForTest = { false } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("committed trash must remain when deletion is forced to fail", 1, trashDirs.size) - assertEquals( - "commit flip must be persisted in the trash state file", - TokenStatSpool.ACK_STATE_COMMITTED + "\n", - File(trashDirs.single(), TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).readText(), - ) - - // 行先于失败 DAO 生成(行生成需要真实价格读取);DAO 只负责排空失败 - val lines = (0 until 200).map { index -> - line(request("trash-cap-$index")) to "trash-cap-$index" - } - // DAO 永久失败 → sealed 段只增不减;递归总容量必须计入 trash 残留 - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertEquals( - "every append must be refused while the trash residue holds the admission budget: $rejected", - lines.size, - rejected, - ) - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - fun recursiveTotal(): Long = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "recursive total including trash must never exceed the cap: ${recursiveTotal()}", - recursiveTotal() <= cap, - ) - // 冻结断言:拒绝后不再发布任何字节 - val frozen = recursiveTotal() - repeat(10) { - try { - TokenStatSpool.append(context, lines[0].first, "refused-trash-$it") - fail("append after trash-inclusive cap must keep failing") - } catch (e: TokenStatsPersistenceException) { - } - } - assertEquals(frozen, recursiveTotal()) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 维护补删恢复:删除恢复后 replay 清掉 committed trash - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.replay(context) - val cleanupDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < cleanupDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "committed trash must be removed by maintenance once deletion works", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - // DAO 恢复后排空与 append 都恢复正常 - TokenStatsLedger.databaseProvider = { database } - assertTrue( - TokenStatSpool.append( - context, - line(request("after-trash-recovery")), - "after-trash-recovery", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-trash-recovery") - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.spoolDeleteForTest = previousDelete - } - } - } - - @Test - fun `ack refuses when trash state metadata would push the total over the cap`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - try { - // 大量小证据文件 → mapping 状态文件较大;cap 只留 4KiB 头部空间, - // 4 槽位最坏投影(mapping ~10KiB × 4)必然超限 - val files = (0 until 60).map { index -> - File(spool, "quarantine_many_$index.jsonl").apply { writeText("bad-$index\n") } - } - val totalNow = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - TokenStatSpool.totalSpoolMaxBytesForTest = totalNow + 4 * 1024 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, files.map { it.name }.toSet()) - fail("ack must fail when the trash state metadata does not fit the total cap") - } catch (e: IOException) { - } - // 全部证据仍在原位、没有 trash 目录残留、总量不超限(stage 已回滚) - files.forEach { assertTrue("evidence must stay in place: ${it.name}", it.exists()) } - assertTrue( - "no trash directory may remain after the refused ack", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - assertTrue( - "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L), - ) - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `ack staging failure with rollback failure keeps uncommitted trash and maintenance recovers it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rb-fail-a\n") - File(spool, "sealed_2.jsonl").writeText("{rb-fail-b\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - (safeManifestText(manifest)?.contains("sealed_1.jsonl") != true || - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true) - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - - // 第 2 个文件 stage 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == "sealed_2.jsonl" -> false - to.parentFile?.name != null && - !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && - to.name == "sealed_1.jsonl" -> false - else -> null - } - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "sealed_2.jsonl"), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained after rollback failure", 1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("staged evidence must stay in trash", File(trash, "sealed_1.jsonl").exists()) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertTrue("sealed_2 must stay in place (stage never happened)", File(spool, "sealed_2.jsonl").exists()) - assertTrue( - "trash state must be UNCOMMITTED with a mapping", - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - .readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - assertTrue( - "manifest entry must remain", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertTrue( - "manifest entry must remain", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - - // replay 维护(rename 仍被注入失败):不删 trash、不删证据、manifest 条目保留 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must never delete uncommitted trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive maintenance", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertFalse( - "sealed_2 was restored to the evidence area by maintenance", - File(spool, "sealed_2.jsonl").exists(), - ) - - // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚并自愈 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be gone after a successful maintenance rollback", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - awaitManifestWithout(spool, "sealed_1.jsonl") - awaitManifestWithout(spool, "sealed_2.jsonl") - // 两份证据都回到完整证据区(可导出/可 ack) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.name.contains("sealed_1.jsonl") }) - assertTrue(evidence.any { it.name.contains("sealed_2.jsonl") }) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `crash window with published manifest rolls back uncommitted trash and scanner re-quarantines`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 手工构造崩溃窗口:主 manifest 已发布(不含该身份),但 commit 标记未写。 - // P1-1:UNCOMMITTED 绝不根据 manifest 缺失推断已提交——必须回滚证据, - // 回滚后的损坏 sealed 会被扫描器重新隔离(ack 视失败但不丢证据)。 - val body = "{crash-window-bad\n" - val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", - ) - // manifest 不存在 = 条目已全部移除(旧实现会据此误判 committed 并删除证据) - TokenStatSpool.replay(context) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && trash.exists()) delay(20) - assertFalse( - "maintenance must roll back uncommitted crash-window trash", - trash.exists(), - ) - // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据,绝不丢失 - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var reQuarantined = false - while (System.nanoTime() < evidenceDeadline && !reQuarantined) { - reQuarantined = TokenStatSpool.quarantineEvidence(context).any { - it.name.contains("sealed_1.jsonl") && it.readText() == body - } - if (!reQuarantined) delay(20) - } - assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) - } - } - - @Test - fun `partially corrupt ack trash mapping is fail-closed and maintenance retains everything`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{p12-a\n" - val body2 = "{p12-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - // 首行有效 mapping + 一行损坏 mapping:mapNotNull 会静默丢弃损坏行, - // 只回滚 1 个文件并删除 trash——旧实现会丢失第 2 份证据(P1-2) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - TokenStatSpool.replay(context) - delay(700) - assertTrue("partially corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse("no rollback may happen from a partial mapping", File(spool, "sealed_1.jsonl").exists()) - // UI 可见:作为 stuck 受管证据列出 - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - - // 修复为重复 mapping(同一原名两条)→ 仍然 fail-closed 保留 - val lineA = - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + lineA, - ) - TokenStatSpool.replay(context) - delay(700) - assertTrue("duplicate mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - - // 完整修复 mapping(两份证据都被覆盖)→ 维护回滚并自愈 - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + lineA + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) - // 回滚后的损坏 sealed 被扫描器重新隔离为完整证据 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - } - } - - @Test - fun `partial mapping with unreadable trash enumeration is fail-closed and manifest stays verbatim`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{enum-null-a\n" - val body2 = "{enum-null-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha1\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - // 首行有效 mapping + 一行损坏:全有或全无解析必然失败;trash 枚举再失败时, - // 即使 mapping 已覆盖可见证据,未枚举的证据也无法排除 → 仍必须 fail-closed - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) - File(spool, "sealed_9.jsonl").writeText(line(request("enum-null-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - awaitEvent("enum-null-healthy") - delay(700) - assertTrue("trash must be retained while its enumeration fails", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse( - "no rollback may happen from a partial mapping with failed enumeration", - File(spool, "sealed_1.jsonl").exists(), - ) - assertFalse("no un-enumerated evidence may be deleted", File(spool, "sealed_2.jsonl").exists()) - assertEquals( - "manifest must be preserved verbatim", - manifestLine, - safeManifestText(manifest), - ) - // P1-6 fail-closed:stuck 证据枚举走同一 seam——枚举失败时 UI 查询必须 - // 明确抛错,绝不能返回部分/空列表误导用户删除 - try { - TokenStatSpool.stuckAckTrashEvidence(context) - fail("stuck ack trash evidence must fail while trash enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - try { - TokenStatSpool.stuckAckTrashBytes(context) - fail("stuck ack trash bytes must fail while trash enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 有界:重复维护轮不改写 manifest、不处置 trash - TokenStatSpool.replay(context) - delay(700) - assertTrue(trash.exists()) - assertEquals( - "repeated maintenance rounds must not rewrite the manifest", - manifestLine, - safeManifestText(manifest), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复枚举 + 完整 mapping → 维护回滚并自愈(cleanup 成功) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once enumeration and mapping recover", trash.exists()) - // P1-6:枚举成功且无 trash 时才是真正的空列表 - assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var evidence: List = emptyList() - while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { - evidence = TokenStatSpool.quarantineEvidence(context) - if (evidence.size != 2) delay(20) - } - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - } - } - - @Test - fun `spool root enumeration failure makes trash state unknown and blocks stale removal`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 消失原件的 manifest 条目:根枚举失败时无法证明旧身份不被未枚举的 trash 持有 - val oldBody = "{root-enum-stale\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - // UNCOMMITTED trash 真实持有该身份(根枚举失败时完全不可见) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - // 健康段:根枚举失败期间 drain 必须 fail-closed 退避——段保留、绝不入 Room - File(spool, "sealed_9.jsonl").writeText(line(request("root-enum-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - delay(700) - assertNull( - "no segment may drain while the root enumeration fails", - database.tokenStatsDao().getEvent("root-enum-healthy"), - ) - assertTrue("healthy segment must be preserved", File(spool, "sealed_9.jsonl").exists()) - assertEquals( - "stale removal must be blocked while the root enumeration fails", - manifestLine, - safeManifestText(manifest), - ) - assertTrue( - "trash must be retained while the root enumeration fails", - trash.exists(), - ) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - // 有界:重复维护轮保持原样 - TokenStatSpool.replay(context) - delay(700) - assertEquals(manifestLine, safeManifestText(manifest)) - assertTrue(trash.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 枚举恢复:健康段排空;身份确实被 trash 持有 → 回滚后按 MATCH 处置,条目最终移除 - TokenStatSpool.replay(context) - awaitEvent("root-enum-healthy") - awaitManifestWithout(spool, "sealed_1.jsonl") - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once enumeration recovers", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var restored = false - while (System.nanoTime() < evidenceDeadline && !restored) { - restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } - if (!restored) delay(20) - } - assertTrue("held evidence must be restored after recovery", restored) - } - } - - @Test - fun `maintenance defers rollback while trash enumeration fails and cleanup succeeds after the seam recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{enum-recovery-bad\n" - val sha = sha256Hex(body.toByteArray(Charsets.UTF_8)) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha\"}\n", - ) - // mapping 完全有效也必须在枚举失败时 fail-closed:无法证明没有未枚举的证据 - // 健康段用于证明维护轮确实运行(枚举失败期间照常排空,不做破坏性决策) - File(spool, "sealed_9.jsonl").writeText(line(request("enum-recovery-healthy")) + "\n") - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - awaitEvent("enum-recovery-healthy") - delay(700) - assertTrue( - "valid mapping must still be fail-closed while enumeration fails", - trash.exists(), - ) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertFalse( - "no rollback may happen while enumeration fails", - File(spool, "sealed_1.jsonl").exists(), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam → rollback cleanup 成功:trash 删除、证据回到原槽位、被扫描器隔离 - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be deleted after the successful rollback", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var reQuarantined = false - while (System.nanoTime() < evidenceDeadline && !reQuarantined) { - reQuarantined = TokenStatSpool.quarantineEvidence(context).any { - it.isFile && it.readText() == body - } - if (!reQuarantined) delay(20) - } - assertTrue("rolled-back corrupt segment must be re-quarantined as evidence", reQuarantined) - } - } - - @Test - fun `seal publish with active delete failure is rolled back and later recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("delete-fail-a")) - val lineB = line(request("delete-fail-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var failures = 0 - TokenStatSpool.sealActiveDeleteForTest = { - failures += 1 - false - } - try { - // seal:createLink 成功 → 删除 active 失败 → 回滚链接 → seal 失败 - // → append 明确失败(B 未发布、无伪 durable) - assertFalse( - "append must fail when the post-publish active delete fails", - TokenStatSpool.append(context, lineB, "delete-fail-b"), - ) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - } - assertEquals(1, failures) - // 回滚成功:无 sealed 残留;active 保持原内容 - assertTrue( - "rolled-back seal must leave no sealed residue", - spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }, - ) - assertEquals(lineA + "\n", File(spool, "active.jsonl").readText()) - // 恢复后:既有事件恰一次入 Room,被拒绝的 B 从未发布 - TokenStatSpool.replay(context) - awaitEvent("delete-fail-a") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue( - "append must succeed after the delete failure recovers", - TokenStatSpool.append(context, lineB, "delete-fail-b"), - ) - awaitEvent("delete-fail-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink unsupported falls back to atomic no-replace copy publish`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("copy-fallback-a")) - val lineB = line(request("copy-fallback-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - // 停掉后台 writer:seal 在 append 内同步完成(copy 回退),断言不被并发 drain 干扰 - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal via the copy fallback and succeed", - TokenStatSpool.append(context, lineB, "copy-fallback-b"), - ) - } finally { - TokenStatSpool.sealHardLinkForTest = null - } - // copy 发布成功:sealed_1 = active 原内容,active = 新事件 - assertEquals(lineA + "\n", File(spool, "sealed_1.jsonl").readText()) - assertEquals(lineB + "\n", File(spool, "active.jsonl").readText()) - // 恢复 writer 后全部事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("copy-fallback-a") - awaitEvent("copy-fallback-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("copy-fallback-a", "copy-fallback-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback crash window content duplicate is recovered and drains once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("copy-window-a")) - val lineB = line(request("copy-window-b")) - val lineC = line(request("copy-window-c")) - try { - val content = lineA + "\n" + lineB + "\n" - // 模拟 copy 回退崩溃窗口:sealed_1 复制完成、active 删除未发生 - // (两个独立 inode 同内容) - File(spool, "active.jsonl").writeText(content) - File(spool, "sealed_1.jsonl").writeText(content) - TokenStatSpool.MAX_SEGMENT_BYTES = content.length.toLong() + 1 - TokenStatSpool.shutdownWriterForTest() - // append 必须先按内容识别并删除 sealed 副本;随后的 seal 把内容重新封为 - // 唯一的 sealed_1(单份,绝不重复拼接、绝不污染旧副本) - assertTrue(TokenStatSpool.append(context, lineC, "copy-window-c")) - assertEquals( - "sealed segment must hold the single copy of the old active content", - content, - File(spool, "sealed_1.jsonl").readText(), - ) - assertEquals( - "new event must be durable in active", - lineC + "\n", - File(spool, "active.jsonl").readText(), - ) - TokenStatSpool.replay(context) - awaitEvent("copy-window-a") - awaitEvent("copy-window-b") - awaitEvent("copy-window-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("copy-window-a", "copy-window-b", "copy-window-c"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback target fsync failure retains active and recovers exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("target-fsync-a")) - val lineB = line(request("target-fsync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.fileSyncForTest = { false } - TokenStatSpool.shutdownWriterForTest() - try { - // copy 完成后目标 fsync 失败:必须保留 active、处置目标、明确失败 - assertFalse( - "append must fail when the sealed target fsync fails", - TokenStatSpool.append(context, lineB, "target-fsync-b"), - ) - } finally { - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "no normal sealed segment may be left from the failed publish", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - // 目标被隔离为 seal_failed_*(identity 确认通过,内容 = active 前缀/相等) - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) - // 恢复:维护清理隔离副本,既有事件恰一次入 Room;被拒事件随后发布成功 - TokenStatSpool.replay(context) - awaitEvent("target-fsync-a") - awaitSegmentGone(spool, isolated.name) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "target-fsync-b")) - awaitEvent("target-fsync-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback first dir sync failure retains active and recovers exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dirsync-fail-a")) - val lineB = line(request("dirsync-fail-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - try { - // 目标创建未确认持久(目录 sync 失败):必须保留 active、处置目标、明确失败 - assertFalse( - "append must fail when the target-creating dir sync fails", - TokenStatSpool.append(context, lineB, "dirsync-fail-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED,恢复 - // 路径必须回到注入的 OK 才能运行正常 seal 发布/排空协议) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "no normal sealed segment may be left from the failed publish", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - TokenStatSpool.replay(context) - awaitEvent("dirsync-fail-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "dirsync-fail-b")) - awaitEvent("dirsync-fail-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback post-active-delete dir sync failure keeps durable target and drains once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("post-sync-a")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(目标 - // 创建)成功,第四次(active 删除)失败 - if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - // 目标已 data+creation durable 后删除 active 的目录同步失败:返回 FAILED - // 阻止本轮后续 append 污染,但绝不回滚已 durable 的 target - assertFalse( - "append must fail when the post-delete dir sync fails", - TokenStatSpool.append(context, line(request("post-sync-b")), "post-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals(4, dirSyncCalls) - assertEquals( - "durable target must be kept with the full content", - lineA + "\n", - File(spool, "sealed_1.jsonl").readText(), - ) - assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) - // 恢复:target 是唯一内容持有者,正常排空,事件恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("post-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("post-sync-c")), "post-sync-c")) - awaitEvent("post-sync-c") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal first dir sync failure rolls back link retains active and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("link-sync-a")) - val lineB = line(request("link-sync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - try { - // 链接目录项未确认持久:必须回滚链接、保留 active、明确失败 - assertFalse( - "append must fail when the link-creating dir sync fails", - TokenStatSpool.append(context, lineB, "link-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "rolled-back seal must leave no hardlink residue", - spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, - ) - TokenStatSpool.replay(context) - awaitEvent("link-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, lineB, "link-sync-b")) - awaitEvent("link-sync-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal copy partial cleanup failures tombstone the target never drain it and recover as bounded evidence`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("tombstone-partial-a")) - val lineB = line(request("tombstone-partial-b")) - val partial = lineA + "\n" // 严格部分:只是 active 第一行的前缀内容,身份与完整内容不同 - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - TokenStatSpool.segmentDeleteForTest = { f -> - if (f.name.startsWith("sealed_")) false else null - } - try { - assertFalse( - "append must fail when the seal copy fails mid-way", - TokenStatSpool.append(context, line(request("tombstone-partial-c")), "tombstone-partial-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - } - // rename/delete 都失败 → tombstone skip:manifest 记录身份,scanner 跳过 - val partialFile = File(spool, "sealed_1.jsonl") - assertTrue("partial target must stay at the candidate name", partialFile.exists()) - assertEquals("partial bytes must be preserved", partial, partialFile.readText()) - assertTrue( - "partial target must be recorded in the tombstone manifest", - safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, - ) - // 带 seams 恢复:tombstoned 部分目标被跳过(文件保留、绝不普通排空),健康 - // 内容封到下一编号并恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("tombstone-partial-a") - awaitEvent("tombstone-partial-b") - assertEquals(2, database.tokenStatsDao().countEvents()) - assertTrue("tombstoned partial must still exist", partialFile.exists()) - // 移除失败 seam 后维护把部分目标移入完整证据区(有界证据)并移除 manifest 条目 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_1.jsonl") - awaitManifestWithout(spool, "sealed_1.jsonl") - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "isolated partial must become bounded quarantine evidence", - evidence.any { it.isFile && it.readText() == partial }, - ) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy fallback with durable dir syncs publishes and drains each event exactly once`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dir-durable-a")) - val lineB = line(request("dir-durable-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - TokenStatSpool.DirSyncResult.OK // 模拟 Android/Linux 目录 fsync 成功 - } - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal via copy with durable dir syncs", - TokenStatSpool.append(context, lineB, "dir-durable-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - // P1 终审:封段发布 2 次目录同步(目标创建、active 删除)+ append 侧 1 次 - // (seal 删除 active 后新 active 属首次创建,目录项必须确认持久)+ P1-1 - // bootstrap gate 2 次(filesDir + spool 目录,本测试进程首次使用) - assertEquals(5, dirSyncCalls) - assertEquals("sealed_1 must hold the old content", lineA + "\n", File(spool, "sealed_1.jsonl").readText()) - assertEquals("active must hold the new event", lineB + "\n", File(spool, "active.jsonl").readText()) - TokenStatSpool.replay(context) - awaitEvent("dir-durable-a") - awaitEvent("dir-durable-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("dir-durable-a", "dir-durable-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal failed target deletion failure stays visible exportable ackable and ack frees the cap`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // seal 发布失败隔离的部分目标(受管失败发布证据)与普通证据并存 - val failed = File(spool, "seal_failed_${UUID.randomUUID().toString().replace("-", "")}") - failed.writeText("{partial-copy-evidence\n") - val regular = File(spool, "quarantine_existing_sealed_1.jsonl") - regular.writeText("{regular-evidence\n") - // 维护删除失败(seam):隔离副本保留、下一轮重试,绝不自动消失 - TokenStatSpool.segmentDeleteForTest = { f -> - if (f.name.startsWith("seal_failed_")) false else null - } - try { - TokenStatSpool.replay(context) - delay(700) - assertTrue("deletion failure must keep the failed target", failed.exists()) - // 可见:quarantineEvidence 含 seal_failed_*,字节计入证据总量 - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue("seal_failed target must be visible as evidence", evidence.any { it.name == failed.name }) - assertTrue("regular evidence must stay visible", evidence.any { it.name == regular.name }) - assertTrue( - "seal_failed bytes must count toward the evidence total", - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } >= failed.length(), - ) - // 导出包含隔离目标 - val exported = - TokenStatSpool.exportQuarantineEvidence(context, File(root, "p2-evidence-export")) - assertTrue("seal_failed target must be exportable", exported.any { it.name == failed.name }) - // 用户确认删除(NOFOLLOW/path 根校验在 ack 内部)→ 证据消失、容量释放 - val bytesBefore = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(failed.name)) - assertFalse("ack must delete the seal_failed target", failed.exists()) - val bytesAfter = TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } - assertTrue("ack must release the held evidence bytes", bytesAfter < bytesBefore) - assertTrue( - "remaining evidence must still be intact", - TokenStatSpool.quarantineEvidence(context).any { it.name == regular.name }, - ) - } finally { - TokenStatSpool.segmentDeleteForTest = null - } - } - } - - @Test - fun `corrupt uncommitted trash mapping never drops held manifest identity for vanished original`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P1 场景:sealed_1 是受管失败段(tombstone 条目),文件已被 ack stage 进 - // UNCOMMITTED trash 后崩溃(主 manifest 已发布、commit 翻转未写),随后状态 - // mapping 损坏(一条有效 + 一条损坏)。根文件缺失时,旧实现因 held 集合为空 - // 会把 sealed_1 条目按 stale 移除——fail-closed 被违背。 - val body1 = "{corrupt-held-a\n" - val body2 = "{corrupt-held-b\n" - val sha1 = sha256Hex(body1.toByteArray(Charsets.UTF_8)) - val sha2 = sha256Hex(body2.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${body1.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$sha1\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(body1) - File(trash, "sealed_2.jsonl").writeText(body2) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{corrupt-json\n", - ) - - TokenStatSpool.replay(context) - delay(700) - assertTrue("corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertTrue(File(trash, "sealed_2.jsonl").exists()) - assertFalse( - "no rollback may happen from a partial mapping", - File(spool, "sealed_1.jsonl").exists(), - ) - assertEquals( - "manifest sealed_1 entry must be preserved verbatim", - manifestLine, - safeManifestText(manifest), - ) - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - - // 后续维护轮保持有界:hasUnknown 时整轮跳过 manifest 重写,条目逐字不变 - TokenStatSpool.replay(context) - delay(700) - assertEquals( - "repeated maintenance rounds must not rewrite the manifest", - manifestLine, - safeManifestText(manifest), - ) - - // 修复 mapping(两份证据都被覆盖)→ 维护回滚到根并安全重新隔离/处置: - // sealed_1 与 manifest 身份 MATCH → 移入完整证据区并移除条目;sealed_2 被 - // 扫描器重新隔离。manifest 与证据状态最终一致。 - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${body1.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha1\"}\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${body2.toByteArray(Charsets.UTF_8).size},\"s\":\"$sha2\"}\n", - ) - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the mapping is complete", trash.exists()) - awaitManifestWithout(spool, "sealed_1.jsonl") - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var evidence: List = emptyList() - while (System.nanoTime() < evidenceDeadline && evidence.size != 2) { - evidence = TokenStatSpool.quarantineEvidence(context) - if (evidence.size != 2) delay(20) - } - assertEquals(2, evidence.size) - assertTrue(evidence.any { it.isFile && it.readText() == body1 }) - assertTrue(evidence.any { it.isFile && it.readText() == body2 }) - } - } - - @Test - fun `scanner keeps manifest identity when corrupt uncommitted trash may hold the reused-name original`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 旧身份仍可能被损坏 mapping 的 UNCOMMITTED trash 持有:根目录同名新文件与 - // manifest 条目 MISMATCH 时,scanner 绝不能按 stale 移除条目(否则旧身份 - // 失去保护,回滚后重新隔离也无法与受管集合对应)。 - val oldBody = "{scanner-held-old\n" - val newBody = "{scanner-held-new\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n" + - "{corrupt-json\n", - ) - File(spool, "sealed_1.jsonl").writeText(newBody) - - TokenStatSpool.replay(context) - // 同名新文件照常被处理进完整证据区(内容不变),但 manifest 条目必须保留 - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var quarantined = false - while (System.nanoTime() < evidenceDeadline && !quarantined) { - quarantined = TokenStatSpool.quarantineEvidence(context).any { - it.isFile && it.readText() == newBody - } - if (!quarantined) delay(20) - } - assertTrue("the reused-name new file must be processed into the evidence area", quarantined) - assertTrue("corrupt mapping must keep the trash", trash.exists()) - assertTrue(File(trash, "sealed_1.jsonl").exists()) - assertEquals( - "manifest entry must be retained while the old identity may be held in trash", - manifestLine, - safeManifestText(manifest), - ) - } - } - - @Test - fun `stateless non-empty ack trash is visible exportable ackable and append recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap 32KiB:准入上限 = 32K − min(512K, 32K−8K) = 8KiB;28KiB 无状态 - // trash 残留必须让每次 append 的递归投影超限(占用绝不隐藏,P1-3) - TokenStatSpool.totalSpoolMaxBytesForTest = 32L * 1024 - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - val evidence = File(trash, "sealed_stuck_1.jsonl") - RandomAccessFile(evidence, "rw").use { it.setLength(28L * 1024) } - // 无状态非空 trash:maintenance fail-closed 保留(绝不删除),UI 可见 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must retain a stateless non-empty trash", trash.exists()) - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - assertEquals(listOf(trash), TokenStatSpool.quarantineEvidence(context)) - assertEquals(28L * 1024, TokenStatSpool.stuckAckTrashBytes(context)) - - // 释放前:cap 被 trash 占用 → 新统计 append 明确拒绝 - val lines = (0 until 200).map { index -> - line(request("stuck-cap-$index")) to "stuck-cap-$index" - } - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertEquals(lines.size, rejected) - - // export 将 trash 目录内容复制到唯一子目录(含状态/sidecar) - val base = File(root, "export-stuck").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination) - assertTrue(exported.any { it.name == trash.name }) - val exportedTrash = File(destination, trash.name) - assertTrue(exportedTrash.isDirectory) - assertEquals(28L * 1024, File(exportedTrash, evidence.name).length()) - - // 确认删除 stuck trash(显式授权,无需 mapping)→ 容量释放 → append 恢复 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(trash.name)) - assertFalse("ack must delete the acknowledged stuck trash", trash.exists()) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-stuck-ack")), - "after-stuck-ack", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-stuck-ack") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `rollback never overwrites an occupied slot and recovers after the slot frees`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃窗口 + 回滚目标被同名不同内容的新文件占用 - val oldBody = "{old-occupied-bad\n" - val newBody = "{new-occupant-bad\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_1.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_1.jsonl\",\"t\":\"sealed_1.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - // 主 manifest 仍含旧身份(ack 未提交)→ 必须回滚而非删除 - File(spool, "quarantine_skip_manifest.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - File(spool, "sealed_1.jsonl").writeText(newBody) - - TokenStatSpool.replay(context) - // 回滚目标被不同内容占用:绝不覆盖,保留 trash 证据并 fail-closed - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - File(spool, "sealed_1.jsonl").exists() - ) { - delay(20) - } - assertTrue( - "occupied-slot rollback must retain the trash evidence", - trash.exists() && File(trash, "sealed_1.jsonl").exists(), - ) - // 新内容未被覆盖:作为健康处理进入完整证据区(身份仍是新内容) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "the new occupant must be processed into the evidence area untouched", - evidence.any { it.name.contains("sealed_1.jsonl") }, - ) - assertTrue( - "the new occupant content must be intact", - evidence.first { it.name.contains("sealed_1.jsonl") }.readText() == newBody, - ) - assertTrue( - "manifest entry must be retained while the old identity is held in trash", - safeManifestText(File(spool, "quarantine_skip_manifest.jsonl"))?.contains("sealed_1.jsonl") == true, - ) - - // 槽位释放后(新文件已移入证据区)→ replay:回滚成功并自愈 - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back once the slot frees", trash.exists()) - awaitManifestWithout(spool, "sealed_1.jsonl") - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals("both the old and the new evidence must be present", 2, restored.size) - assertTrue(restored.any { it.readText() == oldBody }) - assertTrue(restored.any { it.readText() == newBody }) - } - } - - @Test - fun `ack refuses path traversal names without touching spool files`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_safe_sealed_1.jsonl").writeText("safe-bad\n") - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("../outside.jsonl")) - fail("ack must refuse traversal names") - } catch (e: IOException) { - assertTrue("refusal must name the unsafe target", e.message!!.contains("unsafe")) - } - assertTrue(File(spool, "quarantine_safe_sealed_1.jsonl").exists()) - } - - @Test - fun `first summary publish at the total cap edge with fallback sidecars keeps total bounded`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 - // 强制回退协议(P1-1:canonical/.new/.bak/tmp sidecar 瞬态同时存在) - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 证据区打满(16MiB 硬 cap)→ 新损坏段必须走 summarize 路径(首次 summary 写) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - // 数据总量:16MiB 证据 + 7.5MiB 损坏段 ≈ 23.5MiB,接近 24MiB 总上限边缘 - val segment = File(spool, "sealed_2.jsonl") - RandomAccessFile(segment, "rw").use { - it.setLength(7L * 1024 * 1024 + 512L * 1024) - } - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - val totalBytes: () -> Long = { - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - } - TokenStatSpool.replay(context) - // 轮询:整个处置过程实际 top-level 总字节始终 ≤ 总上限 - val pollDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (segment.exists() && System.nanoTime() < pollDeadline) { - assertTrue( - "total must stay within the cap while draining: ${totalBytes()}", - totalBytes() <= cap, - ) - delay(20) - } - assertTrue("total must stay within the cap at rest: ${totalBytes()}", totalBytes() <= cap) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("first summary must be published at the cap edge", summary) - assertTrue( - "summary must carry the over-cap segment record", - File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl"), - ) - // sidecar 已清理(回退发布完成) - assertFalse(File(spool, "quarantine_summary.jsonl.new").exists()) - assertFalse(File(spool, "quarantine_summary.jsonl.bak").exists()) - // 维护(ack 证据区)后 append 恢复 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-cap-edge-summary")), - "after-cap-edge-summary", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-cap-edge-summary") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic - } - } - } - - @Test - fun `seal-failed target cleanup with FAILED and UNSUPPORTED dir sync does not advance and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val isolated = File(spool, "seal_failed_$tag-partial") - isolated.writeText("{partial-$tag\n") - File(spool, "sealed_9.jsonl").writeText(line(request("syncfail-sealfailed-healthy-$tag")) + "\n") - // bootstrap gate(2) OK,seal_failed 删除后的目录项 sync(第 3 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - delay(900) - // 删除可见但未确认:本轮不推进(健康段也不排空);隔离副本不丢证据 - assertFalse("seal-failed target deletion is visible", isolated.exists()) - assertTrue("healthy segment must stay pending while the round is not durable", File(spool, "sealed_9.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项确认持久后健康段恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitEvent("syncfail-sealfailed-healthy-$tag") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone over-cap delete with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_$tag.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_2.jsonl").writeText("{$tag-managed-bad\n") - // 阶段 1:删除失败 + 证据区已满 → over-cap tombstone 条目(正常协议) - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name == "sealed_2.jsonl") false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_2.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_2.jsonl") == true) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - // 阶段 1 的 drain 可能仍在收尾(摘要/条目发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - // 阶段 2:维护删除成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; - // 本阶段第 1 次 sync 是 manifest 严格读取,第 2 次才是删除的目录项)→ manifest - // 条目保留(可重试记录)、本轮不推进 - TokenStatSpool.segmentDeleteForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 2) result else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "manifest entry must be retained while the deletion is unconfirmed", - safeManifestText(manifest)?.contains("sealed_2.jsonl") == true, - ) - assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:确认“消失”持久后条目幂等移除,摘要记录不重复 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitManifestWithout(spool, "sealed_2.jsonl") - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack staging with FAILED and UNSUPPORTED dir sync fails closed keeps evidence and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - // 两个失败点:trash 目录创建后的目录项 sync(failCall=1)、首个证据移动后的 - // 目录项 sync(failCall=2,跨 spool 根与 trash 两个目录) - for (failCall in 1..2) { - spool.deleteRecursively() - spool.mkdirs() - TokenStatSpool.clearPendingStateForTest() - File(spool, "sealed_1.jsonl").writeText("{$tag-ackstage-bad\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "phase-1 tombstone entry must exist before ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先 - // 静默至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - TokenStatSpool.segmentRenameForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == failCall) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail when a staging boundary dir sync is not OK; calls=$calls failCall=$failCall result=$result") - } catch (e: IOException) { - } - // 操作失败、状态保留:证据未丢、manifest 未改 - assertTrue("managed evidence must stay in place", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "manifest entry must survive the failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - } - // 清理失败迭代留下的空 trash(mkdir 已可见但目录项 sync 未确认;维护入口对 - // 空 trash 同样安全删除,此处等价地清理后重试) - spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - .forEach { it.deleteRecursively() } - // 恢复:目录项 sync OK 后 ack 恰一次完成(证据删除、条目移除) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertTrue( - "no trash residue after a successful ack", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack commit flip with FAILED and UNSUPPORTED dir sync fails closed retains uncommitted trash and maintenance recovers it`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val sealedBody = "{$tag-flip-bad\n" - val areaBody = "area-body-$tag\n" - File(spool, "sealed_1.jsonl").writeText(sealedBody) - File(spool, "quarantine_area_$tag.jsonl").writeText(areaBody) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue( - "phase-1 tombstone entry must exist before ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,ack 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - TokenStatSpool.segmentRenameForTest = null - // 第 11 次 sync = COMMITTED 翻转的暂存目录项(manifest 严格读取 1 + mkdir 1 - // + staging 4 + 状态文件 2 + manifest 重写 2 + 翻转 staging 1)——翻转未确认 - // 持久 → ack 失败、状态保留 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 11) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf("sealed_1.jsonl", "quarantine_area_$tag.jsonl"), - ) - fail("ack must fail when the commit flip is not durable; calls=$calls result=$result") - } catch (e: IOException) { - assertTrue(e.message!!.contains("commit")) - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained", 1, trashDirs.size) - val trash = trashDirs.single() - val state = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue( - "state must remain UNCOMMITTED for maintenance rollback", - state.readText().startsWith(TokenStatSpool.ACK_STATE_UNCOMMITTED), - ) - assertTrue("staged managed evidence stays in trash", File(trash, "sealed_1.jsonl").exists()) - assertTrue("staged area evidence stays in trash", File(trash, "quarantine_area_$tag.jsonl").exists()) - assertTrue( - "manifest entries were already published", - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true, - ) - // 维护恢复:UNCOMMITTED 按 mapping+identity 回滚 → 证据各恰一次回到原路径 - // (损坏 sealed 随后被扫描器重新隔离为证据) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be rolled back by maintenance", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.count { it.readText() == sealedBody }) - assertEquals(1, evidence.count { it.readText() == areaBody }) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `ack trash delete with FAILED and UNSUPPORTED dir sync fails closed and retry is idempotent`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val evidence = File(spool, "quarantine_ackdelete_$tag.jsonl") - evidence.writeText("{$tag-ackdelete\n") - // 第 8 次 sync = COMMITTED 翻转后 trash 删除的目录项(mkdir 1 + staging 2 + - // 状态文件 2 + 翻转 2 + 删除 sync 1)——删除可见但未确认 → ack 失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 8) result else TokenStatSpool.DirSyncResult.OK - } - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - fail("ack must fail when the trash deletion is not durable") - } catch (e: IOException) { - assertTrue(e.message!!.contains("deletion not durable")) - } - assertFalse("trash deletion is visible", evidence.exists()) - assertTrue( - "no trash residue", - spool.listFiles().orEmpty().none { it.name.startsWith("quarantine_ack_trash_") }, - ) - // 重试幂等:证据已可见删除,再次 ack 无操作成功(崩溃后 COMMITTED trash 重现 - // 由维护有界补删) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(evidence.name)) - assertFalse(evidence.exists()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone manifest publish with FAILED and UNSUPPORTED dir sync fails closed keeps old manifest and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText("{$tag-publish-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-publish-healthy-$tag")) + "\n") - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - // bootstrap gate(2) OK,tombstone manifest 严格发布的暂存目录项 sync(第 3 次) - // 失败 → 发布 FAILED(不是 RECORDED):manifest 未发布、段保留、健康段不排空 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - delay(900) - assertFalse("manifest must not be published", manifest.exists()) - assertTrue("original segment must be retained", File(spool, "sealed_1.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项 sync OK 后按正常协议完成——损坏段作为证据恰一次隔离、健康段 - // 恰一次入 Room(manifest 从未发布,无重复条目) - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitEvent("syncfail-publish-healthy-$tag") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == "{$tag-publish-bad\n" }, - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `seal duplicate cleanup with FAILED and UNSUPPORTED dir sync fails closed until confirmed and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("syncfail-dup-a-$tag")) - val lineB = line(request("syncfail-dup-b-$tag")) - File(spool, "active.jsonl").writeText(lineA + "\n") - File(spool, "sealed_1.jsonl").writeText(lineA + "\n") // copy 回退崩溃窗口副本 - // bootstrap gate(2) OK,重复副本删除后的目录项 sync(第 3 次)失败 → 恢复 - // 未确认:append fail-closed,绝不带着“可能还有重复”的状态写入 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - assertFalse(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) - assertTrue("active is intact", File(spool, "active.jsonl").readText() == lineA + "\n") - assertFalse("duplicate removal is visible", File(spool, "sealed_1.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:无重复 → 正常追加,两事件各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "syncfail-dup-b-$tag")) - TokenStatSpool.replay(context) - awaitEvent("syncfail-dup-a-$tag") - awaitEvent("syncfail-dup-b-$tag") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("syncfail-dup-a-$tag", "syncfail-dup-b-$tag"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - // ==== P2 终审:目录遗漏修复(回滚删除/反向 rename 的严格目录同步、mapping 身份捕获) ==== - - @Test - fun `seal rollback deletion sync failure fails closed and next append re-bootstraps before writing`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("rollback-sync-a")) - val lineB = line(request("rollback-sync-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 硬链接创建目录项 OK → active 删除失败 → 回滚删除链接:删除后的目录项 - // sync(第 4 次)失败(P2 终审)→ 回滚未确认持久、gate 同步失效、seal 明确失败 - var dirSyncCalls = 0 - TokenStatSpool.sealActiveDeleteForTest = { false } - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 1-2 bootstrap gate;3 链接创建目录项 OK;4 回滚删除的目录项 FAILED - if (dirSyncCalls == 4) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the seal rollback deletion is not durable", - TokenStatSpool.append(context, lineB, "rollback-sync-b"), - ) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals("rollback deletion must be sync-confirmed (P2)", 4, dirSyncCalls) - assertEquals("active must be retained", lineA + "\n", File(spool, "active.jsonl").readText()) - assertFalse( - "rolled-back seal must leave no sealed residue", - spool.listFiles().orEmpty().any { it.name.startsWith("sealed_") }, - ) - // 恢复:gate 已失效——下一次 append 先 bootstrap 重新确认目录项再正常写入 - assertTrue(TokenStatSpool.append(context, lineB, "rollback-sync-b")) - TokenStatSpool.replay(context) - awaitEvent("rollback-sync-a") - awaitEvent("rollback-sync-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `ack rollback mixed move success with sync failure writes complete mapping from actual locations and maintenance recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = "{p23-a\n" - val body2 = "{p23-b\n" - val body3 = "{p23-c\n" - val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText(body1) } - val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText(body2) } - val ev3 = File(spool, "quarantine_ord_c_sealed_3.jsonl").apply { writeText(body3) } - // ev1/ev2 成功 stage;ev3 stage 失败触发回滚。回滚时 ev2 移回失败(留在 trash), - // ev1 移回成功但目录项 sync 失败(第 7 次)——此时再写 UNCOMMITTED 状态时 - // ev1 已不在 trash,mapping 身份必须从实际所在位置(original)捕获(P2 终审), - // 绝不能从已移走的 target 盲读(会得到 0 字节/空哈希甚至写失败) - var calls = 0 - TokenStatSpool.ackAtomicMoveForTest = { from, to -> - when { - to.name == ev3.name -> false - to.name == ev2.name && from.parentFile?.name?.startsWith("quarantine_ack_trash_") == true -> - false - else -> null - } - } - TokenStatSpool.dirSyncForTest = { - calls += 1 - // 1 trash 创建;2-5 stage;6-7 回滚 ev1 的双目录 sync(第 7 次失败) - if (calls == 7) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - try { - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(ev1.name, ev2.name, ev3.name), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals("uncommitted trash must be retained", 1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("ev2 rollback failed so it stays in trash", File(trash, ev2.name).exists()) - assertTrue("ev1 rollback move is visible at the original path", ev1.exists()) - assertTrue("ev3 was never staged", ev3.exists()) - // 状态 mapping 必须完整且身份正确(P2 终审:从实际所在位置捕获) - val stateFile = File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME) - assertTrue("state must be written after the not-durable rollback", stateFile.isFile) - val lines = stateFile.readText().lineSequence().filter { it.isNotBlank() }.toList() - assertEquals(TokenStatSpool.ACK_STATE_UNCOMMITTED, lines.first()) - assertEquals("mapping must cover both staged files", 2, lines.size - 1) - val entryA = JSONObject(lines[1]) - assertEquals(ev1.name, entryA.getString("o")) - assertEquals(body1.toByteArray(Charsets.UTF_8).size.toLong(), entryA.getLong("b")) - assertEquals(sha256Hex(body1.toByteArray(Charsets.UTF_8)), entryA.getString("s")) - val entryB = JSONObject(lines[2]) - assertEquals(ev2.name, entryB.getString("o")) - assertEquals(body2.toByteArray(Charsets.UTF_8).size.toLong(), entryB.getLong("b")) - assertEquals(sha256Hex(body2.toByteArray(Charsets.UTF_8)), entryB.getString("s")) - // UI 可管理:stuck trash 作为受管证据可见 - assertEquals(listOf(trash), TokenStatSpool.stuckAckTrashEvidence(context)) - // 维护按 mapping+identity 完整回滚:trash 删除、全部证据回到证据区 - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && - spool.listFiles().orEmpty().any { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - ) { - delay(20) - } - assertTrue( - "trash must be resolved by maintenance once moves recover", - spool.listFiles().orEmpty().none { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") }, - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(3, evidence.size) - assertTrue(evidence.any { it.readText() == body1 }) - assertTrue(evidence.any { it.readText() == body2 }) - assertTrue(evidence.any { it.readText() == body3 }) - assertEquals(emptyList(), TokenStatSpool.stuckAckTrashEvidence(context)) - assertEquals(0, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.dirSyncForTest = null - } - } - } - - // ==== P1 关键链路:drain 请求合并(丢失唤醒修复)==== - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt deleted file mode 100644 index 692d83d83..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/JdbcSQLiteDriver.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import androidx.sqlite.SQLITE_DATA_BLOB -import androidx.sqlite.SQLITE_DATA_FLOAT -import androidx.sqlite.SQLITE_DATA_INTEGER -import androidx.sqlite.SQLITE_DATA_NULL -import androidx.sqlite.SQLITE_DATA_TEXT -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.SQLiteDriver -import androidx.sqlite.SQLiteStatement -import java.sql.Connection as JdbcConnection -import java.sql.DriverManager -import java.sql.PreparedStatement -import java.sql.ResultSet -import java.sql.ResultSetMetaData -import java.sql.SQLException -import java.sql.Types - -/** - * 单元测试用纯 JVM SQLite 驱动:基于 org.xerial:sqlite-jdbc 实现 - * androidx.sqlite 的 KMP [SQLiteDriver]/[SQLiteConnection]/[SQLiteStatement] 接口, - * 让 Android Room 2.8 生成的数据库实现(kapt 产物)可以在 JVM 单元测试中真实打开、 - * 迁移与读写。 - * - * 仅用于测试;应用运行时仍使用 Android 平台的驱动。 - * - * 实现约定: - * - 列索引与 androidx 原生(sqlite3 C API)一致,从 0 开始;JDBC 从 1 开始,内部 +1。 - * - 事务命令(BEGIN/END/ROLLBACK/SAVEPOINT/RELEASE)在 JDBC 层直接翻译, - * 避免 sqlite-jdbc 在显式事务 SQL 下的内部状态冲突。 - * - 无结果集的语句(DDL、PRAGMA 赋值等)在 sqlite-jdbc 的 executeQuery 下会抛出 - * “query does not return ResultSet”,按 step() 返回 false 处理;其他 SQLException 照常抛出。 - */ -class JdbcSQLiteDriver : SQLiteDriver { - override fun open(fileName: String): SQLiteConnection = JdbcSQLiteConnection(fileName) -} - -class JdbcSQLiteConnection(fileName: String) : SQLiteConnection { - - private val connection: JdbcConnection = - DriverManager.getConnection("jdbc:sqlite:$fileName").apply { - // Room 连接池会在同一文件上开多个连接;Windows 上 sqlite-jdbc 的 - // journal 文件删除(SQLITE_IOERR_DELETE)会被其它连接/杀软短暂锁定。 - // 内存日志模式彻底避开 journal 文件(仅测试用,无崩溃恢复需求)。 - createStatement().use { it.execute("PRAGMA journal_mode = MEMORY") } - } - - override fun prepare(sql: String): SQLiteStatement { - val trimmed = sql.trim() - return when { - trimmed.startsWith("BEGIN ") -> - TransactionStatement { beginJdbcTransaction() } - trimmed == "END TRANSACTION" -> - TransactionStatement { endJdbcTransaction(commit = true) } - trimmed.startsWith("ROLLBACK TRANSACTION TO SAVEPOINT") || - trimmed.startsWith("SAVEPOINT ") || - trimmed.startsWith("RELEASE SAVEPOINT ") -> - // Room 连接池的嵌套事务通过 SAVEPOINT 实现(语句形如 - // `SAVEPOINT '1'`):原样执行即可,不要拆名字(sqlite-jdbc - // 的 JDBC Savepoint API 转义有问题) - TransactionStatement { executeRawSql(trimmed) } - trimmed == "ROLLBACK TRANSACTION" -> - TransactionStatement { endJdbcTransaction(commit = false) } - else -> JdbcSQLiteStatement(connection.prepareStatement(sql)) - } - } - - override fun inTransaction(): Boolean = !connection.autoCommit - - override fun close() { - connection.close() - } - - private fun executeRawSql(sql: String) { - try { - connection.createStatement().use { it.execute(sql) } - } catch (e: Exception) { - throw IllegalStateException("raw sql failed: [$sql]", e) - } - } - - private fun beginJdbcTransaction() { - if (connection.autoCommit) { - connection.autoCommit = false - } - } - - private fun endJdbcTransaction(commit: Boolean) { - if (connection.autoCommit) return - if (commit) connection.commit() else connection.rollback() - connection.autoCommit = true - } - - private class TransactionStatement(private val action: () -> Unit) : SQLiteStatement { - override fun bindBlob(index: Int, value: ByteArray) = Unit - override fun bindDouble(index: Int, value: Double) = Unit - override fun bindLong(index: Int, value: Long) = Unit - override fun bindText(index: Int, value: String) = Unit - override fun bindNull(index: Int) = Unit - override fun getBlob(index: Int): ByteArray = ByteArray(0) - override fun getDouble(index: Int): Double = 0.0 - override fun getLong(index: Int): Long = 0L - override fun getText(index: Int): String = "" - override fun isNull(index: Int): Boolean = true - override fun getColumnCount(): Int = 0 - override fun getColumnName(index: Int): String = "" - override fun getColumnType(index: Int): Int = SQLITE_DATA_NULL - override fun step(): Boolean { - action() - return false - } - override fun reset() = Unit - override fun clearBindings() = Unit - override fun close() = Unit - } -} - -private class JdbcSQLiteStatement( - private val statement: PreparedStatement, -) : SQLiteStatement { - - private var resultSet: ResultSet? = null - private var executed = false - - override fun bindBlob(index: Int, value: ByteArray) = statement.setBytes(index, value) - - override fun bindDouble(index: Int, value: Double) = statement.setDouble(index, value) - - override fun bindLong(index: Int, value: Long) = statement.setLong(index, value) - - override fun bindText(index: Int, value: String) = statement.setString(index, value) - - override fun bindNull(index: Int) = statement.setNull(index, Types.NULL) - - override fun getBlob(index: Int): ByteArray = resultSetOrThrow().getBytes(index + 1) ?: ByteArray(0) - - override fun getDouble(index: Int): Double = resultSetOrThrow().getDouble(index + 1) - - override fun getLong(index: Int): Long = resultSetOrThrow().getLong(index + 1) - - override fun getText(index: Int): String = resultSetOrThrow().getString(index + 1) - - override fun isNull(index: Int): Boolean = resultSetOrThrow().getObject(index + 1) == null - - override fun getColumnCount(): Int = metadataOrNull()?.columnCount ?: 0 - - override fun getColumnName(index: Int): String = - metadataOrNull()?.getColumnName(index + 1) ?: "" - - override fun getColumnType(index: Int): Int { - val meta = metadataOrNull() ?: return SQLITE_DATA_NULL - return when (meta.getColumnType(index + 1)) { - Types.INTEGER, Types.SMALLINT, Types.TINYINT, Types.BIGINT, Types.BIT, Types.BOOLEAN -> - SQLITE_DATA_INTEGER - Types.REAL, Types.FLOAT, Types.DOUBLE, Types.NUMERIC, Types.DECIMAL -> - SQLITE_DATA_FLOAT - Types.BLOB, Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY -> - SQLITE_DATA_BLOB - Types.NULL -> - SQLITE_DATA_NULL - else -> - SQLITE_DATA_TEXT - } - } - - override fun step(): Boolean { - ensureExecuted() - return resultSet?.next() ?: false - } - - /** - * 尽早执行查询:Room 生成的代码在 step() 之前先取列元数据 - * (getColumnIndexOrThrow),而 sqlite-jdbc 的 PreparedStatement.getMetaData() - * 会在同一连接上再开一条语句导致 “inconsistent internal state”。 - * 因此元数据访问触发真正的执行,之后 step() 直接从结果集取行。 - */ - private fun ensureExecuted() { - if (executed) return - executed = true - try { - resultSet = statement.executeQuery() - } catch (e: SQLException) { - // sqlite-jdbc 的 executeQuery 对无结果集语句(DDL、PRAGMA 赋值、 - // INSERT/UPDATE/DELETE 等)会直接抛错且**不执行**语句, - // 这里改用 execute() 真正执行(报错文案随版本不同)。 - val message = e.message.orEmpty() - if (message.contains("does not return", ignoreCase = true)) { - statement.execute() - } else { - throw e - } - } - } - - override fun reset() { - resultSet?.close() - resultSet = null - executed = false - } - - override fun clearBindings() { - statement.clearParameters() - } - - override fun close() { - resultSet?.close() - statement.close() - } - - private fun resultSetOrThrow(): ResultSet = - resultSet ?: throw IllegalStateException("statement has not been stepped") - - /** 列元数据:已执行时直接用结果集元数据,未执行时先执行查询再取。 */ - private fun metadataOrNull(): ResultSetMetaData? { - if (resultSet == null) { - ensureExecuted() - } - return runCatching { resultSet?.metaData }.getOrNull() - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt deleted file mode 100644 index 54b913e1c..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/LegacyTokenStatsSnapshotTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.model.BillingMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class LegacyTokenStatsSnapshotTest { - - private fun legacyPreferences( - vararg pairs: Pair, - ): Map = mapOf(*pairs) - - @Test - fun `parses token counts request count and prices for provider model`() { - val raw = - legacyPreferences( - "token_input_DEEPSEEK_deepseek-chat" to 100L, - "token_cached_input_DEEPSEEK_deepseek-chat" to 30L, - "token_output_DEEPSEEK_deepseek-chat" to 50L, - "request_count_DEEPSEEK_deepseek-chat" to 4, - "model_input_price_DEEPSEEK_deepseek-chat" to 1.0f, - "model_cached_input_price_DEEPSEEK_deepseek-chat" to 0.5f, - "model_output_price_DEEPSEEK_deepseek-chat" to 2.0f, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - assertEquals(1, snapshot.providerModels.size) - val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") - assertEquals(100L, stats.inputTokens) - assertEquals(30L, stats.cachedInputTokens) - assertEquals(50L, stats.outputTokens) - assertEquals(4L, stats.requestCount) - assertEquals(1.0, stats.priceSettings.inputPricePerMillion!!, 1e-9) - assertEquals(0.5, stats.priceSettings.cachedInputPricePerMillion!!, 1e-9) - assertEquals(2.0, stats.priceSettings.outputPricePerMillion!!, 1e-9) - } - - @Test - fun `count mode billing and per request price are captured`() { - val raw = - legacyPreferences( - "request_count_OPENAI_gpt-4o" to 2, - "billing_mode_OPENAI_gpt-4o" to "COUNT", - "price_per_request_OPENAI_gpt-4o" to 0.02f, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - val stats = snapshot.providerModels.getValue("OPENAI:gpt-4o") - assertEquals(BillingMode.COUNT, stats.priceSettings.billingMode) - assertEquals(0.02, stats.priceSettings.pricePerRequest!!, 1e-9) - assertEquals(2L, stats.requestCount) - } - - @Test - fun `explicit token billing mode is preserved`() { - val raw = - legacyPreferences( - "token_input_OPENAI_gpt-4o-mini-tts" to 100L, - "billing_mode_OPENAI_gpt-4o-mini-tts" to "TOKEN", - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - val stats = snapshot.providerModels.getValue("OPENAI:gpt-4o-mini-tts") - assertEquals(BillingMode.TOKEN, stats.priceSettings.billingMode) - } - - @Test - fun `missing counters are zero and all-zero models are dropped`() { - val raw = - legacyPreferences( - "token_input_DEEPSEEK_deepseek-chat" to 10L, - "token_cached_input_DEEPSEEK_deepseek-chat" to 0L, - "token_output_DEEPSEEK_deepseek-chat" to 0L, - "token_input_OTHER_some-model" to 0L, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - assertEquals(1, snapshot.providerModels.size) - val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") - assertEquals(10L, stats.inputTokens) - assertEquals(0L, stats.cachedInputTokens) - assertEquals(0L, stats.outputTokens) - } - - @Test - fun `int token values are widened to long`() { - val raw = - legacyPreferences( - "token_input_MOONSHOT_moonshot-v1-8k" to 42, - "token_output_MOONSHOT_moonshot-v1-8k" to 7, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - val stats = snapshot.providerModels.getValue("MOONSHOT:moonshot-v1-8k") - assertEquals(42L, stats.inputTokens) - assertEquals(7L, stats.outputTokens) - } - - @Test - fun `provider only keys are kept for the migrator to skip`() { - val raw = - legacyPreferences( - "token_input_DEEPSEEK" to 10L, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - assertEquals(1, snapshot.providerModels.size) - assertTrue(snapshot.providerModels.containsKey("DEEPSEEK")) - } - - @Test - fun `unknown provider decoding preserves model underscores`() { - val raw = - legacyPreferences( - "token_input_Custom_gpt_4" to 10L, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - assertTrue(snapshot.providerModels.containsKey("Custom:gpt_4")) - } - - @Test - fun `registered provider decoding preserves provider and model underscores`() { - val raw = - legacyPreferences( - "token_input_My_Custom_gpt_4" to 10L, - "token_output_My_Custom_gpt_4" to 5L, - ) - - val snapshot = - LegacyTokenStatsSnapshot.parse( - rawPreferences = raw, - additionalProviderNames = listOf("My", "My_Custom"), - ) - - val stats = snapshot.providerModels.getValue("My_Custom:gpt_4") - assertEquals(10L, stats.inputTokens) - assertEquals(5L, stats.outputTokens) - } - - @Test - fun `zero prices are treated as unset`() { - val raw = - legacyPreferences( - "token_input_DEEPSEEK_deepseek-chat" to 10L, - "model_input_price_DEEPSEEK_deepseek-chat" to 0.0f, - ) - - val snapshot = LegacyTokenStatsSnapshot.parse(raw) - - val stats = snapshot.providerModels.getValue("DEEPSEEK:deepseek-chat") - assertNull(stats.priceSettings.inputPricePerMillion) - assertFalse(stats.priceSettings.hasAnyUserSetting()) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt index cebe3c6ea..39801ed34 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt @@ -258,10 +258,6 @@ class ProviderUsageNormalizerTest { assertEquals(20L, snapshot.outputTokens) assertEquals(80L, snapshot.reasoningTokens) assertEquals(false, snapshot.reasoningIncludedInOutput) - assertEquals( - 100L, - TokenCostCalculator.billedOutputTokens(snapshot.toTokenUsageInput()), - ) } @Test @@ -285,10 +281,6 @@ class ProviderUsageNormalizerTest { assertEquals(0L, snapshot.outputTokens) assertEquals(0L, snapshot.reasoningTokens) assertEquals(false, snapshot.reasoningIncludedInOutput) - assertEquals( - 0L, - TokenCostCalculator.billedOutputTokens(snapshot.toTokenUsageInput()), - ) } @Test @@ -316,13 +308,15 @@ class ProviderUsageNormalizerTest { // ==== 本地模型 ==== @Test - fun `local providers report measured counts with explicit zero cache`() { - val snapshot = ProviderUsageNormalizer.local(1200, 340, ProviderUsageNormalizer.SOURCE_LLAMA) - assertEquals(1200L, snapshot.uncachedInputTokens) + fun `local providers preserve long measured counts with explicit zero cache`() { + val inputTokens = Int.MAX_VALUE.toLong() + 1L + val outputTokens = Int.MAX_VALUE.toLong() + 2L + val snapshot = ProviderUsageNormalizer.local(inputTokens, outputTokens, ProviderUsageNormalizer.SOURCE_LLAMA) + assertEquals(inputTokens, snapshot.uncachedInputTokens) assertEquals(0L, snapshot.cachedInputTokens) assertEquals(0L, snapshot.cacheWriteTokens) - assertEquals(1200L, snapshot.totalInputTokens) - assertEquals(340L, snapshot.outputTokens) + assertEquals(inputTokens, snapshot.totalInputTokens) + assertEquals(outputTokens, snapshot.outputTokens) assertNull(snapshot.reasoningTokens) assertNull(snapshot.reasoningIncludedInOutput) assertFalse(snapshot.cacheWriteSeparateBilling) @@ -353,30 +347,6 @@ class ProviderUsageNormalizerTest { assertEquals(100L, snapshot.totalInputTokens) assertNull(snapshot.uncachedInputTokens) assertNull(snapshot.cachedInputTokens) - val pricing = - ResolvedPricing( - billingMode = com.ai.assistance.operit.data.model.BillingMode.TOKEN, - currency = com.ai.assistance.operit.data.collects.PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 1.0, - source = PricingSource.DEFAULT, - known = true, - ) - assertNull( - TokenCostCalculator.computeCost( - snapshot.toTokenUsageInput(), - pricing, - ).amount - ) - assertEquals( - 120.0 / 1_000_000.0, - TokenCostCalculator.computeCost( - snapshot.toTokenUsageInput(), - pricing.copy(cachedInputPricePerMillion = 1.0), - ).amount!!, - 1e-12, - ) } @Test diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt deleted file mode 100644 index de4f4348a..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/QuarantineReliabilityTest.kt +++ /dev/null @@ -1,758 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class QuarantineReliabilityTest : TokenStatReliabilityTestBase() { - @Test - fun `summary only evidence can be explicitly acknowledged before snapshot`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summary = File(spool, "quarantine_summary.jsonl") - summary.writeText("{\"count\":1}\n") - var blockRan = false - - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must not silently omit the quarantine summary") - } catch (e: IOException) { - assertTrue(e.message!!.contains("quarantine evidence")) - } - assertFalse(blockRan) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context = context, - names = emptySet(), - deleteSummary = true, - ) - - assertFalse(summary.exists()) - assertEquals(null, TokenStatSpool.quarantineSummaryInfo(context)) - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - assertTrue(blockRan) - } - - @Test - fun `two corrupt segments quarantine uniquely and healthy segment drains`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{bad-one\n") - File(spool, "sealed_2.jsonl").writeText("{bad-two\n") - File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-corrupt")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-corrupt") - } - assertEquals(1, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(2, evidence.size) - assertEquals(2, evidence.map { it.name }.toSet().size) - } - - @Test - fun `quarantine at cap summarizes over-cap segment and keeps within-cap full evidence`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{new-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - // 硬边界:容量内完整证据保留;超限损坏段替换为固定大小摘要并移除原段 - assertTrue(existing.exists()) - assertFalse("over-cap corrupt segment must be replaced by its summary", File(spool, "sealed_2.jsonl").exists()) - assertEquals(1, TokenStatSpool.quarantineEvidence(context).size) - assertTrue( - "evidence disk usage must stay within the hard cap", - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("over-cap evidence must be reported as a bounded summary", summary) - assertEquals(1, summary!!.recordCount) - - // 导出包含摘要文件;确认删除只作用于完整证据(摘要保留为滚动记录) - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals(2, exported.size) - assertTrue(exported.any { it.name == "quarantine_summary.jsonl" }) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf(existing.name)) - assertFalse(existing.exists()) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } - - @Test - fun `quarantine hard cap keeps disk bounded far beyond cap and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - repeat(8) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{bad-$index\n") } - File(spool, "sealed_10.jsonl").writeText(line(request("healthy-beyond-cap")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitEvent("healthy-beyond-cap") - } - // 远超上限时:磁盘占用有界(完整证据不超上限)、摘要累计、健康段照常排空 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-beyond-cap", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence disk usage must stay within the hard cap", - evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(summary) - assertEquals(8, summary!!.recordCount) - assertTrue( - "summary must have a fixed upper bound", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES - ) - - // 导出/删除入口在满容量时可调用,摘要随导出提供 - val exported = TokenStatSpool.exportQuarantineEvidence(context, File(root, "evidence-export")) - assertEquals(2, exported.size) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, evidence.map { it.name }.toSet()) - assertEquals(0, TokenStatSpool.quarantineEvidence(context).size) - } - - @Test - fun `quarantine summary is rolling and never contains corrupt content`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val total = TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES + 50 - repeat(total) { index -> File(spool, "sealed_${index + 2}.jsonl").writeText("{corrupt-body-$index\n") } - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(summary) - assertTrue( - "summary must roll at a fixed line cap: ${summary!!.recordCount}", - summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES - ) - assertTrue( - "summary must have a fixed byte cap", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES - ) - val summaryText = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest records must survive the roll", summaryText.contains("sealed_${total + 1}.jsonl")) - assertTrue("summary must carry hash, bytes and line counts", summaryText.contains("sha256")) - assertFalse("summary must never embed corrupt content", summaryText.contains("corrupt-body")) - assertTrue(existing.exists()) - assertTrue( - TokenStatSpool.quarantineEvidence(context).sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES - ) - } - - @Test - fun `quarantine summary publish failure keeps old summary and pending segment`() = runBlocking { - val previousAtomic = TokenStatSpool.quarantineAtomicMoveForTest - TokenStatSpool.quarantineAtomicMoveForTest = { _, _ -> false } - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summaryFile = File(spool, "quarantine_summary.jsonl") - summaryFile.writeText("{\"old\":\"preserved\"}\n") - // 让回退提交失败:.bak 位置放一个非空目录,renameTo 无法覆盖(发布失败路径) - val bakDir = File(spool, "quarantine_summary.jsonl.bak") - bakDir.mkdirs() - File(bakDir, "lock").writeText("x") - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val segment = File(spool, "sealed_2.jsonl") - segment.writeText("{fail-publish-bad\n") - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - delay(800) - } - // 发布失败:旧摘要保持完整、待处理段保留、错误可见(不声称成功) - assertEquals("{\"old\":\"preserved\"}\n", summaryFile.readText()) - assertTrue("pending segment must be retained on publish failure", segment.exists()) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } finally { - TokenStatSpool.quarantineAtomicMoveForTest = previousAtomic - File(bakDir, "lock").delete() - bakDir.delete() - File(spool, "quarantine_summary.jsonl.new").delete() - } - } - - @Test - fun `quarantine summary survives interruption at each replacement step`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val summaryFile = File(spool, "quarantine_summary.jsonl") - val oldContent = "{\"k\":\"old\"}\n" - val newContent = "{\"k\":\"new\"}\n" - - // 窗口 A:target 缺失、.new 完整就绪(target→bak 之后、.new→target 之前崩溃) - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.new").writeText(newContent) - assertTrue(summaryFile.delete()) - val infoA = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull(infoA) - val recoveredA = summaryFile.readText().trim() - assertTrue( - "interruption must recover complete old or new: $recoveredA", - recoveredA == oldContent.trim() || recoveredA == newContent.trim(), - ) - - // 窗口 B:target 缺失、.bak=完整旧(bak 已就绪但恢复前崩溃) - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) - assertTrue(summaryFile.delete()) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(oldContent.trim(), summaryFile.readText().trim()) - - // 窗口 C:target=完整新、.bak=残留旧(提交后、清理前崩溃) - summaryFile.writeText(newContent) - File(spool, "quarantine_summary.jsonl.bak").writeText(oldContent) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(newContent.trim(), summaryFile.readText().trim()) - assertFalse("stale backup must be cleaned after a successful read", File(spool, "quarantine_summary.jsonl.bak").exists()) - - // 窗口 D:仅 .tmp 残留(tmp 写入后崩溃)→ target 完整旧 - summaryFile.writeText(oldContent) - File(spool, "quarantine_summary.jsonl.tmpstale").writeText(newContent) - assertNotNull(TokenStatSpool.quarantineSummaryInfo(context)) - assertEquals(oldContent.trim(), summaryFile.readText().trim()) - } - - @Test - fun `quarantine summary byte cap enforced with oversized pre-existing summary`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 预置超字节上限但行数很少的旧摘要(旧版本残留/手工膨胀),裁剪必须自愈 - val bigLine = "{\"padding\":\"${"x".repeat(30 * 1024)}\"}\n" - File(spool, "quarantine_summary.jsonl").writeText(bigLine.repeat(3)) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{byte-cap-bad\n") - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertTrue( - "summary must shrink below the byte cap: ${summary.summaryBytes}", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, - ) - assertTrue(summary.recordCount <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_LINES) - assertTrue("newest record must survive the byte roll", File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) - } - - @Test - fun `quarantine summary retry after crash does not duplicate record`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - val body = "{retry-bad\n" - File(spool, "sealed_2.jsonl").writeText(body) - // 模拟“上次摘要已发布、段删除前崩溃”:摘要已有同一段(file+sha256)的完整记录 - val sha = - java.security.MessageDigest.getInstance("SHA-256") - .digest(body.toByteArray(Charsets.UTF_8)) - .joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } - File(spool, "quarantine_summary.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${body.length}," + - "\"sha256\":\"$sha\",\"lineCount\":1,\"corruptLines\":1}\n", - ) - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - } - // 崩溃重试幂等:不重复追加记录,段正常处置 - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertEquals("crash retry must not duplicate the record", 1, summary.recordCount) - assertTrue(File(spool, "quarantine_summary.jsonl").readText().contains("sealed_2.jsonl")) - } - - @Test - fun `within-cap corrupt rename failure is kept as bounded pending-delete evidence and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{rename-fail-bad\n") - File(spool, "sealed_2.jsonl").writeText(line(request("healthy-after-pending")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - // 只让“移入证据区”的重命名失败,pending-delete 重命名放行(容量内预算允许) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-pending") - awaitNoSealedSegments(spool) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - // 健康事件恰一次入库;删除/重命名失败的段已移出 sealed 扫描队列为有界证据 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-after-pending", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - val pending = evidence.filter { it.name.startsWith("quarantine_pending_delete_") } - assertEquals("failed rename must be retained as pending-delete evidence", 1, pending.size) - assertTrue("full evidence must be preserved", pending.single().readText().contains("rename-fail-bad")) - assertTrue( - "error evidence must stay within the hard cap", - evidence.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, - ) - assertFalse("no tombstone needed while the pending budget fits", File(spool, "quarantine_skip_manifest.jsonl").exists()) - - // 维护/后台重试:恢复重命名能力后,下一次 drain 把 pending 证据移回证据区 - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "pending-delete evidence must be restored to the evidence area", - restored.any { it.name.startsWith("quarantine_") && !it.name.startsWith("quarantine_pending_delete_") }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `over-cap delete failure with full evidence area tombstone the segment and healthy drain continues`() = - runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{tombstone-bad\n") - File(spool, "sealed_3.jsonl").writeText(line(request("healthy-after-tombstone")) + "\n") - Mockito.mockStatic(AppLogger::class.java).use { - // 注入删除失败:只对 over-cap 损坏段生效(P1-2),健康段删除不受影响 - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name == "sealed_2.jsonl") false else null - } - try { - TokenStatSpool.replay(context) - awaitEvent("healthy-after-tombstone") - } finally { - TokenStatSpool.segmentDeleteForTest = null - } - } - // 后续健康事件恰一次入库;删除失败的 over-cap 段被 tombstone 跳过(摘要已有 hash/bytes) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("healthy-after-tombstone", database.tokenStatsDao().getAllEvents().single().eventId) - assertTrue( - "tombstoned segment must be recorded in the bounded manifest", - File(spool, "quarantine_skip_manifest.jsonl").readText().contains("sealed_2.jsonl"), - ) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "quarantine evidence area must stay within the hard cap (managed set separately bounded)", - evidence.filter { it.name.startsWith("quarantine_") }.sumOf { it.length() } <= TokenStatSpool.MAX_QUARANTINE_BYTES, - ) - // P1-3:tombstoned 原 sealed 作为 managed evidence 可见(参与 UI 计数/导出/删除) - assertTrue( - "tombstoned original sealed must appear as managed evidence", - evidence.any { it.name == "sealed_2.jsonl" }, - ) - assertTrue( - TokenStatSpool.quarantineEvidence(context).none { it.name.startsWith("quarantine_pending_delete_") }, - ) - - // 维护/后台重试:恢复删除能力后,下一次 drain 删除 tombstoned 段并移除记录 - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_2.jsonl") - awaitManifestWithout(spool, "sealed_2.jsonl") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - } - - @Test - fun `permanent dispose failures fill the managed set bounded then refuse appends and recover`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P1-1:删除与重命名永久失败(只针对 sealed 段:损坏处置、维护移回全部失败) - TokenStatSpool.segmentDeleteForTest = { file -> - if (file.name.startsWith("sealed_")) false else null - } - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - try { - // 超过受管集合上限的损坏段:受管集合封顶,剩余段有界跳过 - repeat(TokenStatSpool.MAX_TOMBSTONE_ENTRIES + 5) { index -> - File(spool, "sealed_${index + 1}.jsonl").writeText("{permanent-fail-$index\n") - } - val drainStart = System.nanoTime() - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val manifestCount: () -> Int = { - safeManifestText(manifest) - ?.lineSequence()?.filter { it.isNotBlank() }?.count() ?: 0 - } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - manifestCount() != TokenStatSpool.MAX_TOMBSTONE_ENTRIES - ) { - delay(20) - } - val drainMs = (System.nanoTime() - drainStart) / 1_000_000 - assertTrue("drain must return bounded: ${drainMs}ms", drainMs < 10_000) - delay(500) - val entryCount = manifestCount() - assertEquals( - "managed set must cap at the hard limit, never roll identities away", - TokenStatSpool.MAX_TOMBSTONE_ENTRIES, - entryCount, - ) - // 总占用有界:受管段(≤4MiB/段 × 上限)+ manifest(≤64KiB)+ 证据区 - val totalBytes = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "total spool usage must be bounded: $totalBytes", - totalBytes <= TokenStatSpool.MAX_TOMBSTONE_ENTRIES * (TokenStatSpool.MAX_SEGMENT_BYTES + 4096) + - TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES + 1_048_576, - ) - - // 超限新业务明确失败且无伪 durable - try { - TokenStatSpool.append(context, line(request("refused-after-cap")), "refused-after-cap") - fail("append beyond managed capacity must throw TokenStatsPersistenceException") - } catch (e: TokenStatsPersistenceException) { - } - assertEquals(0, database.tokenStatsDao().countEvents()) - - // snapshot barrier 有界失败(未受管段仍在队列),绝不死锁 - val snapStart = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { } - fail("snapshot must not claim drained while unmanageable segments remain") - } catch (e: IOException) { - assertTrue(e.message!!.contains("pending events")) - } - val snapMs = (System.nanoTime() - snapStart) / 1_000_000 - assertTrue("snapshot must be bounded: ${snapMs}ms", snapMs < 10_000) - - // 恢复文件系统:maintenance 清理受管段与陈旧条目 → 容量释放 → 新业务可继续 - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - awaitManifestWithout(spool, "sealed_") - TokenTrackingAIService.recordSafely(context, request("after-managed-recovery")) - awaitEvent("after-managed-recovery") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "after-managed-recovery", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - } finally { - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `stale tombstone identity never deletes or skips a reused-name healthy segment`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 旧损坏段 sealed_1 曾因处置失败被 tombstone(身份 = 旧内容 hash) - val oldBody = "{old-corrupt\n" - File(spool, "sealed_1.jsonl").writeText(oldBody) - // 崩溃窗口:旧文件被外部删除,manifest 尚未更新 - assertTrue(File(spool, "sealed_1.jsonl").delete()) - File(spool, "quarantine_skip_manifest.jsonl").writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"${sha256Hex(oldBody.toByteArray(Charsets.UTF_8))}\",\"overCap\":false}\n", - ) - // 新健康段复用同名(不同 hash) - File(spool, "sealed_1.jsonl").writeText(line(request("reused-name-healthy")) + "\n") - TokenStatSpool.replay(context) - awaitEvent("reused-name-healthy") - awaitSegmentGone(spool, "sealed_1.jsonl") - // P1-2:健康段恰一次真实入库,绝不被 tombstone 跳过或删除 - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("reused-name-healthy", database.tokenStatsDao().getAllEvents().single().eventId) - // 陈旧记录被移除 - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertFalse( - "stale tombstone must be removed", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - } - } - - @Test - fun `quarantine summary byte cap counts UTF-8 bytes for non-ASCII lines`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // P2-1:预置摘要的 UTF-16 长度低于 64KiB,但 UTF-8 字节超上限(每字符 3 字节) - val chineseLine = "{\"padding\":\"${"统".repeat(22 * 1024)}\"}\n" - File(spool, "quarantine_summary.jsonl").writeText(chineseLine) - val existing = File(spool, "quarantine_existing_sealed_1.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{utf8-cap-bad\n") - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - val summary = TokenStatSpool.quarantineSummaryInfo(context)!! - assertTrue( - "summary UTF-8 bytes must respect the cap: ${summary.summaryBytes}", - summary.summaryBytes <= TokenStatSpool.MAX_QUARANTINE_SUMMARY_BYTES, - ) - val text = File(spool, "quarantine_summary.jsonl").readText() - assertTrue("newest record must survive the roll", text.contains("sealed_2.jsonl")) - assertTrue( - "oversized non-ASCII line must be replaced by a fixed ASCII truncated record", - text.contains("\"truncated\":true"), - ) - assertFalse("truncated record must never embed content", text.contains("统")) - } - } - - @Test - fun `append capacity check recovers a full managed set from backup sidecar`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃窗口:canonical 缺失,只有 .bak(完整旧值)——受管集合已满 - val content = (1..TokenStatSpool.MAX_TOMBSTONE_ENTRIES).joinToString("\n") { index -> - "{\"ts\":1,\"file\":\"sealed_$index.jsonl\",\"bytes\":3," + - "\"sha256\":\"${sha256Hex("x$index".toByteArray(Charsets.UTF_8))}\",\"overCap\":true}" - } + "\n" - File(spool, "quarantine_skip_manifest.jsonl.bak").writeText(content) - try { - // append 容量检查必须看到恢复后的满受管集合:明确拒绝且不发布新文件 - TokenStatSpool.append(context, line(request("refused-bak-recovery")), "refused-bak-recovery") - fail("append must fail when the recovered managed set is full") - } catch (e: TokenStatsPersistenceException) { - } - assertEquals(0, database.tokenStatsDao().countEvents()) - // canonical 已恢复且 .bak 身份被清理 - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertEquals( - TokenStatSpool.MAX_TOMBSTONE_ENTRIES, - manifest.readText().lineSequence().filter { it.isNotBlank() }.count(), - ) - assertFalse(File(spool, "quarantine_skip_manifest.jsonl.bak").exists()) - } - } - - @Test - fun `seal copy partial target isolation with not durable dir sync writes tombstone evidence and fails closed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dispose-sync-a")) - val lineB = line(request("dispose-sync-b")) - val partial = lineA + "\n" - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - // gate(2) OK;隔离 rename 后的目录项 sync(第 3 次)失败 → dispose 返回 - // 失败并写 tombstone(候选 sealed 身份受管证据),绝不只留日志(P2) - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the partial target disposal is not durable", - TokenStatSpool.append(context, line(request("dispose-sync-c")), "dispose-sync-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - // 隔离文件本身是受管证据(seal_failed_*,可见/导出/ack/维护),tombstone - // 按候选 sealed 身份记录:崩溃后 sealed_1 以同内容重现时 scanner 跳过 - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertTrue( - "candidate identity must be tombstoned so a reappeared sealed_1 is skipped", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertEquals( - "active must be retained", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:维护清理隔离副本,健康内容各恰一次入 Room(tombstone 条目随文件 - // 消失确认后移除) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitEvent("dispose-sync-a") - awaitEvent("dispose-sync-b") - awaitSegmentGone(spool, isolated.name) - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal copy partial target deletion with not durable dir sync writes tombstone evidence and fails closed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("dispose-del-a")) - val lineB = line(request("dispose-del-b")) - val partial = lineA + "\n" - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - // 隔离 rename 失败 → 走安全删除;删除后的目录项 sync(第 3 次)失败 → - // 按候选 sealed 身份写 tombstone 并返回失败(P2:绝不只留日志) - TokenStatSpool.segmentRenameForTest = { from, _ -> - if (from.name.startsWith("sealed_")) false else null - } - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the partial target deletion is not durable", - TokenStatSpool.append(context, line(request("dispose-del-c")), "dispose-del-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - } - // 删除可见但未确认:候选名字不再存在,tombstone 记录其稳定身份(崩溃后 - // 以同内容重现时 scanner 跳过,绝不普通排空) - assertFalse("partial target deletion is visible", File(spool, "sealed_1.jsonl").exists()) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - assertTrue( - "candidate identity must be tombstoned for the crash-reappearance window", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertEquals( - "active must be retained", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:tombstone 条目随文件消失确认后移除,健康内容各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitEvent("dispose-del-a") - awaitEvent("dispose-del-b") - awaitManifestWithout(spool, "sealed_1.jsonl") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `over-cap dispose delete with FAILED and UNSUPPORTED dir sync keeps summary retryable and recovers without duplicate`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val existing = File(spool, "quarantine_existing_$tag.jsonl") - RandomAccessFile(existing, "rw").use { it.setLength(TokenStatSpool.MAX_QUARANTINE_BYTES) } - File(spool, "sealed_2.jsonl").writeText("{$tag-overcap-bad\n") - // bootstrap gate(2) + 摘要严格发布(2) 成功,删除后目录项 sync(第 5 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 4) TokenStatSpool.DirSyncResult.OK else result - } - TokenStatSpool.replay(context) - awaitSummaryPublishedAndSegmentGone(spool, "sealed_2.jsonl") - // 摘要已发布(可见)、段已删除(可见)但目录项未确认:本轮不得声称完成—— - // 无事件入 Room;恢复后摘要不重复。目录 sync 未恢复前严格读取不信任 canonical - // (P1-2),此处直接断言摘要文件可见。 - assertTrue( - "summary canonical must be published and visible", - File(spool, "quarantine_summary.jsonl").isFile, - ) - assertFalse("over-cap segment deletion is visible", File(spool, "sealed_2.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录项 sync OK 后幂等完成(摘要记录不重复、无遗留队列) - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - assertEquals(1, TokenStatSpool.quarantineSummaryInfo(context)!!.recordCount) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt new file mode 100644 index 000000000..e450346ec --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt @@ -0,0 +1,88 @@ +package com.ai.assistance.operit.data.stats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ReleasedProviderModelKeyDecoderTest { + @Test + fun `toolpkg provider id preserves underscores when separating the model`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_example_openai_compatible_provider:deepseek-chat", + provider = "Example OpenAI Compatible Provider", + model = "deepseek-chat", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_example_openai_compatible_provider_deepseek-chat", + mapOf("TOOLPKG_example_openai_compatible_provider" to "Example OpenAI Compatible Provider"), + ), + ) + } + + @Test + fun `future toolpkg providers use the same exact identity rule`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_future_provider_with_underscores:model_with_underscores", + provider = "Future Provider", + model = "model_with_underscores", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_future_provider_with_underscores_model_with_underscores", + mapOf("TOOLPKG_future_provider_with_underscores" to "Future Provider"), + ), + ) + } + + @Test + fun `toolpkg provider id takes precedence over a shorter display name`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_future_provider:model", + provider = "Future Provider", + model = "model", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_future_provider_model", + mapOf( + "TOOLPKG" to "ToolPkg", + "TOOLPKG_future_provider" to "Future Provider", + ), + ), + ) + } + + @Test + fun `legacy provider names are decoded when they are no longer registered`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "示例供应商:deepseek-chat", + provider = "示例供应商", + model = "deepseek-chat", + ), + ReleasedProviderModelKeyDecoder.decode("示例供应商_deepseek-chat"), + ) + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "unknown:provider_model", + provider = "unknown", + model = "provider_model", + ), + ReleasedProviderModelKeyDecoder.decode("unknown_provider_model"), + ) + } + + @Test + fun `malformed released keys still fail with a precise error`() { + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("unknownprovidermodel") + } + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("_model") + } + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("provider_") + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt deleted file mode 100644 index 1f9709504..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/RestoreReliabilityTest.kt +++ /dev/null @@ -1,780 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class RestoreReliabilityTest : TokenStatReliabilityTestBase() { - @Test - fun `restore barrier waits for segment read and old task cannot insert afterward`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("old-before-restore")) + "\n") - val read = CountDownLatch(1) - val release = CountDownLatch(1) - TokenStatSpool.afterSegmentReadForTest = { - read.countDown() - release.await(10, TimeUnit.SECONDS) - } - TokenStatSpool.replay(context) - assertTrue(read.await(10, TimeUnit.SECONDS)) - - val restore = async { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - // Simulates the restored database contents replacing everything inserted before - // this exclusive section. No old worker may insert after this point. - database.tokenStatsDao().deleteAllEvents() - } - } - delay(100) - assertFalse("restore must wait for the in-flight old drain", restore.isCompleted) - release.countDown() - restore.await() - delay(100) - assertNull(database.tokenStatsDao().getEvent("old-before-restore")) - } - - @Test - fun `deferred restore commit failure preserves old and new request accounting`() = runBlocking { - val oldRequest = request("old-request-after-commit-failure") - val oldEpoch = oldRequest.sessionEpoch - try { - TokenStatSpool.withExclusiveRestoreAccess( - context = context, - prepareBeforeCommit = {}, - commitReplacement = { throw IOException("REPLACING write failed") }, - ) { - fail("replacement must not run when commit fails") - } - fail("commit failure must propagate") - } catch (e: IOException) { - assertEquals("REPLACING write failed", e.message) - } - - assertEquals(oldEpoch, TokenStatSpool.captureRestoreEpoch()) - assertTrue(TokenStatSpool.isAcceptingEvents()) - TokenTrackingAIService.recordSafely(context, oldRequest) - TokenTrackingAIService.recordSafely(context, request("new-request-after-commit-failure")) - awaitEvent("old-request-after-commit-failure") - awaitEvent("new-request-after-commit-failure") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - - @Test - fun `restore with a live Room insert fails bounded before replacement and later restore is clean`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousInsert = TokenStatSpool.insertTimeoutMs - val previousQuiesce = TokenStatSpool.exclusiveQuiesceTimeoutMs - TokenStatSpool.insertTimeoutMs = 100 - TokenStatSpool.exclusiveQuiesceTimeoutMs = 150 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("evt-live-a")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n") - - // 真实 Room + 真实 spool 文件;DAO 层在 fence 之后、事务写入前挂起 - // (模拟 SQLite 已持有连接、忽略中断的旧 insert),释放后委托真实 DAO - val realDao = database.tokenStatsDao() - val entered = CountDownLatch(1) - val release = CountDownLatch(1) - val blockingDao = mock() - whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> - entered.countDown() - // SQLite 忽略中断:阻塞中的 insert 必须继续等待,不能被 task.cancel 打断 - while (true) { - try { - if (release.await(1, TimeUnit.SECONDS)) break - } catch (_: InterruptedException) { - } - } - runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } - } - whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> - runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } - } - whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> - runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } - } - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) - TokenStatsLedger.databaseProvider = { proxy } - - TokenStatSpool.replay(context) - assertTrue( - "insert must have passed the fence and be inside Room", - entered.await(10, TimeUnit.SECONDS) - ) - assertEquals(1, TokenStatSpool.activeInsertCountForTest()) - - // insert timeout 已释放 lifecycleMutex;restore 门闩必须有界失败, - // 替换块绝不执行(数据库不被覆盖/污染),durable 段保留 - val startedRestore = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - fail("replacement must never run while an old insert is live") - } - fail("restore must fail bounded") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 - assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) - assertEquals(0, database.tokenStatsDao().countEvents()) - assertTrue( - "durable segment must survive a failed restore", - File(spool, "sealed_1.jsonl").exists() - ) - - // 释放旧 insert:它只能写入未被替换的旧库;registry 清空后重试 restore 干净通过 - release.countDown() - awaitEvent("evt-live-a") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - // 模拟恢复数据库替换:旧事件必须已从排空路径彻底消失 - database.tokenStatsDao().deleteAllEvents() - } - assertEquals(0, database.tokenStatsDao().countEvents()) - // 自定义 SQLiteDriver 的 Room 没有 SupportSQLiteOpenHelper:直接复用 JVM 驱动 - // 打开同一数据库文件校验完整性 - val integrity = - JdbcSQLiteDriver().open(File(root, "app_database").absolutePath).use { connection -> - connection.prepare("PRAGMA integrity_check").use { statement -> - statement.step() - statement.getText(0) - } - } - assertEquals("restored database must pass integrity check", "ok", integrity) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.insertTimeoutMs = previousInsert - TokenStatSpool.exclusiveQuiesceTimeoutMs = previousQuiesce - } - } - } - - @Test - fun `restore cleanup deletion failure is explicit`() = runBlocking { - File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs(); resolve("active.jsonl").writeText("x") } - TokenStatSpool.spoolDeleteForTest = { false } - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore cleanup failure must propagate") - } catch (e: IOException) { - assertTrue(e.message!!.contains("cleanup failed")) - } - } - - @Test - fun `snapshot barrier moves spool only event into Room exactly once`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("spool-only-backup")) + "\n") - - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(spool.listFiles().orEmpty().none { it.name.startsWith("sealed_") }) - } - // A replay after the snapshot/restore boundary is idempotent and cannot duplicate it. - TokenStatSpool.replay(context) - delay(100) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `snapshot fails before block while quarantine evidence would be excluded`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{corrupt snapshot evidence\n") - var blockRan = false - - Mockito.mockStatic(AppLogger::class.java).use { - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must not silently omit quarantine evidence") - } catch (e: IOException) { - assertTrue(e.message!!.contains("quarantine evidence")) - } - } - - assertFalse("snapshot block must not run", blockRan) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue(evidence.single().readText().contains("corrupt snapshot evidence")) - } - - @Test - fun `manifest read failure fails closed scanner ack and append and recovers after restore`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText("{managed-bad\n") - // 先正常建立受管失败段(重命名失败 → tombstone 记录落盘) - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - try { - TokenStatSpool.replay(context) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - - // 注入 manifest 读取失败:scanner/容量/维护全部中止退避,受管段不处理 - TokenStatSpool.metadataReadErrorForTest = { file -> - file.name == "quarantine_skip_manifest.jsonl" - } - TokenStatSpool.replay(context) - delay(700) - assertTrue( - "managed segment must not be processed while the manifest is unreadable", - File(spool, "sealed_1.jsonl").exists(), - ) - assertTrue( - "manifest entry must be retained", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // ack 报错:manifest 不可读时不能确认删除 - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - fail("ack must fail while the manifest is unreadable") - } catch (e: IOException) { - } - assertTrue( - "entry must survive a failed ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - - // 容量检查 fail-closed:append 不发布、不声称 durable - assertFalse( - "append must fail closed while the manifest is unreadable", - TokenStatSpool.append( - context, - line(request("fail-closed-append")), - "fail-closed-append", - ), - ) - assertFalse( - File(spool, "active.jsonl").isFile && File(spool, "active.jsonl").length() > 0L, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复后正常:append 重新 durable,ack 按身份删除并清理记录 - TokenStatSpool.metadataReadErrorForTest = null - assertTrue( - TokenStatSpool.append( - context, - line(request("after-manifest-recovery")), - "after-manifest-recovery", - ), - ) - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertFalse(File(spool, "sealed_1.jsonl").exists()) - assertFalse( - "entry must be removed after a successful ack", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - TokenStatSpool.replay(context) - awaitEvent("after-manifest-recovery") - } finally { - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `ordinary evidence stage failure with rollback failure keeps uncommitted trash and maintenance restores it`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 普通(非受管)quarantine 证据:从不在 tombstone manifest 中。P1-1 修复前, - // 维护会因 manifest 缺失推断“已提交”而删除 trash(丢失未确认的证据)。 - val ev1 = File(spool, "quarantine_ord_a_sealed_1.jsonl").apply { writeText("{ord-a\n") } - val ev2 = File(spool, "quarantine_ord_b_sealed_2.jsonl").apply { writeText("{ord-b\n") } - // 第 2 个文件 stage rename 失败 + 第 1 个文件回滚失败 → ack 报错,trash 保留 - TokenStatSpool.segmentRenameForTest = { _, to -> - when { - to.parentFile?.name?.startsWith("quarantine_ack_trash_") == true && - to.name == ev2.name -> false - to.parentFile?.name != null && - !to.parentFile!!.name.startsWith("quarantine_ack_trash_") && - to.name == ev1.name -> false - else -> null - } - } - try { - try { - TokenStatSpool.acknowledgeAndDeleteQuarantine( - context, - setOf(ev1.name, ev2.name), - ) - fail("ack must report the staging failure") - } catch (e: IOException) { - } - val trashDirs = spool.listFiles().orEmpty() - .filter { it.isDirectory && it.name.startsWith("quarantine_ack_trash_") } - assertEquals(1, trashDirs.size) - val trash = trashDirs.single() - assertTrue("staged evidence must stay in trash", File(trash, ev1.name).exists()) - assertFalse(ev1.exists()) - assertTrue("ev2 stage never happened", ev2.exists()) - - // replay 维护(回滚 rename 仍被注入失败):绝不删除 trash 与证据 - TokenStatSpool.replay(context) - delay(700) - assertTrue("maintenance must never delete uncommitted ordinary evidence", trash.exists()) - assertTrue(File(trash, ev1.name).exists()) - assertFalse("no partial rollback may occur", ev1.exists()) - - // 恢复 rename 能力后 replay:维护按 mapping+identity 回滚,证据不删最终恢复 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.replay(context) - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be gone after a successful maintenance rollback", trash.exists()) - assertTrue("ev1 must be restored to the evidence area", ev1.exists()) - assertTrue("ev2 must stay in the evidence area", ev2.exists()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(setOf(ev1.name, ev2.name), evidence.map { it.name }.toSet()) - } finally { - TokenStatSpool.segmentRenameForTest = null - } - } - } - - @Test - fun `export fails closed when the spool root enumeration fails and recovers after the seam is restored`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 两个非空 stuck trash + 一个普通隔离文件:根枚举失败时 export 绝不能 - // 成功遗漏任何证据(P1-6) - val trashA = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trashA.mkdirs() - val evidenceA = File(trashA, "sealed_1.jsonl") - evidenceA.writeText("{root-null-a\n") - val trashB = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trashB.mkdirs() - val evidenceB = File(trashB, "sealed_2.jsonl") - evidenceB.writeText("{root-null-b\n") - val quarantineFile = File(spool, "quarantine_sealed_3.jsonl") - quarantineFile.writeText("{root-null-ev\n") - val base = File(root, "export-root-null").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - try { - TokenStatSpool.exportQuarantineEvidence(context, destination) - fail("export must fail when the spool root enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 源证据全部保留 - assertTrue(trashA.exists()) - assertTrue(evidenceA.exists()) - assertTrue(trashB.exists()) - assertTrue(evidenceB.exists()) - assertTrue(quarantineFile.exists()) - // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 - assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) - assertFalse(destination.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam 后完整 export 含全部证据(stuck trash 子目录 + 隔离文件) - val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) - assertTrue(exported.any { it.name == trashA.name }) - assertTrue(exported.any { it.name == trashB.name }) - assertTrue(exported.any { it.name == quarantineFile.name }) - val exportedTrash = File(destination2, trashA.name) - assertTrue(exportedTrash.isDirectory) - assertTrue(File(exportedTrash, evidenceA.name).readText() == "{root-null-a\n") - } - } - - @Test - fun `export fails closed when an ack trash directory enumeration fails and recovers after the seam is restored`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val trash = File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - val evidence = File(trash, "sealed_1.jsonl") - evidence.writeText("{child-null-evidence\n") - // 普通隔离文件先于 trash 被复制:child 枚举失败时目标目录已含部分内容 - val quarantineFile = File(spool, "quarantine_sealed_2.jsonl") - quarantineFile.writeText("{child-null-quarantine\n") - val base = File(root, "export-child-null").apply { mkdirs() } - val destination = File(base, "run-1").also { Files.createDirectory(it.toPath()) } - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == trash) null else dir.listFiles() - } - try { - try { - TokenStatSpool.exportQuarantineEvidence(context, destination) - fail("export must fail when an ack trash directory enumeration fails") - } catch (e: IOException) { - assertTrue("failure must name the enumeration error", e.message!!.contains("enumerate")) - } - // 源证据全部保留 - assertTrue(trash.exists()) - assertTrue(evidence.exists()) - assertTrue(quarantineFile.exists()) - // partial 目标未报告成功;UI 清理 helper 确认本轮目标被清除 - assertTrue(QuarantineExportCleanup.deleteRecursively(destination)) - assertFalse(destination.exists()) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复 seam 后完整 export 含全部证据 - val destination2 = File(base, "run-2").also { Files.createDirectory(it.toPath()) } - val exported = TokenStatSpool.exportQuarantineEvidence(context, destination2) - assertTrue(exported.any { it.name == trash.name }) - assertTrue(exported.any { it.name == quarantineFile.name }) - val exportedTrash = File(destination2, trash.name) - assertTrue(exportedTrash.isDirectory) - assertTrue(File(exportedTrash, evidence.name).readText() == "{child-null-evidence\n") - } - } - - @Test - fun `metadata publish refused at the hard cap edge stays bounded and maintenance restores appends`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 * 1024 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 证据区打满 16MiB:12MiB + 4MiB 两个文件(ack 其中一个后仍满 → 继续 summarize) - val existingBig = File(spool, "quarantine_a_sealed_1.jsonl") - RandomAccessFile(existingBig, "rw").use { it.setLength(12L * 1024 * 1024) } - val existingSmall = File(spool, "quarantine_b_sealed_2.jsonl") - RandomAccessFile(existingSmall, "rw").use { it.setLength(4L * 1024 * 1024) } - // 数据总量恰好等于总上限:首次 summary 发布的投影(+4×content)必超限 - val segment = File(spool, "sealed_3.jsonl") - RandomAccessFile(segment, "rw").use { it.setLength(8L * 1024 * 1024) } - val cap = TokenStatSpool.totalSpoolMaxBytesForTest ?: 0L - TokenStatSpool.replay(context) - delay(900) - // 有界失败:不写正式 summary、段保留、总量不超过上限 - assertFalse( - "summary must not be published when the metadata budget is exhausted", - File(spool, "quarantine_summary.jsonl").exists(), - ) - assertTrue("pending segment must be retained", segment.exists()) - assertTrue( - "total must stay within the cap: ${spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }}", - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } <= cap, - ) - // 维护释放空间(此时总量恰好 = cap,ack 的 trash 状态元数据投影按 P1-1 - // 必被拒——见 ack 状态预算测试;这里模拟外部/维护释放:移除一个证据文件) - // → 重试成功发布摘要 → append 恢复 - assertTrue(existingSmall.delete()) - TokenStatSpool.replay(context) - awaitSegmentGone(spool, "sealed_3.jsonl") - val summary = TokenStatSpool.quarantineSummaryInfo(context) - assertNotNull("summary must be published after maintenance frees the budget", summary) - assertTrue( - TokenStatSpool.append( - context, - line(request("after-budget-recovery")), - "after-budget-recovery", - ), - ) - TokenStatSpool.replay(context) - awaitEvent("after-budget-recovery") - } finally { - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `restore cleanup dir sync failure invalidates the gate so consecutive appends fail without writing and recover exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineB = line(request("gate-restore-b")) - val lineC = line(request("gate-restore-c")) - var calls = 0 - try { - // 阶段 0:gate=true——经快照 barrier 完成 bootstrap 两次确认(filesDir + - // spool),不触发 drain(append 会调度 drain 与阶段 1 的恢复竞态)。 - // 行构造在屏障外:屏障排他期间门控立即拒绝统计数据库访问(自死锁防护)。 - val lineA = line(request("gate-restore-a")) - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - File(spool, "active.jsonl").writeText(lineA + "\n") - } - assertEquals(2, calls) - // 阶段 1:restore 清理删除 spool 目录,删除后 filesDir 目录项 sync(第 3 次) - // 失败 → restore 明确失败;删除开始前 gate 必须已失效(P1-1 修复) - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore must fail when the cleanup dir sync fails") - } catch (e: IOException) { - assertTrue(e.message!!.contains("durable")) - } - assertEquals(3, calls) - assertFalse("spool deletion is visible", spool.exists()) - // 阶段 2:restore 替换已开始(清理失败属替换后失败)——P1 终审 fence - // 拒绝本进程一切后续 append(accepting=false,直到重启),任何事件绝不 - // 写入;若 fence 失效,bootstrap gate 也已失效,同样全部失败 - assertFalse(TokenStatSpool.append(context, lineB, "gate-restore-b")) - assertFalse(TokenStatSpool.append(context, lineC, "gate-restore-c")) - assertFalse( - "no event may be written while dir entries are unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复 OK。P1 终审:恢复替换已开始(清理失败属于替换后失败), - // 同进程事件被明确拒绝——先模拟进程重启(reset 状态)才允许写入; - // 重启后目录项重新确认(bootstrap),两事件各恰一次入 Room。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.clearPendingStateForTest() - assertTrue(TokenStatSpool.append(context, lineB, "gate-restore-b")) - assertTrue(TokenStatSpool.append(context, lineC, "gate-restore-c")) - TokenStatSpool.replay(context) - awaitEvent("gate-restore-b") - awaitEvent("gate-restore-c") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `pending-delete evidence restore with FAILED and UNSUPPORTED dir sync rebuilds retryable record and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{$tag-pending-bad\n" - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-pending-healthy-$tag")) + "\n") - // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - TokenStatSpool.replay(context) - awaitEvent("syncfail-pending-healthy-$tag") - assertEquals(1, database.tokenStatsDao().countEvents()) - val pending = spool.listFiles().orEmpty().single { - it.isFile && it.name.startsWith("quarantine_pending_delete_") - } - // 阶段 2:维护恢复 rename 后目录项 sync 失败(bootstrap gate 已在阶段 1 确认, - // 本阶段第一次 sync 就是恢复 rename 的目录项)→ 尽力移回 pending-delete 名 - // (重建可重试记录),本轮不推进 - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.dirSyncForTest = { result } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "pending-delete record must be rebuilt when the restore rename is not durable", - pending.exists(), - ) - // pending-delete 文件本身是受管证据(计入 quarantineEvidence),但必须仍是 - // pending-delete 名(未被推进到完整证据区) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence must still be the pending-delete record", - evidence.single().name.startsWith("quarantine_pending_delete_"), - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 恢复:rename 目录项确认持久后证据恰一次回到完整证据区 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, restored.size) - assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `tombstone evidence restore rename with FAILED and UNSUPPORTED dir sync keeps manifest entry and recovers exactly once`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{$tag-evidence-bad\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("syncfail-evidence-healthy-$tag")) + "\n") - // 阶段 1:两次 rename 都失败 → tombstone(容量内,overCap=false);健康段排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_")) false else null - } - TokenStatSpool.replay(context) - awaitEvent("syncfail-evidence-healthy-$tag") - val entryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < entryDeadline && - safeManifestText(manifest)?.contains("sealed_1.jsonl") != true - ) { - delay(20) - } - assertTrue(safeManifestText(manifest)?.contains("sealed_1.jsonl") == true) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 1 的 drain 可能仍在收尾(tombstone 发布后的队列复扫 sync)——先静默 - // 至 drain 完全结束,阶段 2 的计数 seam 才能从确定的第一笔 sync 开始 - delay(300) - // 阶段 2:恢复 rename 成功但目录项 sync 失败(bootstrap gate 已在阶段 1 确认; - // 本阶段第 1 次 sync 是 manifest 严格读取、第 2 次是容量判定读取、第 3 次才是 - // restore rename 的目录项)→ 条目保留、本轮不推进 - TokenStatSpool.segmentRenameForTest = null - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls == 3) result else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "manifest entry must be retained while the restore rename is unconfirmed", - safeManifestText(manifest)?.contains("sealed_1.jsonl") == true, - ) - assertFalse("sealed original is gone (rename visible)", File(spool, "sealed_1.jsonl").exists()) - assertTrue( - "evidence must already be at the quarantine name", - TokenStatSpool.quarantineEvidence(context).any { it.readText() == body }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 恢复:确认 rename 持久后条目幂等移除,证据恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitManifestWithout(spool, "sealed_1.jsonl") - assertEquals( - 1, - TokenStatSpool.quarantineEvidence(context).count { it.readText() == body }, - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - - @Test - fun `restore cleanup with FAILED and UNSUPPORTED dir sync fails closed and retry after recovery is idempotent`() = - runBlocking { - suspend fun scenario(result: TokenStatSpool.DirSyncResult, tag: String) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "active.jsonl").writeText("{$tag-restore\n") - // bootstrap gate(2) OK,spool 目录删除后的 filesDir 目录项 sync(第 3 次)失败 - var calls = 0 - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls <= 2) TokenStatSpool.DirSyncResult.OK else result - } - try { - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - fail("restore must fail when the spool cleanup is not durable") - } catch (e: IOException) { - assertTrue("restore state must be retained", e.message!!.contains("durable")) - } - assertFalse("spool deletion is visible", spool.exists()) - // 重试幂等:目录已不存在时跳过删除,确认持久后 restore 成功 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { } - assertFalse(spool.exists()) - } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.FAILED, "fail") { r, t -> scenario(r, t) } - runDirSyncFailClosedScenario(TokenStatSpool.DirSyncResult.UNSUPPORTED, "unsupported") { r, t -> scenario(r, t) } - } - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt deleted file mode 100644 index 0e01968f4..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolConcurrencyTest.kt +++ /dev/null @@ -1,378 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class SpoolConcurrencyTest : TokenStatReliabilityTestBase() { - @Test - fun `generation handles same millisecond clock rollback and request spanning reset`() = runBlocking { - val dao = database.tokenStatsDao() - val oldSameMillisecond = request("old-same-ms", generation = 0L, startedAt = 5_000L) - val oldFinishesAfterReset = request("old-spanning", generation = 0L, startedAt = 9_000L) - - dao.resetAllStatisticsTx() - assertEquals(1L, dao.currentResetGeneration()) - TokenStatsLedger.recordWith(context, dao, oldSameMillisecond) - TokenStatsLedger.recordWith(context, dao, oldFinishesAfterReset) - assertEquals(0, dao.countEvents()) - - // New request after reset is accepted even if its wall clock moved backwards. - TokenStatsLedger.recordWith( - context, - dao, - request("new-clock-rollback", generation = 1L, startedAt = 1L), - ) - assertEquals(1, dao.countEvents()) - assertEquals(1L, dao.getEvent("new-clock-rollback")!!.acceptedGeneration) - } - - @Test - fun `interrupt ignoring insert never locks spool and restore barrier stays clean`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousInsert = TokenStatSpool.insertTimeoutMs - TokenStatSpool.insertTimeoutMs = 100 - try { - val spoolDir = File(root, TokenStatSpool.SPOOL_DIR_NAME) - spoolDir.mkdirs() - // 先构建行(价格解析走真实 DAO),再安装忽略中断但可释放的 insert 挂起 - val lineA = line(request("evt-hung-a")) - val lineB = line(request("evt-hung-b")) - val realDao = database.tokenStatsDao() - val release = CountDownLatch(1) - val blockingDao = mock() - whenever(blockingDao.insertIdentityIfAbsent(any())).thenAnswer { invocation -> - // SQLite 忽略中断:cancel(true) 无法终止;释放后委托真实 DAO 完成 - gateIgnoringInterrupts(release) - runBlocking { realDao.insertIdentityIfAbsent(invocation.getArgument(0)) } - } - whenever(blockingDao.upsertDisplayModel(any())).thenAnswer { invocation -> - runBlocking { realDao.upsertDisplayModel(invocation.getArgument(0)) } - } - whenever(blockingDao.insertEventIfNotResetCovered(any())).thenAnswer { invocation -> - runBlocking { realDao.insertEventIfNotResetCovered(invocation.getArgument(0)) } - } - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(blockingDao) - TokenStatsLedger.databaseProvider = { proxy } - - // append A durable;drain 启动后 insert 挂起(忽略中断) - assertTrue(TokenStatSpool.append(context, lineA, "evt-hung-a")) - val latchDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.pendingLatchCountForTest() == 0 && System.nanoTime() < latchDeadline) { - delay(10) - } - assertEquals(1, TokenStatSpool.pendingLatchCountForTest()) - - // 硬上限(insertTimeoutMs)之后:锁必须已释放,append 不再被阻塞 - val startedSecond = System.nanoTime() - assertTrue(TokenStatSpool.append(context, lineB, "evt-hung-b")) - val secondElapsedMs = (System.nanoTime() - startedSecond) / 1_000_000 - assertTrue("append must never block on the hung insert: ${secondElapsedMs}ms", secondElapsedMs < 10_000) - - // restore barrier:wedged insert 仍存活(已通过 fence、正在 Room 内)时, - // 必须有界失败且绝不替换文件;等待结束后旧 insert 仍登记在 registry - val startedRestore = System.nanoTime() - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false, clearAfter = true) { } - fail("restore must fail bounded while an old insert is still live") - } catch (e: IOException) { - assertTrue("restore must report the live insert", e.message!!.contains("still active")) - } - val restoreElapsedMs = (System.nanoTime() - startedRestore) / 1_000_000 - assertTrue("restore must be bounded: ${restoreElapsedMs}ms", restoreElapsedMs < 10_000) - assertEquals(1, TokenStatSpool.activeInsertCountForTest()) - - // 模拟重启前必须释放并确认旧 insert 线程终止:释放门闩 → registry 真正清空 - release.countDown() - val registryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (TokenStatSpool.activeInsertCountForTest() != 0 && System.nanoTime() < registryDeadline) { - delay(10) - } - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - - // 丢弃已完成的旧 worker(shutdown 后线程真实终止),再模拟进程重启; - // 被卡任务由新排空重放幂等完成(失败的 restore 从未替换数据库) - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - assertEquals(0, TokenStatSpool.activeInsertCountForTest()) - assertEquals(0, TokenStatSpool.pendingLatchCountForTest()) - - TokenStatsLedger.databaseProvider = { database } - TokenTrackingAIService.recordSafely(context, request("evt-after-restore")) - awaitEvent("evt-after-restore") - awaitEvent("evt-hung-a") - awaitEvent("evt-hung-b") - assertEquals(3, database.tokenStatsDao().countEvents()) - - // 重试 restore:registry 已空,替换模拟可执行,恢复后的 DB 无旧事件 - TokenStatSpool.withExclusiveSnapshotAccess( - context, - drainBefore = false, - clearAfter = true, - ) { - database.tokenStatsDao().deleteAllEvents() - } - assertEquals(0, database.tokenStatsDao().countEvents()) - assertNull(database.tokenStatsDao().getEvent("evt-hung-a")) - assertNull(database.tokenStatsDao().getEvent("evt-hung-b")) - assertNull(database.tokenStatsDao().getEvent("evt-after-restore")) - - // 恢复后的新事件正常落账且只出现一次。P1 终审:恢复替换已开始(accepting= - // false),同进程后续事件被明确拒绝——必须先模拟进程重启(reset 状态)才 - // 允许写入;这正是 UI“稍后重启”窗口的语义。 - TokenStatSpool.resetExecutorsForTest() - TokenTrackingAIService.recordSafely(context, request("evt-post-restore")) - awaitEvent("evt-post-restore") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("evt-post-restore", database.tokenStatsDao().getAllEvents().single().eventId) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.insertTimeoutMs = previousInsert - } - } - } - - @Test - fun `database preparation timeouts stay single flight with bounded threads`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousPrepare = TokenStatSpool.prepareTimeoutMs - TokenStatSpool.prepareTimeoutMs = 50 - try { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "sealed_1.jsonl").writeText(line(request("evt-db-prep-hang")) + "\n") - // 数据库准备挂起且忽略中断(可释放):每次 drain 循环都必须单飞复用同一任务 - val release = CountDownLatch(1) - TokenStatsLedger.databaseProvider = { - gateIgnoringInterrupts(release) - database - } - TokenStatSpool.replay(context) - // 第 1 个退避周期 - delay(1_200) - TokenStatSpool.replay(context) - // 第 2 个退避周期 - delay(2_200) - val dbThreads = Thread.getAllStackTraces().keys.count { - it.isAlive && it.name.startsWith("operit-token-stats-database") - } - assertTrue("database preparation must stay single-flight: $dbThreads", dbThreads <= 1) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 释放被卡住的准备任务并确认旧 worker 真实终止后再模拟重启 - release.countDown() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - - // 恢复后(重置 worker 模拟重启)事件仍能落账 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitEvent("evt-db-prep-hang") - assertEquals(1, database.tokenStatsDao().countEvents()) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.prepareTimeoutMs = previousPrepare - } - } - } - - @Test - fun `concurrent exports keep unique directories and one failing export never deletes the other success`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - File(spool, "quarantine_aa_sealed_1.jsonl").writeText("aa-bad\n") - File(spool, "quarantine_bb_sealed_2.jsonl").writeText("bb-bad\n") - val base = File(root, "export-runs").apply { mkdirs() } - // P2:UI 命名 = 时间戳前缀 + UUID;同一毫秒前缀下 UUID 保证目录唯一, - // 目录用 Files.createDirectory 独占创建(已存在即失败) - fun uniqueExportDir(ts: String): File = - File(base, "token_stats_quarantine_${ts}_${UUID.randomUUID().toString().replace("-", "")}") - .also { Files.createDirectory(it.toPath()) } - val destA = uniqueExportDir("20260806_120000") - val destB = uniqueExportDir("20260806_120000") - // 两个导出经 lifecycleMutex 串行:A 的 manifest 两次读取(manifestContent + - // evidence 列表)放行,B 的 manifestContent 读取(第 3 次)注入失败 - var manifestReads = 0 - TokenStatSpool.metadataReadErrorForTest = { file -> - if (file.name == "quarantine_skip_manifest.jsonl") { - manifestReads += 1 - manifestReads == 3 - } else { - false - } - } - try { - val exportA = async { TokenStatSpool.exportQuarantineEvidence(context, destA) } - val exportB = async { - try { - TokenStatSpool.exportQuarantineEvidence(context, destB) - fail("export B must fail with the injected manifest read failure") - } catch (e: IOException) { - } - } - val exportedA = exportA.await() - exportB.await() - // 各自目录独立且完整:A 成功导出两份证据 - assertEquals( - setOf("quarantine_aa_sealed_1.jsonl", "quarantine_bb_sealed_2.jsonl"), - exportedA.map { it.name }.toSet(), - ) - assertEquals(exportedA.size, destA.listFiles().orEmpty().size) - // 失败的导出绝不删除另一成功导出的目录/内容;自身目录也未被删除 - assertTrue("successful export directory must stay intact", destA.isDirectory) - assertTrue(destA.listFiles().orEmpty().all { it.isFile }) - assertTrue("failed export directory must not be deleted by the spool", destB.isDirectory) - - // 恢复后正常:新的导出成功且只含本次证据 - TokenStatSpool.metadataReadErrorForTest = null - val retried = TokenStatSpool.exportQuarantineEvidence( - context, - uniqueExportDir("20260806_120001"), - ) - assertTrue(retried.any { it.name.startsWith("quarantine_aa_") }) - assertTrue(retried.any { it.name.startsWith("quarantine_bb_") }) - } finally { - TokenStatSpool.metadataReadErrorForTest = null - } - } - } - - // ── P1 终审:durable bootstrap gate / 维护目录项严格同步 ────────────────────── - - @Test - fun `schedule during an in-flight drain round is not lost and the worker reruns`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("rerun-a")) - val lineB = line(request("rerun-b")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") - var rounds = 0 - var replayInjected = false - TokenStatSpool.afterDrainRoundForTest = { - rounds += 1 - // 第一轮结束、轮末决策之前注入一次 replay:请求必须被保留并由同一 worker - // 立即 rerun(旧实现:drainScheduled=true 直接丢弃该请求,轮数恒为 1)。 - if (!replayInjected) { - replayInjected = true - TokenStatSpool.replay(context) - } - } - try { - TokenStatSpool.replay(context) - // 两轮结束:第 1 轮排空数据,第 2 轮消费注入的请求(维护轮)后 retire - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (rounds < 2 && System.nanoTime() < deadline) delay(10) - assertEquals("injected replay must trigger a rerun round", 2, rounds) - awaitEvent("rerun-a") - awaitEvent("rerun-b") - awaitNoSealedSegments(spool) - assertFalse("request must be consumed by the rerun", TokenStatSpool.drainRequestPendingForTest()) - assertFalse("worker must retire after the rerun", TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.afterDrainRoundForTest = null - } - } - - @Test - fun `rejected drain schedule retains the request and recovers on the next schedule`() = runBlocking { - TokenStatSpool.rejectDrainScheduleForTest = true - try { - val lineA = line(request("rejected-schedule-a")) - assertTrue("append must succeed durably despite rejected scheduling", TokenStatSpool.append(context, lineA, "rejected-schedule-a")) - assertTrue("request must be retained after rejection", TokenStatSpool.drainRequestPendingForTest()) - assertFalse("schedule token must be released after rejection", TokenStatSpool.drainScheduledForTest()) - // 恢复调度能力后 replay:请求不丢,事件最终入 Room - TokenStatSpool.rejectDrainScheduleForTest = false - TokenStatSpool.replay(context) - awaitEvent("rejected-schedule-a") - assertFalse(TokenStatSpool.drainRequestPendingForTest()) - assertFalse(TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.rejectDrainScheduleForTest = false - } - } - - @Test - fun `await initial drain joins concurrent waiters and failed rounds are retryable`() = runBlocking { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("init-drain-a")) - val lineB = line(request("init-drain-b")) - File(spool, "sealed_1.jsonl").writeText(lineA + "\n" + lineB + "\n") - // 失败轮:bootstrap gate 目录 sync 失败 → drainCore false → 等待者按失败完成 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - try { - assertFalse("failed round must complete the waiter with false", TokenStatSpool.awaitInitialDrain(context, 5_000)) - // 失败不缓存:恢复后重试成功;并发调用 join 同一轮 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - val r1 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } - val r2 = async { TokenStatSpool.awaitInitialDrain(context, 10_000) } - assertTrue("retry must succeed", r1.await()) - assertTrue("concurrent join must see the same success", r2.await()) - awaitEvent("init-drain-a") - awaitEvent("init-drain-b") - awaitNoSealedSegments(spool) - assertFalse(TokenStatSpool.drainRequestPendingForTest()) - assertFalse(TokenStatSpool.drainScheduledForTest()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - - @Test - fun `timed out initial drain waiter is removed when scheduling stays rejected`() = runBlocking { - TokenStatSpool.rejectDrainScheduleForTest = true - try { - assertFalse(TokenStatSpool.awaitInitialDrain(context, 25)) - assertEquals(0, TokenStatSpool.initialDrainWaiterCountForTest()) - assertTrue("drain request remains retryable", TokenStatSpool.drainRequestPendingForTest()) - } finally { - TokenStatSpool.rejectDrainScheduleForTest = false - } - } - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt deleted file mode 100644 index d9b5b7dca..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolRecoveryTest.kt +++ /dev/null @@ -1,855 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class SpoolRecoveryTest : TokenStatReliabilityTestBase() { - @Test - fun `spool root enumeration failure aborts snapshot and seal without touching segments then recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body1 = line(request("enum-null-sealed")) + "\n" - val body2 = line(request("enum-null-active")) + "\n" - val sealed1 = File(spool, "sealed_1.jsonl") - sealed1.writeText(body1) - val active = File(spool, "active.jsonl") - active.writeText(body2) - val activeText = active.readText() - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - // active 已有内容:下一次 append 必走 seal 路径(P1-7 场景) - TokenStatSpool.MAX_SEGMENT_BYTES = active.length() + 1 - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - // 1) 快照的 drain 阶段 fail-closed → block 绝不执行、文件原字节不变 - var blockRan = false - try { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = true) { - blockRan = true - } - fail("snapshot must fail while the spool root enumeration fails") - } catch (e: IOException) { - assertTrue( - "failure must come from the snapshot drain barrier", - e.message!!.contains("drain"), - ) - } - assertFalse("snapshot block must not run", blockRan) - assertEquals("sealed_1 must stay byte-identical", body1, sealed1.readText()) - assertEquals("active must stay byte-identical", activeText, active.readText()) - - // 2) seal 绝不覆盖:需要 seal 的 append 明确失败,sealed_1/active 原样 - assertFalse( - "append requiring a seal must fail while enumeration fails", - TokenStatSpool.append( - context, - line(request("enum-null-extra")), - "enum-null-extra", - ), - ) - assertEquals("sealed_1 must never be overwritten", body1, sealed1.readText()) - assertEquals("active must not be sealed or truncated", activeText, active.readText()) - - // 3) drain 保留:后台 drain 轮退避,文件与事件原样 - TokenStatSpool.replay(context) - delay(700) - assertEquals("sealed_1 must be preserved by the failing drain", body1, sealed1.readText()) - assertEquals("active must be preserved by the failing drain", activeText, active.readText()) - assertNull( - "sealed event must not reach Room while enumeration fails", - database.tokenStatsDao().getEvent("enum-null-sealed"), - ) - assertNull( - "active event must not reach Room while enumeration fails", - database.tokenStatsDao().getEvent("enum-null-active"), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 4) 恢复 seam → 两事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("enum-null-sealed") - awaitEvent("enum-null-active") - assertEquals( - "each preserved event must be recorded exactly once", - 2, - database.tokenStatsDao().countEvents(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal never overwrites an occupied target and picks the next safe index`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val bodyA = line(request("seal-collide-a")) + "\n" - val bodyB = line(request("seal-collide-b")) + "\n" - File(spool, "sealed_1.jsonl").writeText(bodyA) - val sealed2 = File(spool, "sealed_2.jsonl") - sealed2.writeText(bodyB) - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - try { - // active 已含一条完整行;枚举缝隐藏 sealed_2 → 计算出的 next=2 已被 - // 占用,seal 必须跳到 3,绝不覆盖 sealed_2 - val bodyC = line(request("seal-collide-c")) + "\n" - File(spool, "active.jsonl").writeText(bodyC) - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) { - dir.listFiles()?.filter { it.name != "sealed_2.jsonl" }?.toTypedArray() - } else { - dir.listFiles() - } - } - try { - assertTrue( - "append must seal active to a free index and succeed", - TokenStatSpool.append( - context, - line(request("seal-collide-d")), - "seal-collide-d", - ), - ) - // seam 仍生效:并发 drain 与 seal 都看不见 sealed_2 → 占用目标不可能被覆盖 - assertEquals( - "occupied sealed target must never be overwritten", - bodyB, - sealed2.readText(), - ) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 恢复枚举后全部事件各恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("seal-collide-a") - awaitEvent("seal-collide-b") - awaitEvent("seal-collide-c") - awaitEvent("seal-collide-d") - assertEquals( - "each event must be recorded exactly once", - 4, - database.tokenStatsDao().countEvents(), - ) - } finally { - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `seal publish conflict keeps target bytes and seals active at a higher index`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val seamBody = "{pre-existing-conflict\n" - try { - // active 已含两条完整行;下一次 append 必触发 seal(候选编号 1) - val lineA = line(request("seal-seam-a")) - val lineB = line(request("seal-seam-b")) - val lineC = line(request("seal-seam-c")) - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var hooks = 0 - TokenStatSpool.beforeSealPublishForTest = { target -> - hooks += 1 - if (hooks == 1) { - // 候选选定后、实际 publish 前出现同名不同内容的目标(模拟异常残留) - target.writeText(seamBody) - true - } else { - null - } - } - // 停掉后台 writer:seal 仍在 append 内同步完成,但断言阶段不会被并发 drain - // 改写/隔离文件(确定性) - TokenStatSpool.shutdownWriterForTest() - try { - assertTrue( - "append must seal active to a free index and succeed", - TokenStatSpool.append(context, lineC, "seal-seam-c"), - ) - } finally { - TokenStatSpool.beforeSealPublishForTest = null - } - // 冲突目标原字节不变;active 数据安全落到更高编号 sealed_2;新事件在 active - assertEquals( - "conflict target must keep its original bytes", - seamBody, - File(spool, "sealed_1.jsonl").readText(), - ) - assertEquals( - "active data must be sealed to a higher index", - lineA + "\n" + lineB + "\n", - File(spool, "sealed_2.jsonl").readText(), - ) - assertEquals( - "new event must be durable in active", - lineC + "\n", - File(spool, "active.jsonl").readText(), - ) - // 全部事件各恰一次入 Room;冲突残留被隔离为完整证据、字节不变 - TokenStatSpool.replay(context) - awaitEvent("seal-seam-a") - awaitEvent("seal-seam-b") - awaitEvent("seal-seam-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "conflict residue must be preserved byte-identical as evidence", - evidence.any { it.isFile && it.readText() == seamBody }, - ) - } finally { - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal crash window recovers before append and each event drains exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("hardlink-window-a")) - val lineB = line(request("hardlink-window-b")) - val active = File(spool, "active.jsonl") - active.writeText(lineA + "\n") - // 模拟崩溃窗口:createLink(sealed_1, active) 成功但 active 删除前崩溃 → 同 inode - Files.createLink(File(spool, "sealed_1.jsonl").toPath(), active.toPath()) - // 停掉后台 writer:恢复发生在 append 内同步完成,断言不被并发 drain 干扰 - TokenStatSpool.shutdownWriterForTest() - // append 必须先恢复重复(删除 sealed 副本)再写入,绝不能把新内容写进已 seal 段 - assertTrue(TokenStatSpool.append(context, lineB, "hardlink-window-b")) - assertFalse( - "sealed duplicate must be removed before append, never polluted", - File(spool, "sealed_1.jsonl").exists(), - ) - TokenStatSpool.replay(context) - awaitEvent("hardlink-window-a") - awaitEvent("hardlink-window-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("hardlink-window-a", "hardlink-window-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `hardlink seal post-active-delete dir sync failure keeps durable link and drains once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("link-post-sync-a")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录);第三次(链接 - // 创建)成功,第四次(active 删除)失败 - if (dirSyncCalls != 4) TokenStatSpool.DirSyncResult.OK else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the post-delete dir sync fails", - TokenStatSpool.append(context, line(request("link-post-sync-b")), "link-post-sync-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - assertEquals(4, dirSyncCalls) - assertTrue( - "durable link must be kept", - File(spool, "sealed_1.jsonl").exists(), - ) - assertFalse("active must have been removed in-process", File(spool, "active.jsonl").exists()) - // 恢复:link 是唯一内容持有者(同 inode),正常排空,事件恰一次入 Room - TokenStatSpool.replay(context) - awaitEvent("link-post-sync-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("link-post-sync-c")), "link-post-sync-c")) - awaitEvent("link-post-sync-c") - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `hardlink seal dir sync UNSUPPORTED never deletes active and never publishes`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("unsupported-link-a")) - val lineB = line(request("unsupported-link-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 平台明确不支持目录 fsync:发布路径必须 fail-closed(UNSUPPORTED ≠ 成功), - // 硬链接已建立但目录项未持久 → 回滚链接、保留唯一 fsynced active、绝不 PUBLISHED。 - // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), - // 第三次是链接创建的目录项,第四次是回滚删除链接的目录项(P2 终审: - // 回滚删除同样是目录项变更,必须确认持久,非 OK 同时失效 gate)。 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the platform does not support dir fsync", - TokenStatSpool.append(context, lineB, "unsupported-link-b"), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - assertEquals(4, dirSyncCalls) - assertEquals( - "active must be retained byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "UNSUPPORTED must never publish a sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `copy seal dir sync UNSUPPORTED never deletes active and never publishes`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("unsupported-copy-a")) - val lineB = line(request("unsupported-copy-b")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - // copy 回退的目录同步同样 fail-closed:目标已处置、active 保留、绝不 PUBLISHED。 - // 前两次 sync 是 P1-1 bootstrap gate(filesDir + spool 目录,已确认), - // 第三次是 copy 目标创建的目录项。 - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail when the platform does not support dir fsync", - TokenStatSpool.append(context, lineB, "unsupported-copy-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED)—— - // 后续 quarantineEvidence 读取需要恢复 tombstone 写入残留的 `.new` - // sidecar(P2 受管证据),strict 读取要求目录 sync OK 才能返回。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealHardLinkForTest = null - } - // 5 次目录 sync = bootstrap gate(2) + copy 目标创建(1) + 失败目标隔离 - // rename(1) + tombstone 暂存(1,P2:隔离后目录项未确认 → 按候选 sealed - // 身份写 tombstone 受管证据,绝不只留日志) - assertEquals(5, dirSyncCalls) - assertEquals( - "active must be retained byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "UNSUPPORTED must never publish a sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - // 部分目标被隔离为 seal_failed_*(受管失败发布证据,立即可见) - val isolated = - spool.listFiles().orEmpty().single { it.isFile && it.name.startsWith("seal_failed_") } - assertEquals("isolated target must keep the copied bytes", lineA + "\n", isolated.readText()) - assertTrue( - "isolated target must be visible as quarantine evidence", - TokenStatSpool.quarantineEvidence(context).any { it.name == isolated.name }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `unsupported dir sync fails closed never clears active and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("failclosed-a")) - val lineB = line(request("failclosed-b")) - val lineC = line(request("failclosed-c")) - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - // 平台明确不支持目录 fsync:封段发布必须 fail-closed——绝不原地清空/删除 - // 唯一 fsynced active,也绝不返回 durable - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must fail closed when the platform does not support dir fsync", - TokenStatSpool.append(context, lineC, "failclosed-c"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // active 原字节保留;无 sealed/seal_failed 发布残留;无事件入 Room - assertEquals( - "active must be retained byte-identical", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertTrue( - "fail-closed mode must never publish sealed segments", - spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("sealed_") }, - ) - assertTrue( - "fail-closed mode must never create seal_failed targets", - spool.listFiles().orEmpty().none { it.isFile && it.name.startsWith("seal_failed_") }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录 fsync 恢复 OK 后 append 成功,全部事件各恰一次入 Room - assertTrue(TokenStatSpool.append(context, lineC, "failclosed-c")) - TokenStatSpool.replay(context) - awaitEvent("failclosed-a") - awaitEvent("failclosed-b") - awaitEvent("failclosed-c") - awaitNoSealedSegments(spool) - assertEquals(3, database.tokenStatsDao().countEvents()) - assertEquals( - "each event must be recorded exactly once", - setOf("failclosed-a", "failclosed-b", "failclosed-c"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `first spool directory creation with unsupported dir sync returns false and retries after recovery`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - assertFalse("spool must not pre-exist", spool.exists()) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - assertFalse( - "append must not return durable when the first spool dir creation cannot be confirmed", - TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // 已创建的目录可保留供重试,但从未声明 durable;active 尚未写入 - assertTrue("created spool dir may be retained for retry", spool.isDirectory) - assertFalse("active must not be written before the dir entry is durable", File(spool, "active.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复能力 OK:重试成功,事件恰一次入 Room - assertTrue( - TokenStatSpool.append(context, line(request("first-dir-a")), "first-dir-a"), - ) - TokenStatSpool.replay(context) - awaitEvent("first-dir-a") - awaitNoSealedSegments(spool) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("first-dir-a"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `first active file creation with unsupported dir sync returns false retains line and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - var dirSyncCalls = 0 - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - // 前两次是 P1-1 bootstrap gate(filesDir + spool 目录,已确认);第三次 - // (首建 active 的目录项)平台不支持——内容已写+fsync 但目录项未确认 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.UNSUPPORTED - } - TokenStatSpool.shutdownWriterForTest() - val lineA = line(request("first-active-a")) - try { - assertFalse( - "append must not return durable when the first active creation dir sync is unsupported", - TokenStatSpool.append(context, lineA, "first-active-a"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // 写+fd.sync 已发生但目录项未确认:本次不 durable,源 line 内容保留在 active - assertEquals(3, dirSyncCalls) - assertEquals( - "source line must be retained on disk", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复能力 OK:下一次 append 先经 bootstrap gate 重新确认目录项再写新事件, - // 两者各恰一次 - assertTrue( - TokenStatSpool.append(context, line(request("first-active-b")), "first-active-b"), - ) - TokenStatSpool.replay(context) - awaitEvent("first-active-a") - awaitEvent("first-active-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("first-active-a", "first-active-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } - } - - @Test - fun `unsupported dir sync fails closed for every append until recovery then drains exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val count = 24 - try { - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.MAX_SEGMENT_BYTES = 700 - TokenStatSpool.shutdownWriterForTest() - // 目录 fsync 不支持:首次 spool/active 创建无法确认目录项——每次 append - // 都 fail-closed(绝不返回 durable、绝不永久挂起、绝不清空已写入内容) - repeat(count) { index -> - assertFalse( - "append must fail closed under unsupported dir sync without stalling: $index", - TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), - ) - } - assertTrue("created spool dir may be retained for retry", spool.isDirectory) - assertFalse( - "active must not be written before any directory entry is durable", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:能力恢复 OK 后重试/replay,全部事件各恰一次入 Room - // (P1 终审:Windows JVM 真实探测恒为 UNSUPPORTED,必须回到注入的 OK) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - repeat(count) { index -> - assertTrue( - TokenStatSpool.append(context, line(request("win-failclosed-$index")), "win-failclosed-$index"), - ) - } - TokenStatSpool.replay(context) - repeat(count) { index -> awaitEvent("win-failclosed-$index") } - awaitNoSealedSegments(spool) - assertEquals(count, database.tokenStatsDao().countEvents()) - assertEquals( - count, - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet().size, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `corrupt active tail with unsupported dir sync fails closed retaining original bytes then recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("corrupt-failclosed-a")) - val lineB = line(request("corrupt-failclosed-b")) - // 崩溃残留:active 尾部半行 JSON,无换行 - val original = lineA + "\n" + "{\"v\":2,\"eventId\":\"corrupt-failclosed-tail\"" - try { - File(spool, "active.jsonl").writeText(original) - TokenStatSpool.MAX_SEGMENT_BYTES = original.length.toLong() + 1 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.UNSUPPORTED } - TokenStatSpool.shutdownWriterForTest() - try { - // 损坏尾行需要封段处置:目录项未确认持久前绝不隔离证据、绝不插入 - // 健康行、绝不截断/清空 active(copy+file sync 之后必须 dir sync OK - // 才允许继续) - assertFalse( - "append must fail closed when sealing a corrupt tail needs dir fsync", - TokenStatSpool.append(context, lineB, "corrupt-failclosed-b"), - ) - } finally { - // P1 终审:恢复平台正常态(Windows JVM 真实探测恒为 UNSUPPORTED) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - } - // active 原字节不动(含损坏尾行);尚无证据被切走;无事件入 Room - assertEquals( - "active must retain the original bytes including the corrupt tail", - original, - File(spool, "active.jsonl").readText(), - ) - assertTrue( - "no evidence may be cut before its directory entry is durable", - TokenStatSpool.quarantineEvidence(context).isEmpty(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 恢复:目录 fsync OK 后损坏尾行作为完整证据隔离(至少一个 durable 位置), - // 健康事件各恰一次入 Room - assertTrue(TokenStatSpool.append(context, lineB, "corrupt-failclosed-b")) - TokenStatSpool.replay(context) - awaitEvent("corrupt-failclosed-a") - awaitEvent("corrupt-failclosed-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertTrue( - "corrupt tail evidence must be preserved with the original bytes", - evidence.any { it.readText() == original }, - ) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `bootstrap gate re-confirms unconfirmed spool dir entry after simulated restart and never merges events`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - val lineA = line(request("bootstrap-dir-a")) - val lineB = line(request("bootstrap-dir-b")) - try { - // 上一进程:首次创建 spool 目录,父目录/新目录的目录项 sync 失败(磁盘 - // 可见但未确认持久)——append 明确失败,active 未写入 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - TokenStatSpool.shutdownWriterForTest() - assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) - assertTrue("created spool dir is visible on disk", spool.isDirectory) - assertFalse("no line may be written before dir entries are durable", File(spool, "active.jsonl").exists()) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),磁盘状态保留 - TokenStatSpool.clearPendingStateForTest() - // 目录项仍无法确认:本次 append 必须失败,绝不写新行(第二事件此前从未写入) - assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) - assertFalse( - "no line may be written while the spool dir entry is unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复:bootstrap gate 重新确认目录项后,两个事件各恰一次入 Room - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineA, "bootstrap-dir-a")) - assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-dir-b")) - TokenStatSpool.replay(context) - awaitEvent("bootstrap-dir-a") - awaitEvent("bootstrap-dir-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("bootstrap-dir-a", "bootstrap-dir-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `bootstrap gate re-confirms unconfirmed active entry after simulated restart and keeps bytes until confirmed`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("bootstrap-active-a")) - val lineB = line(request("bootstrap-active-b")) - var dirSyncCalls = 0 - try { - // 上一进程:bootstrap gate 两次确认通过,但首建 active 的目录项 sync 失败 - // (内容已写+fsync、磁盘可见、未确认) - TokenStatSpool.dirSyncForTest = { - dirSyncCalls += 1 - if (dirSyncCalls <= 2) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - assertFalse(TokenStatSpool.append(context, lineA, "bootstrap-active-a")) - assertEquals(3, dirSyncCalls) - assertEquals( - "unconfirmed line must stay visible on disk", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 模拟进程重启:清空全部内存状态(含 bootstrap gate 标记),active 字节保留 - TokenStatSpool.clearPendingStateForTest() - // 目录项仍无法确认:本次 append 失败且 active 字节不变(绝不再追加新行) - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - assertFalse(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) - assertEquals( - "active bytes must be unchanged while the dir entry is unconfirmed", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // 恢复:gate 重新确认后追加第二事件(此前从未写入),两事件各恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "bootstrap-active-b")) - TokenStatSpool.replay(context) - awaitEvent("bootstrap-active-a") - awaitEvent("bootstrap-active-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - assertEquals( - setOf("bootstrap-active-a", "bootstrap-active-b"), - database.tokenStatsDao().getAllEvents().map { it.eventId }.toSet(), - ) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - @Test - fun `maintenance seal dir sync failure after gate true forces the next append to re-bootstrap before writing`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val lineA = line(request("gate-maint-a")) - val lineB = line(request("gate-maint-b")) - var calls = 0 - try { - File(spool, "active.jsonl").writeText(lineA + "\n") - // 阶段 1:bootstrap gate 两次确认 OK(gate=true),随后维护 drain 的封段 - // 发布目录项 sync(第 3 次)失败 → 维护失败;gate 必须同步失效(P1-1) - TokenStatSpool.dirSyncForTest = { - calls += 1 - if (calls != 3) TokenStatSpool.DirSyncResult.OK - else TokenStatSpool.DirSyncResult.FAILED - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - val sealDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (calls < 3 && System.nanoTime() < sealDeadline) delay(10) - TokenStatSpool.shutdownWriterForTest() - assertTrue("seal must have been attempted", calls >= 3) - // 阶段 2:gate 已失效且目录 sync 持续失败——下一次 append 必须重新 - // bootstrap;bootstrap 失败 → append 明确失败且 active 字节不变 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.FAILED } - assertFalse(TokenStatSpool.append(context, lineB, "gate-maint-b")) - assertEquals( - "active must stay byte-identical", - lineA + "\n", - File(spool, "active.jsonl").readText(), - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复——bootstrap 重新确认后 append 成功,事件各恰一次 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - assertTrue(TokenStatSpool.append(context, lineB, "gate-maint-b")) - TokenStatSpool.replay(context) - awaitEvent("gate-maint-a") - awaitEvent("gate-maint-b") - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.dirSyncForTest = null - } - } - } - - /** - * P1-2/P1-3 终审通用 runner:以 [result](FAILED/UNSUPPORTED)运行一次完整场景,保证 - * 前后内存/磁盘/数据库状态隔离(spool 目录重建 + 内存标记复位 + 事件表清空),并在 - * finally 还原全部注入缝。场景开始前恢复“目录 fsync 支持且成功”的平台常态 - * (Windows JVM 真实探测恒为 UNSUPPORTED),使场景内部的 phase-1 正常协议可用。 - */ -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt deleted file mode 100644 index 86775bcd5..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/SpoolWriterReliabilityTest.kt +++ /dev/null @@ -1,487 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService.Companion.RecordOutcome -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal class SpoolWriterReliabilityTest : TokenStatReliabilityTestBase() { - @Test - fun `price override read timeout is durable unknown not default`() = runBlocking { - database.tokenStatsDao().upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 99.0, - cachedInputPricePerMillion = 99.0, - outputPricePerMillion = 99.0, - ) - TokenStatsLedger.legacyPriceProvider = { _, _ -> delay(Long.MAX_VALUE); null } - TokenStatSpool.prepareTimeoutMs = 50L - - TokenTrackingAIService.recordSafely(context, request("price-timeout")) - awaitEvent("price-timeout") - val event = database.tokenStatsDao().getEvent("price-timeout")!! - assertEquals(PricingSource.UNKNOWN.name, event.pricingSource) - assertNull(event.inputPricePerMillion) - assertNull(event.costInPricingCurrency) - assertTrue(event.diagnosticsJson!!.contains("pricing_read_timeout")) - } - - @Test - fun `more than two thousand append failures never return durable`() = runBlocking { - File(root, TokenStatSpool.SPOOL_DIR_NAME).writeText("not a directory") - Mockito.mockStatic(AppLogger::class.java).use { - // P1-2:统计收尾 fail-open——append 失败明确返回 LOST(绝不抛出、绝不伪 durable) - repeat(2_001) { index -> - assertEquals( - RecordOutcome.LOST, - TokenTrackingAIService.recordSafely(context, request("disk-failure-$index")), - ) - } - } - assertEquals(0, TokenStatSpool.emergencyQueueSizeForTest()) - assertEquals(0, database.tokenStatsDao().countEvents()) - } - - @Test - fun `crash half line never splices the next healthy event`() = runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 崩溃残留:active 尾部半行 JSON,无换行 - File(spool, "active.jsonl").writeText("{\"v\":2,\"eventId\":\"evt-crash-half\"") - TokenTrackingAIService.recordSafely(context, request("evt-healthy-after-crash")) - TokenStatSpool.replay(context) - awaitEvent("evt-healthy-after-crash") - // 健康事件恰好一次进入 Room,残缺证据完整保留在 quarantine - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals("evt-healthy-after-crash", database.tokenStatsDao().getAllEvents().single().eventId) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue("partial evidence must be preserved", evidence.single().readText().contains("evt-crash-half")) - } - } - - @Test - fun `same-name same-size same-mtime replacement is never skipped isolated or acked away`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val healthyLine = line(request("replacement-healthy")) - val healthyBytes = (healthyLine + "\n").toByteArray(Charsets.UTF_8).size - // 旧损坏段与健康新段字节数完全一致(P1-1:仅凭 length+mtime 的缓存才会被骗) - val oldBody = "{old-corrupt-" + "x".repeat(healthyBytes - "{old-corrupt-".length - 1) + "\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val fixedMtime = 1_700_000_000_000L - - val file = File(spool, "sealed_1.jsonl") - file.writeText(oldBody) - file.setLastModified(fixedMtime) - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText( - "{\"ts\":1,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - // 先建立旧身份(旧实现中身份哈希缓存在此记住 length+mtime+sha) - TokenStatSpool.quarantineEvidence(context) - - // 同名同长同 mtime 替换为不同内容(健康行) - assertTrue(file.delete()) - file.writeText(healthyLine + "\n") - file.setLastModified(fixedMtime) - - // ack:陈旧记录被移除,健康文件绝不删除 - TokenStatSpool.acknowledgeAndDeleteQuarantine(context, setOf("sealed_1.jsonl")) - assertTrue("ack must never delete a replaced same-name healthy segment", file.exists()) - assertFalse( - "stale tombstone must be removed by ack", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - - // 重建陈旧记录,让维护入口与扫描器都看到它 - manifest.writeText( - "{\"ts\":2,\"file\":\"sealed_1.jsonl\",\"bytes\":$healthyBytes," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n", - ) - // replay:维护清理不删不隔离、扫描器不跳过,健康事件恰一次 - TokenStatSpool.replay(context) - awaitEvent("replacement-healthy") - awaitSegmentGone(spool, "sealed_1.jsonl") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "replacement-healthy", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - assertFalse( - "stale tombstone must be removed after replay", - manifest.isFile && manifest.readText().contains("sealed_1.jsonl"), - ) - assertTrue( - "healthy segment must never be isolated as evidence", - TokenStatSpool.quarantineEvidence(context).none { it.name == "sealed_1.jsonl" }, - ) - } - } - - @Test - fun `total spool cap stops appends while dao permanently fails and recovers after drain`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - // 行先于失败 DAO 生成(行生成需要真实价格读取),DAO 只负责排空失败 - val lines = (0 until 400).map { index -> - line(request("cap-$index")) to "cap-$index" - } - TokenStatSpool.MAX_SEGMENT_BYTES = 8L * 1024 - // 总 cap:约 3 个小段 + 行余量;DAO 永久失败 → sealed 段只增不减 - TokenStatSpool.totalSpoolMaxBytesForTest = 24L * 1024 - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - try { - var rejected = 0 - for ((text, eventId) in lines) { - try { - TokenStatSpool.append(context, text, eventId) - } catch (e: TokenStatsPersistenceException) { - rejected++ - } - } - assertTrue("append must be refused once the total cap is reached: $rejected", rejected > 0) - val totalAtRejection = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - assertTrue( - "total spool bytes must never exceed the cap: $totalAtRejection", - totalAtRejection <= (TokenStatSpool.totalSpoolMaxBytesForTest ?: 0), - ) - // 固定 cap 前停止:拒绝后不再发布任何新字节(seal 只是改名不增字节) - val frozen = spool.walkTopDown().filter { it.isFile }.sumOf { it.length() } - repeat(20) { - try { - TokenStatSpool.append(context, lines[0].first, "refused-$it") - fail("append after cap must keep failing") - } catch (e: TokenStatsPersistenceException) { - } - } - assertEquals( - "no new spool bytes may be published after the cap", - frozen, - spool.walkTopDown().filter { it.isFile }.sumOf { it.length() }, - ) - assertEquals(0, database.tokenStatsDao().countEvents()) - - // drain 成功(DAO 恢复)后空间释放,append 继续 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitNoSealedSegments(spool) - TokenTrackingAIService.recordSafely(context, request("after-total-cap-recovery")) - awaitEvent("after-total-cap-recovery") - assertEquals( - "after-total-cap-recovery", - database.tokenStatsDao().getEvent("after-total-cap-recovery")!!.eventId, - ) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `single legal line exactly at the total cap is accepted and the next is refused`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val previousCap = TokenStatSpool.totalSpoolMaxBytesForTest - // cap 与单行合法上限一致:恰好一行可写入,第二行必须明确拒绝 - TokenStatSpool.totalSpoolMaxBytesForTest = TokenStatSpool.MAX_LINE_BYTES.toLong() - // 行必须先于失败 DAO 生成(行生成需要真实价格读取);排空失败段才留在 - // spool,第二次 append 才会在总容量检查处触顶 - val padded = - padLineTo( - line(request("single-line-at-cap")), - TokenStatSpool.MAX_LINE_BYTES, - ) - val secondLine = line(request("refused-after-single")) - val failingDao = mock() - whenever(failingDao.insertIdentityIfAbsent(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.upsertDisplayModel(any())).thenThrow(RuntimeException("dao down")) - whenever(failingDao.insertEventIfNotResetCovered(any())).thenThrow(RuntimeException("dao down")) - val proxy = mock() - whenever(proxy.tokenStatsDao()).thenReturn(failingDao) - TokenStatsLedger.databaseProvider = { proxy } - try { - assertEquals( - TokenStatSpool.MAX_LINE_BYTES, - (padded + "\n").toByteArray(Charsets.UTF_8).size, - ) - assertTrue(TokenStatSpool.append(context, padded, "single-line-at-cap")) - try { - TokenStatSpool.append(context, secondLine, "refused-after-single") - fail("append beyond the total cap must throw TokenStatsPersistenceException") - } catch (e: TokenStatsPersistenceException) { - } - // DAO 恢复后排空成功:单行事件恰一次,被拒绝的行从未发布 - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.replay(context) - awaitEvent("single-line-at-cap") - assertEquals(1, database.tokenStatsDao().countEvents()) - assertEquals( - "single-line-at-cap", - database.tokenStatsDao().getAllEvents().single().eventId, - ) - } finally { - TokenStatsLedger.databaseProvider = { database } - TokenStatSpool.totalSpoolMaxBytesForTest = previousCap - } - } - } - - @Test - fun `drain stays bounded and healthy appends stay durable while enumeration fails then recovers`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - // 健康段:根枚举失败期间 drain fail-closed 退避——段保留、不入 Room - File(spool, "sealed_1.jsonl").writeText(line(request("enum-drain-1")) + "\n") - // 陈旧候选:消失原件身份仍在 manifest - val oldBody = "{enum-drain-stale\n" - val oldSha = sha256Hex(oldBody.toByteArray(Charsets.UTF_8)) - val manifestLine = - "{\"ts\":1,\"file\":\"sealed_2.jsonl\",\"bytes\":${oldBody.toByteArray(Charsets.UTF_8).size}," + - "\"sha256\":\"$oldSha\",\"overCap\":false}\n" - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - manifest.writeText(manifestLine) - // UNCOMMITTED trash 真实持有 sealed_2(valid mapping) - val trash = - File(spool, "quarantine_ack_trash_${UUID.randomUUID().toString().replace("-", "")}") - trash.mkdirs() - File(trash, "sealed_2.jsonl").writeText(oldBody) - File(trash, TokenStatSpool.ACK_TRASH_STATE_FILE_NAME).writeText( - TokenStatSpool.ACK_STATE_UNCOMMITTED + "\n" + - "{\"o\":\"sealed_2.jsonl\",\"t\":\"sealed_2.jsonl\",\"b\":${oldBody.toByteArray(Charsets.UTF_8).size},\"s\":\"$oldSha\"}\n", - ) - TokenStatSpool.directoryListingForTest = { dir -> - if (dir == spool) null else dir.listFiles() - } - try { - TokenStatSpool.replay(context) - delay(700) - assertNull( - "no segment may drain while the root enumeration fails", - database.tokenStatsDao().getEvent("enum-drain-1"), - ) - assertTrue("healthy segment must be preserved", File(spool, "sealed_1.jsonl").exists()) - // 有界:trash 不处置、manifest 不重写 - assertTrue(trash.exists()) - assertEquals(manifestLine, safeManifestText(manifest)) - // 健康 append 在枚举失败期间仍然 durable(事件留在 active,不排空) - assertTrue( - TokenStatSpool.append( - context, - line(request("enum-append-2")), - "enum-append-2", - ), - ) - delay(700) - assertNull( - "appended event must stay durable but not drain while the root enumeration fails", - database.tokenStatsDao().getEvent("enum-append-2"), - ) - assertTrue("appended event must stay in active.jsonl", File(spool, "active.jsonl").exists()) - assertTrue(trash.exists()) - assertEquals(manifestLine, safeManifestText(manifest)) - } finally { - TokenStatSpool.directoryListingForTest = null - } - // 枚举恢复后处理:健康段与 active 排空;stale 清理与回滚完成 - TokenStatSpool.replay(context) - awaitEvent("enum-drain-1") - awaitEvent("enum-append-2") - awaitManifestWithout(spool, "sealed_2.jsonl") - val restoreDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < restoreDeadline && trash.exists()) delay(20) - assertFalse("trash must be rolled back after enumeration recovers", trash.exists()) - val evidenceDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - var restored = false - while (System.nanoTime() < evidenceDeadline && !restored) { - restored = TokenStatSpool.quarantineEvidence(context).any { it.readText() == oldBody } - if (!restored) delay(20) - } - assertTrue("held identity evidence must be restored after recovery", restored) - } - } - - @Test - fun `mid-copy partial target is isolated never drained or overwritten and active is retained`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val previousSegmentBytes = TokenStatSpool.MAX_SEGMENT_BYTES - val lineA = line(request("partial-a")) - val lineB = line(request("partial-b")) - val partial = lineA + "\n" // copy 中途只写入了完整行的前缀内容 - try { - File(spool, "active.jsonl").writeText(lineA + "\n" + lineB + "\n") - TokenStatSpool.MAX_SEGMENT_BYTES = File(spool, "active.jsonl").length() + 1 - TokenStatSpool.sealHardLinkForTest = { _, _ -> false } - TokenStatSpool.sealCopyForTest = { _, target -> - target.writeText(partial) - false - } - TokenStatSpool.shutdownWriterForTest() - try { - // copy 中途失败留下部分目标:append 必须明确失败且 active 保留 - assertFalse( - "append must fail when the seal copy fails mid-way", - TokenStatSpool.append(context, line(request("partial-c")), "partial-c"), - ) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - } - assertEquals( - "active must be retained with the full content", - lineA + "\n" + lineB + "\n", - File(spool, "active.jsonl").readText(), - ) - assertFalse( - "partial target must not remain as a normal sealed segment", - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") }, - ) - val isolated = spool.listFiles().orEmpty().single { it.name.startsWith("seal_failed_") } - assertEquals("partial bytes must be preserved as isolated evidence", partial, isolated.readText()) - // 恢复:隔离副本由维护清理;部分内容绝不入 Room(完整内容只排空一次) - TokenStatSpool.replay(context) - awaitEvent("partial-a") - awaitEvent("partial-b") - awaitSegmentGone(spool, isolated.name) - awaitNoSealedSegments(spool) - assertEquals(2, database.tokenStatsDao().countEvents()) - } finally { - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.MAX_SEGMENT_BYTES = previousSegmentBytes - } - } - } - - @Test - fun `pending-delete reverse rename not durable keeps retryable record and recovers exactly once`() = - runBlocking { - Mockito.mockStatic(AppLogger::class.java).use { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME).apply { mkdirs() } - val body = "{reverse-rename-bad\n" - File(spool, "sealed_1.jsonl").writeText(body) - File(spool, "sealed_2.jsonl").writeText(line(request("reverse-rename-healthy")) + "\n") - // 阶段 1:证据区 rename 失败 → pending-delete 有界证据;健康段照常排空 - TokenStatSpool.segmentRenameForTest = { _, to -> - if (to.name.startsWith("quarantine_") && !to.name.startsWith("quarantine_pending_delete_")) { - false - } else { - null - } - } - TokenStatSpool.replay(context) - awaitEvent("reverse-rename-healthy") - val pending = spool.listFiles().orEmpty().single { - it.isFile && it.name.startsWith("quarantine_pending_delete_") - } - TokenStatSpool.segmentRenameForTest = null - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 2:维护恢复 rename 可见但目录项 sync 失败 → 尽力反向 rename 回 - // pending-delete 名(重建明确可重试记录);反向 rename 的目录项同样必须严格 - // sync(P2 终审),未确认持久绝不视为已重建 → 本轮退避,记录保留 - var failSyncs = true - TokenStatSpool.dirSyncForTest = { - if (failSyncs) TokenStatSpool.DirSyncResult.FAILED - else TokenStatSpool.DirSyncResult.OK - } - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - delay(900) - assertTrue( - "pending-delete record must be rebuilt when the restore rename is not durable", - pending.exists(), - ) - val evidence = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, evidence.size) - assertTrue( - "evidence must still be the pending-delete record", - evidence.single().name.startsWith("quarantine_pending_delete_"), - ) - // 退避期间任何 append 都不发布新字节(gate 已失效,bootstrap 重新确认前拒绝) - assertFalse( - TokenStatSpool.append(context, line(request("reverse-rename-blocked")), "reverse-rename-blocked"), - ) - assertFalse( - "no event may be written while dir entries are unconfirmed", - File(spool, "active.jsonl").exists(), - ) - assertEquals(1, database.tokenStatsDao().countEvents()) - // 阶段 3:恢复——记录移回完整证据区,事件仍恰一次,后续 append 正常 - failSyncs = false - TokenStatSpool.shutdownWriterForTest() - TokenStatSpool.replay(context) - awaitNoPendingEvidence(spool) - val restored = TokenStatSpool.quarantineEvidence(context) - assertEquals(1, restored.size) - assertTrue("full evidence must be restored exactly once", restored.single().readText() == body) - assertEquals(1, database.tokenStatsDao().countEvents()) - assertTrue(TokenStatSpool.append(context, line(request("reverse-rename-post")), "reverse-rename-post")) - TokenStatSpool.replay(context) - awaitEvent("reverse-rename-post") - assertEquals(2, database.tokenStatsDao().countEvents()) - } - } - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt index 5a549268e..f139a86eb 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt @@ -9,208 +9,56 @@ class TokenActivityAggregatorTest { private val zone = ZoneId.of("Asia/Shanghai") @Test - fun `activity row honors total input and reasoning inclusion contract`() { - val included = TokenActivityEventRow( - startedAtMs = 1L, - uncachedInputTokens = null, - cachedInputTokens = null, - cacheWriteTokens = null, - totalInputTokens = 100L, - outputTokens = 40L, - reasoningTokens = 30L, - reasoningIncludedInOutput = true, - ) - val separate = TokenActivityEventRow( - startedAtMs = 2L, - uncachedInputTokens = 50L, - cachedInputTokens = 20L, - cacheWriteTokens = 10L, - totalInputTokens = null, - outputTokens = 40L, - reasoningTokens = 30L, - reasoningIncludedInOutput = false, - ) - // OpenAI 非独立计费:cacheWrite 已含在输入内,不得重复计入 - val openai = TokenActivityEventRow( - startedAtMs = 3L, - uncachedInputTokens = 50L, - cachedInputTokens = 20L, - cacheWriteTokens = 10L, - totalInputTokens = null, - outputTokens = 40L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - ) - - assertEquals(140L, included.toActivityRecord().tokens) - assertEquals(150L, separate.toActivityRecord().tokens) - assertEquals(110L, openai.toActivityRecord().tokens) // (50+20)+40,不含 cacheWrite - } - - @Test - fun `activity row with unknown canonical total maps to zero tokens`() { - // 输入未知(无权威 totalInput 且拆分缺失)→ canonical 总 token 未知, - // 热力图按 0 展示(不把 null 当 0),请求计数仍由记录本身承载 - val unknownInput = TokenActivityEventRow( - startedAtMs = 1L, - uncachedInputTokens = null, - cachedInputTokens = null, - cacheWriteTokens = null, - totalInputTokens = null, - outputTokens = 40L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - ) - // 独立计费下 cacheWrite 未知 → 输入 unknown - val unknownCacheWrite = TokenActivityEventRow( - startedAtMs = 2L, - uncachedInputTokens = 50L, - cachedInputTokens = 20L, - cacheWriteTokens = null, - totalInputTokens = null, - outputTokens = 40L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = true, - ) - assertEquals(0L, unknownInput.toActivityRecord().tokens) - assertEquals(0L, unknownCacheWrite.toActivityRecord().tokens) - } - - @Test - fun `recent data contains exactly the latest 365 calendar days`() { - val today = LocalDate.of(2026, 8, 8) - val records = listOf( - record(today.minusDays(364).toString(), 10), - record(today.toString(), 20), - record(today.minusDays(365).toString(), 40), - ) - - val result = TokenActivityAggregator.recentData( - records = records, + fun `range data contains every selected calendar day and excludes surrounding activity`() { + val range = dateRange("2026-08-02", "2026-08-04") + val snapshot = TokenActivitySnapshot( zone = zone, - nowMs = today.atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), - ) - - assertEquals(365, result.daily.size) - assertEquals(today.minusDays(364), result.daily.first().date) - assertEquals(today, result.daily.last().date) - assertEquals(30L, result.stats.totalTokens) + dayTotals = mapOf( + LocalDate.of(2026, 8, 1) to 40L, + LocalDate.of(2026, 8, 2) to 10L, + LocalDate.of(2026, 8, 4) to 30L, + LocalDate.of(2026, 8, 5) to 50L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals( + listOf( + LocalDate.of(2026, 8, 2), + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 4), + ), + result.daily.map(TokenActivityDay::date), + ) + assertEquals(listOf(10L, 0L, 30L), result.daily.map(TokenActivityDay::tokens)) + assertEquals(40L, result.stats.totalTokens) + assertEquals(30L, result.stats.peakTokens) } @Test - fun `year data computes totals peaks and streaks`() { - val records = listOf( - record("2026-01-01", 10), - record("2026-01-02", 20), - record("2026-01-04", 30), - record("2026-01-05", 40), - ) - - val result = TokenActivityAggregator.yearData( - records = records, + fun `range data calculates streaks and cumulative totals inside the selected range`() { + val range = dateRange("2026-08-01", "2026-08-05") + val snapshot = TokenActivitySnapshot( zone = zone, - year = 2026, - // 尾日 01-05 有活动:currentStreak 从尾部连续正值(01-05/01-04) - nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), + dayTotals = mapOf( + LocalDate.of(2026, 8, 1) to 10L, + LocalDate.of(2026, 8, 2) to 20L, + LocalDate.of(2026, 8, 4) to 30L, + LocalDate.of(2026, 8, 5) to 40L, + ), ) - assertEquals(100L, result.stats.totalTokens) - assertEquals(40L, result.stats.peakTokens) - assertEquals(2, result.stats.currentStreak) - assertEquals(2, result.stats.longestStreak) - assertEquals(100L, result.cumulative.last().tokens) - } - - @Test - fun `current streak counts only consecutive positive days at the tail`() { - // 尾部连续正值只有 01-04/01-05 两天;01-03 的 0 断开更早的 01-01/01-02 - val records = listOf( - record("2026-01-01", 10), - record("2026-01-02", 20), - record("2026-01-03", 0), - record("2026-01-04", 30), - record("2026-01-05", 40), - ) - - val result = TokenActivityAggregator.yearData( - records = records, - zone = zone, - year = 2026, - nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), - ) + val result = TokenActivityAggregator.rangeData(snapshot, range) assertEquals(2, result.stats.currentStreak) assertEquals(2, result.stats.longestStreak) + assertEquals(listOf(10L, 30L, 30L, 60L, 100L), result.cumulative.map(TokenActivityDay::tokens)) } - @Test - fun `current streak is zero when the tail day has no activity`() { - // 尾日 01-05 无活动(01-03/01-04 同样为空)→ currentStreak 必须为 0 - val records = listOf( - record("2026-01-01", 10), - record("2026-01-02", 20), - ) - - val result = TokenActivityAggregator.yearData( - records = records, - zone = zone, - year = 2026, - nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), - ) - - assertEquals(0, result.stats.currentStreak) - // longest 只看全区间最长连续段,不受尾部影响 - assertEquals(2, result.stats.longestStreak) - } - - @Test - fun `longest streak is independent of the tail and reflects the longest run anywhere`() { - // 中部连续 3 天(01-01..01-03),尾部仅 01-05 一天(01-04 为 0 断开) - val records = listOf( - record("2026-01-01", 10), - record("2026-01-02", 10), - record("2026-01-03", 10), - record("2026-01-05", 20), + private fun dateRange(start: String, inclusiveEnd: String): TokenStatsTimeRange = + TokenStatsTimeRanges.customRange( + LocalDate.parse(start).atStartOfDay(zone).toInstant().toEpochMilli(), + LocalDate.parse(inclusiveEnd).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli(), ) - - val result = TokenActivityAggregator.yearData( - records = records, - zone = zone, - year = 2026, - nowMs = LocalDate.of(2026, 1, 5).atTime(18, 0).atZone(zone).toInstant().toEpochMilli(), - ) - - assertEquals(1, result.stats.currentStreak) - assertEquals(3, result.stats.longestStreak) - } - - @Test - fun `insights use all requests and rank peak hours`() { - val records = listOf( - record("2026-01-01", 1, 9), - record("2026-01-02", 1, 9), - record("2026-01-03", 1, 20), - record("2026-01-04", 1, 8), - ) - - val insights = TokenActivityAggregator.insights(records, zone) - - assertEquals(4L, insights.totalRequests) - assertEquals(listOf(9, 8, 20), insights.topHours) - } - - @Test - fun `available years span earliest event through current year`() { - val records = listOf(record("2024-06-01", 1), record("2026-01-01", 1)) - val now = LocalDate.of(2026, 8, 1).atStartOfDay(zone).toInstant().toEpochMilli() - - assertEquals(listOf(2026, 2025, 2024), TokenActivityAggregator.availableYears(records, zone, now)) - } - - private fun record(date: String, tokens: Long, hour: Int = 12): TokenActivityRecord { - val timestamp = LocalDate.parse(date).atTime(hour, 0).atZone(zone).toInstant().toEpochMilli() - return TokenActivityRecord(timestamp, tokens) - } } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt deleted file mode 100644 index 278fe9c29..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineImportRunnerTest.kt +++ /dev/null @@ -1,785 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.datastore.preferences.core.MutablePreferences -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.edit -import androidx.room.Room -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * 导入器测试: - * 1. 取消传播:ensureMigrated 的 catch(Exception) 不得吞掉 CancellationException。 - * 2. 冻结价格 + 计数跟踪(真实 ApiPreferences 快照路径 + 真实 - * Room 数据库): - * - 无自定义价格迁移也冻结;计数不变时普通 setter/快照变化不重估 baseline; - * - 计数变化(真实累计 setter 增长 / 用户 reset 降低)用行内冻结价格重估, - * 整体替换为快照绝对值,不产生负增量;冻结价格列永不被普通启动替换; - * - legacy cleanup outbox fence:导入前排空 pending cleanup,旧快照被拒。 - * - * DataStore 隔离:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建一个 - * DataStore 实例(绑定首个访问它的 Context),且每个文件的 DataStore 写入在 - * Windows 上不稳定(tmp→目标 renameTo)。因此: - * - 每个“生命周期阶段”使用独立 filesDir 临时目录,阶段间通过反射清空单例, - * 使每阶段只读/写自己的文件; - * - “旧 DataStore 文件恢复”用种子文件(独立 DataStore 实例单次 edit 生成) - * 复制到目标阶段目录的 datastore/ 来真实模拟,之后重建 ApiPreferences - * 读取恢复后的文件——与恢复完成→冷启动的实际生命周期一致,全程不删除 - * 或覆盖活动 actor 的文件。 - */ -class TokenBaselineImportRunnerTest { - - @Before - fun isolate() { - clearApiDataStoreSingleton() - TokenBaselineImportRunner.databaseProvider = null - injectApiPreferences(null) - ApiPreferences.toolPkgProviderNamesProvider = { emptyList() } - } - - @After - fun tearDown() { - injectApiPreferences(null) - clearApiDataStoreSingleton() - TokenBaselineImportRunner.databaseProvider = null - TokenStatsResetCoordinator.daoProvider = null - ApiPreferences.toolPkgProviderNamesProvider = null - } - - /** 清空 `Context.apiDataStore` 委托缓存的数据存储单例(隔离生命周期)。 */ - private fun clearApiDataStoreSingleton() { - val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") - val delegateField = facade.getDeclaredField("apiDataStore\$delegate") - delegateField.isAccessible = true - val delegate = delegateField.get(null) - val instanceField = - delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } - instanceField.set(delegate, null) - } - - private fun injectApiPreferences(instance: ApiPreferences?) { - val field = - ApiPreferences::class.java - .getDeclaredField("INSTANCE") - .apply { isAccessible = true } - field.set(null, instance) - } - - private fun constructApiPreferences(context: Context): ApiPreferences { - val constructor = - ApiPreferences::class.java - .getDeclaredConstructor(Context::class.java) - .apply { isAccessible = true } - return constructor.newInstance(context) - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.noBackupFilesDir).thenReturn(File(filesDir, "no_backup")) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - /** 真实 Room 数据库(JVM 驱动),与迁移测试同一套支撑。 */ - private fun openDatabase(filesDir: File): AppDatabase = - Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - // ==== 旧 DataStore 文件种子与“恢复”模拟 ==== - - private val providerModel = "DEEPSEEK:deepseek-chat" - private val providerModelB = "OPENAI:gpt-4o" - - private fun seedTwoModels(seedFile: File, modelBStats: Triple) { - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L - prefs[ApiPreferences.getTokenInputKey(providerModelB)] = modelBStats.first - prefs[ApiPreferences.getTokenCachedInputKey(providerModelB)] = modelBStats.second - prefs[ApiPreferences.getTokenOutputKey(providerModelB)] = modelBStats.third - } - } - - /** - * 用独立 DataStore 实例单次 edit 生成“旧偏好文件”种子(等价于备份中的 - * api_settings.preferences_pb)。种子文件独立于被测阶段目录,不触碰任何 - * 活动 actor 的文件。 - * - * 注意:不取消 scope——取消会打断 DataStore 内部 actor 并触发 - * CompletionHandlerException;测试进程短命,遗留的闲置 actor 无影响。 - */ - private fun seedPreferencesFile(seedFile: File, block: (MutablePreferences) -> Unit) { - seedFile.parentFile?.mkdirs() - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - val store = - PreferenceDataStoreFactory.create( - scope = scope, - produceFile = { seedFile }, - ) - runBlocking { store.edit { block(it) } } - } - - private fun seedCountsOnly(seedFile: File) { - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L - } - } - - private fun seedWithInputPrice(seedFile: File, inputPrice: Double) { - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L - prefs[ApiPreferences.getModelInputPriceKey(providerModel)] = inputPrice.toFloat() - } - } - - private fun seedWithOutputPrice(seedFile: File, outputPrice: Double) { - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 500_000L - prefs[ApiPreferences.getModelOutputPriceKey(providerModel)] = outputPrice.toFloat() - } - } - - /** - * 模拟“恢复完成”:把种子文件复制到目标阶段的 datastore 目录,随后该阶段 - * 重建 ApiPreferences(首次访问读取恢复后的文件)——与恢复完成→冷启动的 - * 真实生命周期一致。复制发生在该阶段 DataStore actor 创建之前,不删除/ - * 不覆盖任何活动 actor 的文件。 - */ - private fun restorePreferencesInto(filesDir: File, seedFile: File) { - val target = File(filesDir, "datastore/api_settings.preferences_pb") - target.parentFile?.mkdirs() - seedFile.copyTo(target, overwrite = true) - } - - private suspend fun assertBaselineFrozenAt( - database: AppDatabase, - expectedCost: Double, - expectedInputTokens: Long, - ) { - val dao = database.tokenStatsDao() - val baseline = dao.getAllBaselines().single() - assertEquals(expectedCost, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(expectedInputTokens, baseline.inputTokens) - } - - // ==== 测试 ==== - - @Test - fun `strict migration reports database failure as not ready`() = runBlocking { - TokenBaselineImportRunner.databaseProvider = { throw java.io.IOException("database unavailable") } - val context = mock() - whenever(context.applicationContext).thenReturn(context) - - val ready = Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigratedStrict(context) - } - assertFalse(ready) - } - - @Test - fun `cancellation propagates through import runner instead of being swallowed`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - try { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - val prefs = mock() - // P1 闭环:快照读取已改为带 marker 的单次读取(fence 数据源) - whenever(prefs.legacyStatsSnapshotWithMarkers()) - .thenThrow(CancellationException("import cancelled")) - injectApiPreferences(prefs) - try { - TokenBaselineImportRunner.ensureMigrated(context) - fail("expected CancellationException to propagate") - } catch (e: CancellationException) { - assertEquals("import cancelled", e.message) - } - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `migration without custom price freezes and later price change does not reprice`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - - // 阶段 A:旧偏好文件只有计数、没有自定义价格(普通用户从未自定义价格 - // 也代表完整状态)→ 首次迁移按内置默认价估算并冻结 - val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileA = File(seedA, "seed.preferences_pb") - seedCountsOnly(seedFileA) - restorePreferencesInto(phaseA, seedFileA) - val ctxA = mockContext(phaseA) - val prefsA = constructApiPreferences(ctxA) - injectApiPreferences(prefsA) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxA) - } - // 800k*1 + 200k*0.02 + 500k*2 = 1.804 - assertBaselineFrozenAt(database, 1.804, 1_000_000L) - } finally { - injectApiPreferences(null) - } - - // 阶段 B:冷启动后快照含用户价格(相当于首次普通改价的快照路径), - // 但没有恢复生命周期信号 → 已冻结 baseline 不得重估 - clearApiDataStoreSingleton() - val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileB = File(seedB, "seed.preferences_pb") - seedWithInputPrice(seedFileB, 2.0) - restorePreferencesInto(phaseB, seedFileB) - val ctxB = mockContext(phaseB) - val prefsB = constructApiPreferences(ctxB) - injectApiPreferences(prefsB) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxB) - } - assertBaselineFrozenAt(database, 1.804, 1_000_000L) - assertEquals(1, database.tokenStatsDao().countBaselines()) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - fun `normal import preserves baseline for model missing from current snapshot`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - - // 阶段 A:旧偏好含 A+B 两个模型 → 两个 baseline - val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileA = File(seedA, "seed.preferences_pb") - seedTwoModels(seedFileA, Triple(2_000_000L, 0L, 1_000_000L)) - restorePreferencesInto(phaseA, seedFileA) - val ctxA = mockContext(phaseA) - val prefsA = constructApiPreferences(ctxA) - injectApiPreferences(prefsA) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxA) - } - assertEquals(2, database.tokenStatsDao().countBaselines()) - } finally { - injectApiPreferences(null) - } - - // 阶段 B:快照暂时只含 A(B 的偏好键缺失/被清空)→ 普通导入 - // 不得删除 B 的 baseline,只更新明确存在的 A - clearApiDataStoreSingleton() - val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileB = File(seedB, "seed.preferences_pb") - seedPreferencesFile(seedFileB) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 2_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 1_000_000L - } - restorePreferencesInto(phaseB, seedFileB) - val ctxB = mockContext(phaseB) - val prefsB = constructApiPreferences(ctxB) - injectApiPreferences(prefsB) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxB) - } - val dao = database.tokenStatsDao() - assertEquals("B baseline must survive a normal import", 2, dao.countBaselines()) - val identityB = - TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") - val baselineB = dao.getBaseline(identityB)!! - assertEquals(2_000_000L, baselineB.inputTokens) - assertEquals(1_000_000L, baselineB.outputTokens) - val identityA = - TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - assertEquals(2_000_000L, dao.getBaseline(identityA)!!.inputTokens) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `explicit reset deletes only the reset model baseline`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - - val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedTwoModels(seedFile, Triple(2_000_000L, 0L, 1_000_000L)) - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val prefs = constructApiPreferences(ctx) - injectApiPreferences(prefs) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) - } - val dao = database.tokenStatsDao() - assertEquals(2, dao.countBaselines()) - - // 显式重置 B:独立删除路径,只删 B 的 baseline;P1 闭环会在删除事务后 - // 排空 B 的 legacy 累计键(该文件的唯一真实写入)——Windows 先读入 - // 缓存再移除磁盘文件,使排空 edit 的 rename 目标不存在。 - prefs.getInputTokensForProviderModel(providerModelB) - check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) - TokenStatsResetCoordinator.daoProvider = { dao } - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenStatsResetCoordinator.resetStatisticsForProviderModel( - ctx, - providerModelB, - ) - } - } finally { - TokenStatsResetCoordinator.daoProvider = null - } - assertEquals(1, dao.countBaselines()) - val identityB = - TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") - assertEquals(null, dao.getBaseline(identityB)) - val identityA = - TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - assertEquals(1_000_000L, dao.getBaseline(identityA)!!.inputTokens) - // 排空确认:B 的旧键已清除且 marker 已写(A 键保留) - assertEquals(0L, prefs.getInputTokensForProviderModel(providerModelB)) - assertEquals(1_000_000L, prefs.getInputTokensForProviderModel(providerModel)) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `cumulative setter growth on normal startup updates counts with frozen pricing`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - - // 阶段 A:首次导入 N(含自定义价格 1.0/2.0)→ 冻结 - val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileA = File(seedA, "seed.preferences_pb") - seedWithInputPrice(seedFileA, 1.0) - restorePreferencesInto(phaseA, seedFileA) - val ctxA = mockContext(phaseA) - val prefsA = constructApiPreferences(ctxA) - injectApiPreferences(prefsA) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxA) - } - val dao = database.tokenStatsDao() - val before = dao.getAllBaselines().single() - assertEquals(1_000_000L, before.inputTokens) - assertEquals(1.804, before.costInPricingCurrency!!, 1e-9) - val frozenInput = before.frozenInputPricePerMillion - val frozenOutput = before.frozenOutputPricePerMillion - } finally { - injectApiPreferences(null) - } - - // 阶段 B:真实累计 setter 增长计数(updateTokensForProviderModel 是 - // 现有累计 setter;新阶段独立 DataStore 文件,首写安全),再次普通启动: - // 计数更新为 N+X,但冻结价格不变(按冻结价重估成本)。 - clearApiDataStoreSingleton() - val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val ctxB = mockContext(phaseB) - val prefsB = constructApiPreferences(ctxB) - injectApiPreferences(prefsB) - try { - // 真实累计 setter:在空快照上累计写入 N+X(输入 2M、输出 1M、缓存 200k) - prefsB.updateTokensForProviderModel( - providerModel, - inputTokens = 2_000_000, - outputTokens = 1_000_000, - cachedInputTokens = 200_000, - ) - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxB) - } - val dao = database.tokenStatsDao() - val after = dao.getAllBaselines().single() - assertEquals(2_000_000L, after.inputTokens) - assertEquals(1_000_000L, after.outputTokens) - assertEquals(200_000L, after.cachedInputTokens) - // 冻结价格列不被普通启动替换(输入仍 1.0、输出仍 2.0) - assertEquals(1.0, after.frozenInputPricePerMillion!!, 1e-9) - assertEquals(2.0, after.frozenOutputPricePerMillion!!, 1e-9) - // 按冻结价重估:1.8M*1.0 + 200k*0.02 + 1M*2.0 = 1.8+0.004+2.0 - assertEquals(3.804, after.costInPricingCurrency!!, 1e-9) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `user reset drop on normal startup replaces baseline with absolute smaller value`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - TokenBaselineImportRunner.databaseProvider = { database } - - // 阶段 A:首次导入 N(含自定义价格) - val phaseA = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedA = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileA = File(seedA, "seed.preferences_pb") - seedWithInputPrice(seedFileA, 1.0) - restorePreferencesInto(phaseA, seedFileA) - val ctxA = mockContext(phaseA) - val prefsA = constructApiPreferences(ctxA) - injectApiPreferences(prefsA) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxA) - } - assertEquals(1_000_000L, database.tokenStatsDao().getAllBaselines().single().inputTokens) - } finally { - injectApiPreferences(null) - } - - // 阶段 B:用户 reset 旧统计 → 快照计数变小(绝对值替换,不产生负增量) - clearApiDataStoreSingleton() - val phaseB = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seedB = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFileB = File(seedB, "seed.preferences_pb") - seedPreferencesFile(seedFileB) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModel)] = 100_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerModel)] = 0L - prefs[ApiPreferences.getTokenOutputKey(providerModel)] = 50_000L - prefs[ApiPreferences.getModelInputPriceKey(providerModel)] = 1.0f - } - restorePreferencesInto(phaseB, seedFileB) - val ctxB = mockContext(phaseB) - val prefsB = constructApiPreferences(ctxB) - injectApiPreferences(prefsB) - try { - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctxB) - } - val dao = database.tokenStatsDao() - val after = dao.getAllBaselines().single() - assertEquals(100_000L, after.inputTokens) - assertEquals(50_000L, after.outputTokens) - // 冻结价重估:100k*1.0 + 50k*2.0 = 0.1 + 0.1 - assertEquals(0.2, after.costInPricingCurrency!!, 1e-9) - assertEquals(1.0, after.frozenInputPricePerMillion!!, 1e-9) - assertEquals(2.0, after.frozenOutputPricePerMillion!!, 1e-9) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `cold start drains pending cleanup before import and deleted baseline never resurrects`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenBaselineImportRunner.databaseProvider = { database } - try { - // Room:legacy A 在 group-x(将随删除移除 baseline),legacy B 保留 - seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") - seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") - dao.upsertBaseline(legacyBaseline(identityIdA)) - // DataStore:A 与 B 都有累计计数 - val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerA)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L - prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L - prefs[ApiPreferences.getTokenOutputKey(providerModelB)] = 1_000_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val prefs = constructApiPreferences(ctx) - injectApiPreferences(prefs) - // Windows DataStore:读入缓存后移除磁盘文件,使排空的 edit 成为唯一真实写入 - prefs.getInputTokensForProviderModel(providerA) - check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) - - // 删除事务:A 的 baseline 删除 + PENDING operation(items 精确到 A) - val op = - dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - .cleanupOperation!! - assertEquals(1, dao.countPendingCleanupOperations()) - assertEquals(0, dao.countBaselines()) - - // 冷启动导入:先排空(A 键清除 + marker),再以清理后的快照导入 B - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) - } - assertEquals("A baseline must stay deleted", null, dao.getBaseline(identityIdA)) - assertNotNull("B baseline must be imported from the remaining snapshot", dao.getBaseline(identityIdB)) - assertEquals(0, dao.countPendingCleanupOperations()) - assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) - assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) - assertEquals(2_000_000L, prefs.getInputTokensForProviderModel(providerModelB)) - - // 再次冷启动:幂等——B 不重复、A 不复活 - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) - } - assertEquals(1, dao.countBaselines()) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `import skips entirely while pending cleanup cannot drain`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenBaselineImportRunner.databaseProvider = { database } - try { - seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") - seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") - dao.upsertBaseline(legacyBaseline(identityIdA)) - val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! - - // DataStore 排空失败:ensureMigrated 捕获并跳过本次导入(不应用旧快照) - val failingPrefs = mock() - whenever(failingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) - .thenAnswer { throw java.io.IOException("datastore down") } - injectApiPreferences(failingPrefs) - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) - } - assertEquals("import must not run while cleanup is pending", 0, dao.countBaselines()) - assertEquals(1, dao.countPendingCleanupOperations()) - - // 排空恢复后(模拟下次启动):清理完成、B 导入、A 保持删除 - val realPrefs = constructApiPreferences(ctx) - injectApiPreferences(realPrefs) - realPrefs.getInputTokensForProviderModel(providerA) - check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) - val retryReady = Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigratedStrict(ctx) - } - val retryRead = realPrefs.legacyStatsSnapshotWithMarkers() - assertTrue( - "retry not ready: pending=${dao.countPendingCleanupOperations()}, " + - "markers=${retryRead.cleanupMarkerIds}, " + - "models=${retryRead.snapshot.providerModels.keys}", - retryReady, - ) - assertNull(dao.getBaseline(identityIdA)) - assertNotNull(dao.getBaseline(identityIdB)) - assertEquals(0, dao.countPendingCleanupOperations()) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `stale snapshot missing the applied marker is rejected by the fence`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("runner-test").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenBaselineImportRunner.databaseProvider = { database } - try { - seedLegacyIdentity(dao, identityIdB, "OPENAI", "gpt-4o", "gpt-4o") - // operation 已 APPLIED(清理完成)但快照读取发生在其 marker 写入之前: - // 模拟“先读旧快照 → cleanup 完成 → 旧快照写回”窗口 - val phase = kotlin.io.path.createTempDirectory("runner-phase").toFile() - val seed = kotlin.io.path.createTempDirectory("runner-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerModelB)] = 2_000_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - // Room:先建 op 再手动 ACK(模拟已完成清理、快照仍旧) - seedLegacyIdentity(dao, identityIdA, "DEEPSEEK", "deepseek-chat", "group-x") - val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! - dao.ackCleanupOperation(op.operationId) - assertEquals(0, dao.countPendingCleanupOperations()) - - val stalePrefs = mock() - whenever(stalePrefs.legacyStatsSnapshotWithMarkers()) - .thenReturn( - com.ai.assistance.operit.data.preferences.LegacyStatsSnapshotRead( - snapshot = - com.ai.assistance.operit.data.stats.LegacyTokenStatsSnapshot.parse( - mapOf( - ApiPreferences.getTokenInputKey(providerModelB).name to 2_000_000L, - ) - ), - cleanupMarkerIds = emptySet(), - ) - ) - injectApiPreferences(stalePrefs) - Mockito.mockStatic(AppLogger::class.java).use { - TokenBaselineImportRunner.ensureMigrated(ctx) - } - assertEquals( - "stale snapshot without the applied marker must be rejected", - 0, - dao.countBaselines(), - ) - } finally { - injectApiPreferences(null) - TokenBaselineImportRunner.databaseProvider = null - database.close() - } - } - - @Test - fun `import and reset are rejected before resolving Room during snapshot`() = runBlocking { - TokenStatSpool.clearPendingStateForTest() - val phase = kotlin.io.path.createTempDirectory("barrier-entry-test").toFile() - val ctx = mockContext(phase) - val importProviderCalls = AtomicInteger(0) - val resetProviderCalls = AtomicInteger(0) - TokenBaselineImportRunner.databaseProvider = { - importProviderCalls.incrementAndGet() - error("import must not resolve Room while the barrier is active") - } - TokenStatsResetCoordinator.daoProvider = { - resetProviderCalls.incrementAndGet() - error("reset must not resolve Room while the barrier is active") - } - var importRejected = false - var resetRejected = false - - TokenStatSpool.withExclusiveSnapshotAccess(ctx, drainBefore = false) { - try { - TokenBaselineImportRunner.runImport(ctx, forceReplace = false) - } catch (_: TokenStatsBarrierActiveException) { - importRejected = true - } - try { - TokenStatsResetCoordinator.deleteEventsInRange(ctx, 0L, 1L) - } catch (_: TokenStatsBarrierActiveException) { - resetRejected = true - } - } - - assertTrue("baseline import must be rejected during snapshot", importRejected) - assertTrue("reset must be rejected during snapshot", resetRejected) - assertEquals(0, importProviderCalls.get()) - assertEquals(0, resetProviderCalls.get()) - TokenStatSpool.clearPendingStateForTest() - } - - // ==== P1 闭环:legacy cleanup outbox 导入 fence ==== - private val providerA = "DEEPSEEK:deepseek-chat" - private val identityIdA = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - private val identityIdB = TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o") - - private suspend fun seedLegacyIdentity( - dao: com.ai.assistance.operit.data.dao.TokenStatsDao, - identityId: String, - provider: String, - model: String, - displayModelId: String, - ) { - dao.insertIdentityIfAbsent( - com.ai.assistance.operit.data.model.TokenStatIdentityEntity( - identityId = identityId, - configId = "", - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - } - - private fun legacyBaseline(identityId: String): com.ai.assistance.operit.data.model.TokenStatBaselineEntity = - com.ai.assistance.operit.data.model.TokenStatBaselineEntity( - identityId = identityId, - inputTokens = 100L, - cachedInputTokens = 0L, - outputTokens = 50L, - requestCount = 1L, - pricingCurrency = "USD", - costInPricingCurrency = 0.0002, - isEstimated = true, - fingerprint = "fp-$identityId", - importedAtMs = 1L, - frozenBillingMode = BillingMode.TOKEN.name, - frozenInputPricePerMillion = 1.0, - frozenOutputPricePerMillion = 2.0, - ) -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt deleted file mode 100644 index d8df457c6..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenBaselineMigratorTest.kt +++ /dev/null @@ -1,819 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.ModelPricingDefaults -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TokenBaselineMigratorTest { - - private val knownDefaults = - ModelPricingDefaults( - billingMode = BillingMode.TOKEN, - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - pricePerRequest = 0.01, - currency = PricingCurrency.USD, - ) - - private val zeroDefaults = - ModelPricingDefaults( - billingMode = BillingMode.TOKEN, - inputPricePerMillion = 0.0, - outputPricePerMillion = 0.0, - cachedInputPricePerMillion = 0.0, - pricePerRequest = 0.01, - currency = PricingCurrency.CNY, - ) - - private fun snapshotOf( - providerModel: String, - input: Long = 0L, - cached: Long = 0L, - output: Long = 0L, - requests: Long = 0L, - priceSettings: LegacyPriceSettings = LegacyPriceSettings(), - ) = LegacyTokenStatsSnapshot( - providerModels = - mapOf( - providerModel to - LegacyProviderModelStats( - providerModel = providerModel, - inputTokens = input, - cachedInputTokens = cached, - outputTokens = output, - requestCount = requests, - priceSettings = priceSettings, - ) - ) - ) - - private fun plan( - snapshot: LegacyTokenStatsSnapshot, - existingBaselines: Map = emptyMap(), - defaults: ModelPricingDefaults = knownDefaults, - nowMs: Long = 1_000L, - forceReplace: Boolean = false, - legacyOverrideOverrides: List = emptyList(), - existingIdentities: Map = emptyMap(), - ) = - TokenBaselineMigrator.planImport( - snapshot = snapshot, - existingBaselines = existingBaselines, - nowMs = nowMs, - forceReplace = forceReplace, - resolveIdentity = { providerModel -> - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("", provider, model), - configId = "", - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - }, - resolveDisplayModel = { TokenBaselineMigrator.defaultDisplayModel(it) }, - resolvePricing = { providerModel -> - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - TokenPriceResolver.resolve( - provider = provider, - model = model, - configId = null, - overrides = legacyOverrideOverrides, - legacyOverride = snapshot.providerModels[providerModel]?.priceSettings, - defaults = defaults, - ) - }, - existingIdentities = existingIdentities, - ) - - /** - * 与导入执行器一致:已有 baseline 都对应旧系统迁移身份(configId 空)。 - * 快照中仍存在的身份用快照解析结果,快照外的 baseline 身份用合成旧系统身份 - * (删除过滤只依赖 configId 是否为空,provider/model 内容不影响语义)。 - */ - private fun legacyIdentitiesFor( - baselines: Map, - snapshot: LegacyTokenStatsSnapshot = snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3), - ): Map { - val result = - snapshot.providerModels.keys.map { providerModel -> - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(providerModel) - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("", provider, model), - configId = "", - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - }.associateBy { it.identityId }.toMutableMap() - baselines.keys.forEach { id -> - result.putIfAbsent( - id, - TokenStatIdentityEntity( - identityId = id, - configId = "", - provider = "", - model = "", - displayModelId = id, - ) - ) - } - return result - } - - @Test - fun `first import creates estimated baseline with fingerprint`() { - val p = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - - assertEquals(1, p.baselines.size) - val baseline = p.baselines.single() - assertTrue(baseline.isEstimated) - assertTrue(baseline.fingerprint.isNotBlank()) - assertEquals(1, p.identities.size) - assertEquals(1, p.displayModels.size) - } - - @Test - fun `baseline cost is estimated from legacy pricing chain in native currency`() { - val p = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - cached = 200_000, - output = 500_000, - requests = 3, - ) - ) - - val baseline = p.baselines.single() - // 800k*1 + 200k*0.5 + 500k*2 = 0.8 + 0.1 + 1.0 (每百万计价) - assertEquals(1.9, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(PricingCurrency.USD.name, baseline.pricingCurrency) - } - - @Test - fun `count mode baseline cost is per request price times request count`() { - val legacy = - LegacyPriceSettings( - billingMode = BillingMode.COUNT, - pricePerRequest = 0.02, - ) - val p = plan(snapshotOf("OPENAI:gpt-4o", requests = 5, priceSettings = legacy)) - - val baseline = p.baselines.single() - assertEquals(0.1, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(5L, baseline.requestCount) - } - - @Test - fun `unknown pricing yields null estimated cost not zero`() { - val p = plan(snapshotOf("MYSTERY:model-x", input = 1000), defaults = zeroDefaults) - - val baseline = p.baselines.single() - assertNull(baseline.costInPricingCurrency) - assertTrue(baseline.isEstimated) - } - - @Test - fun `same snapshot imported twice skips second time`() { - val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - val existing = first.baselines.associateBy { it.identityId } - - val second = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3), existing) - - assertEquals(0, second.baselines.size) - assertEquals(0, second.identities.size) - } - - @Test - fun `growing legacy counters re-estimate with frozen pricing`() { - val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - val existing = first.baselines.associateBy { it.identityId } - - // 普通导入:计数增长 → 用冻结价重估(此处默认价 1.0/0.5/2.0) - val second = - plan( - snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4), - existing, - ) - assertEquals(1, second.baselines.size) - val regrown = second.baselines.single() - assertEquals(1500L, regrown.inputTokens) - assertEquals(4L, regrown.requestCount) - assertFalse(regrown.fingerprint == existing.getValue(regrown.identityId).fingerprint) - - // 受控补导(forceReplace):以当前快照重新解析定价并整体替换 - val forced = - plan( - snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4), - existing, - forceReplace = true, - ) - assertEquals(1, forced.baselines.size) - } - - @Test - fun `database restore triggers full reimport`() { - // 第一次导入成功,随后数据库被恢复到导入前的状态(baseline 表为空) - val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - - val afterRestore = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - - assertEquals(1, first.baselines.size) - assertEquals(1, afterRestore.baselines.size) - assertEquals(first.baselines.single().fingerprint, afterRestore.baselines.single().fingerprint) - assertEquals(1000L, afterRestore.baselines.single().inputTokens) - } - - @Test - fun `interrupted import reruns and converges to single baseline`() { - // 中断 = 什么都没写入(existing 为空),重跑与首次结果一致且只产生一行 - val interruptedRun = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 800, output = 300, requests = 2)) - val retry = - plan( - snapshotOf("DEEPSEEK:deepseek-chat", input = 800, output = 300, requests = 2), - emptyMap(), - ) - - assertEquals(interruptedRun.baselines.single().fingerprint, retry.baselines.single().fingerprint) - assertEquals(1, retry.baselines.size) - } - - @Test - fun `preferences changed later without lifecycle signal still track counts with frozen pricing`() { - val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3)) - val existing = first.baselines.associateBy { it.identityId } - - // 快照计数变化(偏好文件被覆盖)但没有恢复生命周期信号: - // 普通导入用冻结价跟踪计数;只有受控补导(forceReplace)才重解析价格 - val restored = - plan( - snapshotOf("DEEPSEEK:deepseek-chat", input = 600, output = 200, requests = 1), - existing, - ) - - assertEquals(1, restored.baselines.size) - val baseline = restored.baselines.single() - assertEquals(600L, baseline.inputTokens) - assertEquals(1L, baseline.requestCount) - } - - @Test - fun `count growth re-estimates with frozen pricing while keeping frozen prices`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - val before = first.baselines.single() - assertEquals(2.0, before.costInPricingCurrency!!, 1e-9) - - // 普通启动:累计 setter 增长计数(同时快照价格被普通 setter 改为 99)—— - // 计数变化触发重估,但必须用行内冻结价 (1.0/2.0),冻结价格列不变。 - val regrown = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 2_000_000, - output = 1_000_000, - requests = 6, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 99.0, - outputPricePerMillion = 99.0, - ), - ), - existing, - ) - - assertEquals(1, regrown.baselines.size) - val baseline = regrown.baselines.single() - // 按冻结价 (1.0/2.0) 重估:2M*1.0 + 1M*2.0 = 2.0 + 2.0 - assertEquals(4.0, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(2_000_000L, baseline.inputTokens) - assertEquals(1_000_000L, baseline.outputTokens) - assertEquals(6L, baseline.requestCount) - // 冻结价格列不被普通启动替换(仍是 1.0/2.0,而非 99) - assertEquals(1.0, baseline.frozenInputPricePerMillion!!, 1e-9) - assertEquals(2.0, baseline.frozenOutputPricePerMillion!!, 1e-9) - } - - @Test - fun `count drop after user reset replaces baseline with absolute value`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - - // 用户 reset 旧统计:计数变小(绝对值替换,不产生负增量/不拒绝) - val dropped = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 100_000, - output = 50_000, - requests = 1, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ), - existing, - ) - - assertEquals(1, dropped.baselines.size) - val baseline = dropped.baselines.single() - assertEquals(100_000L, baseline.inputTokens) - assertEquals(50_000L, baseline.outputTokens) - assertEquals(1L, baseline.requestCount) - // 冻结价重估:100k*1.0 + 50k*2.0 = 0.1 + 0.1 - assertEquals(0.2, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(1.0, baseline.frozenInputPricePerMillion!!, 1e-9) - } - - @Test - fun `normal import never removes baseline for model missing from snapshot`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - val identities = legacyIdentitiesFor(existing) - - // 普通启动:当前快照不再包含该模型(偏好文件暂时缺失/部分恢复/被清空)—— - // 必须保留其 baseline,绝不因快照缺失删除(显式删除走用户重置路径)。 - val normal = plan(emptySnapshot(), existing, existingIdentities = identities) - // 快照中 OTHER:model-x 正常导入;DEEPSEEK 的 baseline 不得被更新或删除 - assertEquals(1, normal.baselines.size) - assertTrue(normal.baselines.none { it.identityId == existing.keys.single() }) - assertEquals("normal import must not delete missing baselines", emptyList(), normal.removedBaselineIdentityIds) - } - - @Test - fun `controlled restore removes legacy baseline missing from restored snapshot but not config baselines`() { - val legacy = - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - val first = plan(legacy) - val legacyBaseline = first.baselines.single() - - // 配置实例身份(configId 非空)的 baseline:不属于旧累计快照范围, - // 受控补导也必须保留 - val configIdentity = - TokenStatIdentityEntity( - identityId = "config-identity", - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - val configBaseline = legacyBaseline.copy(identityId = configIdentity.identityId) - val existing = mapOf(legacyBaseline.identityId to legacyBaseline, configBaseline.identityId to configBaseline) - val identities = legacyIdentitiesFor(existing, legacy) + mapOf(configIdentity.identityId to configIdentity) - - // 恢复快照只含另一个模型(DEEPSEEK:deepseek-chat 消失)→ forceReplace: - // 只删除旧系统身份(configId 空)的 baseline,配置身份 baseline 保留 - val restoredSnapshot = - LegacyTokenStatsSnapshot( - providerModels = mapOf( - "OTHER:model-x" to - LegacyProviderModelStats( - providerModel = "OTHER:model-x", - inputTokens = 10L, - cachedInputTokens = 0L, - outputTokens = 0L, - requestCount = 0L, - priceSettings = LegacyPriceSettings(), - ) - ) - ) - val forced = plan(restoredSnapshot, existing, forceReplace = true, existingIdentities = identities) - - assertEquals(listOf(legacyBaseline.identityId), forced.removedBaselineIdentityIds) - } - - @Test - fun `model disappearing from snapshot is marked for removal only on controlled restore`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - val identities = legacyIdentitiesFor(existing) - - // 恢复快照不再包含该模型(旧统计在备份中已清空)→ 受控补导删除其 baseline - val empty = - LegacyTokenStatsSnapshot( - providerModels = mapOf( - "OTHER:model-x" to - LegacyProviderModelStats( - providerModel = "OTHER:model-x", - inputTokens = 10L, - cachedInputTokens = 0L, - outputTokens = 0L, - requestCount = 0L, - priceSettings = LegacyPriceSettings(), - ) - ) - ) - val forcedPlan = plan(empty, existing, forceReplace = true, existingIdentities = identities) - - assertEquals( - listOf(existing.keys.single()), - forcedPlan.removedBaselineIdentityIds - ) - } - - @Test - fun `controlled restore with empty snapshot removes all legacy baselines but keeps config baselines`() { - val legacy = - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - val first = plan(legacy) - val legacyBaseline = first.baselines.single() - - val configIdentity = - TokenStatIdentityEntity( - identityId = "config-identity", - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - val configBaseline = legacyBaseline.copy(identityId = configIdentity.identityId) - val existing = mapOf(legacyBaseline.identityId to legacyBaseline, configBaseline.identityId to configBaseline) - val identities = legacyIdentitiesFor(existing, legacy) + mapOf(configIdentity.identityId to configIdentity) - - // 恢复后的权威旧偏好快照完全为空:forceReplace 仍产出删除计划—— - // 全部 legacy(configId 空)baseline 被删除,config baseline 保留 - val empty = LegacyTokenStatsSnapshot(providerModels = emptyMap()) - val forced = plan(empty, existing, forceReplace = true, existingIdentities = identities) - - assertEquals(0, forced.baselines.size) - assertEquals(listOf(legacyBaseline.identityId), forced.removedBaselineIdentityIds) - } - - private fun emptySnapshot() = - LegacyTokenStatsSnapshot( - providerModels = mapOf( - "OTHER:model-x" to - LegacyProviderModelStats( - providerModel = "OTHER:model-x", - inputTokens = 10L, - cachedInputTokens = 0L, - outputTokens = 0L, - requestCount = 0L, - priceSettings = LegacyPriceSettings(), - ) - ) - ) - - @Test - fun `existing baseline is frozen when counts and prices change`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - val baselineBefore = first.baselines.single() - - // 普通用户从未自定义价格也代表完整状态;首次迁移无价格也冻结。 - val noCustomPrice = - plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3)) - assertEquals(1, noCustomPrice.baselines.size) - - // 计数不变 + 价格变化(普通 setter 改价):不重估(指纹只含计数) - val repriced = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 99.0, - outputPricePerMillion = 99.0, - ), - ), - existing, - ) - - assertEquals(0, repriced.baselines.size) - assertEquals(2.0, baselineBefore.costInPricingCurrency!!, 1e-9) - } - - @Test - fun `force replace re-imports all baselines from the current snapshot`() { - val first = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ), - ) - ) - val existing = first.baselines.associateBy { it.identityId } - - // 受控补导(恢复后):forceReplace 忽略已有 baseline,用当前快照重估 - val restored = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 1_000_000, - output = 500_000, - requests = 3, - priceSettings = - LegacyPriceSettings( - inputPricePerMillion = 2.0, - outputPricePerMillion = 4.0, - ), - ), - existing, - forceReplace = true, - ) - - assertEquals(1, restored.baselines.size) - val baseline = restored.baselines.single() - // 1000k*2 + 500k*4 = 2.0 + 2.0 - assertEquals(4.0, baseline.costInPricingCurrency!!, 1e-9) - } - - @Test - fun `current price override change does not re-estimate imported baseline`() { - // 首次导入时旧配置链与当前覆盖并存(覆盖为空 → 走旧配置链) - val first = plan(snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3)) - val existing = first.baselines.associateBy { it.identityId } - val baselineBefore = first.baselines.single() - - // 用户后续新增当前价格覆盖(新系统价格):指纹与估算都必须不受影响 - val override = - TokenPriceResolver.normalizedOverride( - scope = "PROVIDER_MODEL", - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 99.0, - outputPricePerMillion = 99.0, - ) - - val second = - plan( - snapshotOf("DEEPSEEK:deepseek-chat", input = 1_000_000, output = 500_000, requests = 3), - existing, - legacyOverrideOverrides = listOf(override), - ) - - assertEquals(0, second.baselines.size) - assertEquals(2.0, baselineBefore.costInPricingCurrency!!, 1e-9) - } - - @Test - fun `provider-only legacy keys are skipped without crash`() { - val p = plan(snapshotOf("DEEPSEEK", input = 10L)) - - assertEquals(0, p.baselines.size) - assertEquals(listOf("DEEPSEEK"), p.skippedProviderModels) - } - - @Test - fun `multiple provider models produce one baseline each`() { - val snapshot = - LegacyTokenStatsSnapshot( - providerModels = - mapOf( - "DEEPSEEK:deepseek-chat" to - LegacyProviderModelStats( - providerModel = "DEEPSEEK:deepseek-chat", - inputTokens = 10L, - cachedInputTokens = 0L, - outputTokens = 5L, - requestCount = 1L, - priceSettings = LegacyPriceSettings(), - ), - "OPENAI:gpt-4o" to - LegacyProviderModelStats( - providerModel = "OPENAI:gpt-4o", - inputTokens = 20L, - cachedInputTokens = 0L, - outputTokens = 8L, - requestCount = 2L, - priceSettings = LegacyPriceSettings(), - ), - ) - ) - - val p = plan(snapshot) - - assertEquals(2, p.baselines.size) - } - - @Test - fun `baseline estimate is safe for cumulative values beyond Int max`() { - val hugeInput = 3_000_000_000L - val hugeCached = 1_500_000_000L - val hugeOutput = 2_000_000_000L - val p = - plan( - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = hugeInput, - cached = hugeCached, - output = hugeOutput, - requests = 4, - ) - ) - - val baseline = p.baselines.single() - val uncached = hugeInput - hugeCached - val expected = uncached / 1_000_000.0 * 1.0 + hugeCached / 1_000_000.0 * 0.5 + hugeOutput / 1_000_000.0 * 2.0 - assertEquals(expected, baseline.costInPricingCurrency!!, 1e-9) - assertEquals(hugeInput, baseline.inputTokens) - } - - @Test - fun `reimport preserves manually merged display group and alias`() { - val snapshot = snapshotOf("DEEPSEEK:deepseek-chat", input = 1000, output = 500, requests = 3) - val first = plan(snapshot) - val identity = first.identities.single() - val displayModel = first.displayModels.single() - - // 用户手动合并展示组:别名 + 新的分组 ID - val mergedGroupId = "my-deepseek-group" - val existingIdentities = - mapOf( - identity.identityId to - identity.copy(displayModelId = mergedGroupId) - ) - val existingDisplayModels = - mapOf( - mergedGroupId to - TokenStatDisplayModelEntity( - displayModelId = mergedGroupId, - normalizedModel = "deepseek-chat", - displayName = "我的 DeepSeek", - ), - displayModel.displayModelId to displayModel, - ) - - // 受控补导(恢复后 forceReplace):分组与别名必须保留 - val repricedSnapshot = - snapshotOf("DEEPSEEK:deepseek-chat", input = 1500, output = 700, requests = 4) - val plan = - TokenBaselineMigrator.planImport( - snapshot = repricedSnapshot, - existingBaselines = first.baselines.associateBy { it.identityId }, - nowMs = 2_000L, - forceReplace = true, - resolveIdentity = { pm -> - val (provider, model) = TokenStatIdentityResolver.splitProviderModel(pm) - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("", provider, model), - configId = "", - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - }, - resolveDisplayModel = { TokenBaselineMigrator.defaultDisplayModel(it) }, - resolvePricing = { pm -> - TokenPriceResolver.resolve( - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = null, - overrides = emptyList(), - legacyOverride = repricedSnapshot.providerModels[pm]?.priceSettings, - defaults = knownDefaults, - ) - }, - ) - - val preserved = - TokenBaselineMigrator.preserveExistingGroups( - plan = plan, - existingIdentities = existingIdentities, - existingDisplayModels = existingDisplayModels, - ) - - assertEquals(mergedGroupId, preserved.identities.single().displayModelId) - // 已存在的展示模型不重写(别名保留),只补缺省模型行 - assertTrue(preserved.displayModels.none { it.displayModelId == mergedGroupId }) - assertEquals(1, preserved.baselines.size) - assertEquals(1500L, preserved.baselines.single().inputTokens) - } - - @Test - fun `fingerprint covers counts only and ignores legacy price settings`() { - val statsA = - snapshotOf("DEEPSEEK:deepseek-chat", input = 10L) - .providerModels.getValue("DEEPSEEK:deepseek-chat") - val statsB = - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 10L, - priceSettings = LegacyPriceSettings(inputPricePerMillion = 2.0), - ).providerModels.getValue("DEEPSEEK:deepseek-chat") - val statsC = - snapshotOf( - "DEEPSEEK:deepseek-chat", - input = 20L, - priceSettings = LegacyPriceSettings(inputPricePerMillion = 2.0), - ).providerModels.getValue("DEEPSEEK:deepseek-chat") - - // 价格设置变化不改变指纹(价格编辑不触发重导) - assertEquals(TokenBaselineMigrator.fingerprint(statsA), TokenBaselineMigrator.fingerprint(statsB)) - // 计数变化改变指纹(计数变化触发重导) - assertFalse( - TokenBaselineMigrator.fingerprint(statsB) == - TokenBaselineMigrator.fingerprint(statsC) - ) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt index 94c249d10..20b347253 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -1,582 +1,193 @@ package com.ai.assistance.operit.data.stats import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow import com.ai.assistance.operit.data.model.BillingMode -import org.json.JSONObject import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test class TokenCostCalculatorTest { - - private val tokenPricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 0.75, - outputPricePerMillion = 2.0, - source = PricingSource.DEFAULT, - known = true, - ) - - private val unknownPricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.CNY, - source = PricingSource.UNKNOWN, - known = false, - ) - - private val countPricing = - ResolvedPricing( - billingMode = BillingMode.COUNT, - currency = PricingCurrency.CNY, - pricePerRequest = 0.01, - source = PricingSource.DEFAULT, - known = true, - ) - - @Test - fun `token cost is computed per million in native currency`() { - val cost = - TokenCostCalculator.computeCost( - usage = - TokenUsageInput( -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -cacheWriteTokens = 0L, -outputTokens = 500L, - ), - pricing = tokenPricing, - ) - - assertEquals(0.0019, cost.amount!!, 1e-12) - // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 = 0.0008 + 0.0001 + 0.001 - assertEquals(PricingCurrency.USD, cost.currency) - assertEquals(1000L, cost.billedInputTokens) - assertEquals(500L, cost.billedOutputTokens) - } - - @Test - fun `reasoning included in output is not billed twice`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, -reasoningTokens = 300L, - reasoningIncludedInOutput = true, - ) - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertEquals(500L, cost.billedOutputTokens) - // 1000/1e6*1 + 0 + 500/1e6*2 = 0.001 + 0.001 - assertEquals(0.002, cost.amount!!, 1e-12) - } - - @Test - fun `reasoning declared separate is added to billed output`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, -reasoningTokens = 300L, - reasoningIncludedInOutput = false, - ) - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertEquals(800L, cost.billedOutputTokens) - // 1000/1e6*1 + 800/1e6*2 = 0.001 + 0.0016 - assertEquals(0.0026, cost.amount!!, 1e-12) - } - - @Test - fun `gemini end to end bills thoughts on top of candidates`() { - // P1-4:Gemini thoughtsTokenCount 独立于 candidatesTokenCount,按输出计费; - // prompt=100, candidates=20, thoughts=80, total=200 → billed output = 100。 - val usage = - ProviderUsageNormalizer.gemini( - JSONObject( - """ - { - "promptTokenCount": 100, - "cachedContentTokenCount": 0, - "candidatesTokenCount": 20, - "thoughtsTokenCount": 80, - "totalTokenCount": 200 - } - """.trimIndent() - ) - )!!.toTokenUsageInput() - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertEquals(100L, cost.billedOutputTokens) - assertEquals(100L, cost.billedInputTokens) - // 100/1e6*1 + 0/1e6*0.5 + 100/1e6*2 = 0.0003 - assertEquals(0.0003, cost.amount!!, 1e-12) - } - @Test - fun `reasoning without inclusion declaration defaults to included`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -outputTokens = 500L, -reasoningTokens = 300L, - reasoningIncludedInOutput = null, - ) - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertEquals(500L, cost.billedOutputTokens) - } - - @Test - fun `unknown output tokens produce unknown cost not zero`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, - outputTokens = null, - ) - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertNull(cost.amount) - } - - @Test - fun `unknown input tokens produce unknown cost not zero`() { - val usage = - TokenUsageInput( - uncachedInputTokens = null, -cachedInputTokens = 0L, -outputTokens = 500L, - ) - - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - assertNull(cost.amount) - } - - @Test - fun `null cached input keeps cost unknown while zero cached input is a real zero`() { - val unknownCache = - TokenUsageInput( -uncachedInputTokens = 1000L, - cachedInputTokens = null, -outputTokens = 500L, - ) - val noCacheRead = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, - ) - - assertNull(TokenCostCalculator.computeCost(unknownCache, tokenPricing).amount) - // 确认无缓存读取:1000/1e6*1 + 0 + 500/1e6*2 - assertEquals(0.002, TokenCostCalculator.computeCost(noCacheRead, tokenPricing).amount!!, 1e-12) - } - - @Test - fun `null cache write keeps cost unknown while zero cache write is a real zero`() { - val unknownWrite = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, - cacheWriteTokens = null, -outputTokens = 500L, - ) - val noCacheWrite = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, - ) - - assertNull(TokenCostCalculator.computeCost(unknownWrite, tokenPricing).amount) + fun `token cost uses current split prices`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow( + uncachedInputTokens = 800L, + cachedInputTokens = 200L, + totalInputTokens = 1_000L, + outputTokens = 500L, + ), + pricing = tokenPricing(), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, + ) + + assertEquals(0.0019, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) assertEquals( - 0.002, - TokenCostCalculator.computeCost(noCacheWrite, tokenPricing).amount!!, - 1e-12 + 0.0019, + result.originalCurrencyAmounts.getValue(PricingCurrency.USD), + 1e-12, ) } @Test - fun `cache write tokens are billed at cache write price when known`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 400L, -outputTokens = 500L, + fun `equal input prices use total input when split is unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow( + uncachedInputKnown = 0L, + cachedInputKnown = 0L, + totalInputTokens = 1_000L, + outputTokens = 500L, + ), + pricing = tokenPricing(cachedInputPricePerMillion = 1.0), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, ) - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - - // 1000/1e6*1 + 0 + 400/1e6*0.75 + 500/1e6*2 = 0.001 + 0.0003 + 0.001 - assertEquals(0.0023, cost.amount!!, 1e-12) - assertEquals(400L, cost.billedCacheWriteTokens) + assertEquals(0.002, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) } @Test - fun `cache write without known price keeps cost unknown`() { - val pricingWithoutWritePrice = - tokenPricing.copy(cacheWritePricePerMillion = null) - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 400L, -outputTokens = 500L, + fun `missing priced token field counts request as unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(outputKnown = 0L), + pricing = tokenPricing(), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, ) - val cost = TokenCostCalculator.computeCost(usage, pricingWithoutWritePrice) - - assertNull(cost.amount) + assertEquals(1L, result.unknownContributionCount) + assertEquals(1L, result.totalContributionCount) } @Test - fun `unknown pricing produces unknown cost not zero`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -outputTokens = 500L, - ) - - val cost = TokenCostCalculator.computeCost(usage, unknownPricing) - - assertNull(cost.amount) - } - - @Test - fun `unknown pricing with zero prices is still unknown not zero`() { - // P1-1:默认价表对未知模型给出全 0 价格但 known = false(zeroPricing), - // 统一边界必须返回 null——即使用量完整也不能把“无法定价”算成 0 元。 - val zeroValuedUnknown = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 0.0, - cachedInputPricePerMillion = 0.0, - cacheWritePricePerMillion = 0.0, - outputPricePerMillion = 0.0, - source = PricingSource.UNKNOWN, - known = false, - ) - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, - ) - - assertNull(TokenCostCalculator.computeCost(usage, zeroValuedUnknown).amount) - } - - @Test - fun `unknown count pricing with zero per request price is still unknown`() { - // P1-1:COUNT 模式同样受 known 边界约束,不能按 0 元单次价算出伪 0。 - val unknownCount = - ResolvedPricing( - billingMode = BillingMode.COUNT, - currency = PricingCurrency.USD, - pricePerRequest = 0.0, - source = PricingSource.UNKNOWN, - known = false, - ) - - assertNull(TokenCostCalculator.computeCost(TokenUsageInput(outputTokens = 500L), unknownCount).amount) - } - - @Test - fun `count mode cost equals per request price`() { - val cost = - TokenCostCalculator.computeCost( -usage = TokenUsageInput(outputTokens = 500L), - pricing = countPricing, - ) - - assertEquals(0.01, cost.amount!!, 1e-12) - assertEquals(PricingCurrency.CNY, cost.currency) - } - - @Test - fun `known zero pricing yields real zero cost`() { - val zeroPricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 0.0, - cachedInputPricePerMillion = 0.0, - outputPricePerMillion = 0.0, - source = PricingSource.PROVIDER_MODEL_OVERRIDE, - known = true, - ) - - val cost = - TokenCostCalculator.computeCost( - usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, + fun `unknown zero pricing counts every request as unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(requests = 3L), + pricing = + tokenPricing( + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + cacheWritePricePerMillion = 0.0, + outputPricePerMillion = 0.0, + source = PricingSource.UNKNOWN, ), - pricing = zeroPricing, - ) - - assertEquals(0.0, cost.amount!!, 1e-12) - } - - @Test - fun `total input bills at unified price when split unknown and prices equal`() { - val equalPricing = tokenPricing.copy(cachedInputPricePerMillion = 1.0) - val usage = - TokenUsageInput( - uncachedInputTokens = null, - cachedInputTokens = null, -totalInputTokens = 1000L, -outputTokens = 500L, - // OpenAI 兼容系/Gemini:无独立缓存写入计费概念 - cacheWriteSeparateBilling = false, - ) - - val cost = TokenCostCalculator.computeCost(usage, equalPricing) - - // 1000/1e6*1 + 500/1e6*2 = 0.001 + 0.001 - assertEquals(0.002, cost.amount!!, 1e-12) - assertEquals(1000L, cost.billedInputTokens) - } - - @Test - fun `total input keeps cost unknown when split unknown and prices differ`() { - // tokenPricing:input 1.0 vs cached 0.5,单价不同且拆分未知 → 不得伪造 uncached - val usage = - TokenUsageInput( - uncachedInputTokens = null, - cachedInputTokens = null, -totalInputTokens = 1000L, -outputTokens = 500L, - cacheWriteSeparateBilling = false, - ) - - assertNull(TokenCostCalculator.computeCost(usage, tokenPricing).amount) - } - - @Test - fun `split known ignores total input fallback`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -totalInputTokens = 1000L, -outputTokens = 500L, - cacheWriteSeparateBilling = false, + targetCurrency = PricingCurrency.CNY, + usdToCnyRate = 7.0, ) - val cost = TokenCostCalculator.computeCost(usage, tokenPricing) - assertEquals(1000L, cost.billedInputTokens) - // 800/1e6*1 + 200/1e6*0.5 + 500/1e6*2 - assertEquals(0.0019, cost.amount!!, 1e-12) + assertEquals(0.0, result.knownAmount, 0.0) + assertEquals(3L, result.unknownContributionCount) } @Test - fun `non finite token price and overflowing token cost are unknown`() { - val usage = - TokenUsageInput( - uncachedInputTokens = Long.MAX_VALUE, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - outputTokens = Long.MAX_VALUE, + fun `count billing uses current per request price`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(requests = 4L), + pricing = + ResolvedTokenPricing( + billingMode = BillingMode.COUNT, + currency = PricingCurrency.CNY, + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + outputPricePerMillion = 0.0, + pricePerRequest = 0.02, + source = PricingSource.USER, + ), + targetCurrency = PricingCurrency.CNY, + usdToCnyRate = 7.0, ) - assertNull( - TokenCostCalculator.computeCost( - usage, - tokenPricing.copy(inputPricePerMillion = 1e308), - ).amount - ) - assertNull( - TokenCostCalculator.computeCost( - usage.copy(uncachedInputTokens = 1L, outputTokens = 1L), - tokenPricing.copy(outputPricePerMillion = Double.NaN), - ).amount - ) - } - @Test - fun `non finite count price is unknown`() { - assertNull( - TokenCostCalculator.computeCost( - TokenUsageInput(), - countPricing.copy(pricePerRequest = Double.POSITIVE_INFINITY), - ).amount - ) + assertEquals(0.08, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) } -} - -class TokenCostCurrencyTest { @Test - fun `query params reject non finite manual rate`() { - assertTrue(runCatching { TokenStatsQueryParams(manualRate = Double.POSITIVE_INFINITY) }.isFailure) - assertTrue(runCatching { TokenStatsQueryParams(manualRate = Double.NaN) }.isFailure) - } - - - @Test - fun `default manual rate is 7`() { - assertEquals(7.0, TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, 1e-12) - } - - @Test - fun `usd converts to cny with manual rate`() { - val converted = + fun `currency conversion uses configured rate`() { + assertEquals( + 70.0, TokenCostCurrency.convertTo( amount = 10.0, - from = PricingCurrency.USD, - to = PricingCurrency.CNY, - manualRate = 7.0, - ) - assertEquals(70.0, converted!!, 1e-12) - } - - @Test - fun `cny converts to usd by dividing manual rate`() { - val converted = + source = PricingCurrency.USD, + target = PricingCurrency.CNY, + usdToCnyRate = 7.0, + ), + 1e-12, + ) + assertEquals( + 10.0, TokenCostCurrency.convertTo( amount = 70.0, - from = PricingCurrency.CNY, - to = PricingCurrency.USD, - manualRate = 7.0, - ) - assertEquals(10.0, converted!!, 1e-12) - } - - @Test - fun `same currency conversion is identity`() { - val converted = - TokenCostCurrency.convertTo( - amount = 5.0, - from = PricingCurrency.CNY, - to = PricingCurrency.CNY, - manualRate = 7.0, - ) - assertEquals(5.0, converted!!, 1e-12) - } - - @Test - fun `changing manual rate changes converted total but not native cost`() { - val nativeCost = 10.0 // USD - - val atRate7 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 7.0) - val atRate8 = TokenCostCurrency.convertTo(nativeCost, PricingCurrency.USD, PricingCurrency.CNY, 8.0) - - assertEquals(70.0, atRate7!!, 1e-12) - assertEquals(80.0, atRate8!!, 1e-12) - assertEquals(10.0, nativeCost, 1e-12) // 原币成本不受汇率影响 - } - - @Test - fun `historical unknown cost stays unknown after conversion`() { - val converted = - TokenCostCurrency.historicalCostConverted( - costInPricingCurrency = null, - costCurrency = PricingCurrency.USD, - targetCurrency = PricingCurrency.CNY, - manualRate = 7.0, - ) - assertNull(converted) - } - - @Test - fun `revaluation uses current pricing instead of historical snapshot`() { - val usage = - TokenUsageInput( -uncachedInputTokens = 1000L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 500L, - ) - val historicalSnapshot = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 2.0, - source = PricingSource.DEFAULT, - known = true, - ) - val currentPricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 1.0, - outputPricePerMillion = 4.0, - source = PricingSource.PROVIDER_MODEL_OVERRIDE, - known = true, - ) - - val historical = TokenCostCalculator.computeCost(usage, historicalSnapshot) - val revalued = TokenCostCurrency.revaluedCost(usage, currentPricing) - - // 历史按事件快照价:0.001 + 0.001 = 0.002 - assertEquals(0.002, historical.amount!!, 1e-12) - // 重估按当前价:0.002 + 0.002 = 0.004 - assertEquals(0.004, revalued.amount!!, 1e-12) - // 两种模式按同一手动汇率换算为统一币种 - val historicalCny = - TokenCostCurrency.historicalCostConverted( - historical.amount, - historical.currency, - PricingCurrency.CNY, - 7.0, - ) - val revaluedCny = - TokenCostCurrency.convertTo( - revalued.amount!!, - revalued.currency, - PricingCurrency.CNY, - 7.0, - ) - assertEquals(0.014, historicalCny!!, 1e-12) - assertEquals(0.028, revaluedCny!!, 1e-12) + source = PricingCurrency.CNY, + target = PricingCurrency.USD, + usdToCnyRate = 7.0, + ), + 1e-12, + ) } @Test - fun `currency conversion overflow returns unknown`() { - assertNull( - TokenCostCurrency.convertTo( - Double.MAX_VALUE, - PricingCurrency.USD, - PricingCurrency.CNY, - 2.0, - ) - ) - assertNull( - TokenCostCurrency.historicalCostConverted( - Double.MAX_VALUE, - PricingCurrency.USD, - PricingCurrency.CNY, - 2.0, - ) - ) - } + fun `saturated add clamps overflow`() { + assertEquals(Long.MAX_VALUE, TokenCostCalculator.saturatedAdd(Long.MAX_VALUE, 1L)) + assertEquals(7L, TokenCostCalculator.saturatedAdd(3L, 4L)) + } + + private fun tokenPricing( + inputPricePerMillion: Double = 1.0, + cachedInputPricePerMillion: Double = 0.5, + outputPricePerMillion: Double = 2.0, + source: PricingSource = PricingSource.BUILT_IN, + ) = ResolvedTokenPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = inputPricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = 0.0, + source = source, + ) + + private fun aggregateRow( + requests: Long = 1L, + uncachedInputTokens: Long = 1_000L, + uncachedInputKnown: Long = requests, + cachedInputTokens: Long = 0L, + cachedInputKnown: Long = requests, + cacheWriteTokens: Long = 0L, + cacheWriteKnown: Long = requests, + totalInputTokens: Long = uncachedInputTokens + cachedInputTokens, + totalInputKnown: Long = requests, + outputTokens: Long = 500L, + outputKnown: Long = requests, + ) = TokenUsageModelAggregateRow( + provider = "OPENAI", + model = "gpt-test", + configId = "test-config", + requests = requests, + requestCountKnown = requests, + usageRows = requests, + uncachedInputTokens = uncachedInputTokens, + uncachedInputKnown = uncachedInputKnown, + cachedInputTokens = cachedInputTokens, + cachedInputKnown = cachedInputKnown, + cacheWriteTokens = cacheWriteTokens, + cacheWriteKnown = cacheWriteKnown, + totalInputTokens = totalInputTokens, + totalInputKnown = totalInputKnown, + outputTokens = outputTokens, + outputKnown = outputKnown, + reasoningTokens = 0L, + reasoningKnown = requests, + ttftTotalMs = 0L, + ttftSamples = 0L, + durationTotalMs = 0L, + durationSamples = 0L, + ) } diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt deleted file mode 100644 index 8ebc3aae4..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenPriceResolverTest.kt +++ /dev/null @@ -1,330 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.ModelPricingDefaults -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class TokenPriceResolverTest { - - private val knownDefaults = - ModelPricingDefaults( - billingMode = BillingMode.TOKEN, - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - pricePerRequest = 0.01, - currency = PricingCurrency.USD, - ) - - private val zeroDefaults = - ModelPricingDefaults( - billingMode = BillingMode.TOKEN, - inputPricePerMillion = 0.0, - outputPricePerMillion = 0.0, - cachedInputPricePerMillion = 0.0, - pricePerRequest = 0.01, - currency = PricingCurrency.CNY, - ) - - private fun overrideRow( - scope: String, - provider: String, - model: String, - configId: String? = null, - billingMode: BillingMode = BillingMode.TOKEN, - currency: String = PricingCurrency.USD.name, - input: Double? = 3.0, - cached: Double? = 1.5, - cacheWrite: Double? = 0.75, - output: Double? = 6.0, - perRequest: Double? = null, - ) = TokenPriceResolver.normalizedOverride( - scope = scope, - provider = provider, - model = model, - configId = configId, - billingMode = billingMode, - pricingCurrency = currency, - inputPricePerMillion = input, - cachedInputPricePerMillion = cached, - cacheWritePricePerMillion = cacheWrite, - outputPricePerMillion = output, - pricePerRequest = perRequest, - ) - - private fun resolve( - provider: String = "DEEPSEEK", - model: String = "deepseek-chat", - configId: String? = null, - overrides: List = emptyList(), - legacy: LegacyPriceSettings? = null, - defaults: ModelPricingDefaults = knownDefaults, - ) = - TokenPriceResolver.resolve( - provider = provider, - model = model, - configId = configId, - overrides = overrides, - legacyOverride = legacy, - defaults = defaults, - ) - - @Test - fun `config override beats provider model override and defaults`() { - val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") - val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") - - val resolved = resolve(configId = "cfg-1", overrides = listOf(config, providerModel)) - - assertEquals(PricingSource.CONFIG_OVERRIDE, resolved.source) - assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) - assertTrue(resolved.known) - } - - @Test - fun `config override only applies to its own config instance`() { - val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") - val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") - - val resolved = resolve(configId = "cfg-other", overrides = listOf(config, providerModel)) - - assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) - assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) - } - - @Test - fun `provider model override beats built-in defaults`() { - val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") - - val resolved = resolve(overrides = listOf(providerModel)) - - assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) - assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) - assertTrue(resolved.known) - } - - @Test - fun `override cached price falls back to input price`() { - val providerModel = - overrideRow( - "PROVIDER_MODEL", - "DEEPSEEK", - "deepseek-chat", - input = 3.0, - cached = null, - output = 6.0, - ) - - val resolved = resolve(overrides = listOf(providerModel)) - - assertEquals(3.0, resolved.cachedInputPricePerMillion!!, 1e-9) - } - - @Test - fun `legacy user price is used when no db override exists`() { - val legacy = - LegacyPriceSettings( - inputPricePerMillion = 4.0, - cachedInputPricePerMillion = 2.0, - outputPricePerMillion = 8.0, - ) - - val resolved = resolve(legacy = legacy) - - assertEquals(PricingSource.LEGACY_OVERRIDE, resolved.source) - assertEquals(4.0, resolved.inputPricePerMillion!!, 1e-9) - assertEquals(8.0, resolved.outputPricePerMillion!!, 1e-9) - assertTrue(resolved.known) - } - - @Test - fun `legacy zero or absent values fall back to defaults and stay known`() { - val legacy = - LegacyPriceSettings( - inputPricePerMillion = 4.0, - cachedInputPricePerMillion = 0.0, - outputPricePerMillion = null, - ) - - val resolved = resolve(legacy = legacy) - - assertEquals(4.0, resolved.inputPricePerMillion!!, 1e-9) - assertEquals(0.5, resolved.cachedInputPricePerMillion!!, 1e-9) - assertEquals(2.0, resolved.outputPricePerMillion!!, 1e-9) - assertTrue(resolved.known) - } - - @Test - fun `legacy with no user setting falls through to defaults`() { - val resolved = resolve(legacy = LegacyPriceSettings()) - - assertEquals(PricingSource.DEFAULT, resolved.source) - assertEquals(1.0, resolved.inputPricePerMillion!!, 1e-9) - assertTrue(resolved.known) - } - - @Test - fun `known built-in defaults resolve as DEFAULT source`() { - val resolved = resolve(defaults = knownDefaults) - - assertEquals(PricingSource.DEFAULT, resolved.source) - assertTrue(resolved.known) - } - - @Test - fun `unknown model falls back to zero pricing marked unknown`() { - val resolved = resolve(defaults = zeroDefaults) - - assertEquals(PricingSource.UNKNOWN, resolved.source) - assertFalse(resolved.known) - } - - @Test - fun `unknown pricing currency still follows provider convention`() { - val resolved = resolve(defaults = zeroDefaults) - - assertEquals(PricingCurrency.CNY, resolved.currency) - assertEquals(BillingMode.TOKEN, resolved.billingMode) - } - - @Test - fun `normalized override rows carry normalized business columns`() { - val rowA = TokenPriceResolver.normalizedOverride("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat", null, BillingMode.TOKEN, PricingCurrency.USD.name) - val rowB = TokenPriceResolver.normalizedOverride("PROVIDER_MODEL", "deepseek", "DeepSeek-Chat", null, BillingMode.TOKEN, PricingCurrency.USD.name) - val rowConfig = TokenPriceResolver.normalizedOverride("CONFIG", "DEEPSEEK", "deepseek-chat", " cfg-1 ", BillingMode.TOKEN, PricingCurrency.USD.name) - - // 规范化后业务列一致(provider/model 小写、configId trim) - assertEquals(rowA.provider, rowB.provider) - assertEquals(rowA.model, rowB.model) - assertEquals("deepseek", rowA.provider) - assertEquals("deepseek-chat", rowA.model) - assertEquals("", rowA.configId) // PROVIDER_MODEL 范围用空串 - assertEquals("cfg-1", rowConfig.configId) - assertTrue(rowA.configId != rowConfig.configId) - } - - @Test - fun `resolver matches override rows by normalized business columns`() { - // 行以原始大小写写入,但业务列规范化;查询侧规范化后仍命中 - val row = - TokenStatPriceOverrideEntity( - scope = "PROVIDER_MODEL", - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 3.0, - ) - - val resolved = resolve(provider = "deepseek", model = "DeepSeek-Chat", overrides = listOf(row)) - - assertEquals(PricingSource.PROVIDER_MODEL_OVERRIDE, resolved.source) - assertEquals(3.0, resolved.inputPricePerMillion!!, 1e-9) - } - - @Test - fun `row business columns and query mismatch cannot resolve wrongly`() { - // 行内容(业务列)是 openai/gpt-4o:无论怎样“伪造”都不能被 deepseek 查询命中 - val row = - TokenStatPriceOverrideEntity( - scope = "PROVIDER_MODEL", - provider = "openai", - model = "gpt-4o", - configId = "", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 3.0, - ) - - val resolved = resolve(provider = "DEEPSEEK", model = "deepseek-chat", overrides = listOf(row)) - - // 不命中伪造行 → 落到内置默认价(而不是错误使用 openai 的价格) - assertEquals(PricingSource.DEFAULT, resolved.source) - assertEquals(1.0, resolved.inputPricePerMillion!!, 1e-9) - } - - @Test - fun `config and provider model rows with same normalized model stay distinct`() { - val config = overrideRow("CONFIG", "DEEPSEEK", "deepseek-chat", configId = "cfg-1") - val providerModel = overrideRow("PROVIDER_MODEL", "DEEPSEEK", "deepseek-chat") - - assertTrue(config.configId != providerModel.configId) - assertEquals( - PricingSource.CONFIG_OVERRIDE, - resolve(configId = "cfg-1", overrides = listOf(config, providerModel)).source - ) - } - - @Test - fun `override cache write price is preserved when present`() { - val providerModel = - overrideRow( - "PROVIDER_MODEL", - "DEEPSEEK", - "deepseek-chat", - cacheWrite = 0.75, - ) - - val resolved = resolve(overrides = listOf(providerModel)) - - assertEquals(0.75, resolved.cacheWritePricePerMillion!!, 1e-9) - } - - @Test - fun `built-in defaults and legacy pricing have no cache write price`() { - assertNull(resolve(defaults = knownDefaults).cacheWritePricePerMillion) - assertNull( - resolve( - legacy = - LegacyPriceSettings( - inputPricePerMillion = 4.0, - cachedInputPricePerMillion = 2.0, - outputPricePerMillion = 8.0, - ) - ).cacheWritePricePerMillion - ) - } - - @Test - fun `count mode override is known only with per request price`() { - val withPrice = - overrideRow( - "PROVIDER_MODEL", - "DEEPSEEK", - "deepseek-chat", - billingMode = BillingMode.COUNT, - input = null, - cached = null, - output = null, - perRequest = 0.02, - ) - // 同一规范化业务键、无按次价格 → 解析结果确定:known = false - val withoutPrice = withPrice.copy(pricePerRequest = null) - - assertTrue(resolve(overrides = listOf(withPrice)).known) - assertFalse(resolve(overrides = listOf(withoutPrice)).known) - assertNull(resolve(overrides = listOf(withoutPrice)).pricePerRequest) - } - - @Test - fun `legacy count mode uses stored per request price`() { - val legacy = - LegacyPriceSettings( - billingMode = BillingMode.COUNT, - pricePerRequest = 0.05, - ) - - val resolved = resolve(legacy = legacy) - - assertEquals(BillingMode.COUNT, resolved.billingMode) - assertEquals(0.05, resolved.pricePerRequest!!, 1e-9) - assertTrue(resolved.known) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt deleted file mode 100644 index 6e50552e4..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatReliabilityTestBase.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.api.chat.llmprovider.TokenStatsPersistenceException -import com.ai.assistance.operit.api.chat.llmprovider.TokenTrackingAIService -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.ui.features.settings.screens.QuarantineExportCleanup -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import java.io.RandomAccessFile -import java.nio.file.Files -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.json.JSONObject -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** File + Room tests for the stage-2 durability linearization points. */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -internal abstract class TokenStatReliabilityTestBase { - protected lateinit var root: File - protected lateinit var context: Context - protected lateinit var database: AppDatabase - - @Before - fun setUp() { - root = kotlin.io.path.createTempDirectory("token-stat-reliability").toFile() - context = mock().also { ctx -> - whenever(ctx.applicationContext).thenReturn(ctx) - whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") - whenever(ctx.filesDir).thenReturn(root) - whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } - } - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - TokenStatsLedger.databaseProvider = { database } - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - TokenStatSpool.clearPendingStateForTest() - TokenTrackingAIService.resetPricingExecutorForTest() - // 收尾日志可能跑在 Dispatchers.IO 等后台线程,thread-local 的 mockStatic(AppLogger) - // 无法覆盖;统一关闭 android.util.Log 调用(文件日志照常),避免 "not mocked"。 - AppLogger.enableSystemLog = false - TokenStatSpool.afterSegmentReadForTest = null - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.quarantineAtomicMoveForTest = null - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.directoryListingForTest = null - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.fileSyncForTest = null - // P1 终审:Windows JVM 测试统一注入“目录 fsync 支持且成功”(平台无关)——生产 - // Android/Linux 支持目录 fd fsync;UNSUPPORTED/FAILED 只由显式 fail-closed 测试在 - // 测试体内注入并在 finally 还原,不存在“原地排空”平台模式。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - TokenStatSpool.sealCopyForTest = null - } - - @After - fun tearDown() { - AppLogger.enableSystemLog = true - // P1-1 终审修复:测试可能以“目录项未确认持久”状态结束(gate=false),tearDown 的快照 - // barrier 会重新 bootstrap——必须先恢复“目录 fsync 支持且成功”的平台常态(Windows JVM - // 真实探测恒为 UNSUPPORTED),否则 gate 在 tearDown 中失败并掩盖测试结果。 - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - runBlocking { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { } - } - TokenStatsLedger.databaseProvider = null - TokenStatsLedger.legacyPriceProvider = null - TokenStatSpool.afterSegmentReadForTest = null - TokenStatSpool.spoolDeleteForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.quarantineAtomicMoveForTest = null - TokenStatSpool.metadataReadErrorForTest = null - TokenStatSpool.metadataWriteErrorForTest = null - TokenStatSpool.segmentReadErrorForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.directoryListingForTest = null - TokenStatSpool.beforeSealPublishForTest = null - TokenStatSpool.sealHardLinkForTest = null - TokenStatSpool.sealActiveDeleteForTest = null - TokenStatSpool.fileSyncForTest = null - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.sealCopyForTest = null - TokenStatSpool.afterDrainRoundForTest = null - TokenStatSpool.rejectDrainScheduleForTest = false - TokenStatSpool.prepareTimeoutMs = 5_000L - TokenStatSpool.insertTimeoutMs = 5_000L - TokenStatSpool.exclusiveQuiesceTimeoutMs = 5_000L - // 每个测试结束必须无遗留 spool worker 线程:shutdown 已释放的 worker 后确认终止 - TokenTrackingAIService.resetPricingExecutorForTest() - TokenStatSpool.resetExecutorsForTest() - TokenStatSpool.shutdownWriterForTest() - awaitNoSpoolWorkerThreads() - database.close() - } - - protected fun request( - id: String, - generation: Long = 0L, - startedAt: Long = 1_000L, - ) = TokenStatRequestContext( - eventId = id, - category = TokenStatCategory.CHAT, - configId = "cfg", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = startedAt, - acceptedGeneration = generation, - // P1 终审:请求“开始”时同步捕获 restore epoch(与生产 newRequest 一致);恢复屏障 - // 递增 epoch 后,捕获于屏障前的旧请求在 append 时被明确拒绝。 - sessionEpoch = TokenStatSpool.captureRestoreEpoch(), - ).apply { - onUsage( - ProviderUsageSnapshot( - uncachedInputTokens = 10L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - outputTokens = 5L, - source = "test", - ), - ) - finish(TokenStatStatus.COMPLETED, startedAt) - } - - protected suspend fun line(request: TokenStatRequestContext): String = - TokenStatsLedger.prepareEventLine(context, request, request.toSpoolBaseJson()) - - protected suspend fun awaitEvent(id: String) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (database.tokenStatsDao().getEvent(id) == null && System.nanoTime() < deadline) delay(20) - } - - /** - * 模拟 SQLite 忽略线程中断但可释放的挂起:任何 cancel(true) 都无法终止,直到门闩 - * 打开才返回(释放后线程能真正终止,测试结束不留遗留线程)。 - */ - protected fun gateIgnoringInterrupts(gate: CountDownLatch) { - while (true) { - try { - if (gate.await(1, TimeUnit.SECONDS)) return - } catch (_: InterruptedException) { - } - } - } - - /** 等待 spool 专属 worker 线程全部终止;超时即失败(测试结束必须无遗留线程)。 */ - protected fun awaitNoSpoolWorkerThreads() { - fun live(): List = - Thread.getAllStackTraces().entries - .filter { it.key.isAlive && it.key.name.startsWith("operit-token-stats-") } - .map { it.key.name } - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) - while (System.nanoTime() < deadline) { - if (live().isEmpty()) return - Thread.sleep(20) - } - fail("spool worker threads leaked: ${live()}") - } - - protected suspend fun runDirSyncFailClosedScenario( - result: TokenStatSpool.DirSyncResult, - tag: String, - scenario: suspend (TokenStatSpool.DirSyncResult, String) -> Unit, - ) { - val spool = File(root, TokenStatSpool.SPOOL_DIR_NAME) - Mockito.mockStatic(AppLogger::class.java).use { - try { - spool.deleteRecursively() - TokenStatSpool.clearPendingStateForTest() - TokenStatSpool.dirSyncForTest = { TokenStatSpool.DirSyncResult.OK } - database.tokenStatsDao().deleteAllEvents() - scenario(result, tag) - } finally { - TokenStatSpool.dirSyncForTest = null - TokenStatSpool.segmentRenameForTest = null - TokenStatSpool.segmentDeleteForTest = null - TokenStatSpool.ackAtomicMoveForTest = null - TokenStatSpool.spoolDeleteForTest = null - spool.deleteRecursively() - } - } - } - - protected fun padLineTo(line: String, targetBytes: Int): String { - val overhead = ",\"pad\":\"\"".toByteArray(Charsets.UTF_8).size - val current = (line + "\n").toByteArray(Charsets.UTF_8).size - val padding = targetBytes - current - overhead - check(padding >= 0) { "line too large to pad: $current + $overhead > $targetBytes" } - return line + ",\"pad\":\"" + "x".repeat(padding) + "\"" - } - - /** - * manifest 轮询安全读:drain 线程可能正在原子替换该文件,Windows 下同一瞬间的读取 - * 会以共享冲突失败;失败时短暂重试,持续失败返回 null 由调用方重试/断言兜底。 - */ - protected fun safeManifestText(manifest: File): String? { - repeat(3) { - try { - return if (manifest.isFile) manifest.readText() else null - } catch (e: Exception) { - Thread.sleep(10) - } - } - return null - } - - protected fun sha256Hex(bytes: ByteArray): String { - val digest = java.security.MessageDigest.getInstance("SHA-256").digest(bytes) - return digest.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } - } - - protected suspend fun awaitNoPendingEvidence(spool: File) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("quarantine_pending_delete_") } - ) { - delay(20) - } - } - - protected suspend fun awaitSegmentGone(spool: File, name: String) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && File(spool, name).exists()) delay(20) - } - - protected suspend fun awaitSummaryPublishedAndSegmentGone(spool: File, segmentName: String) { - val summary = File(spool, "quarantine_summary.jsonl") - val segment = File(spool, segmentName) - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && (!summary.isFile || segment.exists())) { - delay(20) - } - } - - protected suspend fun awaitManifestWithout(spool: File, name: String) { - val manifest = File(spool, "quarantine_skip_manifest.jsonl") - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - safeManifestText(manifest)?.contains(name) == true - ) { - delay(20) - } - assertFalse( - "tombstone entry must be removed after cleanup", - safeManifestText(manifest)?.contains(name) == true, - ) - } - - protected suspend fun awaitNoSealedSegments(spool: File) { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) - while (System.nanoTime() < deadline && - spool.listFiles().orEmpty().any { it.isFile && it.name.startsWith("sealed_") } - ) { - delay(20) - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt deleted file mode 100644 index 436b2596a..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatRequestContextTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class TokenStatRequestContextTest { - private fun context(eventId: String = "evt-context") = - TokenStatRequestContext( - eventId = eventId, - category = TokenStatCategory.CHAT, - configId = "cfg", - provider = "TEST", - model = "model", - startedAtMs = 1L, - ) - - private fun usage(input: Long, output: Long) = - ProviderUsageSnapshot( - uncachedInputTokens = input, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - totalInputTokens = input, - outputTokens = output, - source = "test", - ) - - @Test - fun `missing earlier attempt makes every usage component unknown`() { - val request = context() - request.onUsage(usage(input = 200L, output = 20L), attempt = 2) - - val aggregated = request.aggregatedUsage()!! - assertEquals(2, request.attemptCount) - assertNull(aggregated.uncachedInputTokens) - assertNull(aggregated.cachedInputTokens) - assertNull(aggregated.cacheWriteTokens) - assertNull(aggregated.totalInputTokens) - assertNull(aggregated.outputTokens) - assertNull(aggregated.reasoningTokens) - } - - @Test - fun `contiguous attempts sum and duplicate snapshot semantics stay unchanged`() { - val request = context() - request.onUsage(usage(input = 100L, output = 10L), attempt = 1) - request.onUsage(usage(input = 200L, output = 20L), attempt = 2) - request.onUsage(usage(input = 250L, output = 25L), attempt = 2) - - val aggregated = request.aggregatedUsage()!! - assertEquals(350L, aggregated.uncachedInputTokens) - assertEquals(35L, aggregated.outputTokens) - } - - @Test - fun `spool replay preserves already aggregated usage when attempt count exceeds one`() { - val request = context("evt-replay") - request.onUsage(usage(input = 100L, output = 10L), attempt = 1) - request.onUsage(usage(input = 200L, output = 20L), attempt = 2) - request.finish(TokenStatStatus.COMPLETED, 2L) - val pricing = - ResolvedPricing( - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 1.0, - outputPricePerMillion = 1.0, - source = PricingSource.DEFAULT, - known = true, - ) - - val replay = TokenStatRequestContext.fromSpoolLine(request.toSpoolLine(pricing, 0.00033)) - val aggregated = replay.aggregatedUsage()!! - assertEquals(2, replay.attemptCount) - assertEquals(300L, aggregated.uncachedInputTokens) - assertEquals(30L, aggregated.outputTokens) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt deleted file mode 100644 index 8e85db364..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsAggregatorTest.kt +++ /dev/null @@ -1,1037 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import java.time.ZoneId -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * 聚合器纯函数测试(阶段 3): - * unknown 与 0 严格区分、部分未知费用、历史快照/当前重估双口径、汇率变化 - * 只影响换算不影响原币、模型分组/别名/多 config 一致性、桶合计 == 范围总计、 - * 性能 unknown 排除平均、Long 饱和、BigDecimal 精度。 - */ -class TokenStatsAggregatorTest { - - private val shanghai = ZoneId.of("Asia/Shanghai") - private val params = TokenStatsQueryParams() - - // ==== 构建辅助 ==== - - private fun identity( - id: String, - configId: String = "cfg-$id", - provider: String = "PROVIDER", - model: String = "model-$id", - displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), - ): TokenStatIdentityEntity = - TokenStatIdentityEntity( - identityId = id, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - - private fun displayModel(id: String, name: String): TokenStatDisplayModelEntity = - TokenStatDisplayModelEntity( - displayModelId = id, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(id), - displayName = name, - ) - - private fun event( - id: String, - identityId: String, - startedAtMs: Long, - endedAtMs: Long = startedAtMs + 1_000L, - firstTokenAtMs: Long? = startedAtMs + 200L, - uncached: Long? = 100L, - cached: Long? = 0L, - cacheWrite: Long? = 0L, - totalInput: Long? = null, - output: Long? = 50L, - reasoning: Long? = null, - reasoningIncluded: Boolean? = true, - cacheWriteSeparateBilling: Boolean? = null, - category: String = TokenStatCategory.CHAT.name, - status: String = TokenStatStatus.COMPLETED.name, - billingMode: String = BillingMode.TOKEN.name, - pricingCurrency: String = PricingCurrency.USD.name, - inputPrice: Double? = 1.0, - cachedPrice: Double? = 1.0, - cacheWritePrice: Double? = null, - outputPrice: Double? = 2.0, - pricePerRequest: Double? = null, - pricingSource: String = PricingSource.DEFAULT.name, - cost: Double? = null, - ): TokenStatEventEntity = - TokenStatEventEntity( - eventId = id, - statIdentityId = identityId, - category = category, - status = status, - acceptedGeneration = 0L, - startedAtMs = startedAtMs, - endedAtMs = endedAtMs, - firstTokenAtMs = firstTokenAtMs, - uncachedInputTokens = uncached, - cachedInputTokens = cached, - cacheWriteTokens = cacheWrite, - totalInputTokens = totalInput, - outputTokens = output, - reasoningTokens = reasoning, - reasoningIncludedInOutput = reasoningIncluded, - cacheWriteSeparateBilling = cacheWriteSeparateBilling, - billingMode = billingMode, - pricingCurrency = pricingCurrency, - inputPricePerMillion = inputPrice, - cachedInputPricePerMillion = cachedPrice, - cacheWritePricePerMillion = cacheWritePrice, - outputPricePerMillion = outputPrice, - pricePerRequest = pricePerRequest, - pricingSource = pricingSource, - costInPricingCurrency = cost, - diagnosticsJson = null, - ) - - private fun baseline( - identityId: String, - inputTokens: Long = 100L, - cachedInputTokens: Long = 10L, - outputTokens: Long = 50L, - requestCount: Long = 5L, - pricingCurrency: String = PricingCurrency.USD.name, - cost: Double? = 2.0, - isEstimated: Boolean = true, - ): TokenStatBaselineEntity = - TokenStatBaselineEntity( - identityId = identityId, - inputTokens = inputTokens, - cachedInputTokens = cachedInputTokens, - outputTokens = outputTokens, - requestCount = requestCount, - pricingCurrency = pricingCurrency, - costInPricingCurrency = cost, - isEstimated = isEstimated, - fingerprint = "fp", - importedAtMs = 0L, - frozenBillingMode = BillingMode.TOKEN.name, - ) - - private fun aggregated(events: List, identities: List): TokenStatsTotals = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = identities.associateBy { it.identityId }, - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ).summary - - @Test - fun `reasoning aggregate counts only separately billed reasoning`() { - val id1 = identity("id-1") - val totals = - aggregated( - events = - listOf( - event("e1", "id-1", 1000L, reasoning = 30L, reasoningIncluded = true), - event("e2", "id-1", 2000L, reasoning = 20L, reasoningIncluded = false), - ), - identities = listOf(id1), - ) - // 输出已含推理的事件不再独立计入,仅独立计费的推理入账 - assertEquals(20L, totals.reasoning.knownSum) - assertEquals(1L, totals.reasoning.knownEventCount) - } - - // ==== 生命周期 ==== - - @Test - fun `lifetime combines events and baseline with converted costs`() { - val id1 = identity("id-1") - val events = - listOf( - event("e1", "id-1", 1000L, cost = 0.5), - event("e2", "id-1", 2000L, cost = 1.5), - ) - val overview = - TokenStatsAggregator.lifetime( - events = events, - baselines = listOf(baseline("id-1", cost = 2.0)), - identitiesById = mapOf("id-1" to id1), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params, - ) - assertEquals(2L, overview.eventTotals.requests) - assertEquals(14.0, overview.eventTotals.cost.knownAmount, 1e-9) // (0.5+1.5)*7 - assertTrue(overview.eventTotals.cost.isFullyKnown) - assertEquals(1L, overview.baselineTotals.identityCount) - assertEquals(5L, overview.baselineTotals.requests) - assertEquals(14.0, overview.baselineTotals.cost.knownAmount, 1e-9) // 2.0*7 - assertEquals(7L, overview.combinedRequests) - assertTrue(overview.baselineTotals.anyEstimated) - assertEquals(TokenStatsCostMode.HISTORICAL, overview.baselineTotals.cost.mode) - } - - @Test - fun `incremental lifetime accumulator equals single list aggregation`() { - // P2-1:生命周期分页路径(DAO 逐页喂入累加器)与单列表路径必须逐字段一致。 - val id1 = identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20") - val events = - (0 until 2500).map { index -> - event( - "e$index", "id-1", index.toLong() * 1000L, - uncached = 100L, cached = 0L, output = 50L, cost = null, - ) - } - val baselines = listOf(baseline("id-1", cost = 2.0)) - - val single = - TokenStatsAggregator.lifetime( - events = events, - baselines = baselines, - identitiesById = mapOf("id-1" to id1), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ) - - val accumulator = - TokenStatsAggregator.TokenStatsEventTotalsAccumulator( - legacyPrices = emptyMap(), - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ) - events.chunked(333).forEach { chunk -> - accumulator.addPage(chunk, mapOf("id-1" to id1), emptyList()) - } - val paged = - TokenStatsAggregator.lifetimeFrom( - eventsTotals = accumulator.totals(), - baselines = baselines, - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ) - - val a = single.eventTotals - val b = paged.eventTotals - assertEquals(a.requests, b.requests) - assertEquals(a.uncachedInput, b.uncachedInput) - assertEquals(a.cachedInput, b.cachedInput) - assertEquals(a.cacheWrite, b.cacheWrite) - assertEquals(a.totalInput, b.totalInput) - assertEquals(a.output, b.output) - assertEquals(a.reasoning, b.reasoning) - // canonical 总 token 同样必须逐字段一致(P2-1 分页等价) - assertEquals(a.totalTokens, b.totalTokens) - assertEquals(a.cost.knownAmount, b.cost.knownAmount, 1e-9) - assertEquals(a.cost.unknownContributionCount, b.cost.unknownContributionCount) - assertEquals(a.cost.totalContributionCount, b.cost.totalContributionCount) - assertEquals(a.cost.originalCurrencyAmounts, b.cost.originalCurrencyAmounts) - assertEquals(a.cost.isFullyKnown, b.cost.isFullyKnown) - assertEquals(single.combinedRequests, paged.combinedRequests) - assertEquals(single.baselineTotals, paged.baselineTotals) - } - - @Test - fun `unknown cost contributions are partial not zero`() { - val events = - listOf( - event("e1", "id-1", 1000L, cost = 1.0), - event("e2", "id-1", 2000L, cost = null), - ) - val cost = aggregated(events, listOf(identity("id-1"))).cost - assertEquals(7.0, cost.knownAmount, 1e-9) - assertEquals(1L, cost.unknownContributionCount) - assertEquals(2L, cost.totalContributionCount) - assertFalse(cost.isFullyKnown) - } - - @Test - fun `non finite historical costs are unknown and never reach BigDecimal`() { - val id = identity("id-1") - val events = - listOf( - event("finite", id.identityId, 1000L, cost = 1.0), - event("infinite", id.identityId, 2000L, cost = Double.POSITIVE_INFINITY), - event("nan", id.identityId, 3000L, cost = Double.NaN), - ) - val rangeCost = aggregated(events, listOf(id)).cost - assertEquals(7.0, rangeCost.knownAmount, 1e-9) - assertEquals(2L, rangeCost.unknownContributionCount) - - val lifetime = - TokenStatsAggregator.lifetime( - events = events, - baselines = listOf(baseline(id.identityId, cost = Double.POSITIVE_INFINITY)), - identitiesById = mapOf(id.identityId to id), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params, - ) - assertEquals(2L, lifetime.eventTotals.cost.unknownContributionCount) - assertEquals(1L, lifetime.baselineTotals.cost.unknownContributionCount) - } - - @Test - fun `zero cost is a known contribution`() { - val cost = aggregated(listOf(event("e1", "id-1", 1000L, cost = 0.0)), listOf(identity("id-1"))).cost - assertEquals(0.0, cost.knownAmount, 1e-9) - assertEquals(0L, cost.unknownContributionCount) - assertTrue(cost.isFullyKnown) - assertTrue(cost.originalCurrencyAmounts.isEmpty()) - } - - @Test - fun `token unknown and zero are distinguished`() { - val events = - listOf( - event("e1", "id-1", 1000L, uncached = null, output = 0L), - event("e2", "id-1", 2000L, uncached = 0L, output = 0L), - event("e3", "id-1", 3000L, uncached = 10L, output = 5L), - ) - val totals = aggregated(events, listOf(identity("id-1"))) - assertEquals(10L, totals.uncachedInput.knownSum) - assertEquals(2L, totals.uncachedInput.knownEventCount) - assertEquals(1L, totals.uncachedInput.unknownEventCount) - assertEquals(3L, totals.uncachedInput.totalEventCount) - assertFalse(totals.uncachedInput.isFullyKnown) - // output:三个事件都已知(含两个真实 0) - assertEquals(5L, totals.output.knownSum) - assertEquals(0L, totals.output.unknownEventCount) - assertTrue(totals.output.isFullyKnown) - } - - // ==== 汇率与原币 ==== - - @Test - fun `rate change affects converted total but not original currency amounts`() { - val events = - listOf( - event("e1", "id-1", 1000L, cost = 1.0), - event("e2", "id-1", 2000L, cost = 1.0), - ) - val low = aggregatedWithRate(events, 7.0).cost - val high = aggregatedWithRate(events, 7.5).cost - assertEquals(14.0, low.knownAmount, 1e-9) - assertEquals(15.0, high.knownAmount, 1e-9) - assertEquals(2.0, low.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) - assertEquals(2.0, high.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) - } - - @Test - fun `mixed currency events convert per currency`() { - val events = - listOf( - event("e1", "id-1", 1000L, pricingCurrency = PricingCurrency.USD.name, cost = 1.0), - event("e2", "id-1", 2000L, pricingCurrency = PricingCurrency.CNY.name, cost = 7.0), - ) - val cny = aggregatedWithParams(events, params.copy(targetCurrency = PricingCurrency.CNY)).cost - assertEquals(14.0, cny.knownAmount, 1e-9) // 1*7 + 7 - assertEquals(1.0, cny.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) - assertEquals(7.0, cny.originalCurrencyAmounts[PricingCurrency.CNY]!!, 1e-9) - - val usd = aggregatedWithParams(events, params.copy(targetCurrency = PricingCurrency.USD)).cost - assertEquals(2.0, usd.knownAmount, 1e-9) // 1 + 7/7 - } - - private fun aggregatedWithRate(events: List, rate: Double): TokenStatsTotals = - aggregatedWithParams(events, params.copy(manualRate = rate)) - - private fun aggregatedWithParams( - events: List, - p: TokenStatsQueryParams, - identities: List = listOf(identity("id-1")), - ): TokenStatsTotals = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = identities.associateBy { it.identityId }, - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = p, - ).summary - - // ==== 当前价格重估 ==== - - @Test - fun `revalued mode recomputes cost from current pricing and usage`() { - val events = - listOf( - // 快照成本缺失(未知),重估后按当前价格算出已知成本 - event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null), - ) - val totals = - aggregatedWithParams( - events, - params.copy( - mode = TokenStatsCostMode.REVALUED, - manualRate = 7.0, - targetCurrency = PricingCurrency.CNY, - ), - identities = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), - ) - val cost = totals.cost - assertTrue(cost.isFullyKnown) - // (1000*1.5 + 500*6)/1e6 = 0.0045 USD -> 0.0315 CNY(gpt-4o-2024-11-20 内置价) - assertEquals(0.0315, cost.knownAmount, 1e-9) - assertEquals(0.0045, cost.originalCurrencyAmounts[PricingCurrency.USD]!!, 1e-9) - } - - @Test - fun `revalued mode does not double bill included reasoning`() { - fun totalsWith(reasoningIncluded: Boolean?): TokenStatsCostSummary { - val events = - listOf( - event( - "e1", "id-1", 1000L, - uncached = 0L, cached = 0L, output = 500L, - reasoning = 100L, reasoningIncluded = reasoningIncluded, cost = null, - ), - ) - return aggregatedWithParams( - events, - params.copy(mode = TokenStatsCostMode.REVALUED, manualRate = 1.0, targetCurrency = PricingCurrency.USD), - identities = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), - ).cost - } - val included = totalsWith(reasoningIncluded = true) - assertEquals(0.003, included.knownAmount, 1e-9) // 500 * 6 / 1e6 - val separate = totalsWith(reasoningIncluded = false) - assertEquals(0.0036, separate.knownAmount, 1e-9) // (500+100) * 6 / 1e6 - assertTrue(separate.knownAmount > included.knownAmount) - } - - @Test - fun `revalued cache write separate billing without price stays unknown`() { - val openai = listOf(identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")) - val events = - listOf( - event( - "e1", "id-1", 1000L, - uncached = 1_000L, cached = 0L, cacheWrite = 100L, output = 500L, - cacheWriteSeparateBilling = true, cacheWritePrice = null, cost = null, - ), - ) - val separateCost = aggregatedWithParams( - events, params.copy(mode = TokenStatsCostMode.REVALUED), identities = openai, - ).cost - assertFalse(separateCost.isFullyKnown) - assertEquals(1L, separateCost.unknownContributionCount) - - val mergedCost = aggregatedWithParams( - listOf( - event( - "e2", "id-1", 2000L, - uncached = 1_000L, cached = 0L, cacheWrite = 100L, output = 500L, - cacheWriteSeparateBilling = false, cacheWritePrice = null, cost = null, - ), - ), - params.copy(mode = TokenStatsCostMode.REVALUED), - identities = openai, - ).cost - assertTrue(mergedCost.isFullyKnown) - } - - @Test - fun `revalued config override wins over provider override`() { - val identityWithConfig = identity("id-1", configId = "cfg-1") - val providerOverride = - TokenStatPriceOverrideEntity.normalized( - scope = TokenPriceResolver.SCOPE_PROVIDER_MODEL, - provider = "PROVIDER", - model = "model-id-1", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 10.0, - cachedInputPricePerMillion = 10.0, - cacheWritePricePerMillion = null, - outputPricePerMillion = 20.0, - pricePerRequest = null, - ) - val configOverride = - TokenStatPriceOverrideEntity.normalized( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "PROVIDER", - model = "model-id-1", - configId = "cfg-1", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 2.0, - cacheWritePricePerMillion = null, - outputPricePerMillion = 4.0, - pricePerRequest = null, - ) - val events = - listOf(event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null)) - val totals = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to identityWithConfig), - displayModelsById = emptyMap(), - overrides = listOf(providerOverride, configOverride), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ).summary - // (1000*2 + 500*4)/1e6 = 0.004 USD -> 0.028 CNY(用 CONFIG 价,非 provider 价的 0.14) - assertEquals(0.028, totals.cost.knownAmount, 1e-9) - } - - @Test - fun `revalued unknown identity contributes unknown cost`() { - val totals = - aggregatedWithParams( - listOf(event("e1", "missing-id", 1000L, cost = null)), - params.copy(mode = TokenStatsCostMode.REVALUED), - ) - assertFalse(totals.cost.isFullyKnown) - assertEquals(1L, totals.cost.unknownContributionCount) - } - - @Test - fun `revalued unknown model with complete usage is unknown not zero`() { - // P1-1:identity 存在但 provider/model 未知(无覆盖、无内置价 → - // zeroPricing known = false):即使用量完整,费用也必须是 unknown 贡献, - // 绝不能伪装成 known 的 0 元。 - val totals = - aggregatedWithParams( - listOf( - event("e1", "id-1", 1000L, uncached = 1_000L, cached = 0L, output = 500L, cost = null), - ), - params.copy(mode = TokenStatsCostMode.REVALUED), - identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), - ) - assertEquals(1L, totals.cost.unknownContributionCount) - assertEquals(1L, totals.cost.totalContributionCount) - assertEquals(0.0, totals.cost.knownAmount, 1e-9) - assertFalse(totals.cost.isFullyKnown) - assertTrue(totals.cost.originalCurrencyAmounts.isEmpty()) - } - - @Test - fun `revalued count mode with unknown pricing is unknown not zero`() { - // P1-1:COUNT 模式同样受 known 边界约束(zeroPricing 的按次价 > 0 只是 - // 兜底默认,未知模型 known = false → 成本 unknown)。 - val totals = - aggregatedWithParams( - listOf( - event( - "e1", "id-1", 1000L, - uncached = 1_000L, cached = 0L, output = 500L, cost = null, - billingMode = BillingMode.COUNT.name, - ), - ), - params.copy(mode = TokenStatsCostMode.REVALUED), - identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), - ) - assertFalse(totals.cost.isFullyKnown) - assertEquals(1L, totals.cost.unknownContributionCount) - assertEquals(0.0, totals.cost.knownAmount, 1e-9) - } - - @Test - fun `revalued unknown pricing never yields fully known zero cost`() { - // P1-1 回归:旧实现把 unknown(known = false)当 0 元已知成本,isFullyKnown - // 为 true;修复后必须为 partial。 - val totals = - aggregatedWithParams( - listOf( - event("e1", "id-1", 1000L, uncached = 0L, cached = 0L, output = 0L, cost = null), - ), - params.copy(mode = TokenStatsCostMode.REVALUED), - identities = listOf(identity("id-1", provider = "NO_SUCH_PROVIDER", model = "no-such-model")), - ) - assertFalse(totals.cost.isFullyKnown) - assertEquals(1L, totals.cost.unknownContributionCount) - assertEquals(0.0, totals.cost.knownAmount, 1e-9) - } - - // ==== 桶 ==== - - /** 本地对齐的 24 小时范围(上海),保证桶 0 起点 == 范围起点。 */ - private val alignedDayStart: Long = - java.time.LocalDateTime.parse("2026-08-07T00:00:00").atZone(shanghai).toInstant().toEpochMilli() - - private fun hourlyRangeData( - events: List, - identities: List = listOf(identity("id-1")), - startHour: Long = alignedDayStart, - ): TokenStatsRangeData = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = identities.associateBy { it.identityId }, - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(startHour, startHour + 24L * TokenStatsTimeRanges.HOUR_MS), - granularity = TokenStatsGranularity.HOURLY, - zone = shanghai, - params = params, - ) - - @Test - fun `bucket sums equal range total and empty buckets are filled`() { - val start = alignedDayStart - val events = - listOf( - event("e1", "id-1", start + TokenStatsTimeRanges.HOUR_MS, cost = 1.0), - event("e2", "id-1", start + 5L * TokenStatsTimeRanges.HOUR_MS, cost = 2.0), - event("e3", "id-1", start + 10L * TokenStatsTimeRanges.HOUR_MS, cost = 0.5), - ) - val data = hourlyRangeData(events, startHour = start) - assertEquals(3L, data.summary.requests) - assertEquals(24, data.buckets.size) - assertEquals(24.5, data.summary.cost.knownAmount, 1e-9) // (1+2+0.5)*7 - - val bucketRequests = data.buckets.sumOf { it.totals.requests } - assertEquals(data.summary.requests, bucketRequests) - assertEquals( - data.summary.uncachedInput.knownSum, - data.buckets.sumOf { it.totals.uncachedInput.knownSum }, - ) - assertEquals( - data.summary.output.knownSum, - data.buckets.sumOf { it.totals.output.knownSum }, - ) - assertEquals( - data.summary.cost.knownAmount, - data.buckets.sumOf { it.totals.cost.knownAmount }, - 1e-9, - ) - assertEquals( - data.summary.cost.unknownContributionCount, - data.buckets.sumOf { it.totals.cost.unknownContributionCount }, - ) - // 空桶补齐:请求为 0、费用已知且为 0 - val empty = data.buckets[3] // 第 4 个桶(e1 在桶 1,e2 在桶 5,e3 在桶 10) - assertEquals(0L, empty.totals.requests) - assertEquals(0.0, empty.totals.cost.knownAmount, 1e-9) - assertTrue(empty.totals.cost.isFullyKnown) - } - - @Test - fun `bucket boundary events land in the correct bucket`() { - val start = alignedDayStart - val events = - listOf( - event("at-start", "id-1", start), - event("at-boundary", "id-1", start + TokenStatsTimeRanges.HOUR_MS), - ) - val data = hourlyRangeData(events, startHour = start) - assertEquals(1L, data.buckets[0].totals.requests) - assertEquals(1L, data.buckets[1].totals.requests) - } - - @Test - fun `bucket model split matches range total`() { - val id1 = identity("id-1", model = "gpt-4o") - val id2 = identity("id-2", configId = "cfg-2", model = "gpt-4o") - val events = - listOf( - event("e1", "id-1", alignedDayStart + TokenStatsTimeRanges.HOUR_MS, cost = 1.0), - event("e2", "id-2", alignedDayStart + 2L * TokenStatsTimeRanges.HOUR_MS, cost = 2.0), - ) - val data = - hourlyRangeData(events, identities = listOf(id1, id2)) - val bucket = data.buckets.first { it.totals.requests == 1L } - assertEquals(1L, bucket.byModel["gpt-4o"]!!.requests) - assertEquals(7.0, bucket.byModel["gpt-4o"]!!.cost.knownAmount, 1e-9) - assertEquals(2, data.summary.requests) - } - - @Test - fun `canonical totals aggregate per event across summary buckets and model buckets`() { - val id1 = identity("id-1", provider = "OPENAI", model = "gpt-4o") - val id2 = identity("id-2", configId = "cfg-2", provider = "ANTHROPIC", model = "claude-3-5-sonnet") - val openaiModelId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - val anthropicModelId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") - val events = - listOf( - // OpenAI:权威 totalInput(600)即使拆分未知也能用 → 600+400 - event( - "e1", "id-1", alignedDayStart, - uncached = null, cached = null, totalInput = 600L, output = 400L, - cacheWriteSeparateBilling = false, - ), - // OpenAI 无 totalInput:输入 = uncached+cached,cacheWrite 不重复 → 500+100+400 - event( - "e2", "id-1", alignedDayStart + TokenStatsTimeRanges.HOUR_MS, - uncached = 500L, cached = 100L, cacheWrite = 50L, output = 400L, - cacheWriteSeparateBilling = false, - ), - // Anthropic:权威 totalInput = 三分量之和,cacheWrite 只计一次 → 650+400 - event( - "e3", "id-2", alignedDayStart + 2L * TokenStatsTimeRanges.HOUR_MS, - uncached = 500L, cached = 100L, cacheWrite = 50L, totalInput = 650L, output = 400L, - cacheWriteSeparateBilling = true, - ), - // 独立推理:output + reasoning → 0+100+20 - event( - "e4", "id-2", alignedDayStart + 3L * TokenStatsTimeRanges.HOUR_MS, - uncached = 0L, cached = 0L, cacheWrite = 0L, totalInput = 0L, output = 100L, - reasoning = 20L, reasoningIncluded = false, cacheWriteSeparateBilling = true, - ), - // 输入未知 → canonical unknown(输出已知也不拼 0) - event( - "e5", "id-1", alignedDayStart + 4L * TokenStatsTimeRanges.HOUR_MS, - uncached = null, cached = null, totalInput = null, output = 50L, - cacheWriteSeparateBilling = false, - ), - ) - val data = hourlyRangeData(events, identities = listOf(id1, id2)) - val summary = data.summary - assertEquals(1000L + 1000L + 1050L + 120L, summary.totalTokens.knownSum) - assertEquals(4L, summary.totalTokens.knownEventCount) - assertEquals(1L, summary.totalTokens.unknownEventCount) - assertEquals(5L, summary.totalTokens.totalEventCount) - // 桶合计 == 范围总计(canonical 与分量同样守恒) - assertEquals( - summary.totalTokens.knownSum, - data.buckets.sumOf { it.totals.totalTokens.knownSum }, - ) - assertEquals( - summary.totalTokens.unknownEventCount, - data.buckets.sumOf { it.totals.totalTokens.unknownEventCount }, - ) - // 模型桶同样聚合 canonical(按桶×模型分组):跨桶合计与范围总计一致 - // OpenAI e1+e2=2000、e5 unknown;Anthropic e3+e4=1170 - val openaiTotal = data.buckets.sumOf { it.byModel[openaiModelId]?.totalTokens ?: 0L } - val openaiUnknown = - data.buckets.sumOf { it.byModel[openaiModelId]?.totalTokensUnknownEventCount ?: 0L } - assertEquals(2000L, openaiTotal) - assertEquals(1L, openaiUnknown) - val anthropicTotal = data.buckets.sumOf { it.byModel[anthropicModelId]?.totalTokens ?: 0L } - val anthropicUnknown = - data.buckets.sumOf { it.byModel[anthropicModelId]?.totalTokensUnknownEventCount ?: 0L } - assertEquals(1170L, anthropicTotal) - assertEquals(0L, anthropicUnknown) - // 桶 0 只有 e1:该桶 OpenAI 模型桶的 canonical = e1 单事件 - assertEquals(1000L, data.buckets[0].byModel.getValue(openaiModelId).totalTokens) - } - - // ==== 模型分组与明细 ==== - - @Test - fun `same normalized model merges into one display group across configs`() { - val id1 = identity("id-1", configId = "cfg-1", provider = "P1", model = "gpt-4o") - val id2 = identity("id-2", configId = "cfg-2", provider = "P1", model = "GPT-4o") - val events = - listOf( - event("e1", "id-1", 1000L, cost = 1.0), - event("e2", "id-2", 2000L, cost = 2.0), - ) - val data = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = listOf(id1, id2).associateBy { it.identityId }, - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - assertEquals(1, data.displayModels.size) - val model = data.displayModels.single() - assertEquals(TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), model.displayModelId) - assertEquals(2, model.identities.size) - assertEquals(2L, model.totals.requests) - assertEquals(21.0, model.totals.cost.knownAmount, 1e-9) // (1+2)*7 - // 身份分项之和 == 展示模型总计 - assertEquals( - model.totals.cost.knownAmount, - model.identities.sumOf { it.totals.cost.knownAmount }, - 1e-9, - ) - assertEquals(2L, model.identities.sumOf { it.totals.requests }) - } - - @Test - fun `manual alias groups different names via displayModelId`() { - val id1 = identity("id-1", model = "model-a", displayModelId = "aliased") - val id2 = identity("id-2", configId = "cfg-2", model = "model-b", displayModelId = "aliased") - val events = - listOf( - event("e1", "id-1", 1000L, cost = 1.0), - event("e2", "id-2", 2000L, cost = 2.0), - ) - val data = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = listOf(id1, id2).associateBy { it.identityId }, - displayModelsById = mapOf("aliased" to displayModel("aliased", "My Alias")), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - assertEquals(1, data.displayModels.size) - val model = data.displayModels.single() - assertEquals("My Alias", model.displayName) - assertEquals(2L, model.totals.requests) - // display name 缺失时回退到 displayModelId - val noDisplayRow = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = listOf(id1, id2).associateBy { it.identityId }, - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - assertEquals("aliased", noDisplayRow.displayModels.single().displayName) - } - - @Test - fun `identity pricing info reflects snapshot or current resolution`() { - val id1 = identity("id-1") - val events = listOf(event("e1", "id-1", 1000L, cost = 1.0, outputPrice = 3.0)) - val historical = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to id1), - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - val identityBreakdown = historical.displayModels.single().identities.single() - assertEquals(3.0, identityBreakdown.pricing!!.outputPricePerMillion!!, 1e-9) - assertEquals(PricingSource.DEFAULT, identityBreakdown.pricing!!.source) - - val revalued = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to identity("id-1", provider = "OPENAI", model = "gpt-4o-2024-11-20")), - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ) - val revaluedPricing = revalued.displayModels.single().identities.single().pricing!! - assertEquals(BillingMode.TOKEN, revaluedPricing.billingMode) - assertTrue(revaluedPricing.known) - } - - // ==== 分类与状态 ==== - - @Test - fun `category breakdown and filter`() { - val events = - listOf( - event("e1", "id-1", 1000L, category = TokenStatCategory.CHAT.name), - event("e2", "id-1", 2000L, category = TokenStatCategory.CHAT.name), - event("e3", "id-1", 3000L, category = TokenStatCategory.SUMMARY.name), - ) - val all = aggregated(events, listOf(identity("id-1"))) - assertEquals(3L, all.requests) - - val data = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to identity("id-1")), - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params.copy(categories = setOf(TokenStatCategory.CHAT)), - ) - assertEquals(2L, data.summary.requests) - assertEquals(listOf(TokenStatCategory.CHAT), data.categories.map { it.category }) - - val allData = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to identity("id-1")), - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - assertEquals(listOf(TokenStatCategory.CHAT, TokenStatCategory.SUMMARY), allData.categories.map { it.category }) - assertEquals(2L, allData.categories.first { it.category == TokenStatCategory.CHAT }.totals.requests) - } - - @Test - fun `status breakdown counts per status in enum order`() { - val events = - listOf( - event("e1", "id-1", 1000L, status = TokenStatStatus.FAILED.name), - event("e2", "id-1", 2000L, status = TokenStatStatus.COMPLETED.name), - event("e3", "id-1", 3000L, status = TokenStatStatus.COMPLETED.name), - event("e4", "id-1", 4000L, status = TokenStatStatus.TIMEOUT.name), - ) - val data = - TokenStatsAggregator.rangeData( - events = events, - identitiesById = mapOf("id-1" to identity("id-1")), - displayModelsById = emptyMap(), - overrides = emptyList(), - legacyPrices = emptyMap(), - range = TokenStatsTimeRanges.customRange(0L, 30L * TokenStatsTimeRanges.DAY_MS), - granularity = TokenStatsGranularity.DAILY, - zone = shanghai, - params = params, - ) - assertEquals( - listOf(TokenStatStatus.COMPLETED, TokenStatStatus.TIMEOUT, TokenStatStatus.FAILED), - data.statuses.map { it.status }, - ) - assertEquals(2L, data.statuses.first { it.status == TokenStatStatus.COMPLETED }.totals.requests) - } - - // ==== 性能 ==== - - @Test - fun `performance excludes unknown samples from averages`() { - val events = - listOf( - // e1: 正常(TTFT 100ms,生成 400ms) - event("e1", "id-1", 0L, endedAtMs = 500L, firstTokenAtMs = 100L), - // e2: 无首 token -> TTFT/生成都 unknown - event("e2", "id-1", 0L, endedAtMs = 100L, firstTokenAtMs = null), - // e3: 结束早于首 token -> TTFT 有效 50ms,生成 unknown - event("e3", "id-1", 0L, endedAtMs = 40L, firstTokenAtMs = 50L), - // e4: 负时间戳 -> 全部 unknown - event("e4", "id-1", -5L, endedAtMs = 10L, firstTokenAtMs = -5L), - ) - val performance = TokenStatsAggregator.performanceOf(events) - assertEquals(2L, performance.ttft.knownCount) - assertEquals(2L, performance.ttft.unknownCount) - assertEquals(150L, performance.ttft.totalMs) - assertEquals(75.0, performance.ttft.averageMs, 1e-9) - assertEquals(1L, performance.generationDuration.knownCount) - assertEquals(3L, performance.generationDuration.unknownCount) - assertEquals(400L, performance.generationDuration.totalMs) - assertEquals(400.0, performance.generationDuration.averageMs, 1e-9) - } - - @Test - fun `performance with no data has zero averages`() { - val performance = TokenStatsAggregator.performanceOf(emptyList()) - assertEquals(0L, performance.ttft.knownCount) - assertEquals(0.0, performance.ttft.averageMs, 1e-9) - assertFalse(performance.ttft.hasData) - } - - // ==== 数值边界 ==== - - @Test - fun `long sums saturate instead of overflowing negative`() { - val events = - listOf( - event("e1", "id-1", 1000L, output = Long.MAX_VALUE), - event("e2", "id-1", 2000L, output = 10L), - ) - val totals = aggregated(events, listOf(identity("id-1"))) - assertEquals(Long.MAX_VALUE, totals.output.knownSum) - assertTrue(totals.output.knownSum > 0) - assertEquals(2L, totals.output.knownEventCount) - } - - @Test - fun `cost accumulation is precise across many events`() { - val events = - (0 until 1000).map { index -> - event("e$index", "id-1", index.toLong() * 1000L, cost = 0.1) - } - val cost = aggregated(events, listOf(identity("id-1"))).cost - assertEquals(100.0 * 7.0, cost.knownAmount, 1e-6) - assertTrue(cost.isFullyKnown) - } - - // ==== baseline ==== - - @Test - fun `baseline unknown cost counted as unknown`() { - val overview = - TokenStatsAggregator.lifetime( - events = emptyList(), - baselines = listOf(baseline("id-1", cost = null)), - identitiesById = mapOf("id-1" to identity("id-1")), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params, - ) - assertEquals(0.0, overview.baselineTotals.cost.knownAmount, 1e-9) - assertEquals(1L, overview.baselineTotals.cost.unknownContributionCount) - assertFalse(overview.baselineTotals.cost.isFullyKnown) - // baseline 恒为估算/冻结快照口径,不受重估参数影响 - val revalued = - TokenStatsAggregator.lifetime( - events = emptyList(), - baselines = listOf(baseline("id-1", cost = 1.0)), - identitiesById = mapOf("id-1" to identity("id-1")), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params.copy(mode = TokenStatsCostMode.REVALUED), - ) - assertEquals(TokenStatsCostMode.HISTORICAL, revalued.baselineTotals.cost.mode) - } - - @Test - fun `default params mark rate as estimated 7 dot 0`() { - val cost = aggregated(listOf(event("e1", "id-1", 1000L, cost = 1.0)), listOf(identity("id-1"))).cost - assertEquals(7.0, cost.rateUsed, 1e-9) - assertTrue(cost.rateIsEstimated) - assertEquals(PricingCurrency.CNY, cost.currency) - } - - @Test - fun `baseline tokens aggregate with saturation`() { - val overview = - TokenStatsAggregator.lifetime( - events = emptyList(), - baselines = - listOf( - baseline("id-1", inputTokens = Long.MAX_VALUE, requestCount = 5L), - baseline("id-2", inputTokens = 10L, requestCount = 5L), - ), - identitiesById = - mapOf("id-1" to identity("id-1"), "id-2" to identity("id-2", configId = "cfg-2")), - overrides = emptyList(), - legacyPrices = emptyMap(), - params = params, - ) - assertEquals(Long.MAX_VALUE, overview.baselineTotals.inputTokens) - assertEquals(10L, overview.baselineTotals.requests) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt deleted file mode 100644 index a7633f4db..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsCleanupOutboxTest.kt +++ /dev/null @@ -1,528 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.datastore.preferences.core.MutablePreferences -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.edit -import androidx.room.Room -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.preferences.applyLegacyCleanupMutation -import com.ai.assistance.operit.util.AppLogger -import java.io.File -import java.io.IOException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -/** - * legacy cleanup outbox 排空协议测试(阶段 5 P1 闭环): - * - * - 删除事务(唯一线性化点)提交后,coordinator 在 Room 事务外排空: - * Room 读 PENDING → DataStore 单次 edit 精准清键 + 写 marker → Room ACK APPLIED; - * - DataStore 失败(含取消)→ operation 保持 PENDING、异常/取消传播,重启排空重试; - * - marker 已存在 → 幂等 no-op(崩溃后重放不二次清键、不写值;新累计值保留); - * - ALL kind 清全部累计键但不触碰价格/计费方式等配置键与 marker 键; - * - 重启排空(无 PENDING)不触碰 DataStore。 - * - * Windows DataStore 约束:模块级 `Context.apiDataStore` 委托在单个 JVM 内只创建 - * 一个 DataStore 实例,且同一文件每次真实写入在 Windows 上不稳定(rename 目标 - * 已存在时失败)。因此每个测试阶段最多一次真实写入:先用独立 DataStore 实例生成 - * 种子文件([seedPreferencesFile]),复制到阶段目录([restorePreferencesInto]), - * 被测流程(排空的 apply)是阶段文件的唯一真实写入;失败路径用注入的 - * ApiPreferences mock 在边界模拟 DataStore 故障(协调器协议用真实 DAO 验证), - * 键级幂等语义由纯函数 [applyLegacyCleanupMutation] 直接验证——不绕过生产协议。 - */ -class TokenStatsCleanupOutboxTest { - - private val providerA = "DEEPSEEK:deepseek-chat" - private val providerB = "OPENAI:gpt-4o" - - @Before - fun isolate() { - clearApiDataStoreSingleton() - injectApiPreferences(null) - TokenStatsResetCoordinator.daoProvider = null - TokenBaselineImportRunner.databaseProvider = null - } - - // ==== DataStore 单例隔离与种子文件(与 TokenBaselineImportRunnerTest 同套技术) ==== - - private fun clearApiDataStoreSingleton() { - val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") - val delegateField = facade.getDeclaredField("apiDataStore\$delegate") - delegateField.isAccessible = true - val delegate = delegateField.get(null) - val instanceField = - delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } - instanceField.set(delegate, null) - } - - private fun injectApiPreferences(instance: ApiPreferences?) { - val field = - ApiPreferences::class.java - .getDeclaredField("INSTANCE") - .apply { isAccessible = true } - field.set(null, instance) - } - - private fun constructApiPreferences(context: Context): ApiPreferences { - val constructor = - ApiPreferences::class.java - .getDeclaredConstructor(Context::class.java) - .apply { isAccessible = true } - return constructor.newInstance(context) - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.noBackupFilesDir).thenReturn(File(filesDir, "no_backup")) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - private fun openDatabase(filesDir: File): AppDatabase = - Room.databaseBuilder(mockContext(filesDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - private fun seedPreferencesFile(seedFile: File, block: (MutablePreferences) -> Unit) { - seedFile.parentFile?.mkdirs() - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - val store = - PreferenceDataStoreFactory.create( - scope = scope, - produceFile = { seedFile }, - ) - runBlocking { store.edit { block(it) } } - } - - private fun restorePreferencesInto(filesDir: File, seedFile: File) { - val target = File(filesDir, "datastore/api_settings.preferences_pb") - target.parentFile?.mkdirs() - seedFile.copyTo(target, overwrite = true) - } - - private suspend fun seedLegacyIdentity( - dao: TokenStatsDao, - identityId: String, - configId: String, - provider: String, - model: String, - displayModelId: String, - ) { - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = displayModelId, - displayName = displayModelId, - ) - ) - } - - /** - * Windows DataStore 约束(与 VM 测试同款技术):DataStore 1.0.0 以 renameTo 原子 - * 替换,Windows 上目标文件已存在时替换失败。先用一次读把种子文件内容载入 - * DataStore 内存缓存(后续 edit 基于缓存状态),再移除磁盘文件,使被测流程的 - * edit(rename 目标不存在)成为该文件的唯一真实写入——不绕过生产协议。 - */ - private suspend fun primeDatastoreForWrite(phase: File, prefs: ApiPreferences) { - prefs.getInputTokensForProviderModel("DEEPSEEK:deepseek-chat") - check(File(File(phase, "datastore"), "api_settings.preferences_pb").delete()) - } - - // ==== 排空协议 ==== - - @Test - fun `drain applies precise legacy keys for display group operation and acks`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - // Room:group-x 含 legacy A 与配置身份 cfg-C;group-y 含 legacy B - seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") - seedLegacyIdentity(dao, "x-cfg", "cfg-c", "OPENAI", "gpt-4o", "group-x") - seedLegacyIdentity(dao, "y-legacy", "", "OPENAI", "gpt-4o", "group-y") - - // DataStore:A 与 B 都有累计键,A 另有价格键与 request_count - val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - prefs[ApiPreferences.getTokenCachedInputKey(providerA)] = 200_000L - prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L - prefs[ApiPreferences.getRequestCountKey(providerA)] = 7 - prefs[ApiPreferences.getModelInputPriceKey(providerA)] = 2.0f - prefs[ApiPreferences.getTokenInputKey(providerB)] = 3_000_000L - prefs[ApiPreferences.getTokenOutputKey(providerB)] = 800_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val prefs = constructApiPreferences(ctx) - injectApiPreferences(prefs) - primeDatastoreForWrite(phase, prefs) - - // 删除事务创建 operation(items 精确到 legacy A) - val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - val op = result.cleanupOperation!! - assertEquals(1, dao.getCleanupItems(op.operationId).size) - - // 排空(该文件的唯一真实写入) - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - - // DataStore:A 键精准清除,B 键与价格配置保留,marker 已写 - assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) - assertEquals(0L, prefs.getOutputTokensForProviderModel(providerA)) - assertEquals(0, prefs.getRequestCountForProviderModel(providerA)) - assertEquals("other model counts must survive", 3_000_000L, prefs.getInputTokensForProviderModel(providerB)) - assertEquals("price config must never be cleared", 2.0, prefs.getModelInputPrice(providerA), 1e-9) - assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) - // Room:operation APPLIED、items 保留(lineage) - assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) - assertEquals(1, dao.getCleanupItems(op.operationId).size) - assertEquals(0, dao.countPendingCleanupOperations()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `datastore failure keeps operation pending and restart drain completes it`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") - val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - prefs[ApiPreferences.getTokenOutputKey(providerA)] = 500_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! - - // 第一次排空:DataStore 边界失败(模拟 edit 抛 IOException)→ 传播、保持 PENDING - val failingPrefs = mock() - whenever(failingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) - .thenAnswer { throw IOException("datastore down") } - injectApiPreferences(failingPrefs) - try { - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - fail("drain must propagate the DataStore failure") - } catch (e: IOException) { - assertEquals("datastore down", e.message) - } - assertEquals( - "failed apply must keep the operation pending", - TokenStatCleanupOperationEntity.STATUS_PENDING, - dao.getPendingCleanupOperations().single().status, - ) - - // 模拟重启:注入真实 prefs(该文件的首个真实写入),排空重试成功 - val realPrefs = constructApiPreferences(ctx) - injectApiPreferences(realPrefs) - primeDatastoreForWrite(phase, realPrefs) - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - val applied = dao.getAllCleanupOperations().single() - assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, applied.status) - assertEquals(0L, ApiPreferences.getInstance(ctx).getInputTokensForProviderModel(providerA)) - assertEquals(setOf(op.operationId), ApiPreferences.getInstance(ctx).appliedLegacyCleanupMarkerIds()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `cancellation propagates through drain and operation stays pending`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") - val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! - - val cancellingPrefs = mock() - whenever(cancellingPrefs.applyLegacyCleanup(op.operationId, listOf(providerA))) - .thenThrow(CancellationException("drain cancelled")) - injectApiPreferences(cancellingPrefs) - try { - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - fail("drain must propagate CancellationException") - } catch (e: CancellationException) { - assertEquals("drain cancelled", e.message) - } - assertEquals(1, dao.countPendingCleanupOperations()) - assertEquals(0, dao.ackCleanupOperation("never-acked")) - assertEquals(1, dao.countPendingCleanupOperations()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `read failure inside deletion transaction fails without cleanup or drain`() = - runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - // 事务内读取失败(成员解析抛错):整个删除事务失败——不得继续排空、 - // 不得产生任何 operation(生产原子性由 DAO @Transaction 回滚保证) - val failingDao = mock() - whenever(failingDao.deleteDisplayModelEventsTx("group-x", true)) - .thenThrow(RuntimeException("member read failed")) - TokenStatsResetCoordinator.daoProvider = { failingDao } - val ctx = mockContext(kotlin.io.path.createTempDirectory("outbox-phase").toFile()) - val spyPrefs = mock() - injectApiPreferences(spyPrefs) - try { - val failure = runCatching { - TokenStatsResetCoordinator.deleteDisplayModel(ctx, "group-x", deleteBaselines = true) - } - assertTrue("read failure must propagate", failure.isFailure) - verify(spyPrefs, never()).applyLegacyCleanup(any(), any()) - assertEquals( - "failed transaction must leave no pending operation", - 0, - dao.countPendingCleanupOperations(), - ) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `all kind clears every cumulative key and keeps prices and markers`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.getTokenInputKey(providerA)] = 1_000_000L - prefs[ApiPreferences.getTokenInputKey(providerB)] = 3_000_000L - prefs[ApiPreferences.getModelInputPriceKey(providerA)] = 2.0f - prefs[ApiPreferences.getBillingModeKey(providerB)] = "COUNT" - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val prefs = constructApiPreferences(ctx) - injectApiPreferences(prefs) - primeDatastoreForWrite(phase, prefs) - - val op = dao.deleteAllStatisticsTx(deleteBaselines = true).cleanupOperation!! - assertEquals(TokenStatCleanupOperationEntity.SCOPE_ALL, op.scope) - assertTrue(dao.getCleanupItems(op.operationId).isEmpty()) - - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - - assertEquals(0L, prefs.getInputTokensForProviderModel(providerA)) - assertEquals(0L, prefs.getInputTokensForProviderModel(providerB)) - assertEquals("price config must survive ALL cleanup", 2.0, prefs.getModelInputPrice(providerA), 1e-9) - assertEquals("billing config must survive ALL cleanup", "COUNT", prefs.getBillingModeForProviderModel(providerB).name) - assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) - assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `restart drain with nothing pending never touches datastore`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - val ctx = mockContext(kotlin.io.path.createTempDirectory("outbox-phase").toFile()) - // 无任何 operation - val spyPrefs = mock() - injectApiPreferences(spyPrefs) - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - verify(spyPrefs, never()).applyLegacyCleanup(any(), any()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - @Test - fun `marker present makes retry a strict no-op preserving re added counts`() = runBlocking { - val dbDir = kotlin.io.path.createTempDirectory("outbox-db").toFile() - val phase = kotlin.io.path.createTempDirectory("outbox-phase").toFile() - val database = openDatabase(dbDir) - val dao = database.tokenStatsDao() - TokenStatsResetCoordinator.daoProvider = { dao } - try { - seedLegacyIdentity(dao, "x-legacy", "", "DEEPSEEK", "deepseek-chat", "group-x") - val op = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true).cleanupOperation!! - - // 崩溃窗口:marker 已写(apply 完成)但 ACK 未提交;随后新使用重新累计了计数 - val seed = kotlin.io.path.createTempDirectory("outbox-seed").toFile() - val seedFile = File(seed, "seed.preferences_pb") - seedPreferencesFile(seedFile) { prefs -> - prefs[ApiPreferences.legacyCleanupMarkerKey(op.operationId)] = true - prefs[ApiPreferences.getTokenInputKey(providerA)] = 42_000L - } - restorePreferencesInto(phase, seedFile) - val ctx = mockContext(phase) - val prefs = constructApiPreferences(ctx) - injectApiPreferences(prefs) - - // 重试排空:marker 已存在 → 幂等 no-op(不二次清键、不写值),只 ACK - Mockito.mockStatic(AppLogger::class.java).use { TokenStatsResetCoordinator.drainPendingCleanup(ctx) } - assertEquals(TokenStatCleanupOperationEntity.STATUS_APPLIED, dao.getAllCleanupOperations().single().status) - assertEquals( - "fresh usage after the crash must be preserved (no double clear)", - 42_000L, - prefs.getInputTokensForProviderModel(providerA), - ) - assertEquals(setOf(op.operationId), prefs.appliedLegacyCleanupMarkerIds()) - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - database.close() - } - } - - // ==== 纯变更函数(键级语义,无 I/O) ==== - - @Test - fun `mutation clears exact provider models and writes marker`() = runBlocking { - val prefs = androidx.datastore.preferences.core.preferencesOf( - ApiPreferences.getTokenInputKey(providerA) to 1L, - ApiPreferences.getTokenCachedInputKey(providerA) to 2L, - ApiPreferences.getTokenOutputKey(providerA) to 3L, - ApiPreferences.getRequestCountKey(providerA) to 4, - ApiPreferences.getTokenInputKey(providerB) to 5L, - ApiPreferences.getModelInputPriceKey(providerA) to 2.0f, - ApiPreferences.getBillingModeKey(providerB) to "TOKEN", - ).toMutablePreferences() - - applyLegacyCleanupMutation(prefs, "op-1", listOf(providerA)) - - assertNull(prefs[ApiPreferences.getTokenInputKey(providerA)]) - assertNull(prefs[ApiPreferences.getTokenCachedInputKey(providerA)]) - assertNull(prefs[ApiPreferences.getTokenOutputKey(providerA)]) - assertNull(prefs[ApiPreferences.getRequestCountKey(providerA)]) - assertEquals(5L, prefs[ApiPreferences.getTokenInputKey(providerB)]) - assertEquals(2.0f, prefs[ApiPreferences.getModelInputPriceKey(providerA)]) - assertEquals("TOKEN", prefs[ApiPreferences.getBillingModeKey(providerB)]) - assertEquals(true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-1")]) - } - - @Test - fun `mutation ALL clears cumulative keys and keeps config and markers`() = runBlocking { - val prefs = androidx.datastore.preferences.core.preferencesOf( - ApiPreferences.getTokenInputKey(providerA) to 1L, - ApiPreferences.getTokenOutputKey(providerB) to 3L, - ApiPreferences.getRequestCountKey(providerB) to 4, - ApiPreferences.getModelInputPriceKey(providerA) to 2.0f, - ApiPreferences.legacyCleanupMarkerKey("op-old") to true, - ).toMutablePreferences() - - applyLegacyCleanupMutation(prefs, "op-all", null) - - assertNull(prefs[ApiPreferences.getTokenInputKey(providerA)]) - assertNull(prefs[ApiPreferences.getTokenOutputKey(providerB)]) - assertNull(prefs[ApiPreferences.getRequestCountKey(providerB)]) - assertEquals("price config must survive ALL", 2.0f, prefs[ApiPreferences.getModelInputPriceKey(providerA)]) - assertEquals("old markers must survive ALL", true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-old")]) - assertEquals("new marker must be written", true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-all")]) - } - - @Test - fun `mutation with existing marker is a strict no-op`() = runBlocking { - val prefs = androidx.datastore.preferences.core.preferencesOf( - ApiPreferences.legacyCleanupMarkerKey("op-1") to true, - ApiPreferences.getTokenInputKey(providerA) to 42L, - ).toMutablePreferences() - - applyLegacyCleanupMutation(prefs, "op-1", listOf(providerA)) - applyLegacyCleanupMutation(prefs, "op-1", null) - - assertEquals( - "marker present must never re-clear keys", - 42L, - prefs[ApiPreferences.getTokenInputKey(providerA)], - ) - assertEquals(true, prefs[ApiPreferences.legacyCleanupMarkerKey("op-1")]) - } - - @Test - fun `mutation with blank operationId is rejected`() = runBlocking { - val prefs = androidx.datastore.preferences.core.preferencesOf().toMutablePreferences() - try { - applyLegacyCleanupMutation(prefs, "", listOf(providerA)) - fail("blank operationId must be rejected") - } catch (e: IllegalArgumentException) { - // expected - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt deleted file mode 100644 index dff9a4344..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsDeletionTest.kt +++ /dev/null @@ -1,664 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatCleanupOperationEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * 阶段 5 删除矩阵的 DAO 级测试(真实 Room + JVM SQLite 驱动): - * - * - 时间范围删除只删有时间戳的事件,边界 [startMs, endMs) 精确,绝不碰 baseline; - * - 按展示分组删除覆盖完整组成员(跨 provider/model 合并组),IDENTITY tombstone - * 精确到身份,同一 provider:model 的其他分组不受影响; - * baseline 是否删除由参数决定(yes/no 两分支); - * - 全部删除 yes/no 两分支;身份/展示分组/价格覆盖一律保留; - * - RANGE/IDENTITY tombstone 与 spool 排空插入同界:删除前接受的事件不复活, - * 删除后接受的事件正常入账(统一 generation 计数,不依赖墙钟); - * - 删除后查询/计数/聚合结果一致,无缓存旧数据。 - */ -class TokenStatsDeletionTest { - - private lateinit var tempDir: File - private lateinit var database: AppDatabase - private lateinit var dao: TokenStatsDao - - @Before - fun setUp() { - tempDir = kotlin.io.path.createTempDirectory("token-stats-deletion-test").toFile() - val context = mockContext(tempDir) - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .allowMainThreadQueries() - .build() - dao = database.tokenStatsDao() - } - - @After - fun tearDown() { - database.close() - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - private suspend fun seedIdentity( - identityId: String, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o", - displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), - ) { - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), - displayName = model, - ) - ) - } - - private fun event( - id: String, - identityId: String, - startedAtMs: Long, - generation: Long = 0L, - status: String = TokenStatStatus.COMPLETED.name, - ): TokenStatEventEntity = - TokenStatEventEntity( - eventId = id, - statIdentityId = identityId, - category = TokenStatCategory.CHAT.name, - status = status, - acceptedGeneration = generation, - startedAtMs = startedAtMs, - endedAtMs = startedAtMs + 1_000L, - firstTokenAtMs = startedAtMs + 200L, - uncachedInputTokens = 100L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - totalInputTokens = null, - outputTokens = 50L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.5, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 6.0, - pricePerRequest = null, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.01, - diagnosticsJson = null, - ) - - private fun baseline(identityId: String, requestCount: Long = 3L): TokenStatBaselineEntity = - TokenStatBaselineEntity( - identityId = identityId, - inputTokens = 100L * requestCount, - cachedInputTokens = 0L, - outputTokens = 50L * requestCount, - requestCount = requestCount, - pricingCurrency = PricingCurrency.USD.name, - costInPricingCurrency = 0.01 * requestCount, - isEstimated = true, - fingerprint = "fp-$identityId", - importedAtMs = 1L, - frozenBillingMode = BillingMode.TOKEN.name, - frozenInputPricePerMillion = 1.0, - frozenOutputPricePerMillion = 2.0, - ) - - // ==== 时间范围删除 ==== - - @Test - fun `range deletion removes only in range events and never touches baseline`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a") - seedIdentity("id-b", configId = "cfg-b") - dao.upsertBaseline(baseline("id-a")) - dao.upsertBaseline(baseline("id-b")) - - // 半开区间 [10_000, 20_000):startMs 在界内的删,endMs 处与界外的保留 - dao.insertEvent(event("in-1", "id-a", startedAtMs = 10_000L)) - dao.insertEvent(event("in-2", "id-a", startedAtMs = 19_999L)) - dao.insertEvent(event("boundary-end", "id-a", startedAtMs = 20_000L)) - dao.insertEvent(event("before", "id-b", startedAtMs = 9_999L)) - dao.insertEvent(event("after", "id-b", startedAtMs = 20_001L)) - - val deleted = dao.deleteRangeEventsTx(10_000L, 20_000L) - assertEquals(2, deleted) - - assertEquals("boundary-end must survive at endMs", "boundary-end", dao.getEvent("boundary-end")!!.eventId) - assertEquals("before must survive", "before", dao.getEvent("before")!!.eventId) - assertEquals("after must survive", "after", dao.getEvent("after")!!.eventId) - assertNull(dao.getEvent("in-1")) - assertNull(dao.getEvent("in-2")) - assertEquals(3, dao.countEvents()) - - // baseline 绝不因范围删除被触碰 - assertEquals(2, dao.countBaselines()) - assertNotNull(dao.getBaseline("id-a")) - assertNotNull(dao.getBaseline("id-b")) - - // 身份/展示分组/价格覆盖保留(两个身份同模型 → 同一默认展示组一行) - assertNotNull(dao.getIdentity("id-a")) - assertNotNull(dao.getIdentity("id-b")) - assertEquals(1, dao.getAllDisplayModels().size) - - // 删除后查询一致:范围内无事件 - assertFalse(dao.rangeHasEvents(10_000L, 20_000L)) - assertEquals(1, dao.rangeCutoffs().size) - } - - @Test - fun `range deletion rejects invalid boundaries`() = runBlocking { - val failure = runCatching { dao.deleteRangeEventsTx(20_000L, 10_000L) } - assertTrue("end <= start must be rejected", failure.isFailure) - assertEquals(0, dao.rangeCutoffs().size) - } - - @Test - fun `range deletion tombstone skips pre deletion in range events and accepts post deletion ones`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a") - // 删除范围 [10_000, 20_000),删除时 generation 递增为 1 - dao.insertEvent(event("old-in", "id-a", startedAtMs = 12_000L, generation = 0L)) - dao.insertEvent(event("old-out", "id-a", startedAtMs = 30_000L, generation = 0L)) - dao.deleteRangeEventsTx(10_000L, 20_000L) - assertEquals(1, dao.countEvents()) - assertNull(dao.getEvent("old-in")) - assertNotNull(dao.getEvent("old-out")) - - // 删除前接受但尚未入账的排空事件:落在范围内 → 跳过(不复活) - assertFalse(dao.insertEventIfNotResetCovered(event("drain-in", "id-a", startedAtMs = 12_000L, generation = 0L))) - // 删除前接受但范围外 → 正常入账 - assertTrue(dao.insertEventIfNotResetCovered(event("drain-out", "id-a", startedAtMs = 30_000L, generation = 0L))) - // 删除后接受的新事件(generation >= cutoff):即使落在已删范围内也正常入账 - assertTrue(dao.insertEventIfNotResetCovered(event("new-in", "id-a", startedAtMs = 12_001L, generation = 1L))) - - assertEquals(3, dao.countEvents()) - assertNull(dao.getEvent("drain-in")) - assertNotNull(dao.getEvent("drain-out")) - assertNotNull(dao.getEvent("new-in")) - } - - @Test - fun `range tombstone covers half open boundaries and generation equality`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a") - dao.deleteRangeEventsTx(10_000L, 20_000L) - assertEquals(1L, dao.currentResetGeneration()) - - // 半开区间 [10_000, 20_000):startMs == 左边界被覆盖;startMs == 右边界不被覆盖 - assertFalse( - "startMs at the left boundary must be covered", - dao.insertEventIfNotResetCovered(event("at-start", "id-a", startedAtMs = 10_000L, generation = 0L)) - ) - assertTrue( - "startMs at the right boundary must not be covered", - dao.insertEventIfNotResetCovered(event("at-end", "id-a", startedAtMs = 20_000L, generation = 0L)) - ) - assertNull(dao.getEvent("at-start")) - assertNotNull(dao.getEvent("at-end")) - - // generation == tombstone(删除后接受)→ 不覆盖,即使 startedAtMs 落在范围内 - assertTrue( - "generation equal to the cutoff must not be covered", - dao.insertEventIfNotResetCovered(event("gen-equal", "id-a", startedAtMs = 15_000L, generation = 1L)) - ) - assertNotNull(dao.getEvent("gen-equal")) - - // generation 低于 cutoff 但 startedAtMs 在范围外 → 不覆盖 - assertTrue( - "out of range startedAtMs must not be covered", - dao.insertEventIfNotResetCovered(event("out-of-range", "id-a", startedAtMs = 30_000L, generation = 0L)) - ) - assertNotNull(dao.getEvent("out-of-range")) - - assertEquals(3, dao.countEvents()) - } - - // ==== 按展示分组删除 ==== - - @Test - fun `model deletion covers full group members across providers and preserves other groups`() = - runBlocking { - // 展示组 group-x:两个身份,来自不同 provider:model - seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("x-2", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") - // 其他分组:与 x-1 同 provider:model(验证精确到身份,不误伤) - seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") - - dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L)) - dao.insertEvent(event("e-x2", "x-2", startedAtMs = 2_000L)) - dao.insertEvent(event("e-y1", "y-1", startedAtMs = 3_000L)) - dao.upsertBaseline(baseline("x-1")) - dao.upsertBaseline(baseline("x-2")) - dao.upsertBaseline(baseline("y-1")) - - // 不删 baseline:只删组内事件,组外(含同 provider:model)事件与全部 baseline 保留 - val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) - assertEquals(2, deleted.deletedEvents) - assertNull("non-legacy group with baseline=no creates no cleanup operation", deleted.cleanupOperation) - assertNull(dao.getEvent("e-x1")) - assertNull(dao.getEvent("e-x2")) - assertNotNull("same provider:model in another group must survive", dao.getEvent("e-y1")) - assertEquals(3, dao.countBaselines()) - assertNotNull(dao.getBaseline("x-1")) - - // 身份行保留(只清计数,保留配置/分组) - assertNotNull(dao.getIdentity("x-1")) - assertEquals("group-x", dao.getIdentity("x-1")!!.displayModelId) - } - - @Test - fun `model deletion with baseline removes group member baselines only`() = runBlocking { - seedIdentity("x-1", displayModelId = "group-x") - seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") - dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L)) - dao.insertEvent(event("e-y1", "y-1", startedAtMs = 2_000L)) - dao.upsertBaseline(baseline("x-1")) - dao.upsertBaseline(baseline("y-1")) - - val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - assertEquals(1, deleted.deletedEvents) - assertNull( - "group without legacy members must not create a cleanup operation", - deleted.cleanupOperation, - ) - assertNull(dao.getEvent("e-x1")) - assertNotNull(dao.getEvent("e-y1")) - assertNull("group baseline must be deleted", dao.getBaseline("x-1")) - assertNotNull("other group baseline must survive", dao.getBaseline("y-1")) - assertEquals(1, dao.countBaselines()) - } - - @Test - fun `model deletion writes identity tombstones that do not cover other groups`() = - runBlocking { - seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") - - dao.insertEvent(event("e-x1", "x-1", startedAtMs = 1_000L, generation = 0L)) - dao.insertEvent(event("e-y1", "y-1", startedAtMs = 2_000L, generation = 0L)) - dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) - assertEquals(1, dao.countEvents()) - assertNotNull(dao.getEvent("e-y1")) - - // 删除前接受、删除后排空的同 provider:model 事件: - // 组成员身份 → 跳过;其他分组身份 → 正常入账(IDENTITY 精确到身份) - assertFalse(dao.insertEventIfNotResetCovered(event("drain-x", "x-1", startedAtMs = 3_000L, generation = 0L))) - assertTrue(dao.insertEventIfNotResetCovered(event("drain-y", "y-1", startedAtMs = 3_000L, generation = 0L))) - assertNull(dao.getEvent("drain-x")) - assertNotNull(dao.getEvent("drain-y")) - - // 删除后新接受的事件正常入账 - assertTrue(dao.insertEventIfNotResetCovered(event("new-x", "x-1", startedAtMs = 4_000L, generation = 1L))) - assertNotNull(dao.getEvent("new-x")) - } - - @Test - fun `model deletion on empty group is a no-op`() = runBlocking { - seedIdentity("y-1", displayModelId = "group-y") - dao.insertEvent(event("e-y1", "y-1", startedAtMs = 1_000L)) - - val deleted = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - assertEquals(0, deleted.deletedEvents) - assertNull(deleted.cleanupOperation) - assertEquals(1, dao.countEvents()) - assertEquals("empty group must not write tombstones", 0L, dao.currentResetGeneration()) - } - // ==== 请求接受边界原子性(P1-1) ==== - - /** - * 首次请求(身份尚不存在)在**请求接受边界**原子创建身份并捕获 generation 0; - * 随后删除默认展示组:成员解析必须看见边界已创建的身份并写 IDENTITY tombstone, - * 删除前接受的事件在排空/落账时被跳过,绝不复活。 - */ - @Test - fun `request boundary before group deletion is covered by identity tombstone`() = runBlocking { - seedIdentity("id-a", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o") - dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L, generation = 0L)) - - // 请求接受边界:同一事务内创建 cfg-b 身份(同模型默认组)并读取 generation - val generation = - dao.ensureIdentityAndCaptureGenerationTx( - identity = - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("cfg-b", "OPENAI", "gpt-4o"), - configId = "cfg-b", - provider = "OPENAI", - model = "gpt-4o", - displayModelId = "gpt-4o", - ), - displayModel = - TokenStatDisplayModelEntity( - displayModelId = "gpt-4o", - normalizedModel = "gpt-4o", - displayName = "gpt-4o", - ), - ) - assertEquals("first request captures generation 0", 0L, generation) - - // 删除默认展示组:事务内从 identity 全表解析成员 → 必须包含边界刚创建的身份 - val deleted = dao.deleteDisplayModelEventsTx("gpt-4o", deleteBaselines = false) - assertEquals(1, deleted.deletedEvents) - assertEquals(1L, dao.currentResetGeneration()) - - // cfg-b 的旧事件(接受于删除前)排空时被 IDENTITY tombstone 跳过,不复活 - val identityB = dao.getIdentityByTriple("cfg-b", "OPENAI", "gpt-4o")!! - assertFalse( - dao.insertEventIfNotResetCovered( - event("drain-b", identityB.identityId, startedAtMs = 2_000L, generation = 0L) - ) - ) - assertNull(dao.getEvent("drain-b")) - assertEquals(0, dao.countEvents()) - } - - /** 删除先于请求边界:边界捕获 ≥ tombstone 的新 generation,新请求事件正常入账。 */ - @Test - fun `request boundary after group deletion captures newer generation and records normally`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o") - dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L, generation = 0L)) - dao.deleteDisplayModelEventsTx("gpt-4o", deleteBaselines = false) - assertEquals(1L, dao.currentResetGeneration()) - - val generation = - dao.ensureIdentityAndCaptureGenerationTx( - identity = - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("cfg-b", "OPENAI", "gpt-4o"), - configId = "cfg-b", - provider = "OPENAI", - model = "gpt-4o", - displayModelId = "gpt-4o", - ), - displayModel = - TokenStatDisplayModelEntity( - displayModelId = "gpt-4o", - normalizedModel = "gpt-4o", - displayName = "gpt-4o", - ), - ) - assertEquals("boundary after deletion must capture new generation", 1L, generation) - - // 删除后接受的请求事件正常入账(acceptedGeneration >= tombstone) - val identityB = dao.getIdentityByTriple("cfg-b", "OPENAI", "gpt-4o")!! - assertTrue( - dao.insertEventIfNotResetCovered( - event("new-b", identityB.identityId, startedAtMs = 3_000L, generation = generation) - ) - ) - assertNotNull(dao.getEvent("new-b")) - assertEquals(1, dao.countEvents()) - } - - // ==== 全部删除 ==== - - @Test - fun `delete all without baseline keeps baseline and clears events`() = runBlocking { - seedIdentity("id-a", configId = "cfg-a") - seedIdentity("id-b", configId = "cfg-b") - dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) - dao.insertEvent(event("e-b", "id-b", startedAtMs = 2_000L)) - dao.upsertBaseline(baseline("id-a")) - - dao.deleteAllStatisticsTx(deleteBaselines = false) - assertEquals(0, dao.countEvents()) - assertEquals("baseline must survive when not confirmed", 1, dao.countBaselines()) - assertNotNull(dao.getIdentity("id-a")) - assertNotNull(dao.getIdentity("id-b")) - assertEquals(1, dao.getAllDisplayModels().size) - assertNotNull("FULL tombstone must be written", dao.fullResetCutoff()) - } - - @Test - fun `delete all with baseline removes events and baseline and keeps identity config`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a") - dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) - dao.upsertBaseline(baseline("id-a")) - dao.upsertPriceOverride( - scope = "PROVIDER_MODEL", - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 9.0, - outputPricePerMillion = 9.0, - ) - - dao.deleteAllStatisticsTx(deleteBaselines = true) - assertEquals(0, dao.countEvents()) - assertEquals(0, dao.countBaselines()) - // 身份/展示分组/价格覆盖保留(“重置只清计数、保留配置”语义) - assertNotNull(dao.getIdentity("id-a")) - assertEquals(1, dao.getAllDisplayModels().size) - assertEquals(1, dao.getAllPriceOverrides().size) - } - - @Test - fun `full deletion supersedes older tombstones and unified generation never collides`() = - runBlocking { - seedIdentity("x-1", displayModelId = "group-x") - seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") - - // 依次执行三种删除,generation 跨两表统一递增:1(范围)→ 2(模型)→ 3(全部) - dao.insertEvent(event("e-x", "x-1", startedAtMs = 1_000L, generation = 0L)) - dao.insertEvent(event("e-y", "y-1", startedAtMs = 2_000L, generation = 0L)) - dao.deleteRangeEventsTx(500L, 1_500L) - assertEquals(1L, dao.currentResetGeneration()) - dao.deleteDisplayModelEventsTx("group-y", deleteBaselines = true) - assertEquals(2L, dao.currentResetGeneration()) - assertEquals(0, dao.countEvents()) - - dao.deleteAllStatisticsTx(deleteBaselines = true) - assertEquals(3L, dao.currentResetGeneration()) - // 卫生:FULL 之后旧边界(RANGE/MODEL)全部清除,只剩 FULL - assertTrue("range cutoffs must be cleared by full deletion", dao.rangeCutoffs().isEmpty()) - assertTrue("model cutoffs must be cleared by full deletion", dao.modelResetCutoffs().isEmpty()) - assertEquals(3L, dao.fullResetCutoff()!!.generation) - - // FULL 覆盖:更早接受的事件全部跳过(含跨越中间删除的 generation) - assertFalse(dao.insertEventIfNotResetCovered(event("drain-old", "x-1", startedAtMs = 1_000L, generation = 0L))) - assertFalse(dao.insertEventIfNotResetCovered(event("drain-old-2", "y-1", startedAtMs = 2_000L, generation = 1L))) - // 删除后新事件正常 - assertTrue(dao.insertEventIfNotResetCovered(event("new", "x-1", startedAtMs = 3_000L, generation = 3L))) - assertEquals(1, dao.countEvents()) - assertNotNull(dao.getEvent("new")) - } - - // ==== legacy cleanup outbox(阶段 5 P1 闭环) ==== - - @Test - fun `display group deletion with baseline creates cleanup operation with exact legacy items`() = - runBlocking { - // group-x:legacy A(configId="")、配置身份 cfg-B、legacy C —— 只登记 A 和 C - seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("x-cfg-b", configId = "cfg-b", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") - seedIdentity("x-legacy-c", configId = "", provider = "ANTHROPIC", model = "claude-3-5-sonnet", displayModelId = "group-x") - dao.upsertBaseline(baseline("x-legacy-a")) - dao.upsertBaseline(baseline("x-cfg-b")) - dao.upsertBaseline(baseline("x-legacy-c")) - - val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - assertEquals(0, result.deletedEvents) - val op = result.cleanupOperation - assertNotNull("legacy members must produce a cleanup operation", op) - assertEquals(TokenStatCleanupOperationEntity.SCOPE_DISPLAY_GROUP, op!!.scope) - assertEquals("group-x", op.targetRef) - assertEquals(TokenStatCleanupOperationEntity.STATUS_PENDING, op.status) - val items = dao.getCleanupItems(op.operationId) - assertEquals( - "only legacy members are registered as immutable provider:model snapshots", - listOf("OPENAI:gpt-4o", "ANTHROPIC:claude-3-5-sonnet"), - items.map { "${it.provider}:${it.model}" }, - ) - assertEquals(listOf("x-legacy-a", "x-legacy-c"), items.map { it.identityId }) - // baseline 全删(含配置身份),身份/分组保留 - assertNull(dao.getBaseline("x-legacy-a")) - assertNull(dao.getBaseline("x-cfg-b")) - assertNull(dao.getBaseline("x-legacy-c")) - assertEquals(3, dao.getAllIdentities().size) - } - - @Test - fun `display group deletion with baseline=no never creates cleanup operation`() = runBlocking { - seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - dao.upsertBaseline(baseline("x-legacy-a")) - - // baseline=no:即使组内有 legacy 成员也不建 operation、不清共享键 - val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = false) - assertEquals(0, result.deletedEvents) - assertNull(result.cleanupOperation) - assertEquals(0, dao.countPendingCleanupOperations()) - assertNotNull("baseline must survive when not confirmed", dao.getBaseline("x-legacy-a")) - } - - @Test - fun `member moves linearize with the deletion transaction snapshot`() = runBlocking { - seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("f-legacy", configId = "", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") - seedIdentity("e-legacy", configId = "", provider = "ANTHROPIC", model = "claude-3-5-sonnet", displayModelId = "group-other") - - // 已提交的移入/移出(各自独立事务)——删除事务必须看到提交后的成员归属 - dao.updateIdentityDisplayModel("f-legacy", "group-other") - dao.updateIdentityDisplayModel("e-legacy", "group-x") - - val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - assertEquals(0, result.deletedEvents) - val items = dao.getCleanupItems(result.cleanupOperation!!.operationId) - assertEquals( - "deletion must use the membership snapshot at transaction time", - listOf("e-legacy", "x-legacy-a"), - items.map { it.identityId }.sorted(), - ) - // 删除事务提交后再移入的成员绝不被该 operation 覆盖(快照不可变) - dao.updateIdentityDisplayModel("f-legacy", "group-x") - assertEquals( - listOf("e-legacy", "x-legacy-a"), - dao.getCleanupItems(result.cleanupOperation!!.operationId).map { it.identityId }.sorted(), - ) - } - - @Test - fun `delete all with baseline creates ALL kind operation and without baseline creates none`() = - runBlocking { - seedIdentity("id-a", configId = "cfg-a") - dao.insertEvent(event("e-a", "id-a", startedAtMs = 1_000L)) - dao.upsertBaseline(baseline("id-a")) - - val noBaseline = dao.deleteAllStatisticsTx(deleteBaselines = false) - assertEquals(1, noBaseline.deletedEvents) - assertNull("baseline=no must not create ALL cleanup", noBaseline.cleanupOperation) - assertEquals(0, dao.countPendingCleanupOperations()) - assertNotNull(dao.getBaseline("id-a")) - - val withBaseline = dao.deleteAllStatisticsTx(deleteBaselines = true) - assertEquals(0, withBaseline.deletedEvents) - val op = withBaseline.cleanupOperation - assertNotNull(op) - assertEquals(TokenStatCleanupOperationEntity.SCOPE_ALL, op!!.scope) - assertEquals(TokenStatCleanupOperationEntity.STATUS_PENDING, op.status) - assertTrue("ALL kind carries no items", dao.getCleanupItems(op.operationId).isEmpty()) - } - - @Test - fun `model reset creates cleanup operation only for legacy members`() = runBlocking { - seedIdentity("m-legacy", configId = "", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "deepseek-chat") - seedIdentity("m-cfg", configId = "cfg-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "deepseek-chat") - seedIdentity("other", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "gpt-4o") - dao.upsertBaseline(baseline("m-legacy")) - dao.upsertBaseline(baseline("m-cfg")) - dao.upsertBaseline(baseline("other")) - - val op = dao.resetModelTx("DEEPSEEK", "deepseek-chat") - assertNotNull(op) - assertEquals(TokenStatCleanupOperationEntity.SCOPE_MODEL, op!!.scope) - assertEquals("DEEPSEEK:deepseek-chat", op.targetRef) - val items = dao.getCleanupItems(op.operationId) - assertEquals("only legacy members are registered", listOf("m-legacy"), items.map { it.identityId }) - assertNull(dao.getBaseline("m-legacy")) - assertNull(dao.getBaseline("m-cfg")) - assertNotNull("other model must survive", dao.getBaseline("other")) - - // 另一模型的 legacy 成员同样被登记(精确到自身 provider:model) - val opOther = dao.resetModelTx("OPENAI", "gpt-4o") - assertNotNull(opOther) - assertEquals( - listOf("other"), - dao.getCleanupItems(opOther!!.operationId).map { it.identityId }, - ) - assertNull(dao.getBaseline("other")) - - // 完全无身份的模型:不建 operation - val opEmpty = dao.resetModelTx("ANTHROPIC", "claude-3-5-sonnet") - assertNull(opEmpty) - } - - @Test - fun `cleanup fence rejects pending or unmarked operations`() = runBlocking { - // 空 outbox:任意 marker 集合都通过 - assertTrue(dao.cleanupFenceSatisfied(emptySet())) - assertTrue(dao.cleanupFenceSatisfied(setOf("any-marker"))) - - // PENDING 存在 → 拒绝(即使 marker 齐全) - seedIdentity("x-legacy-a", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - val result = dao.deleteDisplayModelEventsTx("group-x", deleteBaselines = true) - val opId = result.cleanupOperation!!.operationId - assertFalse("PENDING must block the import fence", dao.cleanupFenceSatisfied(setOf(opId))) - - // APPLIED 且 marker 在 → 通过 - assertEquals(1, dao.ackCleanupOperation(opId)) - assertTrue(dao.cleanupFenceSatisfied(setOf(opId))) - // APPLIED 但 marker 缺失(旧快照)→ 拒绝 - assertFalse("missing marker must block the import fence", dao.cleanupFenceSatisfied(emptySet())) - // ACK 幂等:重复 ACK 返回 0 - assertEquals(0, dao.ackCleanupOperation(opId)) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt deleted file mode 100644 index 23f865980..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsLedgerTest.kt +++ /dev/null @@ -1,849 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import java.io.File -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import org.json.JSONObject -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * 统一 usage 记录器测试(真实 Room 数据库,JVM 驱动): - * 身份/展示分组自动创建、价格层级与成本、幂等防重、未知 vs 0、 - * 数据库写入失败不影响业务、取消传播、诊断字段脱敏语义。 - */ -class TokenStatsLedgerTest { - - private lateinit var tempDir: File - private lateinit var database: AppDatabase - private lateinit var context: Context - - @Before - fun setUp() { - tempDir = kotlin.io.path.createTempDirectory("ledger-test").toFile() - context = mockContext(tempDir) - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - TokenStatsLedger.databaseProvider = { database } - TokenStatsLedger.legacyPriceProvider = { _, _ -> null } - } - - @After - fun tearDown() { - TokenStatsLedger.databaseProvider = null - TokenStatsLedger.legacyPriceProvider = null - database.close() - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - private fun request( - eventId: String = "evt-1", - category: TokenStatCategory = TokenStatCategory.CHAT, - status: TokenStatStatus? = TokenStatStatus.COMPLETED, - usage: ProviderUsageSnapshot? = - ProviderUsageSnapshot( -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -cacheWriteTokens = 0L, -outputTokens = 500L, -reasoningTokens = 50L, - reasoningIncludedInOutput = true, - source = "test", - ), - firstTokenAtMs: Long? = 1200, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o-2024-11-20", - ): TokenStatRequestContext { - val ctx = - TokenStatRequestContext( - eventId = eventId, - category = category, - configId = configId, - provider = provider, - model = model, - startedAtMs = 1000, - ) - usage?.let { ctx.onUsage(it) } - firstTokenAtMs?.let { ctx.onFirstToken(it) } - status?.let { ctx.finish(it, 2000) } - return ctx - } - - @Test - fun `records event with identity display group pricing snapshot and cost`() = runBlocking { - TokenStatsLedger.record(context, request()) - - val event = database.tokenStatsDao().getEvent("evt-1")!! - assertEquals(TokenStatCategory.CHAT.name, event.category) - assertEquals(TokenStatStatus.COMPLETED.name, event.status) - assertEquals(1000L, event.startedAtMs) - assertEquals(2000L, event.endedAtMs) - assertEquals(1200L, event.firstTokenAtMs) - assertEquals(800L, event.uncachedInputTokens) - assertEquals(200L, event.cachedInputTokens) - assertEquals(0L, event.cacheWriteTokens) - assertEquals(500L, event.outputTokens) - assertEquals(50L, event.reasoningTokens) - assertEquals(true, event.reasoningIncludedInOutput) - // gpt-4o-2024-11-20 内置默认价(USD 计价):1.5/1.5/6 每百万 - assertEquals("USD", event.pricingCurrency) - assertEquals(PricingSource.DEFAULT.name, event.pricingSource) - assertEquals("TOKEN", event.billingMode) - assertEquals( - 4500.0 / 1_000_000.0, - event.costInPricingCurrency!!, - 1e-12, - ) - - // 身份与展示分组自动创建 - val identity = - database.tokenStatsDao().getIdentityByTriple("cfg-1", "OPENAI", "gpt-4o-2024-11-20")!! - assertEquals(event.statIdentityId, identity.identityId) - val display = database.tokenStatsDao().getDisplayModel(identity.displayModelId) - assertNotNull(display) - - // 诊断字段只含脱敏元数据 - val diagnostics = event.diagnosticsJson!! - assertTrue(diagnostics.contains("\"source\":\"test\"")) - assertTrue(diagnostics.contains("\"usageObserved\":true")) - assertTrue(diagnostics.contains("\"usageReportCount\":1")) - assertTrue("diagnostics must not contain content or credentials", !diagnostics.contains("apiKey")) - } - - @Test - fun `same eventId is idempotent and usage report count is deduplicated`() = runBlocking { - TokenStatsLedger.record(context, request(eventId = "evt-dup")) - TokenStatsLedger.record(context, request(eventId = "evt-dup")) - - assertEquals(1, database.tokenStatsDao().countEvents()) - } - - @Test - fun `accepting and recording requests preserve renamed default display group`() = runBlocking { - val dao = database.tokenStatsDao() - val model = "gpt-4o-2024-11-20" - val displayModelId = TokenStatIdentityResolver.displayModelIdFor(model) - TokenStatsLedger.record(context, request(eventId = "evt-alias-1", model = model)) - dao.updateDisplayModelName(displayModelId, "Primary GPT") - - TokenStatsLedger.ensureIdentityAndCaptureGeneration( - context = context, - configId = "cfg-2", - provider = "OPENAI", - model = model, - ) - assertEquals("Primary GPT", dao.getDisplayModel(displayModelId)!!.displayName) - - TokenStatsLedger.record( - context, - request(eventId = "evt-alias-2", configId = "cfg-2", model = model), - ) - assertEquals("Primary GPT", dao.getDisplayModel(displayModelId)!!.displayName) - } - - @Test - fun `duplicate usage callbacks keep last snapshot only`() = runBlocking { - val ctx = request(eventId = "evt-last") - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 1L, -outputTokens = 2L, - source = "first", - ) - ) - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 10L, -outputTokens = 20L, - source = "last", - ) - ) - // request() 默认已上报一次 + 本次两次 - assertEquals(3, ctx.usageReportCount) - assertEquals("last", ctx.lastUsage!!.source) - } - - @Test - fun `no usage keeps event with null fields and null cost`() = runBlocking { - TokenStatsLedger.record(context, request(usage = null, firstTokenAtMs = null)) - - val event = database.tokenStatsDao().getEvent("evt-1")!! - assertNull(event.uncachedInputTokens) - assertNull(event.cachedInputTokens) - assertNull(event.cacheWriteTokens) - assertNull(event.outputTokens) - assertNull(event.reasoningTokens) - assertNull(event.costInPricingCurrency) - assertNull("no first token -> null", event.firstTokenAtMs) - assertNotNull("event still recorded without usage", event.eventId) - assertTrue(event.diagnosticsJson!!.contains("\"usageObserved\":false")) - } - - @Test - fun `failed and cancelled statuses are recorded with their usage`() = runBlocking { - val failed = - request(eventId = "evt-fail", status = TokenStatStatus.FAILED, firstTokenAtMs = null) - TokenStatsLedger.record(context, failed) - val failedEvent = database.tokenStatsDao().getEvent("evt-fail")!! - assertEquals(TokenStatStatus.FAILED.name, failedEvent.status) - assertEquals(800L, failedEvent.uncachedInputTokens) - assertNull(failedEvent.firstTokenAtMs) - - val cancelled = request(eventId = "evt-cancel", status = TokenStatStatus.CANCELLED) - TokenStatsLedger.record(context, cancelled) - val cancelledEvent = database.tokenStatsDao().getEvent("evt-cancel")!! - assertEquals(TokenStatStatus.CANCELLED.name, cancelledEvent.status) - assertEquals(500L, cancelledEvent.outputTokens) - } - - @Test - fun `category is fixed business classification`() = runBlocking { - val cases = - listOf( - TokenStatCategory.SUBAGENT, - TokenStatCategory.SUMMARY, - TokenStatCategory.TITLE, - TokenStatCategory.MEMORY, - TokenStatCategory.CHARACTER_GENERATION, - TokenStatCategory.CONNECTION_TEST, - TokenStatCategory.OTHER, - ) - cases.forEachIndexed { index, category -> - TokenStatsLedger.record( - context, - request(eventId = "evt-cat-$index", category = category), - ) - assertEquals( - category.name, - database.tokenStatsDao().getEvent("evt-cat-$index")!!.category, - ) - } - } - - @Test - fun `config override and legacy override drive pricing source and cost`() = runBlocking { - val dao = database.tokenStatsDao() - - // CONFIG 覆盖 - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 3.0, - outputPricePerMillion = 5.0, - ) - TokenStatsLedger.record( - context, - request( - eventId = "evt-cfg", - provider = "DEEPSEEK", - model = "deepseek-chat", - ), - ) - val cfgEvent = database.tokenStatsDao().getEvent("evt-cfg")!! - assertEquals(PricingSource.CONFIG_OVERRIDE.name, cfgEvent.pricingSource) - assertEquals("USD", cfgEvent.pricingCurrency) - assertEquals(2.0, cfgEvent.inputPricePerMillion!!, 1e-12) - // 默认 usage:cacheWrite=0(确认无缓存写入),800*2 + 200*0.5 + 500*5 = 4200(每百万) - assertEquals(4200.0 / 1_000_000.0, cfgEvent.costInPricingCurrency!!, 1e-12) - - // LEGACY 覆盖(旧 DataStore 价格) - TokenStatsLedger.legacyPriceProvider = { _, _ -> - LegacyPriceSettings( - billingMode = BillingMode.TOKEN, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 2.0, - ) - } - TokenStatsLedger.record( - context, - request( - eventId = "evt-legacy", - configId = "cfg-2", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = - ProviderUsageSnapshot( -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -cacheWriteTokens = 100L, -outputTokens = 500L, - source = "t", - ), - ), - ) - val legacyEvent = database.tokenStatsDao().getEvent("evt-legacy")!! - assertEquals(PricingSource.LEGACY_OVERRIDE.name, legacyEvent.pricingSource) - // cacheWriteTokens=100 且 cacheWritePricePerMillion=null(旧系统无缓存写入价)→ - // 成本必须为 null(未知),不得静默按 0 计费 - assertNull("cache write without price keeps cost unknown", legacyEvent.costInPricingCurrency) - } - - @Test - fun `separate reasoning billed additionally only when not included in output`() = runBlocking { - val dao = database.tokenStatsDao() - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ) - - // 推理已包含在 output:只按 output 计费 - val included = - request( - eventId = "evt-included", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = - ProviderUsageSnapshot( -uncachedInputTokens = 100L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 200L, -reasoningTokens = 50L, - reasoningIncludedInOutput = true, - source = "t", - ), - ) - TokenStatsLedger.record(context, included) - val includedCost = database.tokenStatsDao().getEvent("evt-included")!!.costInPricingCurrency!! - - // 推理独立计数:output + reasoning 一起按输出单价 - val separate = - request( - eventId = "evt-separate", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = - ProviderUsageSnapshot( -uncachedInputTokens = 100L, -cachedInputTokens = 0L, -cacheWriteTokens = 0L, -outputTokens = 200L, -reasoningTokens = 50L, - reasoningIncludedInOutput = false, - source = "t", - ), - ) - TokenStatsLedger.record(context, separate) - val separateCost = database.tokenStatsDao().getEvent("evt-separate")!!.costInPricingCurrency!! - - assertEquals((100.0 / 1_000_000.0) + (200.0 / 1_000_000.0 * 2.0), includedCost, 1e-12) - assertEquals( - (100.0 / 1_000_000.0) + ((200.0 + 50.0) / 1_000_000.0 * 2.0), - separateCost, - 1e-12, - ) - assertTrue("separate reasoning must cost more", separateCost > includedCost) - } - - @Test - fun `database write failure is logged and never breaks the caller`() = runBlocking { - org.mockito.Mockito.mockStatic(com.ai.assistance.operit.util.AppLogger::class.java).use { - TokenStatsLedger.databaseProvider = { - throw RuntimeException("db unavailable") - } - try { - TokenStatsLedger.record(context, request()) - // 失败被吞掉(已记录日志),不向上抛出 - } catch (e: Exception) { - fail("DB write failure must not propagate: ${e.message}") - } - } - } - - @Test - fun `cancellation propagates and is not swallowed as write failure`() = runBlocking { - TokenStatsLedger.databaseProvider = { - throw CancellationException("test cancellation") - } - - try { - TokenStatsLedger.record(context, request()) - fail("CancellationException must propagate") - } catch (e: CancellationException) { - assertEquals("test cancellation", e.message) - } - } - - @Test - fun `first token is only set once`() { - val ctx = request(firstTokenAtMs = null) - ctx.onFirstToken(1100) - ctx.onFirstToken(9999) - assertEquals(1100L, ctx.firstTokenAtMs) - } - - @Test - fun `finish is only applied once`() { - val ctx = request() - ctx.finish(TokenStatStatus.COMPLETED, 2000) - ctx.finish(TokenStatStatus.FAILED, 9999) - assertEquals(TokenStatStatus.COMPLETED, ctx.status) - assertEquals(2000, ctx.endedAtMs) - } - - // ==== P1-3:费用 fixture(典型 provider 快照) ==== - private suspend fun configPricing(dao: com.ai.assistance.operit.data.dao.TokenStatsDao) { - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 3.0, - outputPricePerMillion = 5.0, - ) - } - - @Test - fun `typical openai chat completion without cache write fields still computes cost`() = - runBlocking { - configPricing(database.tokenStatsDao()) - // OpenAI 常规响应没有 cache_creation:cacheWrite=null 且无独立计费概念, - // 不得因缺该字段令全成本未知 - val snapshot = - ProviderUsageNormalizer.openAiChatCompletions( - JSONObject( - """ - { - "prompt_tokens": 1000, - "completion_tokens": 500, - "prompt_tokens_details": {"cached_tokens": 200} - } - """.trimIndent() - ) - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-openai-typical", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = snapshot, - ), - ) - val event = database.tokenStatsDao().getEvent("evt-openai-typical")!! - assertEquals(800L, event.uncachedInputTokens) - assertEquals(200L, event.cachedInputTokens) - assertNull(event.cacheWriteTokens) - // 800*2 + 200*0.5 + 500*5 = 4200(每百万) - assertEquals(4200.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) - } - - @Test - fun `typical openai responses and gemini fixtures compute cost without cache write`() = - runBlocking { - configPricing(database.tokenStatsDao()) - val openai = - ProviderUsageNormalizer.openAiResponses( - JSONObject( - """ - { - "input_tokens": 1000, - "output_tokens": 500, - "input_tokens_details": {"cached_tokens": 200} - } - """.trimIndent() - ) - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-openai-responses", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = openai, - ), - ) - val responsesEvent = database.tokenStatsDao().getEvent("evt-openai-responses")!! - assertEquals(4200.0 / 1_000_000.0, responsesEvent.costInPricingCurrency!!, 1e-12) - - val gemini = - ProviderUsageNormalizer.gemini( - JSONObject( - """ - { - "promptTokenCount": 1000, - "cachedContentTokenCount": 300, - "candidatesTokenCount": 400 - } - """.trimIndent() - ) - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-gemini", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = gemini, - ), - ) - val geminiEvent = database.tokenStatsDao().getEvent("evt-gemini")!! - // 700*2 + 300*0.5 + 400*5 = 3550(每百万) - assertEquals(3550.0 / 1_000_000.0, geminiEvent.costInPricingCurrency!!, 1e-12) - } - - @Test - fun `anthropic typical fixture bills cache write separately`() = runBlocking { - configPricing(database.tokenStatsDao()) - val anthropic = - ProviderUsageNormalizer.anthropic( - JSONObject( - """ - { - "input_tokens": 500, - "cache_read_input_tokens": 200, - "cache_creation_input_tokens": 100, - "output_tokens": 300 - } - """.trimIndent() - ) - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-anthropic", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = anthropic, - ), - ) - val event = database.tokenStatsDao().getEvent("evt-anthropic")!! - assertEquals(100L, event.cacheWriteTokens) - // 500*2 + 200*0.5 + 100*3 + 300*5 = 2900(每百万) - assertEquals(2900.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) - } - - @Test - fun `anthropic absent cache write keeps cost unknown while openai absent cached split stays null`() = - runBlocking { - configPricing(database.tokenStatsDao()) - // Anthropic 缓存创建独立计费:字段缺失即分量未知 → 成本未知 - val anthropic = - ProviderUsageNormalizer.anthropic( - JSONObject("""{"input_tokens": 500, "output_tokens": 300}""") - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-anthropic-unknown", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = anthropic, - ), - ) - val anthropicEvent = database.tokenStatsDao().getEvent("evt-anthropic-unknown")!! - assertNull("独立计费分量未知 → 成本必须未知", anthropicEvent.costInPricingCurrency) - - // OpenAI 缺 cached details:输入拆分未知 → 不把总输入确定为 uncached,成本未知 - val openai = - ProviderUsageNormalizer.openAiChatCompletions( - JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-openai-unknown-split", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = openai, - ), - ) - val openaiEvent = database.tokenStatsDao().getEvent("evt-openai-unknown-split")!! - assertNull("cached 拆分未知 → uncached 必须未知", openaiEvent.uncachedInputTokens) - assertNull(openaiEvent.cachedInputTokens) - assertNull("输入拆分未知 → 成本未知", openaiEvent.costInPricingCurrency) - } - - // ==== P1-4:attempt 聚合 ==== - - @Test - fun `usage across attempts aggregates without double counting same attempt`() = runBlocking { - configPricing(database.tokenStatsDao()) - val ctx = - TokenStatRequestContext( - eventId = "evt-attempts", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = 1000, - ) - // attempt 1 上报(流式多 chunk 重复上报) - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 310L, -cachedInputTokens = 100L, -outputTokens = 120L, - cacheWriteSeparateBilling = false, - source = "test", - ), - attempt = 1, - ) - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 310L, -cachedInputTokens = 100L, -outputTokens = 120L, - cacheWriteSeparateBilling = false, - source = "test", - ), - attempt = 1, - ) - // attempt 2 成功上报 - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 500L, -cachedInputTokens = 200L, -outputTokens = 400L, - cacheWriteSeparateBilling = false, - source = "test", - ), - attempt = 2, - ) - ctx.finish(TokenStatStatus.COMPLETED, 2000) - TokenStatsLedger.record(context, ctx) - - val event = database.tokenStatsDao().getEvent("evt-attempts")!! - // 同 attempt 取最后一次(310)+ attempt2(500)= 810;输出 120 + 400 = 520 - assertEquals(810L, event.uncachedInputTokens) - assertEquals(300L, event.cachedInputTokens) - assertEquals(520L, event.outputTokens) - // 费用按聚合用量计算:810*2 + 300*0.5 + 520*5 = 4370(每百万) - assertEquals(4370.0 / 1_000_000.0, event.costInPricingCurrency!!, 1e-12) - assertTrue(event.diagnosticsJson!!.contains("\"usageReportCount\":3")) - assertTrue(event.diagnosticsJson!!.contains("\"attemptCount\":2")) - } - - @Test - fun `aggregated usage keeps component unknown when any attempt leaves it unknown`() { - val ctx = - TokenStatRequestContext( - eventId = "evt-partial", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "DEEPSEEK", - model = "deepseek-chat", - startedAtMs = 1000, - ) - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 100L, -outputTokens = 10L, - source = "test", - ), - attempt = 1, - ) - ctx.onUsage( - ProviderUsageSnapshot( -uncachedInputTokens = 200L, - outputTokens = null, - source = "test", - ), - attempt = 2, - ) - val aggregated = ctx.aggregatedUsage()!! - assertEquals(300L, aggregated.uncachedInputTokens) - assertNull("任一 attempt 未知则分量保持未知", aggregated.outputTokens) - } - - // ==== P2-1:总输入(totalInputTokens)与单价相同时的费用覆盖 ==== - - private suspend fun equalPricePricing(dao: com.ai.assistance.operit.data.dao.TokenStatsDao) { - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_CONFIG, - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = "cfg-1", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 1.0, - cacheWritePricePerMillion = 3.0, - outputPricePerMillion = 5.0, - ) - } - - @Test - fun `typical compat endpoint without cached details computes cost when prices equal`() = - runBlocking { - equalPricePricing(database.tokenStatsDao()) - // OpenAI 兼容端点常规响应缺 prompt_tokens_details:拆分未知, - // 但 provider 明确上报总输入;输入与缓存输入单价相同 → 可按总输入计费 - val openai = - ProviderUsageNormalizer.openAiChatCompletions( - JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") - )!! - assertEquals(1000L, openai.totalInputTokens) - assertNull(openai.uncachedInputTokens) - assertNull(openai.cachedInputTokens) - TokenStatsLedger.record( - context, - request( - eventId = "evt-openai-total", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = openai, - ), - ) - val openaiEvent = database.tokenStatsDao().getEvent("evt-openai-total")!! - // 1000*1 + 500*5 = 3500(每百万) - assertEquals(3500.0 / 1_000_000.0, openaiEvent.costInPricingCurrency!!, 1e-12) - - // Gemini 缺 cachedContentTokenCount:同样按总输入计费 - val gemini = - ProviderUsageNormalizer.gemini( - JSONObject("""{"promptTokenCount": 600, "candidatesTokenCount": 200}""") - )!! - assertEquals(600L, gemini.totalInputTokens) - assertNull(gemini.uncachedInputTokens) - TokenStatsLedger.record( - context, - request( - eventId = "evt-gemini-total", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = gemini, - ), - ) - val geminiEvent = database.tokenStatsDao().getEvent("evt-gemini-total")!! - // 600*1 + 200*5 = 1600(每百万) - assertEquals(1600.0 / 1_000_000.0, geminiEvent.costInPricingCurrency!!, 1e-12) - } - - @Test - fun `total input keeps cost unknown when input and cached prices differ`() = runBlocking { - configPricing(database.tokenStatsDao()) // input 2.0 vs cached 0.5:单价不同 - val openai = - ProviderUsageNormalizer.openAiChatCompletions( - JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") - )!! - assertEquals(1000L, openai.totalInputTokens) - TokenStatsLedger.record( - context, - request( - eventId = "evt-openai-diff-price", - provider = "DEEPSEEK", - model = "deepseek-chat", - usage = openai, - ), - ) - val event = database.tokenStatsDao().getEvent("evt-openai-diff-price")!! - // 单价不同且拆分未知:不得把总输入伪装成 uncached,成本保持未知 - assertNull(event.uncachedInputTokens) - assertNull(event.costInPricingCurrency) - } - - // ==== P2-2:结构化列(v30)持久化 ==== - - @Test - fun `structured billing columns are persisted for direct revaluation`() = runBlocking { - val openai = - ProviderUsageNormalizer.openAiChatCompletions( - JSONObject("""{"prompt_tokens": 1000, "completion_tokens": 500}""") - )!! - TokenStatsLedger.record( - context, - request( - eventId = "evt-structured", - usage = openai, - ), - ) - val event = database.tokenStatsDao().getEvent("evt-structured")!! - assertEquals(1000L, event.totalInputTokens) - assertEquals(false, event.cacheWriteSeparateBilling) - // 无 usage 的事件保持 null(未知),与 0 可区分 - TokenStatsLedger.record( - context, - request(eventId = "evt-structured-null", usage = null), - ) - val nullEvent = database.tokenStatsDao().getEvent("evt-structured-null")!! - assertNull(nullEvent.totalInputTokens) - assertNull(nullEvent.cacheWriteSeparateBilling) - } - - // ==== P1-1:同 attempt 增量快照按最新非空字段合并 ==== - - @Test - fun `same attempt incremental snapshots merge latest non-null fields without summing`() { - val ctx = - TokenStatRequestContext( - eventId = "evt-claude-stream", - category = TokenStatCategory.CHAT, - configId = "cfg-1", - provider = "ANTHROPIC", - model = "claude-sonnet", - startedAtMs = 1000, - ) - // 真实 message_start 形态:完整 input/cache/cacheWrite,output 为占位 0 - val messageStart = - ProviderUsageNormalizer.anthropic( - JSONObject( - """ - {"input_tokens": 100, "cache_read_input_tokens": 50, - "cache_creation_input_tokens": 10, "output_tokens": 0} - """.trimIndent() - ) - )!! - // 真实 message_delta 形态:只有累计 output - val messageDelta = - ProviderUsageNormalizer.anthropic(JSONObject("""{"output_tokens": 300}"""))!! - ctx.onUsage(messageStart, attempt = 1) - ctx.onUsage(messageDelta, attempt = 1) - - val aggregated = ctx.aggregatedUsage()!! - assertEquals(100L, aggregated.uncachedInputTokens) - assertEquals(50L, aggregated.cachedInputTokens) - assertEquals(10L, aggregated.cacheWriteTokens) - assertEquals(300L, aggregated.outputTokens) - // 输出是累计值:只取最新,不能 start(0) + delta(300) 相加 - assertEquals(300L, aggregated.outputTokens) - assertEquals(160L, aggregated.totalInputTokens) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt deleted file mode 100644 index 753e57bf2..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsQueryServiceRoomTest.kt +++ /dev/null @@ -1,958 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import java.io.File -import java.time.ZoneId -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers.anyBoolean -import org.mockito.ArgumentMatchers.anyInt -import org.mockito.ArgumentMatchers.anyLong -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verifyBlocking -import org.mockito.kotlin.wheneverBlocking - -/** - * 统计查询服务集成测试(真实 Room + JVM SQLite 驱动,阶段 3): - * 同事务只读快照(P1-2,含并发提交一致性)、固定查询次数(防 N+1,SQL 记录驱动)、 - * 生命周期分页增量聚合(P2-1,>10k 不整表实体化)、展示模型筛选语义与 IN 分块 - * (P2-2,null=全部/空=无事件/1000+ 模型分块)、IO 线程与 startedAtMs 索引 - * (P2-3)、半开边界、初始回退、重估端到端、baseline 不进范围、Context 生产入口。 - */ -class TokenStatsQueryServiceRoomTest { - - private lateinit var tempDir: File - private lateinit var recordingDriver: RecordingSQLiteDriver - private lateinit var database: AppDatabase - private lateinit var context: Context - - private val shanghai = ZoneId.of("Asia/Shanghai") - private val nowMs = localMs("2026-08-07T15:00:00", shanghai) - - private fun localMs(dateTime: String, zone: ZoneId): Long = - java.time.LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() - - @Before - fun setUp() { - TokenStatSpool.clearPendingStateForTest() - tempDir = kotlin.io.path.createTempDirectory("query-service-test").toFile() - context = mockContext(tempDir) - recordingDriver = RecordingSQLiteDriver() - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(recordingDriver) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - } - - @After - fun tearDown() { - TokenStatsQueryService.databaseProvider = null - TokenStatsQueryService.legacyPricesProvider = null - TokenStatsQueryService.queryDispatcher = Dispatchers.IO - TokenStatsQueryService.lifetimeEventPageSize = 1_000 - TokenStatsQueryService.activityEventPageSize = 1_000 - TokenStatSpool.clearPendingStateForTest() - database.close() - } - - private fun mockContext(filesDir: File): Context { - val context = mock() - org.mockito.kotlin.whenever(context.applicationContext).thenReturn(context) - org.mockito.kotlin.whenever(context.packageName).thenReturn("com.ai.assistance.operit") - org.mockito.kotlin.whenever(context.filesDir).thenReturn(filesDir) - org.mockito.kotlin.whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - // ==== 种子数据 ==== - - private suspend fun seedIdentity( - dao: TokenStatsDao, - identityId: String, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o-2024-11-20", - displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), - ) { - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), - displayName = model, - ) - ) - } - - private fun event( - id: String, - identityId: String, - startedAtMs: Long, - cost: Double? = null, - status: String = TokenStatStatus.COMPLETED.name, - category: String = TokenStatCategory.CHAT.name, - uncached: Long? = 100L, - cached: Long? = 0L, - output: Long? = 50L, - ): TokenStatEventEntity = - TokenStatEventEntity( - eventId = id, - statIdentityId = identityId, - category = category, - status = status, - acceptedGeneration = 0L, - startedAtMs = startedAtMs, - endedAtMs = startedAtMs + 1_000L, - firstTokenAtMs = startedAtMs + 200L, - uncachedInputTokens = uncached, - cachedInputTokens = cached, - cacheWriteTokens = 0L, - totalInputTokens = null, - outputTokens = output, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.5, - cachedInputPricePerMillion = 1.5, - cacheWritePricePerMillion = null, - outputPricePerMillion = 6.0, - pricePerRequest = null, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = cost, - diagnosticsJson = null, - ) - - private fun identityEntity( - identityId: String, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o-2024-11-20", - ): TokenStatIdentityEntity = - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = TokenStatIdentityResolver.displayModelIdFor(model), - ) - - // ==== 集成:范围读取 ==== - - @Test - fun `range data aggregates events from one range read with half-open boundary`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - val start = localMs("2026-08-07T10:00:00", shanghai) - val end = localMs("2026-08-07T15:00:00", shanghai) - dao.insertEvents( - listOf( - event("e1", "id-1", start, cost = 1.0), - event("e2", "id-1", start + 3_600_000L, cost = 2.0), - event("e3", "id-1", end - 1L, cost = 3.0), - // 恰好等于 endMs:半开区间 [start, end),不属于范围 - event("e4", "id-1", end, cost = 4.0), - ) - ) - val data = - TokenStatsQueryService.rangeData( - dao, TokenStatsTimeRanges.customRange(start, end), - TokenStatsQueryParams(), shanghai, - ) - assertEquals(3L, data.eventCount) - assertEquals(3L, data.summary.requests) - assertEquals(42.0, data.summary.cost.knownAmount, 1e-9) // (1+2+3)*7 - // 桶合计 == 范围总计;桶数 = 5 小时 / 10 分钟 - assertEquals(30, data.buckets.size) - assertEquals( - data.summary.requests, - data.buckets.sumOf { it.totals.requests }, - ) - assertEquals( - data.summary.cost.knownAmount, - data.buckets.sumOf { it.totals.cost.knownAmount }, - 1e-9, - ) - // 明细与总计一致 - assertEquals(3L, data.displayModels.single().totals.requests) - assertEquals(3L, data.categories.single { it.category == TokenStatCategory.CHAT }.totals.requests) - assertEquals(3L, data.statuses.single().totals.requests) - // e1 在 10:00:00 整点,属于第 0 个桶 - assertEquals(1L, data.buckets[0].totals.requests) - } - - @Test - fun `lifetime overview includes events and baseline`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) - dao.upsertBaseline( - TokenStatBaselineEntity( - identityId = "id-1", - inputTokens = 100L, - cachedInputTokens = 10L, - outputTokens = 50L, - requestCount = 5L, - pricingCurrency = PricingCurrency.USD.name, - costInPricingCurrency = 2.0, - isEstimated = true, - fingerprint = "fp", - importedAtMs = 0L, - frozenBillingMode = BillingMode.TOKEN.name, - ) - ) - val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) - assertEquals(1L, overview.eventTotals.requests) - assertEquals(7.0, overview.eventTotals.cost.knownAmount, 1e-9) - assertEquals(5L, overview.baselineTotals.requests) - assertEquals(14.0, overview.baselineTotals.cost.knownAmount, 1e-9) - assertEquals(6L, overview.combinedRequests) - } - - @Test - fun `baseline never enters range data`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.upsertBaseline( - TokenStatBaselineEntity( - identityId = "id-1", - inputTokens = 100L, - cachedInputTokens = 10L, - outputTokens = 50L, - requestCount = 5L, - pricingCurrency = PricingCurrency.USD.name, - costInPricingCurrency = 2.0, - isEstimated = true, - fingerprint = "fp", - importedAtMs = 0L, - frozenBillingMode = BillingMode.TOKEN.name, - ) - ) - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, nowMs, shanghai) - val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) - assertEquals(0L, data.eventCount) - assertEquals(0L, data.summary.requests) - assertTrue(data.buckets.all { it.totals.requests == 0L }) - } - - // ==== 初始回退 ==== - - @Test - fun `initial preset falls back 5h to 12h to 24h to 7d to 30d`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - val hourMs = TokenStatsTimeRanges.HOUR_MS - - suspend fun presetFor(eventOffsetMs: Long): TokenStatsPreset { - dao.deleteAllEvents() - dao.insertEvents(listOf(event("e", "id-1", nowMs - eventOffsetMs, cost = 0.1))) - return TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs) - } - - assertEquals(TokenStatsPreset.LAST_5H, presetFor(2L * hourMs)) - assertEquals(TokenStatsPreset.LAST_12H, presetFor(10L * hourMs)) - assertEquals(TokenStatsPreset.LAST_24H, presetFor(20L * hourMs)) - // 5 天前:5h/12h/24h 都空,7d(含今天共 7 个自然日)有数据 - assertEquals(TokenStatsPreset.LAST_7D, presetFor(5L * 24L * hourMs)) - // 25 天前:只有 30d 范围有数据 - assertEquals(TokenStatsPreset.LAST_30D, presetFor(25L * 24L * hourMs)) - // 40 天前:任何预设都空 -> 回退默认 5h - assertEquals(TokenStatsPreset.LAST_5H, presetFor(40L * 24L * hourMs)) - - dao.deleteAllEvents() - assertEquals(TokenStatsPreset.LAST_5H, TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs)) - } - - // ==== 模型筛选 ==== - - @Test - fun `display model filter returns only selected model events`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1", model = "gpt-4o-2024-11-20", displayModelId = "gpt-4o-2024-11-20") - seedIdentity(dao, "id-2", configId = "cfg-2", model = "deepseek-chat", displayModelId = "deepseek-chat") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), - event("e2", "id-2", nowMs - 2 * 3_600_000L, cost = 2.0), - ) - ) - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - val data = - TokenStatsQueryService.rangeData( - dao, range, - TokenStatsQueryParams(displayModelIds = setOf("gpt-4o-2024-11-20")), - shanghai, - ) - assertEquals(1L, data.eventCount) - assertEquals(1L, data.summary.requests) - assertEquals(7.0, data.summary.cost.knownAmount, 1e-9) - assertEquals(1, data.displayModels.size) - } - - @Test - fun `empty display model filter returns no events while null returns all`() = runBlocking { - // P2-2 语义:displayModelIds = null → 全部模型;空集合 → 无事件(不是全部!) - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - - val none = - TokenStatsQueryService.rangeData( - dao, range, TokenStatsQueryParams(displayModelIds = emptySet()), shanghai, - ) - assertEquals(0L, none.eventCount) - assertEquals(0L, none.summary.requests) - assertTrue(none.displayModels.isEmpty()) - assertTrue(none.buckets.all { it.totals.requests == 0L }) - - val all = - TokenStatsQueryService.rangeData( - dao, range, TokenStatsQueryParams(displayModelIds = null), shanghai, - ) - assertEquals(1L, all.eventCount) - assertEquals(1L, all.summary.requests) - } - - // ==== 同事务快照与查询次数(SQL 记录驱动,防 N+1) ==== - - @Test - fun `range data does fixed snapshot reads without re-fetching tables`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) - recordingDriver.clear() - - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - val data = - TokenStatsQueryService.rangeData( - dao, range, TokenStatsQueryParams(), shanghai, legacyPrices = emptyMap(), - ) - assertEquals(1L, data.summary.requests) - - val statements = recordingDriver.executed.toList() - fun countWhere(predicate: (RecordedSql) -> Boolean): Int = statements.count(predicate) - // 历史口径:事件/identity/display model 各恰好一次;价格覆盖与 baseline 不读 - assertEquals( - 1, - countWhere { it.sql.contains("FROM token_stat_events") && it.sql.contains("WHERE startedAtMs") }, - ) - assertEquals( - "recorded: ${statements.joinToString("\n") { it.toString() }}", - 1, - countWhere { it.sql.contains("FROM token_stat_identities") }, - ) - assertEquals(1, countWhere { it.sql.contains("FROM token_stat_display_models") }) - assertEquals(0, countWhere { it.sql.contains("FROM token_stat_price_overrides") }) - assertEquals(0, countWhere { it.sql.contains("FROM token_stat_baselines") }) - // 绝不整表读取事件(getAllEvents) - assertEquals(0, countWhere { it.sql.trim() == "SELECT * FROM token_stat_events" }) - - // 重估口径:额外只读一次价格覆盖,其余不变 - TokenStatsQueryService.rangeData( - dao, range, TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), shanghai, - legacyPrices = emptyMap(), - ) - assertEquals( - 1, - recordingDriver.executed.count { it.sql.contains("FROM token_stat_price_overrides") }, - ) - } - - @Test - fun `display model filter uses a single IN join query not per model`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1", model = "gpt-4o-2024-11-20", displayModelId = "gpt-4o-2024-11-20") - seedIdentity(dao, "id-2", configId = "cfg-2", model = "deepseek-chat", displayModelId = "deepseek-chat") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), - event("e2", "id-2", nowMs - 2 * 3_600_000L, cost = 2.0), - ) - ) - recordingDriver.clear() - - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - val data = - TokenStatsQueryService.rangeData( - dao, range, - TokenStatsQueryParams(displayModelIds = setOf("gpt-4o-2024-11-20", "deepseek-chat")), - shanghai, - ) - assertEquals(2L, data.summary.requests) - val inQueries = recordingDriver.executed.filter { it.sql.contains("displayModelId IN") } - assertEquals(1, inQueries.size) - // 2 个范围参数 + 2 个 IN 参数 - assertEquals(4, inQueries.single().questionMarkCount) - } - - @Test - fun `display model filter over 900 models chunks IN queries in one snapshot`() = runBlocking { - // P2-2:SQLite 变量上限(默认 999)防炸;分块 ≤900 在同事务内合并 - val dao = database.tokenStatsDao() - val modelCount = 1_001 - val identities = - (0 until modelCount).map { index -> - TokenStatIdentityEntity( - identityId = "id-$index", - configId = "cfg", - provider = "PROVIDER", - model = "m$index", - displayModelId = "m$index", - ) - } - dao.insertIdentitiesIfAbsent(identities) - dao.upsertDisplayModels( - identities.map { identity -> - TokenStatDisplayModelEntity( - displayModelId = identity.displayModelId, - normalizedModel = identity.model, - displayName = identity.model, - ) - } - ) - dao.insertEvents( - identities.map { identity -> - event("e-${identity.identityId}", identity.identityId, nowMs - 3_600_000L, cost = 0.01) - } - ) - recordingDriver.clear() - - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - val data = - TokenStatsQueryService.rangeData( - dao, range, - TokenStatsQueryParams(displayModelIds = (0 until modelCount).map { "m$it" }.toSet()), - shanghai, - ) - assertEquals(modelCount.toLong(), data.eventCount) - assertEquals(modelCount.toLong(), data.summary.requests) - assertEquals(modelCount, data.displayModels.size) - - val inQueries = recordingDriver.executed.filter { it.sql.contains("displayModelId IN") } - assertEquals("IN 查询必须分块:900 + 101", 2, inQueries.size) - // 每块占位符 = 2 个范围参数 + IN 参数数,均不超过 SQLite 999 上限 - val chunkSizes = inQueries.map { it.questionMarkCount - 2 } - assertTrue("chunk sizes $chunkSizes must not exceed 900", chunkSizes.all { it <= 900 }) - assertEquals(modelCount, chunkSizes.sum()) - } - - // ==== 生命周期分页(P2-1:不整表实体化) ==== - - @Test - fun `lifetime overview pages events within page size and never materializes all`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - val count = 10_500 - dao.insertEvents( - (0 until count).map { index -> - event("bulk-$index", "id-1", nowMs - (index % 24) * 3_600_000L - index, cost = 0.1) - } - ) - recordingDriver.clear() - - val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) - assertEquals(count.toLong(), overview.eventTotals.requests) - assertEquals(count.toLong(), overview.eventTotals.cost.totalContributionCount) - assertTrue(overview.eventTotals.cost.isFullyKnown) - - val statements = recordingDriver.executed.toList() - // 绝不调用整表读取 getAllEvents - assertEquals( - 0, - statements.count { it.sql.trim() == "SELECT * FROM token_stat_events" }, - ) - val pageQueries = statements.filter { it.sql.contains("ORDER BY startedAtMs ASC") } - // 10500 / 1000 = 10 满页 + 1 部分页 - assertEquals(11, pageQueries.size) - // 每页 LIMIT 绑定 == 页大小,最大返回行数 ≤ 页大小 - assertTrue(pageQueries.all { it.binds[4] == "1000" }) - assertTrue(pageQueries.all { it.rows <= 1_000 }) - assertEquals(1_000, pageQueries.maxOf { it.rows }) - } - - @Test - fun `lifetime paging respects injected small page size with exact page bounds`() = runBlocking { - TokenStatsQueryService.lifetimeEventPageSize = 7 - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents( - (0 until 20).map { index -> - event("p$index", "id-1", nowMs - index * 1_000L, cost = 0.1) - } - ) - recordingDriver.clear() - - val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) - assertEquals(20L, overview.eventTotals.requests) - - val pageQueries = recordingDriver.executed.filter { it.sql.contains("ORDER BY startedAtMs ASC") } - // 7 + 7 + 6 - assertEquals(3, pageQueries.size) - assertEquals(listOf(7, 7, 6), pageQueries.map { it.rows }) - assertTrue(pageQueries.all { it.binds[4] == "7" }) - } - - @Test - fun `activity snapshot pages the ledger and incrementally preserves totals`() = runBlocking { - TokenStatsQueryService.activityEventPageSize = 7 - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents( - (0 until 20).map { index -> - event( - id = "activity-$index", - identityId = "id-1", - startedAtMs = localMs("2026-08-${(1 + index / 5).toString().padStart(2, '0')}T${(index % 5 + 8).toString().padStart(2, '0')}:00:00", shanghai), - ) - } - ) - recordingDriver.clear() - - val snapshot = TokenStatsQueryService.activitySnapshot(dao, shanghai) - - assertEquals(20L, snapshot.totalRequests) - assertEquals(4, snapshot.dayTotals.size) - assertEquals(3_000L, snapshot.dayTotals.values.sum()) - assertEquals(20L, snapshot.hourCounts.sum()) - val pageQueries = recordingDriver.executed.filter { - it.sql.contains("SELECT eventId, startedAtMs") && it.sql.contains("ORDER BY startedAtMs ASC") - } - assertEquals(listOf(7, 7, 6), pageQueries.map { it.rows }) - assertTrue(pageQueries.all { it.binds[4] == "7" }) - } - - // ==== 快照一致性(P1-2:并发提交完全前或完全后) ==== - - private fun assertInternallyConsistent(data: TokenStatsRangeData, expectedRequests: Long) { - assertEquals(expectedRequests, data.summary.requests) - assertEquals(data.summary.requests, data.buckets.sumOf { it.totals.requests }) - assertEquals(data.summary.requests, data.displayModels.sumOf { it.totals.requests }) - assertEquals( - data.summary.requests, - data.displayModels.sumOf { model -> model.identities.sumOf { it.totals.requests } }, - ) - assertEquals( - data.summary.cost.knownAmount, - data.buckets.sumOf { it.totals.cost.knownAmount }, - 1e-9, - ) - } - - @Test - fun `concurrent identity and event commit never yields partial snapshot`() = runBlocking { - // P1-2:查询服务的所有 Room 读取在**同一事务快照**内。写入协程在读者 - // 反复查询期间提交新 identity 与事件(两次独立提交,窗口内读者可能读到 - // “identity 已提交、事件未提交”的中间态)。旧实现多次独立读取可跨越该 - // 窗口,出现 summary 有事件但模型桶缺失;新实现任何时刻都只能看到 - // 完全前(5)/完全后(10)且自洽的结果。 - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-a", model = "model-a", displayModelId = "model-a") - dao.insertEvents( - (1..5).map { index -> event("a$index", "id-a", nowMs - index * 3_600_000L, cost = 1.0) } - ) - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - assertInternallyConsistent( - TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai), - 5L, - ) - - val writer = - launch(Dispatchers.IO) { - // identity 与事件分两次提交;间隔由调度器自然产生, - // 保证读者有机会落在两个提交之间 - seedIdentity(dao, "id-b", configId = "cfg-b", model = "model-b", displayModelId = "model-b") - dao.insertEvents( - (6..10).map { index -> - event("b$index", "id-b", nowMs - (index - 5) * 3_600_000L, cost = 2.0) - } - ) - } - - var sawPost = false - repeat(60) { - val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) - val requests = data.summary.requests - assertTrue("requests must be 5 or 10, was $requests", requests == 5L || requests == 10L) - assertInternallyConsistent(data, requests) - if (requests == 10L) sawPost = true - } - writer.join() - // 提交完成后:完全后状态,两个模型明细齐全 - val after = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) - assertInternallyConsistent(after, 10L) - assertEquals(2, after.displayModels.size) - assertEquals( - setOf("id-a", "id-b"), - after.displayModels.flatMap { it.identities.map { identity -> identity.identityId } }.toSet(), - ) - assertTrue(sawPost) - } - - // ==== 查询计划与线程(P2-3) ==== - - @Test - fun `time range query plan uses the startedAtMs index`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - val start = nowMs - 30L * TokenStatsTimeRanges.DAY_MS - dao.insertEvents( - (0 until 2000).map { index -> - event("idx-$index", "id-1", start + index * TokenStatsTimeRanges.HOUR_MS, cost = 0.1) - } - ) - val dbFile = context.getDatabasePath("app_database").absolutePath - JdbcSQLiteDriver().open(dbFile).use { connection -> - connection.prepare("ANALYZE").use { it.step() } - val plan = StringBuilder() - connection.prepare( - "EXPLAIN QUERY PLAN " + - "SELECT * FROM token_stat_events WHERE startedAtMs >= ? AND startedAtMs < ?" - ).use { statement -> - statement.bindLong(1, nowMs - 5L * TokenStatsTimeRanges.HOUR_MS) - statement.bindLong(2, nowMs) - while (statement.step()) { - for (column in 0 until statement.getColumnCount()) { - if (!statement.isNull(column)) plan.append(statement.getText(column)).append(' ') - } - plan.append('\n') - } - } - assertTrue( - "查询计划必须使用 startedAtMs 索引,实际: $plan", - plan.contains("index_token_stat_events_startedAtMs"), - ) - } - } - - @Test - fun `context facade executes room and aggregation on io dispatcher not caller thread`() = runBlocking { - // P2-3:生产入口显式切到 queryDispatcher(默认 Dispatchers.IO), - // 阶段 4 从 Main 调用不阻塞;通过注入缝记录执行线程。 - TokenStatsQueryService.databaseProvider = { database } - var providerThread: String? = null - TokenStatsQueryService.legacyPricesProvider = { _ -> - providerThread = Thread.currentThread().name - emptyMap() - } - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents(listOf(event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0))) - try { - val data = - TokenStatsQueryService.presetRangeData( - context, TokenStatsPreset.LAST_5H, - TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), - shanghai, nowMs, - ) - assertEquals(1L, data.summary.requests) - assertTrue( - "聚合必须运行在非调用线程(IO),实际: $providerThread", - providerThread != null && providerThread != "main", - ) - } finally { - TokenStatsQueryService.legacyPricesProvider = null - } - } - - // ==== 查询次数(mock DAO 固定查询契约) ==== - - @Test - fun `range data loads one snapshot and never re-fetches dao`() = runBlocking { - val dao = mock() - val snapshot = - TokenStatsQuerySnapshot( - events = listOf(event("e1", "id-1", 0L, cost = 1.0)), - identitiesById = mapOf("id-1" to identityEntity("id-1")), - displayModelsById = emptyMap(), - overrides = emptyList(), - baselines = emptyList(), - ) - wheneverBlocking { - dao.loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) - } doReturn snapshot - - val range = TokenStatsTimeRanges.customRange(0L, 3_600_000L) - val data = TokenStatsQueryService.rangeData(dao, range, TokenStatsQueryParams(), shanghai) - assertEquals(1L, data.summary.requests) - - verifyBlocking(dao, Mockito.times(1)) { - loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) - } - verifyBlocking(dao, never()) { getAllEvents() } - verifyBlocking(dao, never()) { getEventsInRange(anyLong(), anyLong()) } - verifyBlocking(dao, never()) { getAllIdentities() } - verifyBlocking(dao, never()) { getAllDisplayModels() } - verifyBlocking(dao, never()) { getAllPriceOverrides() } - verifyBlocking(dao, never()) { getAllBaselines() } - } - - @Test - fun `range data passes display model filter list and revalued override flag`() = runBlocking { - val dao = mock() - wheneverBlocking { - dao.loadRangeSnapshot(anyLong(), anyLong(), anyOrNull(), anyBoolean()) - } doReturn - TokenStatsQuerySnapshot( - events = emptyList(), - identitiesById = emptyMap(), - displayModelsById = emptyMap(), - overrides = emptyList(), - baselines = emptyList(), - ) - - val range = TokenStatsTimeRanges.customRange(0L, 3_600_000L) - TokenStatsQueryService.rangeData( - dao, range, - TokenStatsQueryParams(displayModelIds = setOf("m1", "m2"), mode = TokenStatsCostMode.REVALUED), - shanghai, - ) - verifyBlocking(dao, Mockito.times(1)) { - loadRangeSnapshot(anyLong(), anyLong(), eq(listOf("m1", "m2")), eq(true)) - } - } - - @Test - fun `lifetime overview loads one paged snapshot`() = runBlocking { - val dao = mock() - wheneverBlocking { dao.loadLifetimeSnapshot(anyBoolean(), anyInt(), any()) } doAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - val onPage = - invocation.getArgument(2) as - (List, Map, List) -> Unit - onPage(listOf(event("e1", "id-1", 0L, cost = 1.0)), mapOf("id-1" to identityEntity("id-1")), emptyList()) - TokenStatsLifetimeRead( - identitiesById = emptyMap(), - displayModelsById = emptyMap(), - overrides = emptyList(), - baselines = emptyList(), - totalEvents = 1L, - ) - } - - val overview = TokenStatsQueryService.lifetimeOverview(dao, TokenStatsQueryParams()) - assertEquals(1L, overview.eventTotals.requests) - verifyBlocking(dao, Mockito.times(1)) { loadLifetimeSnapshot(anyBoolean(), anyInt(), any()) } - verifyBlocking(dao, never()) { getAllEvents() } - verifyBlocking(dao, never()) { getAllBaselines() } - } - - @Test - fun `initial preset probes at most five exists queries`() = runBlocking { - val dao = mock() - wheneverBlocking { dao.rangeHasEvents(anyLong(), anyLong()) } doReturn false - // 只有 7d 范围有数据:5h/12h/24h 各探测一次后命中 - val last7 = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_7D, nowMs, shanghai) - wheneverBlocking { dao.rangeHasEvents(last7.startMs, last7.endMs) } doReturn true - - assertEquals(TokenStatsPreset.LAST_7D, TokenStatsQueryService.initialPresetWithData(dao, shanghai, nowMs)) - verifyBlocking(dao, Mockito.times(4)) { rangeHasEvents(anyLong(), anyLong()) } - - // 全部为空:5 次探测后回退默认 - val emptyDao = mock() - wheneverBlocking { emptyDao.rangeHasEvents(anyLong(), anyLong()) } doReturn false - assertEquals( - TokenStatsPreset.LAST_5H, - TokenStatsQueryService.initialPresetWithData(emptyDao, shanghai, nowMs), - ) - verifyBlocking(emptyDao, Mockito.times(5)) { rangeHasEvents(anyLong(), anyLong()) } - } - - // ==== 大事件量 ==== - - @Test - fun `large volume range query is a single read with consistent sums`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "bulk", model = "gpt-4o-2024-11-20", displayModelId = "bulk") - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, nowMs, shanghai) - val count = 10_000 - val stepMs = range.durationMs / count - val events = - (0 until count).map { index -> - event( - id = "bulk-$index", - identityId = "bulk", - startedAtMs = range.startMs + index * stepMs, - cost = (index % 10) * 0.1, - ) - } - dao.insertEvents(events) - - val data = - TokenStatsQueryService.rangeData( - dao, range, TokenStatsQueryParams(), shanghai, legacyPrices = emptyMap(), - ) - - assertEquals(count.toLong(), data.eventCount) - assertEquals(count.toLong(), data.summary.requests) - assertEquals(count.toLong(), data.buckets.sumOf { it.totals.requests }) - assertEquals( - data.summary.cost.knownAmount, - data.buckets.sumOf { it.totals.cost.knownAmount }, - 1e-6, - ) - assertEquals(data.summary.requests, data.displayModels.single().totals.requests) - // 大数据量只验证结果与查询结构,不做脆弱时限断言 - } - - // ==== 重估端到端 ==== - - @Test - fun `revalued mode resolves current overrides end to end`() = runBlocking { - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.upsertPriceOverride( - scope = TokenPriceResolver.SCOPE_PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o-2024-11-20", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 1.0, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L, cost = null, uncached = 1_000L, output = 500L), - ) - ) - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - val data = - TokenStatsQueryService.rangeData( - dao, range, - TokenStatsQueryParams(mode = TokenStatsCostMode.REVALUED), - shanghai, - ) - assertTrue(data.summary.cost.isFullyKnown) - // (1000*1 + 500*2)/1e6 = 0.002 USD -> 0.014 CNY(覆盖价 1/2,非内置 1.5/6) - assertEquals(0.014, data.summary.cost.knownAmount, 1e-9) - val pricing = data.displayModels.single().identities.single().pricing!! - assertEquals(BillingMode.TOKEN, pricing.billingMode) - assertTrue(pricing.known) - } - - // ==== Context 生产入口(注入缝) ==== - - @Test - fun `context facade resolves database through seam`() = runBlocking { - TokenStatsQueryService.databaseProvider = { database } - TokenStatsQueryService.legacyPricesProvider = { emptyMap() } - val dao = database.tokenStatsDao() - seedIdentity(dao, "id-1") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L, cost = 1.0), - event("e2", "id-1", nowMs - 2 * 3_600_000L, cost = 2.0), - ) - ) - val data = - TokenStatsQueryService.presetRangeData( - context, TokenStatsPreset.LAST_5H, TokenStatsQueryParams(), shanghai, nowMs, - ) - assertEquals(2L, data.summary.requests) - assertEquals(21.0, data.summary.cost.knownAmount, 1e-9) - - val preset = TokenStatsQueryService.initialPresetWithData(context, shanghai, nowMs) - assertEquals(TokenStatsPreset.LAST_5H, preset) - } - - @Test - fun `snapshot waits for an active production query before entering its block`() = runBlocking { - val providerEntered = CountDownLatch(1) - val providerRelease = CountDownLatch(1) - val barrierEntered = CountDownLatch(1) - TokenStatsQueryService.databaseProvider = { - providerEntered.countDown() - check(providerRelease.await(10, TimeUnit.SECONDS)) - database - } - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - - val queryJob = launch(Dispatchers.IO) { - assertFalse(TokenStatsQueryService.rangeHasEvents(context, range)) - } - assertTrue("query must register before resolving Room", providerEntered.await(10, TimeUnit.SECONDS)) - // UNDISPATCHED 保证 launch 返回前已执行到 accessor 等待的首次挂起点, - // 排除 IO 调度延迟导致“200ms 未进入 block”的假通过。 - val barrierJob = launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - barrierEntered.countDown() - } - } - assertFalse( - "snapshot block must wait for the registered query", - barrierEntered.await(200, TimeUnit.MILLISECONDS), - ) - providerRelease.countDown() - queryJob.join() - assertTrue("snapshot must enter after the query exits", barrierEntered.await(10, TimeUnit.SECONDS)) - barrierJob.join() - } - - @Test - fun `production query is rejected before Room resolution while snapshot block is active`() = runBlocking { - val providerCalls = AtomicInteger(0) - TokenStatsQueryService.databaseProvider = { - providerCalls.incrementAndGet() - database - } - val range = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, nowMs, shanghai) - var rejected = false - - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - try { - TokenStatsQueryService.rangeHasEvents(context, range) - } catch (_: TokenStatsBarrierActiveException) { - rejected = true - } - } - - assertTrue("query must be rejected during the exclusive block", rejected) - assertEquals("rejected query must not resolve or reopen Room", 0, providerCalls.get()) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt deleted file mode 100644 index b22602dce..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsRoomMigrationTest.kt +++ /dev/null @@ -1,794 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import java.io.File -import java.sql.DriverManager -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever - -/** - * 真实 Room 迁移测试(纯 JVM,sqlite-jdbc): - * 用内嵌 v20 schema SQL 构造 v20 数据库 → 通过 [AppDatabase] + [AppDatabase.MIGRATION_20_21] - * 真实打开 → 验证迁移、schema 校验(Room 会做 identityHash/TableInfo 校验)、 - * 旧数据保留、新表 DAO 读写与幂等语义。 - * - * 事件/价格/别名/baseline 与聊天等旧表位于同一个 app_database 文件,因此现有 - * 整库文件级备份/恢复自动覆盖它们(无需逐表接线)。 - */ -class TokenStatsRoomMigrationTest { - - private fun mockContext(tempDir: File): Context { - val context = mock() - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - // DataStore 委托在 coordinator 排空时按 filesDir 定位偏好文件(隔离到临时目录) - whenever(context.filesDir).thenReturn(tempDir) - // 模拟 Android Context 的数据库目录解析:/ - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(tempDir, invocation.getArgument(0)) - } - return context - } - - private fun injectApiPreferences(instance: ApiPreferences?) { - val field = - ApiPreferences::class.java - .getDeclaredField("INSTANCE") - .apply { isAccessible = true } - field.set(null, instance) - } - - /** 用内嵌 v20 schema SQL 构造一个真实的 v20 数据库文件(上游不导出 schema JSON)。 */ - private fun buildV20Database(dbPath: String) { - DriverManager.getConnection("jdbc:sqlite:$dbPath").use { connection -> - connection.createStatement().use { statement -> - statement.execute( - """ - CREATE TABLE IF NOT EXISTS `chats` ( - `id` TEXT NOT NULL, - `title` TEXT NOT NULL, - `createdAt` INTEGER NOT NULL, - `updatedAt` INTEGER NOT NULL, - `inputTokens` INTEGER NOT NULL, - `outputTokens` INTEGER NOT NULL, - `currentWindowSize` INTEGER NOT NULL, - `group` TEXT, - `displayOrder` INTEGER NOT NULL, - `workspace` TEXT, - `workspaceEnv` TEXT, - `parentChatId` TEXT, - `characterCardName` TEXT, - `characterGroupId` TEXT, - `locked` INTEGER NOT NULL, - `pinned` INTEGER NOT NULL, - PRIMARY KEY(`id`) - ) - """.trimIndent() - ) - statement.execute( - """ - CREATE TABLE IF NOT EXISTS `messages` ( - `messageId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - `chatId` TEXT NOT NULL, - `sender` TEXT NOT NULL, - `content` TEXT NOT NULL, - `timestamp` INTEGER NOT NULL, - `orderIndex` INTEGER NOT NULL, - `roleName` TEXT NOT NULL, - `selectedVariantIndex` INTEGER NOT NULL, - `provider` TEXT NOT NULL, - `modelName` TEXT NOT NULL, - `inputTokens` INTEGER NOT NULL, - `outputTokens` INTEGER NOT NULL, - `cachedInputTokens` INTEGER NOT NULL, - `sentAt` INTEGER NOT NULL, - `outputDurationMs` INTEGER NOT NULL, - `waitDurationMs` INTEGER NOT NULL, - `completedAt` INTEGER NOT NULL, - `displayMode` TEXT NOT NULL, - `isFavorite` INTEGER NOT NULL, - FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE - ) - """.trimIndent() - ) - statement.execute( - "CREATE INDEX IF NOT EXISTS `index_messages_chatId` ON `messages` (`chatId`)" - ) - statement.execute( - "CREATE INDEX IF NOT EXISTS `index_messages_chatId_timestamp` " + - "ON `messages` (`chatId`, `timestamp`)" - ) - statement.execute( - """ - CREATE TABLE IF NOT EXISTS `message_variants` ( - `variantId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - `chatId` TEXT NOT NULL, - `messageTimestamp` INTEGER NOT NULL, - `variantIndex` INTEGER NOT NULL, - `content` TEXT NOT NULL, - `roleName` TEXT NOT NULL, - `provider` TEXT NOT NULL, - `modelName` TEXT NOT NULL, - `inputTokens` INTEGER NOT NULL, - `outputTokens` INTEGER NOT NULL, - `cachedInputTokens` INTEGER NOT NULL, - `sentAt` INTEGER NOT NULL, - `outputDurationMs` INTEGER NOT NULL, - `waitDurationMs` INTEGER NOT NULL, - `completedAt` INTEGER NOT NULL, - FOREIGN KEY(`chatId`) REFERENCES `chats`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE - ) - """.trimIndent() - ) - statement.execute( - "CREATE INDEX IF NOT EXISTS `index_message_variants_chatId_messageTimestamp` " + - "ON `message_variants` (`chatId`, `messageTimestamp`)" - ) - statement.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS " + - "`index_message_variants_chatId_messageTimestamp_variantIndex` " + - "ON `message_variants` (`chatId`, `messageTimestamp`, `variantIndex`)" - ) - // 与真实 v20 Room 数据库一致的 master 表 - statement.execute( - "CREATE TABLE IF NOT EXISTS room_master_table " + - "(id INTEGER PRIMARY KEY, identity_hash TEXT NOT NULL)" - ) - statement.execute( - "INSERT OR REPLACE INTO room_master_table (id, identity_hash) " + - "VALUES(42, 'unused-v20-hash')" - ) - statement.execute("PRAGMA user_version = 20") - // 旧数据:迁移前插入一条聊天,验证迁移后数据保留 - statement.execute( - "INSERT INTO chats " + - "(id, title, createdAt, updatedAt, inputTokens, outputTokens, " + - "currentWindowSize, displayOrder, locked, pinned) " + - "VALUES ('legacy-chat', 'legacy', 1, 2, 3, 4, 5, 6, 0, 0)" - ) - } - } - } - - @Test - fun `v20 database opens through real Room migration and preserves legacy data`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo) - val legacyChat = database.chatDao().getChatById("legacy-chat") - assertNotNull("migration must preserve legacy rows", legacyChat) - assertEquals("legacy", legacyChat!!.title) - - // 新表已存在(独立连接读取同一文件) - val tables = queryTables(dbFile.absolutePath) - assertTrue("token_stat_events", tables.contains("token_stat_events")) - assertTrue("token_stat_identities", tables.contains("token_stat_identities")) - assertTrue("token_stat_display_models", tables.contains("token_stat_display_models")) - assertTrue("token_stat_price_overrides", tables.contains("token_stat_price_overrides")) - assertTrue("token_stat_baselines", tables.contains("token_stat_baselines")) - - // 迁移可重入(CREATE IF NOT EXISTS):以驱动变体再跑一次; - // Room 打开时已应用 28→29→30,重放 28→29 不改变版本号 - JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - AppDatabase.MIGRATION_20_21.migrate(connection) - assertEquals(21, userVersion(connection)) - } - } finally { - database.close() - } - } - - @Test - fun `stats dao roundtrips with identity fk and idempotent event inserts`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - val dao = database.tokenStatsDao() - - val identityId = - TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = "", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = "deepseek-chat", - normalizedModel = "deepseek-chat", - displayName = "deepseek-chat", - ) - ) - assertEquals(identityId, dao.getIdentityByTriple("", "DEEPSEEK", "deepseek-chat")?.identityId) - - val event = - TokenStatEventEntity( - eventId = "req-1", - statIdentityId = identityId, - category = TokenStatCategory.CHAT.name, - status = TokenStatStatus.COMPLETED.name, - startedAtMs = 1000L, - endedAtMs = 2000L, - firstTokenAtMs = 1200L, -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -cacheWriteTokens = 100L, -outputTokens = 500L, -reasoningTokens = 50L, - reasoningIncludedInOutput = true, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 0.75, - outputPricePerMillion = 2.0, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.001975, - ) - dao.insertEvent(event) - dao.insertEvent(event) // 重复 eventId 必须被忽略 - assertEquals(1, dao.countEvents()) - val readBack = dao.getEvent("req-1")!! - assertEquals(0.001975, readBack.costInPricingCurrency!!, 1e-12) - assertEquals(100L, readBack.cacheWriteTokens) - assertEquals(0.75, readBack.cacheWritePricePerMillion!!, 1e-12) - - // 未知分量以 null 落库,0 是确认值:null vs 0 必须可区分 - val nullFieldsEvent = - event.copy( - eventId = "req-2", - uncachedInputTokens = null, - cachedInputTokens = null, - cacheWriteTokens = null, - outputTokens = null, - costInPricingCurrency = null, - ) - dao.insertEvent(nullFieldsEvent) - val nullReadBack = dao.getEvent("req-2")!! - assertNull(nullReadBack.uncachedInputTokens) - assertNull(nullReadBack.cachedInputTokens) - assertNull(nullReadBack.cacheWriteTokens) - assertNull(nullReadBack.outputTokens) - assertNull(nullReadBack.costInPricingCurrency) - assertEquals(2, dao.countEvents()) - - // baseline 以 identityId 为键整体替换(幂等;baseline 无子表,REPLACE 安全) - val baseline = - TokenStatBaselineEntity( - identityId = identityId, - inputTokens = 1000L, - cachedInputTokens = 200L, - outputTokens = 500L, - requestCount = 3L, - pricingCurrency = "USD", - costInPricingCurrency = 1.9, - isEstimated = true, - fingerprint = "fp-1", - importedAtMs = 100L, - frozenBillingMode = BillingMode.TOKEN.name, - frozenInputPricePerMillion = 1.0, - frozenCachedInputPricePerMillion = 0.5, - frozenOutputPricePerMillion = 2.0, - ) - dao.upsertBaseline(baseline) - dao.upsertBaseline(baseline.copy(costInPricingCurrency = 2.5, fingerprint = "fp-2")) - assertEquals(1, dao.countBaselines()) - assertEquals("fp-2", dao.getBaseline(identityId)!!.fingerprint) - - // 价格覆盖:唯一写入入口是 upsertPriceOverride(校验 scope 枚举 + - // 规范化字段)。大小写/空白不同的原始输入 → 规范化后同一业务列 → - // 唯一索引冲突 REPLACE 覆盖,表内只能一行。 - dao.upsertPriceOverride( - scope = "PROVIDER_MODEL", - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 3.0, - cachedInputPricePerMillion = 1.5, - cacheWritePricePerMillion = 0.75, - outputPricePerMillion = 6.0, - ) - dao.upsertPriceOverride( - scope = "PROVIDER_MODEL", - provider = " deepseek ", - model = " DeepSeek-Chat ", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 9.0, - cachedInputPricePerMillion = 4.5, - cacheWritePricePerMillion = 2.25, - outputPricePerMillion = 18.0, - ) - assertEquals(1, dao.getAllPriceOverrides().size) - // 落库的必须是规范化后的业务列 - val override = - dao.getPriceOverride("PROVIDER_MODEL", "deepseek", "deepseek-chat", "") - assertNotNull(override) - assertEquals("deepseek", override!!.provider) - assertEquals("deepseek-chat", override.model) - assertEquals("", override.configId) - assertEquals(9.0, override.inputPricePerMillion!!, 1e-9) - assertEquals(2.25, override.cacheWritePricePerMillion!!, 1e-9) - // rowId 是内部主键,行内容按业务列解析,与 rowId 无关 - assertTrue(override.rowId > 0) - - // CONFIG 范围与 PROVIDER_MODEL 范围同 provider/model 可并存 - dao.upsertPriceOverride( - scope = "CONFIG", - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = " cfg-1 ", - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 12.0, - ) - assertEquals(2, dao.getAllPriceOverrides().size) - val configOverride = - dao.getPriceOverride("CONFIG", "deepseek", "deepseek-chat", "cfg-1") - assertNotNull(configOverride) - assertEquals(12.0, configOverride!!.inputPricePerMillion!!, 1e-9) - - // 业务列错配不命中:另一 provider 的查询不会读到该行 - assertNull(dao.getPriceOverride("PROVIDER_MODEL", "openai", "deepseek-chat", "")) - assertNull(dao.getPriceOverride("CONFIG", "deepseek", "deepseek-chat", "other-cfg")) - - // 非法 scope 必须在写入边界被拒绝(不落库) - try { - dao.upsertPriceOverride( - scope = "BOGUS_SCOPE", - provider = "DEEPSEEK", - model = "deepseek-chat", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - ) - fail("expected IllegalArgumentException for illegal scope") - } catch (e: IllegalArgumentException) { - // expected - } - assertEquals(2, dao.getAllPriceOverrides().size) - - // 按身份删除事件与 baseline(重置语义) - assertEquals(2, dao.deleteEventsByIdentity(identityId)) - assertEquals(1, dao.deleteBaseline(identityId)) - assertEquals(0, dao.countEvents()) - assertEquals(0, dao.countBaselines()) - - // 外键级联:删除身份时事件与 baseline 跟随删除 - dao.insertEvent(event) - dao.upsertBaseline(baseline) - JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - // 关闭日志模式,避免 Windows 上删除 journal 文件的 IOERR_DELETE 抖动 - connection.prepare("PRAGMA journal_mode = OFF").use { it.step() } - connection.prepare("PRAGMA foreign_keys = ON").use { it.step() } - connection.prepare( - "DELETE FROM token_stat_identities WHERE identityId = '$identityId'" - ).use { it.step() } - } - assertEquals(0, dao.countEvents()) - assertEquals(0, dao.countBaselines()) - } finally { - database.close() - } - } - - @Test - fun `reset by provider model deletes events across config identities and baseline`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - val dao = database.tokenStatsDao() - - // 同一 provider/model 的多个配置实例身份 + 一个无关模型 - val identities = - listOf("", "cfg-1", "cfg-2").mapIndexed { index, configId -> - TokenStatIdentityEntity( - identityId = - TokenStatIdentityResolver.identityId(configId, "DEEPSEEK", "deepseek-chat"), - configId = configId, - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ).also { dao.insertIdentityIfAbsent(it) } - } - val otherIdentity = - TokenStatIdentityEntity( - identityId = TokenStatIdentityResolver.identityId("", "OPENAI", "gpt-4o"), - configId = "", - provider = "OPENAI", - model = "gpt-4o", - displayModelId = "gpt-4o", - ).also { dao.insertIdentityIfAbsent(it) } - - fun eventOf(id: String, identity: TokenStatIdentityEntity) = - TokenStatEventEntity( - eventId = id, - statIdentityId = identity.identityId, - category = TokenStatCategory.CHAT.name, - status = TokenStatStatus.COMPLETED.name, - startedAtMs = 1000L, - endedAtMs = 2000L, -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -cacheWriteTokens = 100L, -outputTokens = 500L, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 0.75, - outputPricePerMillion = 2.0, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.0002, - ) - identities.forEachIndexed { index, identity -> - dao.insertEvent(eventOf("deepseek-event-$index", identity)) - } - dao.insertEvent(eventOf("openai-event", otherIdentity)) - // 旧系统 baseline 身份 configId 为空串,属于被重置范围 - dao.upsertBaseline( - TokenStatBaselineEntity( - identityId = identities.first().identityId, - inputTokens = 100L, - cachedInputTokens = 0L, - outputTokens = 50L, - requestCount = 1L, - pricingCurrency = "USD", - costInPricingCurrency = 0.0002, - isEstimated = true, - fingerprint = "fp", - importedAtMs = 1L, - frozenBillingMode = BillingMode.TOKEN.name, - frozenInputPricePerMillion = 1.0, - frozenOutputPricePerMillion = 2.0, - ) - ) - assertEquals(4, dao.countEvents()) - assertEquals(1, dao.countBaselines()) - - // 按 provider/model 重置:所有配置实例的事件 + 全部匹配 baseline 一起清。 - // 通过 daoProvider 注入缝把真实 DAO 交给协调器(生产路径用 - // AppDatabase.withTransaction 包同一组删除)。P1 闭环:删除后协调器 - // 排空 legacy cleanup——DataStore 侧注入 mock 隔离(真实键级协议由 - // TokenStatsCleanupOutboxTest 覆盖,此处聚焦 Room 语义与删除矩阵)。 - TokenStatsResetCoordinator.daoProvider = { dao } - val prefsMock = mock() - injectApiPreferences(prefsMock) - try { - TokenStatsResetCoordinator - .resetStatisticsForProviderModel(mockContext(tempDir), "DEEPSEEK:deepseek-chat") - } finally { - TokenStatsResetCoordinator.daoProvider = null - injectApiPreferences(null) - } - - assertEquals(1, dao.countEvents()) - assertEquals("openai-event", dao.getEvent("openai-event")!!.eventId) - assertEquals(0, dao.countBaselines()) - } finally { - database.close() - } - } - - @Test - fun `identity reinsert and display model update never cascade delete events`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - val dao = database.tokenStatsDao() - val identityId = - TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - val identity = - TokenStatIdentityEntity( - identityId = identityId, - configId = "", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - dao.insertIdentityIfAbsent(identity) - val event = - TokenStatEventEntity( - eventId = "req-1", - statIdentityId = identityId, - category = TokenStatCategory.CHAT.name, - status = TokenStatStatus.COMPLETED.name, - startedAtMs = 1000L, - endedAtMs = 2000L, -uncachedInputTokens = 800L, -cachedInputTokens = 200L, -outputTokens = 500L, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 2.0, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.0019, - ) - dao.insertEvent(event) - assertEquals(1, dao.countEvents()) - - // 重导路径:同一身份再次插入 → INSERT IGNORE,绝不可 REPLACE 删除 - //(REPLACE = DELETE + INSERT 会通过外键级联删除该身份的事件) - dao.insertIdentityIfAbsent(identity) - dao.insertIdentitiesIfAbsent(listOf(identity)) - assertEquals(1, dao.countEvents()) - assertEquals(identityId, dao.getIdentity(identityId)!!.identityId) - - // 分组变更走显式安全 UPDATE,同样不得级联删除事件 - dao.updateIdentityDisplayModel(identityId, "merged-group") - assertEquals(1, dao.countEvents()) - assertEquals("merged-group", dao.getIdentity(identityId)!!.displayModelId) - val readBack = dao.getEvent("req-1")!! - assertEquals(800L, readBack.uncachedInputTokens) - assertEquals(0.0019, readBack.costInPricingCurrency!!, 1e-12) - } finally { - database.close() - } - } - - @Test - fun `v20 database migrates to v21 adding diagnostics columns and new tables`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - // 触发打开与迁移(Room 内部校验 identityHash 与 TableInfo,包括新列/新表) - val dao = database.tokenStatsDao() - val identityId = TokenStatIdentityResolver.identityId("", "DEEPSEEK", "deepseek-chat") - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = "", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - ) - // 事件新列可写:诊断列默认 null,写入后往返一致 - val event = - TokenStatEventEntity( - eventId = "evt-v21", - statIdentityId = identityId, - category = TokenStatCategory.CHAT.name, - status = TokenStatStatus.COMPLETED.name, - startedAtMs = 1000L, - endedAtMs = 2000L, - uncachedInputTokens = 800, - cachedInputTokens = 200, - outputTokens = 500, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - outputPricePerMillion = 2.0, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.0019, - totalInputTokens = 1000L, - cacheWriteSeparateBilling = false, - diagnosticsJson = "{\"source\":\"openai_chat_completions\",\"usageObserved\":true}", - ) - dao.insertEvent(event) - val readBack = dao.getEvent("evt-v21")!! - assertEquals(800L, readBack.uncachedInputTokens) - assertEquals(1000L, readBack.totalInputTokens) - assertEquals(false, readBack.cacheWriteSeparateBilling) - assertTrue(readBack.diagnosticsJson!!.contains("\"source\":\"openai_chat_completions\"")) - - // v21 新增 reset/range tombstone 与 cleanup outbox 表真实存在 - val tables = queryTables(dbFile.absolutePath) - assertTrue("token_stat_reset_cutoffs", tables.contains("token_stat_reset_cutoffs")) - assertTrue("token_stat_range_cutoffs", tables.contains("token_stat_range_cutoffs")) - assertTrue("token_stat_cleanup_operations", tables.contains("token_stat_cleanup_operations")) - assertTrue("token_stat_cleanup_items", tables.contains("token_stat_cleanup_items")) - - // range 删除 tombstone 可读写 - dao.deleteRangeEventsTx(100L, 200L) - assertEquals(1, dao.rangeCutoffs().size) - assertEquals(1L, dao.currentResetGeneration()) - - // 迁移可重入(CREATE IF NOT EXISTS / ALTER 幂等):以驱动变体再跑一次 - JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - AppDatabase.MIGRATION_20_21.migrate(connection) - assertEquals(21, userVersion(connection)) - } - } finally { - database.close() - } - } - - @Test - fun `v21 cleanup outbox tables enforce foreign key and cascade on operation delete`() = - runBlocking { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - val database = - Room.databaseBuilder(mockContext(tempDir), AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - - try { - val dao = database.tokenStatsDao() - // 通过删除事务(真实路径)创建 operation + items - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = "id-legacy", - configId = "", - provider = "DEEPSEEK", - model = "deepseek-chat", - displayModelId = "deepseek-chat", - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = "deepseek-chat", - normalizedModel = "deepseek-chat", - displayName = "deepseek-chat", - ) - ) - val result = dao.deleteDisplayModelEventsTx("deepseek-chat", deleteBaselines = true) - val op = result.cleanupOperation!! - assertEquals(1, dao.getCleanupItems(op.operationId).size) - assertEquals(1, dao.countPendingCleanupOperations()) - // 外键:孤儿 item(operation 不存在)必须被拒绝 - JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - connection.prepare("PRAGMA journal_mode = OFF").use { it.step() } - connection.prepare("PRAGMA foreign_keys = ON").use { it.step() } - val orphan = - runCatching { - connection.prepare( - "INSERT INTO token_stat_cleanup_items " + - "(operationId, identityId, provider, model) " + - "VALUES ('no-such-op', 'id', 'P', 'M')" - ).use { it.step() } - } - assertTrue("orphan item must violate FK", orphan.isFailure) - } - // 级联:删除 operation 后 items 跟随删除 - dao.deleteCleanupOperation(op.operationId) - assertEquals(0, dao.getCleanupItems(op.operationId).size) - assertEquals(0, dao.countPendingCleanupOperations()) - } finally { - database.close() - } - } - - @Test - fun `production support sqlite migration variant runs the shared sql on a real v20 database`() { - val tempDir = kotlin.io.path.createTempDirectory("room-migration-test").toFile() - val dbFile = File(tempDir, "app_database") - buildV20Database(dbFile.absolutePath) - - JvmSupportSQLiteDatabase.open(dbFile.absolutePath).use { supportDb -> - // 生产默认路径:migrate(SupportSQLiteDatabase) 变体 - AppDatabase.MIGRATION_20_21.migrate(supportDb) - - // 新表真实存在且旧数据保留 - val tables = queryTables(dbFile.absolutePath) - assertTrue("token_stat_events", tables.contains("token_stat_events")) - assertTrue("token_stat_identities", tables.contains("token_stat_identities")) - assertTrue("token_stat_display_models", tables.contains("token_stat_display_models")) - assertTrue("token_stat_price_overrides", tables.contains("token_stat_price_overrides")) - assertTrue("token_stat_baselines", tables.contains("token_stat_baselines")) - - JdbcSQLiteConnection(dbFile.absolutePath).use { connection -> - connection.prepare("SELECT title FROM chats WHERE id = 'legacy-chat'").use { statement -> - assertTrue(statement.step()) - assertEquals("legacy", statement.getText(0)) - } - // 事件表带缓存写入列:prepare 即校验列存在(空表无行) - connection.prepare( - "SELECT cacheWriteTokens, cacheWritePricePerMillion " + - "FROM token_stat_events LIMIT 1" - ).use { statement -> - assertFalse(statement.step()) - } - } - - // 迁移可重入(CREATE IF NOT EXISTS) - AppDatabase.MIGRATION_20_21.migrate(supportDb) - } - } - - private fun queryTables(dbPath: String): Set { - val tables = mutableSetOf() - JdbcSQLiteConnection(dbPath).use { connection -> - connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").use { statement -> - while (statement.step()) { - tables += statement.getText(0) - } - } - } - return tables - } - - private fun userVersion(connection: androidx.sqlite.SQLiteConnection): Int = - connection.prepare("PRAGMA user_version").use { statement -> - statement.step() - statement.getLong(0).toInt() - } - -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt deleted file mode 100644 index e463e75ca..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManagerTest.kt +++ /dev/null @@ -1,578 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.whenever - -/** - * 统计页设置管理测试(阶段 4,真实 Room + JVM SQLite): - * 价格覆盖的非负有限校验/新增/编辑/删除,以及分组/别名的安全事务 - * (身份只走 UPDATE,绝不 REPLACE 级联删除事件;恢复默认分组)。 - */ -class TokenStatsSettingsManagerTest { - - private lateinit var tempDir: File - private lateinit var context: Context - private lateinit var database: AppDatabase - private lateinit var dao: TokenStatsDao - private lateinit var manager: TokenStatsSettingsManager - - @Before - fun setUp() { - TokenStatSpool.clearPendingStateForTest() - tempDir = kotlin.io.path.createTempDirectory("token-settings-test").toFile() - context = mockContext(tempDir) - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - dao = database.tokenStatsDao() - manager = TokenStatsSettingsManager(dao) - } - - @After - fun tearDown() { - TokenStatSpool.clearPendingStateForTest() - database.close() - } - - private fun mockContext(filesDir: File): Context { - val context = Mockito.mock(Context::class.java) - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - private suspend fun seedIdentity( - identityId: String, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o", - displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), - ) { - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - dao.upsertDisplayModel( - com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), - displayName = model, - ) - ) - } - - private suspend fun seedEvent(identityId: String, eventId: String) { - dao.insertEvent( - TokenStatEventEntity( - eventId = eventId, - statIdentityId = identityId, - category = TokenStatCategory.CHAT.name, - status = TokenStatStatus.COMPLETED.name, - acceptedGeneration = 0L, - startedAtMs = 1_000_000L, - endedAtMs = 1_001_000L, - firstTokenAtMs = null, - uncachedInputTokens = 10L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - totalInputTokens = null, - outputTokens = 5L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - pricingSource = PricingSource.DEFAULT.name, - costInPricingCurrency = 0.001, - diagnosticsJson = null, - ) - ) - } - - // ==== 价格覆盖 ==== - - @Test - fun `price override rejects negative and non-finite values without writing`() = runBlocking { - val negative = - runCatching { - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = -1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - } - assertTrue("negative price must be rejected", negative.isFailure) - assertEquals(0, dao.getAllPriceOverrides().size) - - val nan = - runCatching { - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = Double.NaN, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - } - assertTrue("NaN price must be rejected", nan.isFailure) - assertEquals(0, dao.getAllPriceOverrides().size) - } - - @Test - fun `price override upsert replaces same business key and normalizes fields`() = runBlocking { - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = " OPENAI ", - model = " Gpt-4o ", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - // 同业务组合(规范化后相同)再次写入 → REPLACE 覆盖,仍只有一行 - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "openai", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 3.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 4.0, - pricePerRequest = null, - ) - val all = dao.getAllPriceOverrides() - assertEquals(1, all.size) - val row = all.single() - assertEquals("openai", row.provider) - assertEquals("gpt-4o", row.model) - assertEquals("", row.configId) - assertEquals(3.0, row.inputPricePerMillion!!, 0.0) - assertEquals(4.0, row.outputPricePerMillion!!, 0.0) - - // 删除 - manager.deletePriceOverride(PriceOverrideScope.PROVIDER_MODEL, "openai", "gpt-4o", null) - assertTrue(dao.getAllPriceOverrides().isEmpty()) - } - - @Test - fun `price override drops fields from the inactive billing mode`() = runBlocking { - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = 0.5, - cacheWritePricePerMillion = 0.8, - outputPricePerMillion = 2.0, - pricePerRequest = 99.0, - ) - val tokenRow = dao.getAllPriceOverrides().single() - assertNull(tokenRow.pricePerRequest) - - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.COUNT, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 99.0, - cachedInputPricePerMillion = 99.0, - cacheWritePricePerMillion = 99.0, - outputPricePerMillion = 99.0, - pricePerRequest = 0.01, - ) - val countRow = dao.getAllPriceOverrides().single() - assertNull(countRow.inputPricePerMillion) - assertNull(countRow.cachedInputPricePerMillion) - assertNull(countRow.cacheWritePricePerMillion) - assertNull(countRow.outputPricePerMillion) - assertEquals(0.01, countRow.pricePerRequest!!, 0.0) - } - - @Test - fun `reading overrides repairs mixed fields saved by older versions`() = runBlocking { - dao.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL.name, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - pricePerRequest = 99.0, - ) - - val repaired = manager.allPriceOverrides().single() - - assertNull(repaired.pricePerRequest) - assertNull(dao.getAllPriceOverrides().single().pricePerRequest) - } - - @Test - fun `config scope override keeps configId and is independent from provider scope`() = runBlocking { - manager.upsertPriceOverride( - scope = PriceOverrideScope.CONFIG, - provider = "OPENAI", - model = "gpt-4o", - configId = " cfg-9 ", - billingMode = BillingMode.COUNT, - pricingCurrency = PricingCurrency.CNY, - inputPricePerMillion = null, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = null, - pricePerRequest = 0.01, - ) - val row = dao.getAllPriceOverrides().single() - assertEquals("cfg-9", row.configId) - assertEquals(PriceOverrideScope.CONFIG.name, row.scope) - assertEquals(BillingMode.COUNT.name, row.billingMode) - assertEquals(0.01, row.pricePerRequest!!, 0.0) - - // 删除时同样按规范化组合匹配 - manager.deletePriceOverride(PriceOverrideScope.CONFIG, "OPENAI", "gpt-4o", "cfg-9") - assertTrue(dao.getAllPriceOverrides().isEmpty()) - } - - @Test - fun `edit keeps business key unchanged and rejects key changes`() = runBlocking { - manager.upsertPriceOverride( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - val existing = dao.getAllPriceOverrides().single() - - // 同业务键编辑(大小写/空白差异经规范化后一致)→ 更新成功,仍只有一行 - manager.updatePriceOverride( - existing, - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = " openai ", - model = "Gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 3.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 4.0, - pricePerRequest = null, - ), - ) - assertEquals(1, dao.getAllPriceOverrides().size) - assertEquals(3.0, dao.getAllPriceOverrides().single().inputPricePerMillion!!, 0.0) - - // 改 provider → 拒绝,行不变 - val keyChanged = - runCatching { - manager.updatePriceOverride( - existing, - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "anthropic", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 5.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 5.0, - pricePerRequest = null, - ), - ) - } - assertTrue("changed provider must be rejected", keyChanged.isFailure) - assertEquals(1, dao.getAllPriceOverrides().size) - - // 改 scope(CONFIG 且带新 configId)→ 拒绝,行不变 - val scopeChanged = - runCatching { - manager.updatePriceOverride( - existing, - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.CONFIG, - provider = "openai", - model = "gpt-4o", - configId = "cfg-2", - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 5.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 5.0, - pricePerRequest = null, - ), - ) - } - assertTrue("changed scope must be rejected", scopeChanged.isFailure) - assertEquals(1, dao.getAllPriceOverrides().size) - } - - @Test - fun `config scope with blank configId is rejected without writing`() = runBlocking { - val blank = runCatching { - manager.upsertPriceOverride( - scope = PriceOverrideScope.CONFIG, - provider = "OPENAI", - model = "gpt-4o", - configId = " ", - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - } - assertTrue("blank configId must be rejected", blank.isFailure) - assertEquals(0, dao.getAllPriceOverrides().size) - - // 编辑路径同样拒绝:CONFIG 空 configId 的草稿在规范化阶段即失败 - val nullConfig = runCatching { - manager.upsertPriceOverride( - scope = PriceOverrideScope.CONFIG, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - pricingCurrency = PricingCurrency.USD, - inputPricePerMillion = 1.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 2.0, - pricePerRequest = null, - ) - } - assertTrue("null configId must be rejected", nullConfig.isFailure) - assertEquals(0, dao.getAllPriceOverrides().size) - } - - // ==== 分组 / 别名 ==== - - @Test - fun `moving identities to a group preserves events and never replaces identity rows`() = - runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-2", configId = "cfg-2", model = "gpt-4o") - seedEvent("id-1", "e1") - seedEvent("id-2", "e2") - - manager.createGroupAndMove("我的组", listOf("id-1", "id-2")) - val groupId = - dao.getAllDisplayModels() - .first { it.displayName == "我的组" && it.displayModelId.startsWith("custom-group-") } - .displayModelId - - // 身份只被 UPDATE:displayModelId 变更,identityId 不变 - val identities = dao.getAllIdentities().associateBy { it.identityId } - assertEquals(groupId, identities.getValue("id-1").displayModelId) - assertEquals(groupId, identities.getValue("id-2").displayModelId) - - // 事件未被级联删除(无 REPLACE) - assertEquals(2, dao.getAllEvents().size) - assertEquals(2, dao.getAllEvents().count { it.statIdentityId in setOf("id-1", "id-2") }) - } - - @Test - fun `rename only changes displayName and keeps identities attached`() = runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedEvent("id-1", "e1") - val defaultId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - - manager.renameDisplayGroup(defaultId, "GPT-4 主力") - val group = dao.getDisplayModel(defaultId) - assertNotNull(group) - assertEquals("GPT-4 主力", group!!.displayName) - assertEquals(defaultId, dao.getIdentity("id-1")!!.displayModelId) - assertEquals(1, dao.getAllEvents().size) - } - - @Test - fun `restore default groups moves each identity back to its own normalized model group`() = - runBlocking { - seedIdentity("id-a", model = "gpt-4o") - seedIdentity("id-b", configId = "cfg-2", model = "claude-3-5-sonnet") - seedEvent("id-a", "e1") - seedEvent("id-b", "e2") - - // 先把两个身份手动合并到 gpt-4o 组 - manager.moveIdentitiesToGroup( - listOf("id-a", "id-b"), - TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), - ) - manager.restoreDefaultGroups(TokenStatIdentityResolver.displayModelIdFor("gpt-4o")) - - val identities = dao.getAllIdentities().associateBy { it.identityId } - assertEquals( - TokenStatIdentityResolver.displayModelIdFor("gpt-4o"), - identities.getValue("id-a").displayModelId, - ) - assertEquals( - TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet"), - identities.getValue("id-b").displayModelId, - ) - // 事件完整保留 - assertEquals(2, dao.getAllEvents().size) - } - - @Test - fun `move to existing group is idempotent and blank names are rejected`() = runBlocking { - seedIdentity("id-1", model = "gpt-4o") - val defaultId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - manager.moveIdentitiesToGroup(listOf("id-1"), defaultId) - manager.moveIdentitiesToGroup(listOf("id-1"), defaultId) - assertEquals(defaultId, dao.getIdentity("id-1")!!.displayModelId) - - val blankRename = runCatching { manager.renameDisplayGroup(defaultId, " ") } - assertTrue("blank display name must be rejected", blankRename.isFailure) - manager.renameDisplayGroup(defaultId, " 新名字 ") - assertEquals("新名字", dao.getDisplayModel(defaultId)!!.displayName) - - val emptyMerge = runCatching { manager.moveIdentitiesToGroup(emptyList(), defaultId) } - assertTrue("empty identity list must be rejected", emptyMerge.isFailure) - } - - // ==== P1 修复:分组元数据与统计筛选无关 ==== - - @Test - fun `group models expose complete membership even when only one member has events`() = - runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") - // 只有 A 有事件:范围明细只能看到 A,但分组操作必须拿到完整成员 - seedEvent("id-1", "e1") - manager.createGroupAndMove("组合", listOf("id-1", "id-2")) - val groupId = - dao.getAllDisplayModels() - .first { it.displayName == "组合" && it.displayModelId.startsWith("custom-group-") } - .displayModelId - - val groups = manager.groupModels() - val merged = groups.first { it.displayModelId == groupId } - assertEquals("组合", merged.displayName) - assertEquals(setOf("id-1", "id-2"), merged.memberIdentityIds.toSet()) - assertEquals( - setOf( - TokenStatsGroupMemberInfo("id-1", "cfg-1", "OPENAI", "gpt-4o"), - TokenStatsGroupMemberInfo("id-2", "cfg-2", "OPENAI", "claude-3-5-sonnet"), - ), - merged.members.toSet(), - ) - - // 默认分组(成员被移出后变空)仍在列表中:空组是合法合并目标 - val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - val emptyGroup = groups.first { it.displayModelId == gptId } - assertTrue(emptyGroup.memberIdentityIds.isEmpty()) - } - - @Test - fun `group models include groups without display row and keep fallback name`() = - runBlocking { - // 身份引用的 displayModelId 没有对应展示行:组名回退到 displayModelId - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = "id-3", - configId = "cfg-3", - provider = "ANTHROPIC", - model = "sonnet", - displayModelId = "orphan-group", - ) - ) - val groups = manager.groupModels() - val orphan = groups.first { it.displayModelId == "orphan-group" } - assertEquals("orphan-group", orphan.displayName) - assertEquals(listOf("id-3"), orphan.memberIdentityIds) - } - - @Test - fun `production settings access is rejected before resolving Room during snapshot`() = - runBlocking { - val productionManager = TokenStatsSettingsManager(context) - var rejected = false - - TokenStatSpool.withExclusiveSnapshotAccess(context, drainBefore = false) { - try { - productionManager.groupModels() - } catch (_: TokenStatsBarrierActiveException) { - rejected = true - } - } - - assertTrue("settings access must not use a cached DAO during snapshot", rejected) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt deleted file mode 100644 index 62103eca5..000000000 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsStartupCoordinatorTest.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.ai.assistance.operit.data.stats - -import android.content.Context -import java.io.File -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.whenever - -/** - * 启动统计 single-flight readiness 测试(P1 关键链路): - * - 并发调用 join 同一轮初始化(各步骤只执行一次); - * - 失败不缓存:下一轮重新执行并可成功。 - * 三个步骤全部注入(不触碰真实 Room/DataStore/spool)。 - */ -class TokenStatsStartupCoordinatorTest { - - private lateinit var context: Context - - @Before - fun setUp() { - val root = kotlin.io.path.createTempDirectory("token-startup-coordinator").toFile() - context = Mockito.mock(Context::class.java).also { ctx -> - whenever(ctx.applicationContext).thenReturn(ctx) - whenever(ctx.packageName).thenReturn("com.ai.assistance.operit") - whenever(ctx.filesDir).thenReturn(root) - whenever(ctx.getDatabasePath(any())).thenAnswer { File(root, it.getArgument(0)) } - } - } - - @After - fun tearDown() { - TokenStatsStartupCoordinator.ensureMigratedStep = null - TokenStatsStartupCoordinator.consumePendingRestoreStep = null - TokenStatsStartupCoordinator.initialDrainStep = null - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = null - } - - @Test - fun `concurrent awaitInitialized joins a single initialization attempt`() = runBlocking { - val enter = CountDownLatch(1) - val release = CountDownLatch(1) - val laterSteps = AtomicInteger(0) - TokenStatsStartupCoordinator.ensureMigratedStep = { - enter.countDown() - // 初始化执行在协调器锁外:阻塞第一个步骤,验证并发调用 join 而非重入 - assertTrue(release.await(10, TimeUnit.SECONDS)) - true - } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { - laterSteps.incrementAndGet() - true - } - TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> - laterSteps.incrementAndGet() - true - } - try { - // 显式分发到 IO:runBlocking 主线程随后会阻塞在 latch 上,默认分发(事件循环) - // 的 async 在阻塞期间无法被调度。 - val a = async(Dispatchers.IO) { TokenStatsStartupCoordinator.awaitInitialized(context, 10_000) } - val b = async(Dispatchers.IO) { TokenStatsStartupCoordinator.awaitInitialized(context, 10_000) } - assertTrue("initialization must start", enter.await(5, TimeUnit.SECONDS)) - delay(100) - assertEquals( - "concurrent join must not start a second attempt", - 0, - laterSteps.get(), - ) - release.countDown() - assertTrue(a.await()) - assertTrue(b.await()) - assertEquals("steps must run exactly once for the joined round", 2, laterSteps.get()) - } finally { - release.countDown() - } - } - - @Test - fun `failed initialization is not cached and a later call retries`() = runBlocking { - val drainAttempts = AtomicInteger(0) - TokenStatsStartupCoordinator.ensureMigratedStep = { true } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } - TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> - drainAttempts.incrementAndGet() - drainAttempts.get() == 2 // 第一次失败,第二次成功 - } - assertFalse("first attempt failure must surface as not ready", TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) - assertTrue("failure must not be cached; retry succeeds", TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) - assertEquals(2, drainAttempts.get()) - } - - @Test - fun `false prerequisite is not ready and is retried without running later steps`() = runBlocking { - val migrationAttempts = AtomicInteger(0) - val laterSteps = AtomicInteger(0) - TokenStatsStartupCoordinator.ensureMigratedStep = { - migrationAttempts.incrementAndGet() == 2 - } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { - laterSteps.incrementAndGet() - true - } - TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> - laterSteps.incrementAndGet() - true - } - - assertFalse(TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) - assertEquals("failed prerequisite must stop this round", 0, laterSteps.get()) - assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 10_000)) - assertEquals(2, migrationAttempts.get()) - assertEquals(2, laterSteps.get()) - } - - @Test - fun `end to end timeout completes false and a later call retries`() = runBlocking { - val attempts = AtomicInteger(0) - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 40L - TokenStatsStartupCoordinator.ensureMigratedStep = { - if (attempts.incrementAndGet() == 1) delay(100) - true - } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } - TokenStatsStartupCoordinator.initialDrainStep = { _, _ -> true } - - assertFalse(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L - assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) - assertEquals(2, attempts.get()) - } - - @Test - fun `drain receives only the budget remaining after prerequisites`() = runBlocking { - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L - TokenStatsStartupCoordinator.ensureMigratedStep = { - delay(100) - true - } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } - var drainBudgetMs = 0L - TokenStatsStartupCoordinator.initialDrainStep = { _, remainingMs -> - drainBudgetMs = remainingMs - true - } - - assertTrue(TokenStatsStartupCoordinator.awaitInitialized(context, 1_000)) - assertTrue("drain budget must be positive", drainBudgetMs > 0L) - assertTrue("prerequisite time must be deducted", drainBudgetMs < 500L) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt index 6576f36df..454c38d38 100644 --- a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt @@ -7,12 +7,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -/** - * 时间预设/范围/桶边界测试(阶段 3): - * 所有边界用 java.time 日历运算,覆盖滚动窗口、自然日、自然月、跨月、 - * DST 春令(23 小时日)与冬令(25 小时日/重复小时)、自定义范围校验、 - * [start, end) 半开语义、桶对齐与归属。 - */ +/** 日历范围及桶边界测试,覆盖 DST 和半开区间语义。 */ class TokenStatsTimeRangeTest { private val shanghai = ZoneId.of("Asia/Shanghai") @@ -24,90 +19,7 @@ class TokenStatsTimeRangeTest { private fun local(epochMs: Long, zone: ZoneId): LocalDateTime = LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(epochMs), zone) - // ==== 滚动窗口 ==== - - @Test - fun `rolling presets are now minus duration half-open`() { - val now = localMs("2026-08-07T15:00:00", shanghai) - val fiveHour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_5H, now, shanghai) - assertEquals(now - 5L * TokenStatsTimeRanges.HOUR_MS, fiveHour.startMs) - assertEquals(now, fiveHour.endMs) - - val twelveHour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_12H, now, shanghai) - assertEquals(now - 12L * TokenStatsTimeRanges.HOUR_MS, twelveHour.startMs) - - val twentyFour = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_24H, now, shanghai) - assertEquals(now - 24L * TokenStatsTimeRanges.HOUR_MS, twentyFour.startMs) - } - - // ==== 自然日 ==== - - @Test - fun `today is local midnight to next midnight`() { - val now = localMs("2026-08-07T15:00:00", shanghai) - val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, shanghai) - assertEquals(localMs("2026-08-07T00:00:00", shanghai), today.startMs) - assertEquals(localMs("2026-08-08T00:00:00", shanghai), today.endMs) - } - - @Test - fun `yesterday is previous natural day`() { - val now = localMs("2026-08-07T02:00:00", shanghai) - val yesterday = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.YESTERDAY, now, shanghai) - assertEquals(localMs("2026-08-06T00:00:00", shanghai), yesterday.startMs) - assertEquals(localMs("2026-08-07T00:00:00", shanghai), yesterday.endMs) - } - - @Test - fun `last 7 and 30 days are natural days including today`() { - val now = localMs("2026-08-07T23:59:00", shanghai) - val seven = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_7D, now, shanghai) - assertEquals(localMs("2026-08-01T00:00:00", shanghai), seven.startMs) - assertEquals(localMs("2026-08-08T00:00:00", shanghai), seven.endMs) - assertEquals(7L * TokenStatsTimeRanges.DAY_MS, seven.durationMs) - - val thirty = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_30D, now, shanghai) - assertEquals(localMs("2026-07-09T00:00:00", shanghai), thirty.startMs) - assertEquals(localMs("2026-08-08T00:00:00", shanghai), thirty.endMs) - assertEquals(30L * TokenStatsTimeRanges.DAY_MS, thirty.durationMs) - } - - // ==== 自然月 ==== - - @Test - fun `this and last month use calendar month boundaries`() { - val now = localMs("2026-08-07T15:00:00", shanghai) - val thisMonth = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, now, shanghai) - assertEquals(localMs("2026-08-01T00:00:00", shanghai), thisMonth.startMs) - assertEquals(localMs("2026-09-01T00:00:00", shanghai), thisMonth.endMs) - - val lastMonth = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, now, shanghai) - assertEquals(localMs("2026-07-01T00:00:00", shanghai), lastMonth.startMs) - assertEquals(localMs("2026-08-01T00:00:00", shanghai), lastMonth.endMs) - } - - @Test - fun `february month boundaries handle 28 and leap 29 days`() { - // 2026-03-01 时的上月 = 2026 年 2 月(28 天) - val nowFeb = localMs("2026-03-01T01:00:00", shanghai) - val feb = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, nowFeb, shanghai) - assertEquals(localMs("2026-02-01T00:00:00", shanghai), feb.startMs) - assertEquals(localMs("2026-03-01T00:00:00", shanghai), feb.endMs) - assertEquals(28L * TokenStatsTimeRanges.DAY_MS, feb.durationMs) - - // 2028-03-01 时的上月 = 2028 年 2 月(闰年 29 天) - val leapNow = localMs("2028-03-01T01:00:00", shanghai) - val leapFeb = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.LAST_MONTH, leapNow, shanghai) - assertEquals(29L * TokenStatsTimeRanges.DAY_MS, leapFeb.durationMs) - - // 本月 = 3 月(31 天) - val march = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, nowFeb, shanghai) - assertEquals(localMs("2026-03-01T00:00:00", shanghai), march.startMs) - assertEquals(localMs("2026-04-01T00:00:00", shanghai), march.endMs) - assertEquals(31L * TokenStatsTimeRanges.DAY_MS, march.durationMs) - } - - // ==== 自定义与校验 ==== + // ==== 日历范围与校验 ==== @Test fun `custom range requires end after start`() { @@ -120,50 +32,6 @@ class TokenStatsTimeRangeTest { } catch (expected: IllegalArgumentException) { // ok } - try { - TokenStatsTimeRanges.rangeFor(TokenStatsPreset.CUSTOM, 1000L, shanghai) - throw AssertionError("expected IllegalArgumentException for CUSTOM preset") - } catch (expected: IllegalArgumentException) { - // ok - } - } - - // ==== DST ==== - - @Test - fun `spring forward day is 23 hours`() { - // 美东 2026-03-08 02:00 -> 03:00 拨快 1 小时 - val now = localMs("2026-03-08T15:00:00", newYork) - val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, newYork) - assertEquals(localMs("2026-03-08T00:00:00", newYork), today.startMs) - assertEquals(localMs("2026-03-09T00:00:00", newYork), today.endMs) - assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, today.durationMs) - - val yesterday = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.YESTERDAY, now, newYork) - assertEquals(24L * TokenStatsTimeRanges.HOUR_MS, yesterday.durationMs) - } - - @Test - fun `fall back day is 25 hours`() { - // 美东 2026-11-01 02:00 EDT -> 01:00 EST 拨慢 1 小时 - val now = localMs("2026-11-01T15:00:00", newYork) - val today = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.TODAY, now, newYork) - assertEquals(localMs("2026-11-01T00:00:00", newYork), today.startMs) - assertEquals(localMs("2026-11-02T00:00:00", newYork), today.endMs) - assertEquals(25L * TokenStatsTimeRanges.HOUR_MS, today.durationMs) - } - - @Test - fun `month range across dst transition is exact calendar span`() { - // 2026-03 月:包含 23 小时日的自然月 - val now = localMs("2026-03-15T12:00:00", newYork) - val march = TokenStatsTimeRanges.rangeFor(TokenStatsPreset.THIS_MONTH, now, newYork) - assertEquals(localMs("2026-03-01T00:00:00", newYork), march.startMs) - assertEquals(localMs("2026-04-01T00:00:00", newYork), march.endMs) - assertEquals( - 31L * TokenStatsTimeRanges.DAY_MS - TokenStatsTimeRanges.HOUR_MS, - march.durationMs, - ) } // ==== 粒度选择 ==== diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt deleted file mode 100644 index a28efb956..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/settings/screens/QuarantineExportCleanupTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import java.io.File -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withContext -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -/** - * P2:导出失败/取消清理的调度缝测试——删除绝不在 Main;失败分支如实上报,取消分支在 - * NonCancellable 下完成有界清理后继续传播取消。 - */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class QuarantineExportCleanupTest { - private lateinit var mainExecutor: ExecutorService - private val ioThreadNames = ConcurrentHashMap.newKeySet() - private lateinit var previousDispatcher: CoroutineDispatcher - - @Before - fun setUp() { - mainExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "test-main-thread") } - Dispatchers.setMain(mainExecutor.asCoroutineDispatcher()) - previousDispatcher = QuarantineExportCleanup.ioDispatcher - QuarantineExportCleanup.ioDispatcher = object : CoroutineDispatcher() { - override fun dispatch(context: CoroutineContext, block: Runnable) { - Dispatchers.IO.dispatch(context) { - ioThreadNames += Thread.currentThread().name - block.run() - } - } - } - } - - @After - fun tearDown() { - QuarantineExportCleanup.ioDispatcher = previousDispatcher - QuarantineExportCleanup.deleteRecursivelyForTest = null - ioThreadNames.clear() - Dispatchers.resetMain() - mainExecutor.shutdown() - } - - private fun exportDir(): File = - File.createTempFile("quarantine-export-cleanup", "").apply { delete(); mkdirs() } - - @Test - fun `cleanup failure is reported and deletion never runs on the main thread`() = runBlocking { - val destination = exportDir() - var deleteCalls = 0 - var deleteThread = "" - QuarantineExportCleanup.deleteRecursivelyForTest = { - deleteCalls += 1 - deleteThread = Thread.currentThread().name - false // 模拟删除失败 - } - try { - val cleaned = withContext(Dispatchers.Main) { - QuarantineExportCleanup.deleteRecursively(destination) - } - assertFalse("cleanup failure must be reported to the caller", cleaned) - assertEquals(1, deleteCalls) - assertTrue("deletion must run on the injected IO dispatcher", ioThreadNames.isNotEmpty()) - assertFalse( - "cleanup must never run on the main thread: $ioThreadNames", - ioThreadNames.any { it == "test-main-thread" }, - ) - assertTrue("deletion thread must not be main", deleteThread != "test-main-thread") - } finally { - QuarantineExportCleanup.deleteRecursivelyForTest = null - } - } - - @Test - fun `cleanup still runs when the caller coroutine is cancelled and cancellation propagates`() = - runBlocking { - val destination = exportDir() - var cleanupCount = 0 - var cleanupResult: Boolean? = null - var cleanupThread = "" - QuarantineExportCleanup.deleteRecursivelyForTest = { - cleanupCount += 1 - cleanupThread = Thread.currentThread().name - true - } - try { - // 门闩确保协程已进入 delay(否则 cancelAndJoin 会在块体启动前取消,catch 不执行) - val enteredDelay = CompletableDeferred() - val job = launch(Dispatchers.Main) { - try { - enteredDelay.complete(Unit) - delay(Long.MAX_VALUE) - } catch (e: CancellationException) { - // 模拟 UI 取消分支:NonCancellable+IO 完成有界清理后再重抛取消 - cleanupResult = - QuarantineExportCleanup.deleteRecursively(destination, nonCancellable = true) - throw e - } - } - enteredDelay.await() - job.cancelAndJoin() - assertEquals("cleanup must run exactly once despite cancellation", 1, cleanupCount) - assertEquals("cleanup must report success under cancellation", true, cleanupResult) - assertTrue( - "cleanup must run on IO even when the caller is cancelled", - cleanupThread != "test-main-thread", - ) - assertFalse( - "cleanup must never run on the main thread: $ioThreadNames", - ioThreadNames.any { it == "test-main-thread" }, - ) - assertTrue("cancellation must keep propagating", job.isCancelled) - } finally { - QuarantineExportCleanup.deleteRecursivelyForTest = null - } - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt deleted file mode 100644 index 042e2d31d..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/AvatarImportDecisionTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class AvatarImportDecisionTest { - - @Test - fun `missing global avatar clears current avatar`() { - listOf(null, "", " \t\n").forEach { globalAvatar -> - val decision = decideAvatarImport(globalAvatar, null, "/old/avatar", 10L, 20L) - assertTrue(decision.applyAvatar) - assertNull(decision.avatarPath) - assertEquals(20L, decision.avatarRevision) - } - } - - @Test - fun `failed nonnull global avatar import preserves current avatar and revision`() { - val decision = decideAvatarImport("content://global", null, "/old/avatar", 10L, 20L) - assertFalse(decision.applyAvatar) - assertEquals("/old/avatar", decision.avatarPath) - assertEquals(10L, decision.avatarRevision) - } - - @Test - fun `successful global avatar import applies new path and revision`() { - val decision = decideAvatarImport("content://global", "/new/avatar", "/old/avatar", 10L, 20L) - assertTrue(decision.applyAvatar) - assertEquals("/new/avatar", decision.avatarPath) - assertEquals(20L, decision.avatarRevision) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt deleted file mode 100644 index cb85ed990..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCanonicalTotalsTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.stats.TokenStatsBaselineTotals -import com.ai.assistance.operit.data.stats.TokenStatsCostMode -import com.ai.assistance.operit.data.stats.TokenStatsCostSummary -import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview -import com.ai.assistance.operit.data.stats.TokenStatsTokenAggregate -import com.ai.assistance.operit.data.stats.TokenStatsTotals -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * UI 总 Token 展示口径测试(阶段 3/4 修复): - * - headline 采用聚合器 canonical 总 token(不再从原始分量重组); - * - legacy baseline 只能按 input + output 合计(旧数据无细分字段); - * - includeLegacy 开关:关 = 只算事件 canonical;开 = 追加 baseline input+output。 - */ -class TokenStatsCanonicalTotalsTest { - - private fun aggregate(sum: Long, known: Long, unknown: Long, total: Long) = - TokenStatsTokenAggregate( - knownSum = sum, - knownEventCount = known, - unknownEventCount = unknown, - totalEventCount = total, - ) - - private fun cost() = - TokenStatsCostSummary( - currency = PricingCurrency.CNY, - knownAmount = 0.0, - unknownContributionCount = 0L, - totalContributionCount = 0L, - rateUsed = 7.0, - rateIsEstimated = true, - mode = TokenStatsCostMode.HISTORICAL, - originalCurrencyAmounts = emptyMap(), - ) - - private fun totals(totalTokens: TokenStatsTokenAggregate): TokenStatsTotals = - TokenStatsTotals( - requests = totalTokens.totalEventCount, - uncachedInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - cachedInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - cacheWrite = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - totalInput = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - output = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - reasoning = aggregate(0L, 0L, 0L, totalTokens.totalEventCount), - totalTokens = totalTokens, - cost = cost(), - ) - - private fun baselineTotals(input: Long, output: Long): TokenStatsBaselineTotals = - TokenStatsBaselineTotals( - identityCount = 1L, - requests = 1L, - inputTokens = input, - cachedInputTokens = 0L, - outputTokens = output, - cost = cost(), - anyEstimated = true, - ) - - @Test - fun `lifetime headline uses canonical event total and baseline input plus output`() { - val overview = - TokenStatsLifetimeOverview( - eventTotals = totals(aggregate(sum = 3_170L, known = 4L, unknown = 1L, total = 5L)), - baselineTotals = baselineTotals(input = 1_000L, output = 500L), - combinedRequests = 6L, - ) - // includeLegacy 开:事件 canonical(3_170)+ baseline(1_000+500) - assertEquals(3_170L + 1_500L, knownLifetimeTokenSum(overview, includeLegacy = true)) - // includeLegacy 关:只算事件 canonical - assertEquals(3_170L, knownLifetimeTokenSum(overview, includeLegacy = false)) - } - - @Test - fun `baseline legacy totals are input plus output`() { - assertEquals(1_500L, knownBaselineTokenSum(baselineTotals(input = 1_000L, output = 500L))) - assertEquals(0L, knownBaselineTokenSum(baselineTotals(input = 0L, output = 0L))) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt deleted file mode 100644 index 71bb4b591..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsChartsTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -/** - * 图表纯逻辑测试(P1-8 无障碍模型 + P2 折线分段): - * 只测可抽出的纯函数,不触碰 Compose UI / 仪器测试。 - */ -class TokenStatsChartsTest { - - // ==== 无障碍上一/下一桶(P1-8,边界禁用) ==== - - @Test - fun `previous and next bucket indices respect bounds`() { - // 上一桶 - assertEquals(0, previousBucketIndex(1, 3)) - assertEquals(1, previousBucketIndex(2, 3)) - assertNull(previousBucketIndex(0, 3)) // 已在最前 → 禁用 - assertNull(previousBucketIndex(1, 1)) // 单桶 → 禁用 - assertNull(previousBucketIndex(0, 0)) // 无桶 → 禁用 - - // 下一桶 - assertEquals(1, nextBucketIndex(0, 3)) - assertEquals(2, nextBucketIndex(1, 3)) - assertNull(nextBucketIndex(2, 3)) // 已在最后 → 禁用 - assertNull(nextBucketIndex(0, 1)) // 单桶 → 禁用 - assertNull(nextBucketIndex(0, 0)) // 无桶 → 禁用 - } - - @Test - fun `chart accessibility description reads summary and rows`() { - // 无明细行:只读摘要 - assertEquals( - "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", - chartAccessibilityDescription("费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", emptyList()), - ) - // 有明细行:摘要 + 行 - assertEquals( - "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000:输出 ¥0.6000,输入 ¥0.4000", - chartAccessibilityDescription( - "费用趋势,14:00,第 2 / 3 桶,合计 ¥1.0000", - listOf("输出 ¥0.6000", "输入 ¥0.4000"), - ), - ) - } - - // ==== 折线分段(P2:连接相邻点,null 断段) ==== - - @Test - fun `line segments connect adjacent points instead of segment start`() { - val p0 = Offset(0f, 0f) - val p1 = Offset(10f, 10f) - val p2 = Offset(20f, 5f) - // 回归:旧实现会得到 [p0->p1, p0->p2],正确实现是 [p0->p1, p1->p2] - assertEquals(listOf(p0 to p1, p1 to p2), lineSegments(listOf(p0, p1, p2))) - } - - @Test - fun `line segments break at null points`() { - val p0 = Offset(0f, 0f) - val p1 = Offset(10f, 10f) - val p2 = Offset(20f, 5f) - assertEquals(listOf(p1 to p2), lineSegments(listOf(p0, null, p1, p2))) - // 全空 → 无线段 - assertEquals(emptyList>(), lineSegments(listOf(null, null))) - } - - @Test - fun `lifetime token sum includes migrated baseline without overflow`() { - val legacyTotal = saturatedTokenSum(15_530_991L, 13_717_376L, 320_485L) - - assertEquals(29_568_852L, legacyTotal) - assertEquals(Long.MAX_VALUE, saturatedTokenSum(Long.MAX_VALUE, 1L)) - assertEquals(12L, includeLegacyValue(5L, 7L, true)) - assertEquals(5L, includeLegacyValue(5L, 7L, false)) - } - - @Test - fun `token trend uses token stack palette`() { - assertEquals(Color(0xFFFFD1DC), TokenStackCacheRead) - assertEquals(Color(0xFFFF85A2), TokenStackUncachedInput) - assertEquals(Color(0xFFE91E63), TokenStackOutput) - } -} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt deleted file mode 100644 index 7459481d3..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsManagementViewModelTest.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatPriceOverrideEntity -import com.ai.assistance.operit.data.stats.TokenStatsGroupMemberInfo -import com.ai.assistance.operit.data.stats.TokenStatsGroupModelInfo -import com.ai.assistance.operit.data.stats.LegacyPriceSettings -import org.junit.Assert.assertEquals -import org.junit.Test - -class TokenStatsManagementViewModelTest { - @Test - fun `pricing models combine identities configs and overrides without case duplicates`() { - val groups = listOf( - TokenStatsGroupModelInfo( - displayModelId = "gpt", - displayName = "GPT", - memberIdentityIds = listOf("id-1"), - members = listOf(TokenStatsGroupMemberInfo("id-1", "cfg-1", "OPENAI", "gpt-4o")), - ) - ) - val configs = listOf( - TokenStatsConfigOption("cfg-1", "Primary", "openai", listOf("GPT-4O")), - TokenStatsConfigOption("cfg-2", "Backup", "ANTHROPIC", listOf("sonnet")), - ) - val overrides = listOf(override("openai", "gpt-4o", "")) - - val result = buildPricingModels(groups, configs, overrides) - - assertEquals(2, result.size) - val gpt = result.first { it.model.equals("gpt-4o", true) } - assertEquals("OPENAI", gpt.provider) - assertEquals(listOf("Primary"), gpt.configs.map { it.name }) - } - - @Test - fun `pricing models retain config overrides whose config was deleted`() { - val orphan = override("openai", "gpt-4o", "deleted-config", PriceOverrideScope.CONFIG) - - val result = buildPricingModels(emptyList(), emptyList(), listOf(orphan)).single() - - assertEquals("deleted-config", result.configs.single().id) - assertEquals("deleted-config", result.configs.single().name) - } - - @Test - fun `pricing models retain legacy pricing key and avoid duplicate observed model`() { - val groups = listOf( - TokenStatsGroupModelInfo( - displayModelId = "gpt", - displayName = "GPT", - memberIdentityIds = listOf("id-1"), - members = listOf(TokenStatsGroupMemberInfo("id-1", "", "OPENAI", "gpt-4o")), - ) - ) - val legacy = LegacyPriceSettings(inputPricePerMillion = 3.0) - - val result = buildPricingModels( - groups, - emptyList(), - emptyList(), - mapOf("OPENAI:gpt-4o" to legacy), - ).single() - - assertEquals("OPENAI:gpt-4o", result.legacyProviderModel) - assertEquals(legacy, result.legacyPricing) - } - - @Test - fun `same provider and model expose each api configuration independently`() { - val groups = listOf( - TokenStatsGroupModelInfo( - displayModelId = "deepseek", - displayName = "DeepSeek", - memberIdentityIds = listOf("official-id", "relay-id"), - members = listOf( - TokenStatsGroupMemberInfo("official-id", "official", "DEEPSEEK", "deepseek-chat"), - TokenStatsGroupMemberInfo("relay-id", "relay", "DEEPSEEK", "deepseek-chat"), - ), - ) - ) - val configs = listOf( - TokenStatsConfigOption( - id = "official", - name = "官方配置", - provider = "DEEPSEEK", - models = listOf("deepseek-chat"), - endpoint = "https://api.deepseek.com", - ), - TokenStatsConfigOption( - id = "relay", - name = "中转站", - provider = "DEEPSEEK", - models = listOf("deepseek-chat"), - endpoint = "https://relay.example.com", - ), - ) - val overrides = listOf( - override("DEEPSEEK", "deepseek-chat", "official", PriceOverrideScope.CONFIG), - override("DEEPSEEK", "deepseek-chat", "relay", PriceOverrideScope.CONFIG), - ) - - val result = buildPricingModels(groups, configs, overrides).single() - - assertEquals(listOf("official", "relay"), result.configs.map { it.id }) - assertEquals( - listOf("https://api.deepseek.com", "https://relay.example.com"), - result.configs.map { it.endpoint }, - ) - } - - private fun override( - provider: String, - model: String, - configId: String, - scope: PriceOverrideScope = PriceOverrideScope.PROVIDER_MODEL, - ) = TokenStatPriceOverrideEntity.normalized( - scope = scope.name, - provider = provider, - model = model, - configId = configId, - billingMode = "TOKEN", - pricingCurrency = "USD", - inputPricePerMillion = 1.0, - outputPricePerMillion = 2.0, - ) -} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt deleted file mode 100644 index 22a349019..000000000 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModelTest.kt +++ /dev/null @@ -1,1466 +0,0 @@ -package com.ai.assistance.operit.ui.features.tokenstats - -import android.content.Context -import androidx.room.Room -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.dao.TokenStatsDao -import com.ai.assistance.operit.data.db.AppDatabase -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.model.PriceOverrideScope -import com.ai.assistance.operit.data.model.TokenStatBaselineEntity -import com.ai.assistance.operit.data.model.TokenStatDisplayModelEntity -import com.ai.assistance.operit.data.model.TokenStatEventEntity -import com.ai.assistance.operit.data.model.TokenStatIdentityEntity -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.stats.JdbcSQLiteDriver -import com.ai.assistance.operit.data.stats.TokenCostCurrency -import com.ai.assistance.operit.data.stats.TokenStatCategory -import com.ai.assistance.operit.data.stats.TokenStatIdentityResolver -import com.ai.assistance.operit.data.stats.TokenStatStatus -import com.ai.assistance.operit.data.stats.TokenStatsCostMode -import com.ai.assistance.operit.data.stats.TokenStatsLedger -import com.ai.assistance.operit.data.stats.TokenStatsPreset -import com.ai.assistance.operit.data.stats.TokenStatsQueryService -import com.ai.assistance.operit.data.stats.TokenStatsResetCoordinator -import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore -import com.ai.assistance.operit.data.stats.TokenStatsReadiness -import com.ai.assistance.operit.data.stats.TokenStatsStartupCoordinator -import com.ai.assistance.operit.data.stats.TokenStatsTimeSelection -import com.ai.assistance.operit.data.stats.TokenStatsPriceOverrideDraft -import java.io.File -import java.time.ZoneId -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.setMain -import kotlinx.coroutines.withTimeout -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.whenever - -/** - * 统计页 ViewModel 逻辑测试(阶段 4,JVM + 真实 Room + 内存假偏好存储): - * 首次自动回退与手选锁定、自定义范围校验、筛选刷新、设置保存与持久化、 - * 价格覆盖与分组操作后刷新。不触碰 DataStore/真机。 - */ -@OptIn(ExperimentalCoroutinesApi::class) -class TokenUsageStatisticsViewModelTest { - - private lateinit var tempDir: File - private lateinit var database: AppDatabase - private lateinit var dao: TokenStatsDao - private lateinit var context: Context - private lateinit var settings: FakeSettingsStore - - private val shanghai = ZoneId.of("Asia/Shanghai") - private val nowMs = localMs("2026-08-07T15:00:00", shanghai) - - private fun localMs(dateTime: String, zone: ZoneId): Long = - java.time.LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() - - @Before - fun setUp() { - Dispatchers.setMain(UnconfinedTestDispatcher()) - // ApiPreferences 是 JVM 级单例:删除“全部 + baseline”路径会走真实 DataStore, - // 每个测试前清空单例,保证绑定到本测试的临时目录(与 ApiPreferencesResetFailureTest - // 的隔离方式一致,避免跨测试共享 DataStore 文件)。 - clearApiPreferencesSingletons() - tempDir = kotlin.io.path.createTempDirectory("token-vm-test").toFile() - context = mockContext(tempDir) - database = - Room.databaseBuilder(context, AppDatabase::class.java, "app_database") - .setDriver(JdbcSQLiteDriver()) - .addMigrations(AppDatabase.MIGRATION_20_21) - .allowMainThreadQueries() - .build() - dao = database.tokenStatsDao() - settings = FakeSettingsStore() - TokenStatsQueryService.databaseProvider = { database } - TokenStatsQueryService.legacyPricesProvider = { emptyMap() } - TokenStatsQueryService.queryDispatcher = UnconfinedTestDispatcher() - // 阶段 5 删除走 TokenStatsResetCoordinator(spool replay 用注入的数据库) - TokenStatsResetCoordinator.daoProvider = { dao } - TokenStatsLedger.databaseProvider = { database } - } - - @After - fun tearDown() { - TokenStatsQueryService.databaseProvider = null - TokenStatsQueryService.legacyPricesProvider = null - TokenStatsQueryService.queryDispatcher = Dispatchers.IO - TokenStatsResetCoordinator.daoProvider = null - TokenStatsLedger.databaseProvider = null - database.close() - Dispatchers.resetMain() - } - - /** 清空 `Context.apiDataStore` 委托缓存与 ApiPreferences INSTANCE(跨测试隔离)。 */ - private fun clearApiPreferencesSingletons() { - val facade = Class.forName("com.ai.assistance.operit.data.preferences.ApiPreferencesKt") - val delegateField = facade.getDeclaredField("apiDataStore\$delegate") - delegateField.isAccessible = true - val delegate = delegateField.get(null) - val instanceField = - delegate.javaClass.getDeclaredField("INSTANCE").apply { isAccessible = true } - instanceField.set(delegate, null) - val prefsInstanceField = - ApiPreferences::class.java.getDeclaredField("INSTANCE").apply { isAccessible = true } - prefsInstanceField.set(null, null) - } - - private fun mockContext(filesDir: File): Context { - val context = Mockito.mock(Context::class.java) - whenever(context.applicationContext).thenReturn(context) - whenever(context.packageName).thenReturn("com.ai.assistance.operit") - whenever(context.filesDir).thenReturn(filesDir) - whenever(context.getDatabasePath(any())).thenAnswer { invocation -> - File(filesDir, invocation.getArgument(0)) - } - return context - } - - /** - * 直接以 DataStore 的 protobuf 文件格式(datastore/.preferences_pb)写入 - * legacy 累计键(token_input_/token_cached_input_/token_output_)。 - * Windows JVM 上同一 DataStore 文件每测试只能经 DataStore 写入一次(见 - * ApiPreferencesResetFailureTest 类注释:二次写入的原子替换 rename 失败),因此 - * 种子键绕过 DataStore 写管线、用库自身 PreferencesProto 编码直接落盘,把唯一 - * 一次真实 DataStore 写入留给被测删除流程(生产路径不受该测试环境约束)。 - * 键名/文件路径与 `preferencesDataStore(name = "api_settings")` 约定一致。 - */ - private fun seedLegacyTokenCountsFile( - filesDir: File, - providerModel: String, - input: Long, - cached: Long, - output: Long, - ) { - val datastoreDir = File(filesDir, "datastore") - check(datastoreDir.mkdirs() || datastoreDir.isDirectory) - val mapBuilder = androidx.datastore.preferences.PreferencesProto.PreferenceMap.newBuilder() - fun putLong(key: String, value: Long) { - mapBuilder.putPreferences( - key, - androidx.datastore.preferences.PreferencesProto.Value.newBuilder() - .setLong(value) - .build(), - ) - } - putLong("token_input_${providerModel.replace(":", "_")}", input) - putLong("token_cached_input_${providerModel.replace(":", "_")}", cached) - putLong("token_output_${providerModel.replace(":", "_")}", output) - File(datastoreDir, "api_settings.preferences_pb").outputStream().use { output -> - mapBuilder.build().writeTo(output) - } - } - - private fun constructViewModel(): TokenUsageStatisticsViewModel = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - // JVM 测试:Room 在后台线程恢复协程,非 Main 调度器 + 假文案, - // 避免 TestMainDispatcher/不可 mock 的 Context.getString - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - // P1 关键链路:readiness 门控注入 no-op(不触碰真实 spool/协调器) - readiness = TokenStatsReadiness { true }, - ) - - private fun newViewModel(): TokenUsageStatisticsViewModel = - constructViewModel().also { it.loadForEntry() } - - /** - * 等待异步查询落定:Room 在 arch 后台线程恢复协程,不能靠虚拟时间推进; - * 用 refreshVersion 单调递增判断“本次操作触发的查询已完成”。 - */ - private fun awaitRefresh(viewModel: TokenUsageStatisticsViewModel, fromVersion: Long) { - val deadline = System.currentTimeMillis() + 15_000 - while (viewModel.state.value.refreshVersion <= fromVersion) { - if (System.currentTimeMillis() > deadline) { - fail("timed out waiting for refresh (loading=${viewModel.state.value.loading})") - } - Thread.sleep(10) - } - } - - private fun awaitActionMessage(viewModel: TokenUsageStatisticsViewModel) { - val deadline = System.currentTimeMillis() + 15_000 - while (viewModel.actionMessage.value == null) { - if (System.currentTimeMillis() > deadline) { - fail("timed out waiting for action message") - } - Thread.sleep(10) - } - } - - private suspend fun seedIdentity( - identityId: String, - configId: String = "cfg-1", - provider: String = "OPENAI", - model: String = "gpt-4o", - displayModelId: String = TokenStatIdentityResolver.displayModelIdFor(model), - ) { - dao.insertIdentityIfAbsent( - TokenStatIdentityEntity( - identityId = identityId, - configId = configId, - provider = provider, - model = model, - displayModelId = displayModelId, - ) - ) - dao.upsertDisplayModel( - TokenStatDisplayModelEntity( - displayModelId = displayModelId, - normalizedModel = TokenStatIdentityResolver.normalizeModelName(model), - displayName = model, - ) - ) - } - - private suspend fun seedBaseline(identityId: String, requestCount: Long = 3L) { - dao.upsertBaseline( - TokenStatBaselineEntity( - identityId = identityId, - inputTokens = 100L * requestCount, - cachedInputTokens = 0L, - outputTokens = 50L * requestCount, - requestCount = requestCount, - pricingCurrency = PricingCurrency.USD.name, - costInPricingCurrency = 0.01 * requestCount, - isEstimated = true, - fingerprint = "fp-$identityId", - importedAtMs = 1L, - frozenBillingMode = BillingMode.TOKEN.name, - frozenInputPricePerMillion = 1.0, - frozenOutputPricePerMillion = 2.0, - ) - ) - } - - private fun event( - id: String, - identityId: String, - startedAtMs: Long, - status: String = TokenStatStatus.COMPLETED.name, - category: String = TokenStatCategory.CHAT.name, - cost: Double? = 0.01, - ): TokenStatEventEntity = - TokenStatEventEntity( - eventId = id, - statIdentityId = identityId, - category = category, - status = status, - acceptedGeneration = 0L, - startedAtMs = startedAtMs, - endedAtMs = startedAtMs + 1_000L, - firstTokenAtMs = startedAtMs + 200L, - uncachedInputTokens = 100L, - cachedInputTokens = 0L, - cacheWriteTokens = 0L, - totalInputTokens = null, - outputTokens = 50L, - reasoningTokens = null, - reasoningIncludedInOutput = true, - cacheWriteSeparateBilling = false, - billingMode = BillingMode.TOKEN.name, - pricingCurrency = PricingCurrency.USD.name, - inputPricePerMillion = 1.5, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 6.0, - pricePerRequest = null, - pricingSource = com.ai.assistance.operit.data.stats.PricingSource.DEFAULT.name, - costInPricingCurrency = cost, - diagnosticsJson = null, - ) - - // ==== 首次自动回退 ==== - - @Test - fun `construction does not load and each route entry loads once`() { - val viewModel = constructViewModel() - Thread.sleep(50) - assertEquals(0L, viewModel.state.value.refreshVersion) - - viewModel.loadForEntry() - awaitRefresh(viewModel, 0) - assertEquals(1L, viewModel.state.value.refreshVersion) - - viewModel.loadForEntry() - awaitRefresh(viewModel, 1) - assertEquals(2L, viewModel.state.value.refreshVersion) - } - - @Test - fun `initial fallback picks first preset with data and persists it as auto`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 - dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - assertEquals(TokenStatsPreset.LAST_7D, viewModel.state.value.selectedPreset) - assertFalse(viewModel.state.value.userChoseTime) - // 自动回退结果持久化为 manual=false,下次进入仍可按最新数据重新探测。 - assertNotNull(settings.savedSelection) - assertEquals(TokenStatsPreset.LAST_7D, settings.savedSelection!!.preset) - assertFalse(settings.savedManual) - assertEquals(1, settings.timeSelectionSaveCount) - } - - @Test - fun `second viewmodel rechecks persisted automatic fallback on entry`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - // 事件只在 6 天前:5h/12h/24h 空,7d 有数据 - dao.insertEvent(event("e1", "id-1", nowMs - 6L * 24 * 3600_000L + 12 * 3600_000L)) - } - val vm1 = newViewModel() - awaitRefresh(vm1, 0) - assertEquals(TokenStatsPreset.LAST_7D, vm1.state.value.selectedPreset) - assertEquals(1, settings.timeSelectionSaveCount) - - // 清空事件后再次进入:全部范围为空,自动回退应回到最短 5h。 - runBlocking { dao.deleteAllEvents() } - val vm2 = newViewModel() - awaitRefresh(vm2, 0) - assertEquals(TokenStatsPreset.LAST_5H, vm2.state.value.selectedPreset) - assertFalse(vm2.state.value.userChoseTime) - assertEquals(2, settings.timeSelectionSaveCount) - assertEquals(0L, vm2.state.value.range?.eventCount) - } - - @Test - fun `manual rolling selection with data stays locked`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - // 24h 内有数据,保留用户手动选择,不缩短到 5h。 - dao.insertEvent(event("e1", "id-1", nowMs - 20 * 3600_000L)) - settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) - settings.savedManual = true - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - // 手动范围本身有数据 → 不自动跳转 - assertEquals(TokenStatsPreset.LAST_24H, viewModel.state.value.selectedPreset) - assertTrue(viewModel.state.value.userChoseTime) - assertEquals(1L, viewModel.state.value.range?.eventCount) - // 已有选择 → 本次 load 不再保存 - assertEquals(0, settings.timeSelectionSaveCount) - } - - @Test - fun `empty manual rolling selection expands on entry`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - // 手选 24h 无数据,但 7d 有数据,进入页面时自动扩展。 - dao.insertEvent(event("e1", "id-1", nowMs - 30 * 3600_000L)) - settings.savedSelection = TokenStatsTimeSelection(TokenStatsPreset.LAST_24H) - settings.savedManual = true - } - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - assertEquals(TokenStatsPreset.LAST_7D, viewModel.state.value.selectedPreset) - assertFalse(viewModel.state.value.userChoseTime) - assertEquals(1L, viewModel.state.value.range?.eventCount) - assertFalse(settings.savedManual) - assertEquals(1, settings.timeSelectionSaveCount) - } - - // ==== 自定义范围 ==== - - @Test - fun `custom range rejects invalid and accepts valid bounds`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 2 * 3600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val start = nowMs - 24 * 3600_000L - - // end <= start → 拒绝,不改变选择 - val rejected = viewModel.setCustomRange(start, start) - assertFalse(rejected) - assertNotNull(viewModel.actionMessage.value) - assertEquals(TokenStatsPreset.LAST_5H, viewModel.state.value.selectedPreset) - - // 超过 3 年 → 拒绝 - val tooEarly = java.time.Instant.ofEpochMilli(start) - .atZone(shanghai) - .toLocalDate() - .minusDays(TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS + 1L) - .atStartOfDay(shanghai) - .toInstant() - .toEpochMilli() - val tooLong = - viewModel.setCustomRange( - tooEarly, - start, - ) - assertFalse(tooLong) - - // 合法范围 → 应用并查询 - val versionBefore = viewModel.state.value.refreshVersion - val accepted = viewModel.setCustomRange(start, nowMs) - assertTrue(accepted) - awaitRefresh(viewModel, versionBefore) - assertEquals(TokenStatsPreset.CUSTOM, viewModel.state.value.selectedPreset) - assertTrue(viewModel.state.value.userChoseTime) - assertNotNull(viewModel.state.value.customRange) - assertEquals(1L, viewModel.state.value.range?.eventCount) - // 持久化(manual=true:用户手动选择) - assertNotNull(settings.savedSelection) - assertEquals(TokenStatsPreset.CUSTOM, settings.savedSelection!!.preset) - assertTrue(settings.savedManual) - } - - // ==== 筛选刷新 ==== - - @Test - fun `model filter refreshes range data`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L), - event("e2", "id-2", nowMs - 3_600_000L), - ) - ) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - assertEquals(2, viewModel.state.value.range?.displayModels?.size) - - val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - val v1 = viewModel.state.value.refreshVersion - viewModel.toggleModel(gptId) - awaitRefresh(viewModel, v1) - val filtered = viewModel.state.value.range - assertEquals(1, filtered?.displayModels?.size) - assertEquals(gptId, filtered?.displayModels?.single()?.displayModelId) - assertEquals(1L, filtered?.eventCount) - - // 再点一次 → 全部 - val v2 = viewModel.state.value.refreshVersion - viewModel.toggleModel(gptId) - awaitRefresh(viewModel, v2) - assertEquals(2, viewModel.state.value.range?.displayModels?.size) - } - - @Test - fun `category and status filters refresh range data`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L), - event( - "e2", "id-1", nowMs - 2 * 3_600_000L, - category = TokenStatCategory.CONNECTION_TEST.name, - ), - event( - "e3", "id-1", nowMs - 3 * 3_600_000L, - status = TokenStatStatus.FAILED.name, - ), - ) - ) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - assertEquals(3L, viewModel.state.value.range?.eventCount) - - val v1 = viewModel.state.value.refreshVersion - viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) - awaitRefresh(viewModel, v1) - val byCategory = viewModel.state.value.range - assertEquals(1L, byCategory?.eventCount) - assertEquals( - setOf(TokenStatCategory.CONNECTION_TEST), - byCategory?.categories?.map { it.category }?.toSet(), - ) - - val v2 = viewModel.state.value.refreshVersion - viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) - awaitRefresh(viewModel, v2) - assertEquals(3L, viewModel.state.value.range?.eventCount) - - val v3 = viewModel.state.value.refreshVersion - viewModel.toggleStatus(TokenStatStatus.FAILED) - awaitRefresh(viewModel, v3) - assertEquals(1L, viewModel.state.value.range?.eventCount) - } - - // ==== P1-5:模型下拉选项不受模型筛选影响 ==== - - @Test - fun `model dropdown options stay complete after selecting a model`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L, category = TokenStatCategory.CHAT.name), - event( - "e2", "id-2", nowMs - 3_600_000L, - category = TokenStatCategory.CONNECTION_TEST.name, - ), - ) - ) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - val claudeId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") - assertEquals(2, viewModel.state.value.availableDisplayModels.size) - - // 选 A(gpt)后:结果只剩 A,但下拉选项仍含 B(P1-5) - val v1 = viewModel.state.value.refreshVersion - viewModel.toggleModel(gptId) - awaitRefresh(viewModel, v1) - assertEquals( - setOf(gptId), - viewModel.state.value.range?.displayModels?.map { it.displayModelId }?.toSet(), - ) - assertEquals( - setOf(gptId, claudeId), - viewModel.state.value.availableDisplayModels.map { it.displayModelId }.toSet(), - ) - - // 再选 B → AB 同时选中,结果恢复两个模型 - val v2 = viewModel.state.value.refreshVersion - viewModel.toggleModel(claudeId) - awaitRefresh(viewModel, v2) - assertEquals(setOf(gptId, claudeId), viewModel.state.value.selectedModels) - assertEquals(2, viewModel.state.value.range?.displayModels?.size) - - // B 被分类筛选出当前结果(也离开 available)后,仍保留在选项中可显示 - val v3 = viewModel.state.value.refreshVersion - viewModel.toggleCategory(TokenStatCategory.CHAT) - awaitRefresh(viewModel, v3) - val range = viewModel.state.value.range - assertEquals( - setOf(gptId), - range?.displayModels?.map { it.displayModelId }?.toSet(), - ) - assertFalse(viewModel.state.value.availableDisplayModels.any { it.displayModelId == claudeId }) - assertEquals("claude-3-5-sonnet", viewModel.state.value.knownModelNames[claudeId]) - assertTrue(claudeId in viewModel.state.value.selectedModels) - } - - // ==== P2:全选/清空只触发一次查询 ==== - - @Test - fun `clearing all categories or statuses triggers exactly one load`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L), - event( - "e2", "id-1", nowMs - 2 * 3_600_000L, - category = TokenStatCategory.CONNECTION_TEST.name, - ), - event( - "e3", "id-1", nowMs - 3 * 3_600_000L, - status = TokenStatStatus.FAILED.name, - ), - ) - ) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - // 先选中两个分类(各自一次查询),再一键清空:必须只再查询一次 - val v0 = viewModel.state.value.refreshVersion - viewModel.toggleCategory(TokenStatCategory.CHAT) - awaitRefresh(viewModel, v0) - val v1 = viewModel.state.value.refreshVersion - viewModel.toggleCategory(TokenStatCategory.CONNECTION_TEST) - awaitRefresh(viewModel, v1) - assertEquals(2, viewModel.state.value.selectedCategories!!.size) - - val beforeClear = viewModel.state.value.refreshVersion - viewModel.clearCategories() - awaitRefresh(viewModel, beforeClear) - assertEquals(beforeClear + 1, viewModel.state.value.refreshVersion) - assertNull(viewModel.state.value.selectedCategories) - - // 状态同理 - val v2 = viewModel.state.value.refreshVersion - viewModel.toggleStatus(TokenStatStatus.COMPLETED) - awaitRefresh(viewModel, v2) - val v3 = viewModel.state.value.refreshVersion - viewModel.toggleStatus(TokenStatStatus.FAILED) - awaitRefresh(viewModel, v3) - assertEquals(2, viewModel.state.value.selectedStatuses!!.size) - - val beforeClearStatus = viewModel.state.value.refreshVersion - viewModel.clearStatuses() - awaitRefresh(viewModel, beforeClearStatus) - assertEquals(beforeClearStatus + 1, viewModel.state.value.refreshVersion) - assertNull(viewModel.state.value.selectedStatuses) - } - - // ==== P1 关键链路:readiness 门控 ==== - - @Test - fun `first query waits for the readiness gate before loading data`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val gate = CompletableDeferred() - val readiness = TokenStatsReadiness { _ -> - gate.await() - true - } - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - readiness = readiness, - ) - vm.loadForEntry() - // 门控挂起期间:首次查询不得完成(loading 保持、无结果、无版本推进) - assertTrue(vm.state.value.loading) - assertEquals(0L, vm.state.value.refreshVersion) - assertNull(vm.state.value.range) - // 释放门控:查询执行并携带数据完成 - gate.complete(Unit) - awaitRefresh(vm, 0) - assertEquals(1L, vm.state.value.range!!.eventCount) - assertFalse(vm.state.value.loading) - } - - @Test - fun `not ready within timeout falls back to query then auto-refreshes after readiness`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val calls = java.util.concurrent.atomic.AtomicInteger(0) - val timeouts = mutableListOf() - val gate = CompletableDeferred() - val readiness = TokenStatsReadiness { timeoutMs -> - timeouts += timeoutMs - if (calls.getAndIncrement() == 0) { - // 首次:模拟超时未就绪——VM 必须先按现状查询,不能无限等待 - false - } else { - gate.await() - true - } - } - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - readiness = readiness, - ) - vm.loadForEntry() - // 首次未就绪:按现状完成查询(pre-replay 快照可暂时展示,但不无限停留) - awaitRefresh(vm, 0) - assertEquals(1L, vm.state.value.range!!.eventCount) - // 后台等待就绪:完成后自动刷新(第二次查询) - gate.complete(Unit) - val versionAfterFallback = vm.state.value.refreshVersion - awaitRefresh(vm, versionAfterFallback) - assertEquals(1L, vm.state.value.range!!.eventCount) - assertFalse(vm.state.value.loading) - assertEquals("readiness must be awaited exactly twice (gate + refresh)", 2, calls.get()) - assertEquals(listOf(5_000L, 65_000L), timeouts) - } - - @Test - fun `initial timeout joins delayed coordinator flight and auto loads once`() { - val migrationCalls = java.util.concurrent.atomic.AtomicInteger(0) - val drainCalls = java.util.concurrent.atomic.AtomicInteger(0) - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = 500L - TokenStatsStartupCoordinator.ensureMigratedStep = { - migrationCalls.incrementAndGet() - kotlinx.coroutines.delay(200) - true - } - TokenStatsStartupCoordinator.consumePendingRestoreStep = { true } - TokenStatsStartupCoordinator.initialDrainStep = { _, remainingMs -> - assertTrue(remainingMs in 1L until 500L) - drainCalls.incrementAndGet() - true - } - try { - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - readiness = TokenStatsStartupCoordinator.readiness(context), - readinessInitialWaitMs = 10L, - readinessRefreshWaitMs = 600L, - ) - vm.loadForEntry() - - awaitRefresh(vm, 0) - val fallbackVersion = vm.state.value.refreshVersion - awaitRefresh(vm, fallbackVersion) - Thread.sleep(100) - assertEquals(fallbackVersion + 1, vm.state.value.refreshVersion) - assertEquals("both waits must share one initialization", 1, migrationCalls.get()) - assertEquals(1, drainCalls.get()) - } finally { - TokenStatsStartupCoordinator.ensureMigratedStep = null - TokenStatsStartupCoordinator.consumePendingRestoreStep = null - TokenStatsStartupCoordinator.initialDrainStep = null - TokenStatsStartupCoordinator.initializationTimeoutMsForTest = null - } - } - - @Test - fun `failed background readiness can retry without duplicate refresh tasks`() { - val calls = java.util.concurrent.atomic.AtomicInteger(0) - val secondAttempt = CompletableDeferred() - val releaseSecondAttempt = CompletableDeferred() - val readiness = TokenStatsReadiness { _ -> - when (calls.incrementAndGet()) { - 1 -> false // initial gate - 2 -> { - secondAttempt.complete(Unit) - releaseSecondAttempt.await() - false // first full-lifecycle subscription fails - } - else -> true - } - } - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - readiness = readiness, - ) - vm.loadForEntry() - runBlocking { withTimeout(5_000) { secondAttempt.await() } } - - val initialVersion = vm.state.value.refreshVersion - vm.load() - vm.load() - assertEquals("active readiness subscription must remain single-flight in the VM", 2, calls.get()) - releaseSecondAttempt.complete(Unit) - awaitRefresh(vm, initialVersion) - - val beforeRetry = vm.state.value.refreshVersion - vm.load() - awaitRefresh(vm, beforeRetry) - Thread.sleep(100) - assertEquals("successful readiness must trigger exactly one load", beforeRetry + 1, vm.state.value.refreshVersion) - assertEquals("later load must retry readiness once", 3, calls.get()) - } - - // ==== P1-4:旧 load 不得污染共享 state ==== - - @Test - fun `stale load cannot overwrite newer load result`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val gated = GatedSettingsStore() - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = gated, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = Dispatchers.Unconfined, - readiness = TokenStatsReadiness { true }, - ) - vm.loadForEntry() - // 第一次 load 卡在偏好读取(构造期间已挂起,尚未写任何 state) - runBlocking { withTimeout(5_000) { gated.firstLoadStarted.await() } } - - // 第二次 load:汇率已改 → 完成后 state 必须是最新参数 - gated.savedRate = 7.5 - gated.rateEstimated = false - val v = vm.state.value.refreshVersion - vm.load() - awaitRefresh(vm, v) - assertEquals(7.5, vm.state.value.manualRate, 0.0) - assertFalse(vm.state.value.rateIsEstimated) - val versionAfterSecond = vm.state.value.refreshVersion - - // 释放 gate:旧 load 已被取消(Job cancel),不得再写 state - gated.gate.complete(Unit) - val deadline = System.currentTimeMillis() + 5_000 - while (vm.state.value.refreshVersion != versionAfterSecond) { - if (System.currentTimeMillis() > deadline) { - fail("stale load overwrote newer state") - } - Thread.sleep(10) - } - assertEquals(7.5, vm.state.value.manualRate, 0.0) - assertFalse(vm.state.value.rateIsEstimated) - assertFalse(vm.state.value.loading) - } - - // ==== P1-3:生命周期 ==== - - @Test - fun `viewmodel clear cancels pending load before it writes state`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val dispatcher = StandardTestDispatcher() - val vm = - TokenUsageStatisticsViewModel( - context = context, - settings = settings, - zone = shanghai, - nowMs = { nowMs }, - dao = dao, - stringResolver = { "msg-$it" }, - dispatcher = dispatcher, - readiness = TokenStatsReadiness { true }, - ) - vm.loadForEntry() - // load 已入队但未执行;ViewModelStore.clear() 触发 onCleared → - // viewModelScope 取消 → 任务不运行、不写 state、不执行首次回退持久化 - val store = androidx.lifecycle.ViewModelStore() - store.put("token-stats", vm) - store.clear() - dispatcher.scheduler.advanceUntilIdle() - assertEquals(0L, vm.state.value.refreshVersion) - assertTrue(vm.state.value.loading) // 初始值,未被 load 改写 - assertEquals(0, settings.timeSelectionSaveCount) - } - - // ==== 设置保存 ==== - - @Test - fun `currency cost mode and rate changes persist and refresh`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - assertEquals(PricingCurrency.CNY, viewModel.state.value.targetCurrency) - assertTrue(viewModel.state.value.rateIsEstimated) - assertEquals(TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, viewModel.state.value.manualRate, 0.0) - - val v1 = viewModel.state.value.refreshVersion - viewModel.setTargetCurrency(PricingCurrency.USD) - awaitRefresh(viewModel, v1) - assertEquals(PricingCurrency.USD, viewModel.state.value.targetCurrency) - assertEquals(PricingCurrency.USD, settings.savedCurrency) - - val v2 = viewModel.state.value.refreshVersion - viewModel.setCostMode(TokenStatsCostMode.REVALUED) - awaitRefresh(viewModel, v2) - assertEquals(TokenStatsCostMode.REVALUED, viewModel.state.value.costMode) - assertEquals(TokenStatsCostMode.REVALUED, settings.savedMode) - - viewModel.setIncludeLegacy(false) - assertFalse(viewModel.state.value.includeLegacy) - assertFalse(settings.savedIncludeLegacy) - - // 手动汇率:合法保存后不再标记估算 - val v3 = viewModel.state.value.refreshVersion - assertTrue(viewModel.setManualRate(7.35)) - awaitRefresh(viewModel, v3) - assertEquals(7.35, viewModel.state.value.manualRate, 0.0) - assertFalse(viewModel.state.value.rateIsEstimated) - assertEquals(7.35, settings.savedRate, 0.0) - - // 非法汇率:拒绝且不持久化 - assertFalse(viewModel.setManualRate(-1.0)) - assertFalse(viewModel.setManualRate(1e-50)) - assertFalse(viewModel.setManualRate(1e50)) - assertFalse(viewModel.setManualRate(Double.NaN)) - assertFalse(viewModel.setManualRate(Double.POSITIVE_INFINITY)) - assertEquals(7.35, settings.savedRate, 0.0) - } - - // ==== 价格覆盖与分组操作 ==== - - @Test - fun `price override save updates overrides and negative value fails with message`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - val v1 = viewModel.state.value.refreshVersion - viewModel.upsertPriceOverride( - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 8.0, - pricePerRequest = null, - ) - ) - awaitRefresh(viewModel, v1) - assertEquals(1, viewModel.state.value.overrides.size) - - viewModel.upsertPriceOverride( - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = -2.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 8.0, - pricePerRequest = null, - ) - ) - awaitActionMessage(viewModel) - assertTrue(viewModel.actionMessage.value!!.isError) - assertEquals(1, viewModel.state.value.overrides.size) - } - - @Test - fun `editing price override keeps business key and only updates values`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - val draft = - TokenStatsPriceOverrideDraft( - scope = PriceOverrideScope.PROVIDER_MODEL, - provider = "OPENAI", - model = "gpt-4o", - configId = null, - billingMode = BillingMode.TOKEN, - currency = PricingCurrency.USD, - inputPricePerMillion = 2.0, - cachedInputPricePerMillion = null, - cacheWritePricePerMillion = null, - outputPricePerMillion = 8.0, - pricePerRequest = null, - ) - val v1 = viewModel.state.value.refreshVersion - viewModel.upsertPriceOverride(draft) - awaitRefresh(viewModel, v1) - val existing = viewModel.state.value.overrides.single() - - // 同键编辑 → 更新成功,仍只有一行(P1-7) - val v2 = viewModel.state.value.refreshVersion - viewModel.updatePriceOverride(existing, draft.copy(outputPricePerMillion = 9.0)) - awaitRefresh(viewModel, v2) - assertEquals(1, viewModel.state.value.overrides.size) - assertEquals(9.0, viewModel.state.value.overrides.single().outputPricePerMillion!!, 0.0) - - // 改业务键 → manager 拒绝:行不变 + 错误消息(P1-7) - val v3 = viewModel.state.value.refreshVersion - viewModel.updatePriceOverride(existing, draft.copy(provider = "ANTHROPIC")) - awaitActionMessage(viewModel) - assertTrue(viewModel.actionMessage.value!!.isError) - assertEquals(1, viewModel.state.value.overrides.size) - assertEquals("openai", viewModel.state.value.overrides.single().provider) - assertEquals(v3, viewModel.state.value.refreshVersion) - } - - @Test - fun `group rename and create reflect in range display models`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - seedIdentity("id-2", configId = "cfg-2") - dao.insertEvents( - listOf( - event("e1", "id-1", nowMs - 3_600_000L), - event("e2", "id-2", nowMs - 3_600_000L), - ) - ) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - - val v1 = viewModel.state.value.refreshVersion - viewModel.renameDisplayGroup(gptId, "GPT-4 主力") - awaitRefresh(viewModel, v1) - assertEquals("GPT-4 主力", viewModel.state.value.range?.displayModels?.single()?.displayName) - - val v2 = viewModel.state.value.refreshVersion - viewModel.createGroupAndMerge("组合模型", listOf("id-1", "id-2")) - awaitRefresh(viewModel, v2) - val models = viewModel.state.value.range?.displayModels.orEmpty() - assertEquals(1, models.size) - assertEquals("组合模型", models.single().displayName) - assertEquals(2, models.single().identities.size) - } - - // ==== P1 修复:分组元数据与统计筛选无关 ==== - - @Test - fun `group metadata stays complete when range only shows one member`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-2", configId = "cfg-2", model = "claude-3-5-sonnet") - // 只有 A 在 5h 预设范围内有事件;B 的事件在 8 天前(不在范围) - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - dao.insertEvent(event("e2", "id-2", nowMs - 8L * 24 * 3600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val gptId = TokenStatIdentityResolver.displayModelIdFor("gpt-4o") - val claudeId = TokenStatIdentityResolver.displayModelIdFor("claude-3-5-sonnet") - - // 范围明细(筛选结果)只含当前范围有事件的身份/分组:只看到 A - assertEquals(1, viewModel.state.value.range?.displayModels?.size) - assertEquals(gptId, viewModel.state.value.range?.displayModels?.single()?.displayModelId) - - // 完整分组元数据含两个分组与各自的完整成员(不受事件/筛选影响) - val groups = viewModel.state.value.groupModels.associateBy { it.displayModelId } - assertEquals(setOf(gptId, claudeId), groups.keys) - assertEquals(listOf("id-1"), groups.getValue(gptId).memberIdentityIds) - assertEquals(listOf("id-2"), groups.getValue(claudeId).memberIdentityIds) - - // 对话框按完整成员 id 执行合并(UI 从 groupModels 取):两个身份都被移动 - val v1 = viewModel.state.value.refreshVersion - viewModel.createGroupAndMerge("组合", listOf("id-1", "id-2")) - awaitRefresh(viewModel, v1) - val groupId = kotlinx.coroutines.runBlocking { - dao.getAllDisplayModels().first { it.displayName == "组合" }.displayModelId - } - assertEquals( - setOf(groupId), - kotlinx.coroutines.runBlocking { - dao.getAllIdentities().map { it.displayModelId }.toSet() - }, - ) - // 刷新后的元数据同步:新组合组含完整成员;原默认组行保留但已无成员 - // (空组仍是合法目标,见 manager 语义) - val groupsAfter = viewModel.state.value.groupModels - assertEquals( - setOf("id-1", "id-2"), - groupsAfter.first { it.displayModelId == groupId }.memberIdentityIds.toSet(), - ) - assertTrue( - groupsAfter.filter { it.displayModelId != groupId } - .all { it.memberIdentityIds.isEmpty() } - ) - } - - @Test - fun `group without events in range is still available as merge target`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", model = "gpt-4o") - seedIdentity("id-3", configId = "cfg-3", model = "gemini-2.0-flash") - // 只有 gpt 有事件;gemini 组完全无事件 - dao.insertEvent(event("e1", "id-1", nowMs - 3_600_000L)) - } - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val geminiId = TokenStatIdentityResolver.displayModelIdFor("gemini-2.0-flash") - - // 范围明细看不到 gemini(无事件),但完整元数据里有 → 对话框可列为目标 - assertFalse( - viewModel.state.value.range?.displayModels?.any { it.displayModelId == geminiId } - ?: true - ) - assertTrue(viewModel.state.value.groupModels.any { it.displayModelId == geminiId }) - - // 把 A 合并进无事件的目标组:操作成功,归属变更 - val v1 = viewModel.state.value.refreshVersion - viewModel.mergeIntoGroup(listOf("id-1"), geminiId) - awaitRefresh(viewModel, v1) - assertEquals( - geminiId, - kotlinx.coroutines.runBlocking { dao.getIdentity("id-1")!!.displayModelId }, - ) - } - - // ==== 阶段 5:删除(范围 / 模型 / 全部;删除后页面状态刷新一致) ==== - - @Test - fun `delete current range removes in range events only and refreshes state`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - seedIdentity("id-2", configId = "cfg-b") - dao.insertEvent(event("e-in", "id-1", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-out", "id-1", startedAtMs = nowMs - 7L * 86_400_000L)) - seedBaseline("id-1") - seedBaseline("id-2") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - // 首次回退:5h 内恰有事件 → LAST_5H;currentRange 与查询同界 - assertEquals(TokenStatsPreset.LAST_5H, viewModel.state.value.selectedPreset) - val range = viewModel.state.value.currentRange - assertNotNull(range) - assertTrue(range!!.startMs <= nowMs - 3_600_000L) - assertTrue(nowMs - 3_600_000L < range.endMs) - - val from = viewModel.state.value.refreshVersion - viewModel.deleteRangeEvents() - awaitRefresh(viewModel, from) - - // 删除真实生效:范围内事件消失,范围外保留 - assertNull(dao.getEvent("e-in")) - assertNotNull(dao.getEvent("e-out")) - // baseline 绝不因范围删除被触碰 - assertNotNull(dao.getBaseline("id-1")) - assertNotNull(dao.getBaseline("id-2")) - // 页面状态刷新一致:生命周期只剩 1 条事件、2 行 baseline;范围空数据 - val lifetime = viewModel.state.value.lifetime!! - assertEquals(1L, lifetime.eventTotals.requests) - assertEquals(2L, lifetime.baselineTotals.identityCount) - assertEquals(0L, viewModel.state.value.range!!.eventCount) - } - } - - @Test - fun `delete display model covers full group members and keeps baseline by choice`() { - kotlinx.coroutines.runBlocking { - // 展示组 group-x:跨 provider:model 的两个身份 + 组外同 provider:model 身份 - seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("x-2", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-x") - seedIdentity("y-1", configId = "cfg-b", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") - dao.insertEvent(event("e-x1", "x-1", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-x2", "x-2", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-y1", "y-1", startedAtMs = nowMs - 3_600_000L)) - seedBaseline("x-1") - seedBaseline("y-1") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - // 第二步选择“仅删除事件”:组内全部成员事件删除(含无事件组成员身份影响 - // 由 DAO 全表解析验证),baseline 保留,组外同 provider:model 不受影响 - val from = viewModel.state.value.refreshVersion - viewModel.deleteDisplayModel("group-x", deleteBaselines = false) - awaitRefresh(viewModel, from) - - assertNull(dao.getEvent("e-x1")) - assertNull(dao.getEvent("e-x2")) - assertNotNull("same provider:model in another group must survive", dao.getEvent("e-y1")) - assertNotNull(dao.getBaseline("x-1")) - assertNotNull(dao.getBaseline("y-1")) - assertEquals(1L, viewModel.state.value.lifetime!!.eventTotals.requests) - } - } - - @Test - fun `delete display model with baseline removes member baselines and refreshes`() { - kotlinx.coroutines.runBlocking { - seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("y-1", provider = "DEEPSEEK", model = "deepseek-chat", displayModelId = "group-y") - dao.insertEvent(event("e-x1", "x-1", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-y1", "y-1", startedAtMs = nowMs - 3_600_000L)) - seedBaseline("x-1") - seedBaseline("y-1") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val from = viewModel.state.value.refreshVersion - viewModel.deleteDisplayModel("group-x", deleteBaselines = true) - awaitRefresh(viewModel, from) - - assertNull(dao.getEvent("e-x1")) - assertNotNull(dao.getEvent("e-y1")) - assertNull(dao.getBaseline("x-1")) - assertNotNull("other group baseline must survive", dao.getBaseline("y-1")) - assertEquals(1L, viewModel.state.value.lifetime!!.baselineTotals.identityCount) - } - } - - @Test - fun `legacy datastore keys are cleared only when the deleted group contains the legacy identity`() { - kotlinx.coroutines.runBlocking { - // 两组同 provider:model:group-x 只含配置身份 cfg-a;group-y 含 legacy 身份 - // (configId="",旧 DataStore 累计键的 baseline 迁移目标,键按 provider:model 共享) - seedIdentity("x-1", configId = "cfg-a", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-x") - seedIdentity("y-legacy", configId = "", provider = "OPENAI", model = "gpt-4o", displayModelId = "group-y") - seedBaseline("x-1") - seedBaseline("y-legacy") - val prefs = ApiPreferences.getInstance(context) - // Windows JVM 约束(见 ApiPreferencesResetFailureTest 类注释):DataStore 1.0.0 - // 以 File.renameTo 原子替换,Windows 上目标文件已存在时替换失败——同一文件 - // 每测试只能被 DataStore 写入一次。因此 legacy 键用库自身 PreferencesProto - // 直接落盘(读断言可用),被测流程的唯一真实 DataStore 写入留给排空 - // (applyLegacyCleanup,P1 闭环:删除事务 → 排空清键 + marker)。 - seedLegacyTokenCountsFile(tempDir, "OPENAI:gpt-4o", input = 100L, cached = 10L, output = 50L) - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - - // 删除只含 cfg-a 的组(baseline=yes):legacy 身份不在目标组 → 不得清旧键 - val from = viewModel.state.value.refreshVersion - viewModel.deleteDisplayModel("group-x", deleteBaselines = true) - awaitRefresh(viewModel, from) - - assertNull("cfg-a baseline must be deleted", dao.getBaseline("x-1")) - assertNotNull("legacy baseline in other group must survive", dao.getBaseline("y-legacy")) - assertEquals( - "legacy DataStore key must survive a non-legacy group deletion", - 100L, - prefs.getInputTokensForProviderModel("OPENAI:gpt-4o"), - ) - assertEquals(50L, prefs.getOutputTokensForProviderModel("OPENAI:gpt-4o")) - - // 删除含 legacy 身份的组(baseline=yes):其 baseline 随组删除、旧键必须清除。 - // 先移除磁盘文件,使排空(applyLegacyCleanup)成为该文件的首次写入 - // (DataStore 内存状态已在上面读断言时缓存,编辑仍基于含键的状态)。 - check(File(File(tempDir, "datastore"), "api_settings.preferences_pb").delete()) - val from2 = viewModel.state.value.refreshVersion - viewModel.deleteDisplayModel("group-y", deleteBaselines = true) - awaitRefresh(viewModel, from2) - - assertNull(dao.getBaseline("y-legacy")) - assertEquals( - "legacy DataStore key must be cleared with its baseline", - 0L, - prefs.getInputTokensForProviderModel("OPENAI:gpt-4o"), - ) - assertEquals(0L, prefs.getOutputTokensForProviderModel("OPENAI:gpt-4o")) - } - } - - @Test - fun `delete all events only keeps baseline and refreshes`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - seedIdentity("id-2", configId = "cfg-b") - dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-2", "id-2", startedAtMs = nowMs - 3_600_000L)) - seedBaseline("id-1") - seedBaseline("id-2") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val from = viewModel.state.value.refreshVersion - viewModel.deleteAllStatistics(deleteBaselines = false) - awaitRefresh(viewModel, from) - - assertEquals(0, dao.countEvents()) - assertEquals("baseline must survive", 2, dao.countBaselines()) - val lifetime = viewModel.state.value.lifetime!! - assertEquals(0L, lifetime.eventTotals.requests) - assertEquals(2L, lifetime.baselineTotals.identityCount) - assertEquals(0L, viewModel.state.value.range!!.eventCount) - } - } - - @Test - fun `delete all with baseline clears legacy keys and refreshes`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - seedIdentity("id-2", configId = "cfg-b") - dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) - dao.insertEvent(event("e-2", "id-2", startedAtMs = nowMs - 3_600_000L)) - seedBaseline("id-1") - seedBaseline("id-2") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - val from = viewModel.state.value.refreshVersion - viewModel.deleteAllStatistics(deleteBaselines = true) - awaitRefresh(viewModel, from) - - assertEquals(0, dao.countEvents()) - assertEquals(0, dao.countBaselines()) - val lifetime = viewModel.state.value.lifetime!! - assertEquals(0L, lifetime.eventTotals.requests) - assertEquals(0L, lifetime.baselineTotals.identityCount) - } - } - - @Test - fun `delete failures surface error message and keep data intact`() { - kotlinx.coroutines.runBlocking { - seedIdentity("id-1", configId = "cfg-a") - dao.insertEvent(event("e-1", "id-1", startedAtMs = nowMs - 3_600_000L)) - seedBaseline("id-1") - - val viewModel = newViewModel() - awaitRefresh(viewModel, 0) - // 数据库不可用:三种删除都应报错而不是假装成功 - val failures = - listOf( - { viewModel.deleteRangeEvents() }, - { viewModel.deleteDisplayModel("id-1", deleteBaselines = false) }, - { viewModel.deleteAllStatistics(deleteBaselines = false) }, - ) - for (action in failures) { - TokenStatsResetCoordinator.daoProvider = - { _: Context -> throw RuntimeException("db down") } - try { - action() - awaitActionMessage(viewModel) - assertTrue("error message expected", viewModel.actionMessage.value!!.isError) - viewModel.consumeActionMessage() - } finally { - TokenStatsResetCoordinator.daoProvider = { dao } - } - } - // 失败不产生任何删除 - assertEquals(1, dao.countEvents()) - assertEquals(1, dao.countBaselines()) - } - } -} - -/** 内存假实现:验证持久化调用与首次回退语义(无用户选择 = null)。 */ -private class FakeSettingsStore : TokenStatsSettingsStore { - var savedRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE - var rateEstimated: Boolean = true - var savedCurrency: PricingCurrency = PricingCurrency.CNY - var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL - var savedIncludeLegacy: Boolean = true - var savedSelection: TokenStatsTimeSelection? = null - var savedManual: Boolean = false - /** saveTimeSelection 调用次数(P1-2:第二个 VM 不得再次保存/探测)。 */ - var timeSelectionSaveCount: Int = 0 - - override suspend fun loadRateWithEstimate(): Pair = - savedRate to rateEstimated - - override suspend fun saveRate(rate: Double) { - savedRate = rate - rateEstimated = false - } - - override suspend fun loadTargetCurrency(): PricingCurrency = savedCurrency - - override suspend fun saveTargetCurrency(currency: PricingCurrency) { - savedCurrency = currency - } - - override suspend fun loadCostMode(): TokenStatsCostMode = savedMode - - override suspend fun saveCostMode(mode: TokenStatsCostMode) { - savedMode = mode - } - - override suspend fun loadIncludeLegacy(): Boolean = savedIncludeLegacy - - override suspend fun saveIncludeLegacy(include: Boolean) { - savedIncludeLegacy = include - } - - override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection - - override suspend fun loadSelectionWasManual(): Boolean = savedManual - - override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { - savedSelection = selection - savedManual = manual - timeSelectionSaveCount++ - } -} - -/** - * 可控制挂起的偏好存储(P1-4):第一次 [loadRateWithEstimate] 挂起在 [gate] 上 - * (期间不写任何 state),用于验证旧 load 被取消后不得覆盖新 load 的结果。 - */ -private class GatedSettingsStore : TokenStatsSettingsStore { - var savedRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE - var rateEstimated: Boolean = true - var savedCurrency: PricingCurrency = PricingCurrency.CNY - var savedMode: TokenStatsCostMode = TokenStatsCostMode.HISTORICAL - var savedIncludeLegacy: Boolean = true - var savedSelection: TokenStatsTimeSelection? = null - var savedManual: Boolean = false - val firstLoadStarted = CompletableDeferred() - val gate = CompletableDeferred() - private var rateReads = 0 - - override suspend fun loadRateWithEstimate(): Pair { - rateReads++ - if (rateReads == 1) { - firstLoadStarted.complete(Unit) - gate.await() - } - return savedRate to rateEstimated - } - - override suspend fun saveRate(rate: Double) { - savedRate = rate - rateEstimated = false - } - - override suspend fun loadTargetCurrency(): PricingCurrency = savedCurrency - - override suspend fun saveTargetCurrency(currency: PricingCurrency) { - savedCurrency = currency - } - - override suspend fun loadCostMode(): TokenStatsCostMode = savedMode - - override suspend fun saveCostMode(mode: TokenStatsCostMode) { - savedMode = mode - } - - override suspend fun loadIncludeLegacy(): Boolean = savedIncludeLegacy - - override suspend fun saveIncludeLegacy(include: Boolean) { - savedIncludeLegacy = include - } - - override suspend fun loadTimeSelection(): TokenStatsTimeSelection? = savedSelection - - override suspend fun loadSelectionWasManual(): Boolean = savedManual - - override suspend fun saveTimeSelection(selection: TokenStatsTimeSelection?, manual: Boolean) { - savedSelection = selection - savedManual = manual - } -} diff --git a/docs/TODO/token_stats_922_review_20260811/3_verification.md b/docs/TODO/token_stats_922_review_20260811/3_verification.md index 395d2fc05..cfd1e876e 100644 --- a/docs/TODO/token_stats_922_review_20260811/3_verification.md +++ b/docs/TODO/token_stats_922_review_20260811/3_verification.md @@ -2,10 +2,82 @@ ## Required Evidence -- Review the merged schema-generation configuration against #926. -- Add or update focused restore regression tests for failure after preparation but - before a completed replacement. -- Run the relevant JVM test target and the candidate build/check path only after - code changes are complete. -- Confirm the final branch remains based on the current `main` and that the PR - candidate checks are green before proposing merge. +- Confirm removed unpublished mechanisms have no production references. +- Confirm existing chat and message token columns are unchanged. +- Inspect the Room schema migration, one-time DataStore import ordering, SQL queries, + and request write path statically. +- Review the final diff and working tree without running compilation, builds, or + tests unless the user explicitly requests them. +- Confirm the final branch remains based on the current `main` before proposing any + merge. + +## 2026-08-12 ToolPkg Released-Key Fix + +- Added focused JVM tests for current and future `TOOLPKG_` identities. +- Kept unknown-provider rejection explicit; no underscore-based fallback was added. +- Compilation and tests were not run because the repository execution rules require + an explicit user request. + +## 2026-08-12 Statistics UI Follow-up + +- [DONE] Statically verified that imported timestamp-free counters join the normal + lifetime aggregate while time-range charts remain timestamp-bound. +- [DONE] Removed the Token Activity profile implementation, its test, and its + dedicated localized strings; no production references remain. +- The targeted token-statistics diff passed `git diff --check`. Compilation and tests + were not run because the user did not request them. + +## 2026-08-12 Lifetime And Theme Follow-up + +- [DONE] Lifecycle totals now always include the imported timestamp-free totals; the + time-range, trend, and activity queries remain timestamp-bound. +- [DONE] Token Activity heatmap uses the active primary color at increasing alpha + levels only. +- No compilation, build, or test was run for this follow-up, per user request. + +## 2026-08-12 Activity And Lifetime Model Layout Follow-up + +- [DONE] Statically confirmed that the removed activity-insights UI no longer has + production references; its hourly Room query and aggregation fields are removed. +- [DONE] Reviewed the lifetime model section: pie slices and list percentages use + the same lifecycle total-token value, including imported timestamp-free totals. +- No compilation, build, or test was run, per user request. + +## 2026-08-12 Date Range And Currency Follow-up + +- [DONE] Statically verified that no production or test code references the removed + preset-selection types, preference APIs, automatic range probing, or localized + preset labels. +- [DONE] Checked the date-range filter call chain: display converts the stored + half-open timestamp range to inclusive local dates, and confirmation converts it + back at local midnight boundaries before persistence and SQL querying. +- [DONE] `git diff --check` completed without whitespace errors. No compilation, + build, or test was run because the user explicitly requested that compilation not + be run. + +## 2026-08-13 Unified Statistics Scope + +- [DONE] Statically traced the shared `TokenStatsQueryParams` from selected model + groups, call types, and results into both range aggregation and activity-day SQL. + The activity query projects the complete identity and filters it after SQL so + grouped models retain configuration-level precision. +- [DONE] Removed the independent activity recent/year state, query, UI controls, + and tests. Activity aggregation now accepts only an explicit selected range. +- No compilation, build, or test was run, per user instruction. + +## 2026-08-13 Read-only History Follow-up + +- [DONE] Statically confirmed that the token-statistics screen exposes no record + deletion actions. Removed the associated ViewModel, repository, DAO, query-helper, + and localized-string code; group and price-rule deletion remain separate settings + operations. +- `git diff --check` completed without whitespace errors. No compilation, build, or + test was run, per user instruction. + +## 2026-08-13 Information Hierarchy Follow-up + +- [DONE] Statically confirmed that lifetime totals, range analysis, trends, range + model details, and statistics settings use one page-section heading component. + Range controls, activity summaries, and the selected visualization share one card; + the card-level labels do not compete with page-section headings. +- No compilation, build, or test was run, per user instruction. diff --git a/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md b/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md new file mode 100644 index 000000000..a6150892d --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md @@ -0,0 +1,21 @@ +# 4. Provider Capabilities And Token Types + +## Previous State + +`OpenAIProvider` inspected `ApiProviderType` to decide whether a request should +include `stream_options.include_usage`. This couples the generic compatibility +base to concrete provider identities. The statistics integration also narrowed +the local MNN and llama.cpp token counters from `Long` to `Int`, then converted +the values back only at API boundaries. + +## Intended Change + +Append the stream usage request field directly while constructing requests for +native OpenAI, DeepSeek, and Kimi. Generic OpenAI-compatible providers do not add +the field. Keep local token counts as `Long` through generation finalization and +statistics normalization. + +## Expected State + +Adding a provider no longer requires editing a central provider-type condition. +Local usage statistics preserve values larger than `Int.MAX_VALUE`. [DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md b/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md new file mode 100644 index 000000000..934ce4d1c --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md @@ -0,0 +1,31 @@ +# 5. Final Storage Design + +## Previous State + +The unpublished implementation treats token statistics as a durable billing ledger +and adds operational recovery systems around it. Released `main` already stores +conversation token state in Room and lifetime provider/model counters in DataStore. + +## Intended Change + +- Use `token_usage_records` for completed requests, copied conversation history, and + imported cumulative counters. The `source` column distinguishes `REQUEST` and + `CONVERSATION`; imported counters are timestamp-free `REQUEST` rows. +- Use `token_stats_models` for complete model identities, group membership, group + names, and model-level or configuration-level price overrides. +- Use a dedicated `token_stats_preferences` Preferences DataStore for currency, + exchange rate, time selection, and `importedAtMs`. These are scalar key/value + settings and do not justify another SQL table. +- Copy AI messages and all message variants once during the Room migration. Do not + query the chat tables at runtime after the schema migration. +- Keep new request identities as configuration, provider, and model columns. +- Key model settings by `configId + provider + model`. An empty `configId` represents + provider/model-wide pricing and an identity without configuration ownership. +- Calculate costs from current settings only. +- Use direct SQL aggregation instead of cached daily or lifetime rollups. + +## Expected State + +The entire statistics feature uses two tables plus one small preferences file, with +no ledger, spool, backup, or recovery state. Lifetime totals do not double count +copied conversation rows already included in the released counters. diff --git a/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md b/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md new file mode 100644 index 000000000..d0e2b89b0 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md @@ -0,0 +1,28 @@ +# 6. Data Layer And Request Integration + +## Previous State + +Provider usage is normalized into a large ledger pipeline with identities, stable +event UUIDs, spool fencing, recovery, price snapshots, and cleanup operations. + +## Intended Change + +- Retain provider usage normalization and `Long` token counts. +- Insert a compact event directly through a repository when a provider request ends. +- Add only the two final Room entities, a dedicated statistics preferences store, + focused DAO methods, and the required schema migration. Do not create intermediate + version-21 tables. +- Store `provider` and `model` separately. Use `configId` only for new + requests where the application actually knows the configuration. +- Import existing DataStore counters, model prices, and exchange rate once, + then remove every token-statistics DataStore key. +- Give each imported cumulative total a stable nullable `importKey`; repeated initialization + replaces that row instead of duplicating it if the process stops between Room and + Preferences commits. +- Record only `importedAtMs` in the dedicated statistics preferences. +- Restore normal application backup and restore behavior. + +## Expected State + +The request path has one understandable statistics write and no filesystem spool or +cross-component lifecycle coordinator. diff --git a/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md b/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md new file mode 100644 index 000000000..5fa407e21 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md @@ -0,0 +1,121 @@ +# 7. SQL Queries And UI Adaptation + +## Previous State + +The UI depends on an in-memory ledger aggregator, historical versus revalued cost +modes, baseline rows, cutoff rows, quarantine management, and event pagination. + +## Intended Change + +- Provide SQL projections for lifetime totals, range totals, trend buckets, models, + categories, statuses, TTFT, duration, and activity data. +- Treat imported cumulative counters as timestamp-free `REQUEST` rows. Lifetime + aggregation includes them; time-range SQL excludes them through its timestamp bounds. +- Use copied `CONVERSATION` rows from `messages` and every `message_variants` row as + the available historical distribution. Treat their exact request count as unknown + because one saved response can contain multiple provider requests. +- Keep legacy conversation rows out of lifetime addition because the DataStore total + already includes them. +- Aggregate models by normalized model name and expose individual call configurations + only when the user expands a model row. +- Keep the statistics, activity, and inline configuration-price editing UI. +- Remove historical-price mode, quarantine, migration, and token-backup controls. +- Present imported cumulative counters in the normal lifetime totals and model + aggregation. They have no timestamp, so they do not appear in a selected time range + or trend chart. +- Remove the Token Activity title, its unrelated profile card, and activity-insights + card while retaining the activity selector, summary metrics, and visualizations. +- Derive cards, typography, charts, and heatmap colors from the + application `MaterialTheme.colorScheme`; no statistics-specific visual palette remains. + +## Expected State + +The UI presents the useful #922 statistics against a compact SQL-backed repository, +while released conversation data remains available through the existing chat domain. + +## 2026-08-12 Follow-up + +[DONE] The imported cumulative counters use the same lifecycle aggregate and model +identity path as all other requests. The activity section no longer owns profile +state, avatar files, or profile controls. + +[DONE] The token statistics UI now follows the active application theme for surfaces, +content, accents, charts, heatmap, and model management. It no longer overrides the +local `MaterialTheme` or retains a white/pink statistics-only palette. + +## 2026-08-12 Activity And Lifetime Model Layout Follow-up + +[DONE] The cumulative-usage card is now the first page section. The activity-insights +card and its hour-based SQL query are removed; the recent/year selector now shares +the activity-mode row. + +[DONE] Lifetime model totals use a total-token distribution pie and compact rows. + +[DONE] Page-level statistics headings and activity controls start at the shared page +edge. Their data cards use the same edge, with a shared 16dp internal card inset. + +## 2026-08-12 Date Range And Currency Follow-up + +[DONE] The range filter is a Material date-range calendar. Its calendar icon sits at +the right edge of the daily, weekly, and cumulative activity-mode row; it retains +the existing nearby recent/year activity selector. The query continues to use its +explicit half-open timestamp interval. + +[DONE] The visible preset menu, rolling-window selection, automatic preset probing, +and associated preference state are removed. The first view uses the most recent +30 natural days; thereafter the selected calendar range is persisted directly. + +[DONE] Display currency is a single CNY/USD dropdown rather than parallel chips. + +[DONE] The first cumulative-usage card uses the active theme's primary-container +surface and matching on-primary-container content color. Remaining statistics cards +continue to use the ordinary application surface. + +[DONE] The three cumulative metrics use compact single-line values. Cumulative cost +is displayed to two decimal places; detailed prices and other cost views retain +their existing precision. + +## 2026-08-13 Unified Statistics Scope + +[DONE] The page now has two explicit scopes: cumulative usage and cumulative model +totals always cover all history; the date-range activity, charts, and range model +details share one date range plus model, call-type, and result conditions. + +[DONE] Removed the unrelated recent/year activity selector. The activity SQL query +receives the same range and query conditions as range statistics, then applies the +selected models before building daily, weekly, and cumulative views. + +[DONE] The visible conditions use labelled values rather than ambiguous standalone +phrases. Currency is shown next to cumulative usage because it changes only money +display, not the records included in a query. The destructive action explicitly +states that it deletes all records in the selected date range. + +## 2026-08-13 Compact Controls Follow-up + +[DONE] The cumulative-usage heading reserves a fixed-width currency control, so a +narrow screen keeps the heading on one line. The date range calendar uses a compact +in-app heading and single-line selected-range summary instead of Material's oversized +default range headline. Cumulative model rows show the five largest models initially; +the complete list remains available through an explicit expand control, while the pie +continues to represent every model. + +[DONE] Token statistics is a read-only history surface. Removed model, date-range, +and all-history usage-record deletion from the UI and the supporting statistics data +APIs. A custom configuration price can be removed directly from its editor because +that action does not discard recorded usage. + +## 2026-08-13 Information Hierarchy Follow-up + +[DONE] The statistics page now separates lifetime totals, range analysis, trends, +configuration details, and settings with one shared page-section heading style. The range +filters and activity visualization are grouped into one range-analysis section; cards +use only compact internal labels. The lifetime card no longer repeats the applied-rate +hint, which belongs to the dedicated statistics-settings section. + +## 2026-08-13 Configuration Details Follow-up + +[DONE] Removed model grouping and the separate model/pricing management screens. +The configuration-details list has no model grouping layer: each compact row is one +configuration, identified by its configured name and provider/model. Expanding a row +reveals its token components and the inline price editor for that configuration. A +custom price can be removed from the same editor. diff --git a/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md b/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md new file mode 100644 index 000000000..2c42eed04 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md @@ -0,0 +1,44 @@ +# 8. Legacy History And Identity + +## Previous State + +The compact rewrite imports released counters into a table keyed only by the +combined `providerModel` string. Imported rows are projected with an empty +configuration ID, and model-group changes reduce complete identity IDs back to the +same combined string. Existing messages and message variants are not visible to the +statistics queries. + +## Intended Change + +- Represent a new request identity as configuration, provider, and model. +- Represent an imported cumulative-counter identity as configuration-unscoped provider and model. +- Store group assignments and price overrides in one model row keyed by the complete + identity. +- Copy assistant messages and all generated variants during migration for token + trends and model distribution. +- Keep historical conversation request counts out of time buckets because a saved response can + aggregate multiple provider calls. +- Include imported DataStore totals in the normal lifetime aggregate. + +## Expected State + +The model-management UI retains its hierarchy and configuration-level operations. +The statistics UI can show recoverable historical conversation usage without +inventing configuration ownership, request events, or duplicate lifetime totals. + +## ToolPkg Released-Key Identity Fix + +The released DataStore decoder now accepts each registered ToolPkg `providerId`, its +legacy `TOOLPKG_` form, and display name as exact prefixes. This preserves +provider IDs containing underscores without changing runtime statistics identities, +database structure, or UI behavior. It reads with the original prefix and imports with +the registered display identity so the historical total and new requests remain in the +same model entry. + +It also preserves released custom-provider totals after a provider is removed or renamed. +Those keys have no registry metadata, so migration decodes the historical provider name +from the first encoded separator instead of failing the entire import. Known ToolPkg IDs +continue to use the longest registered prefix, which keeps underscores in provider IDs +unambiguous. + +[DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/index.md b/docs/TODO/token_stats_922_review_20260811/index.md index 631e58475..f49c73ff1 100644 --- a/docs/TODO/token_stats_922_review_20260811/index.md +++ b/docs/TODO/token_stats_922_review_20260811/index.md @@ -4,35 +4,51 @@ source_pr: https://github.com/AAswordman/Operit/pull/922 working_branch: fix/token-stats-922-review --- -# Token Statistics PR 922 Review +# Token Statistics PR 922 Redesign ## Background -PR #922 introduces token usage tracking, pricing management, durable statistics -spooling, Room schema changes, backup/restore coordination, and their settings and -UI. The feature is useful, but the source PR is too large to merge directly into -`main` while its candidate checks fail and its database replacement path can remove -the active database before replacement succeeds. +PR #922 introduces useful statistics and model-management UI, but its unpublished +storage design adds a request ledger, spool, recovery generations, quarantine, +cleanup outbox, baseline migration, and token-specific backup coordination. The +implementation is much larger than the product requirement and duplicates behavior +already owned by the application database and normal backup system. ## Intent -Preserve #922 as a normal merge on an isolated repair branch, then make the merged -result safe to validate and submit as a focused follow-up PR. `main` remains -unchanged until the repair branch has passing checks and a reviewed data-integrity -path. +Keep the useful UI and provider usage extraction while replacing the unpublished +storage design completely. Use two Room tables for structured token statistics data, +keep scalar UI state in a dedicated Preferences DataStore, and perform aggregation +with SQL. Copy existing messages and every message variant once during migration so +the statistics domain remains self-contained after the schema migration. ## Scope - Preserve the #922 commit topology through merge commit `663a3a59`. -- Reproduce and resolve the current candidate-check blocker against the current - `main` baseline. -- Make database restore replacement preserve the existing database when a final - filesystem operation fails. -- Add focused regression coverage for the repaired restore behavior and run the - relevant repository checks requested for the follow-up PR. +- Delete every unpublished spool, baseline, quarantine, cleanup, cutoff, generation, + token-specific restore, and historical-price mechanism. +- Add `token_usage_records` and `token_stats_models`. +- Add a dedicated `token_stats_preferences` file for currency, exchange rate, time + selection, and the completed-import timestamp. +- Preserve provider, model, and configuration ownership as separate identity + dimensions instead of flattening them into a `providerModel` assignment. +- Import released DataStore counters once as authoritative upgrade-time lifetime + totals. Copy messages and all message variants as recoverable historical + conversation history without adding them to lifetime totals again. +- Keep existing `chats` and `messages` token columns unchanged. +- Query totals, trends, categories, statuses, performance, and activity with SQL. +- Store billing mode and price overrides in structured Room rows. +- Store currency, exchange rate, time selection, and `importedAtMs` in the dedicated + Preferences DataStore. +- Do not run compilation, builds, or tests without an explicit user request. ## Steps 1. [DONE] [Merge baseline](1_merge_baseline_and_reproduction.md) -2. [Restore integrity](2_restore_integrity.md) -3. [Verification](3_verification.md) +2. [DONE] [Restore integrity investigation](2_restore_integrity.md) +3. [Provider capabilities and token types](4_provider_capabilities_and_token_types.md) +4. [Final storage design](5_final_storage_design.md) +5. [Data layer and request integration](6_data_layer_and_request_integration.md) +6. [SQL queries and UI adaptation](7_sql_queries_and_ui.md) +7. [Legacy history and identity](8_legacy_history_and_identity.md) +8. [Verification](3_verification.md) From 8e9bdf67b4241e4d8e563f5f2610ebf8628257a4 Mon Sep 17 00:00:00 2001 From: AAswordman <1002153674@qq.com> Date: Thu, 13 Aug 2026 15:36:03 +0800 Subject: [PATCH 27/28] fix(stats): guard Room access during restore --- .../data/backup/RawSnapshotBackupManager.kt | 73 +++--- .../data/backup/RoomDatabaseRestoreManager.kt | 9 +- .../data/stats/TokenStatsQueryService.kt | 215 +++++++++--------- .../data/stats/TokenStatsSettingsManager.kt | 115 +++++----- .../operit/data/stats/TokenUsageRepository.kt | 148 ++++++------ .../TokenUsageStatisticsViewModel.kt | 2 - .../2_restore_integrity.md | 14 ++ .../3_verification.md | 11 + 8 files changed, 317 insertions(+), 270 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index e3d8f723c..dde3ad46a 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.util.AtomicFile import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.db.ObjectBoxManager +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.OperitPaths import java.io.BufferedInputStream @@ -286,51 +287,53 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") - AppDatabase.closeDatabase() - ObjectBoxManager.closeAll() + TokenUsageRepository.withDatabaseRestore { + AppDatabase.closeDatabase() + ObjectBoxManager.closeAll() - AppLogger.i(TAG, "restore closed databases (room + objectbox)") + AppLogger.i(TAG, "restore closed databases (room + objectbox)") - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } - val manifest = extractZipToWorkDir(cacheZip, workDir) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } + val manifest = extractZipToWorkDir(cacheZip, workDir) - val payloadDir = File(workDir, "payload") - val externalFilesPayloadDir = File(payloadDir, "external_files") + val payloadDir = File(workDir, "payload") + val externalFilesPayloadDir = File(payloadDir, "external_files") - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val preserveTerminal = !manifest.includeTerminalData - val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() - val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> - File(payloadDir, "files/$dirName").exists() - }.toSet() - val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + val preserveTerminal = !manifest.includeTerminalData + val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() + val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> + File(payloadDir, "files/$dirName").exists() + }.toSet() + val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames - AppLogger.i( - TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" - ) + AppLogger.i( + TAG, + "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + ) - AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") + AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } - replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) - if (externalFilesPayloadDir.exists()) { - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) + if (externalFilesPayloadDir.exists()) { + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" + } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } + replaceDirContents(externalFilesPayloadDir, externalFilesDir) } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } + replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } + replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } + replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } + AppLogger.i(TAG, "restore done: ${manifest.packageName}") } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } - replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } - replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } - replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } - AppLogger.i(TAG, "restore done: ${manifest.packageName}") } catch (e: Exception) { AppLogger.e(TAG, "restore failed", e) throw e diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index ec2b4dc33..b288fe39b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.backup import android.content.Context import android.net.Uri import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -72,7 +73,9 @@ object RoomDatabaseRestoreManager { } } ?: throw IllegalStateException("Failed to open uri") - restoreFromBackupFileInternal(context, cacheFile) + TokenUsageRepository.withDatabaseRestore { + restoreFromBackupFileInternal(context, cacheFile) + } } finally { cacheFile.delete() } @@ -83,7 +86,9 @@ object RoomDatabaseRestoreManager { suspend fun restoreFromBackupFile(context: Context, zipFile: File) { withContext(Dispatchers.IO) { RoomDatabaseBackupRestoreLock.mutex.withLock { - restoreFromBackupFileInternal(context, zipFile) + TokenUsageRepository.withDatabaseRestore { + restoreFromBackupFileInternal(context, zipFile) + } } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt index 512bb96d4..c088507e9 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -16,22 +16,22 @@ object TokenStatsQueryService { params: TokenStatsQueryParams, ): TokenStatsLifetimeOverview { val repository = TokenUsageRepository.getInstance(context) - repository.ensureInitialized() - val dao = repository.dao - val requestRows = dao.aggregateRequestModelsForLifetime( - providerModels = params.providerModels.queryValues(), - allModels = params.providerModels == null, - categories = params.categories.namesForQuery(), - allCategories = params.categories == null, - statuses = params.statuses.namesForQuery(), - allStatuses = params.statuses == null, - ) - val modelSettings = dao.getAllStatsModels() - val prices = modelSettings.toPriceSnapshot() - return TokenStatsLifetimeOverview( - totals = combineTotals(requestRows.map { it.toTotals(prices, params) }, params), - displayModels = buildDisplayModels(requestRows, prices, params), - ) + return repository.withDao { dao -> + val requestRows = dao.aggregateRequestModelsForLifetime( + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + TokenStatsLifetimeOverview( + totals = combineTotals(requestRows.map { it.toTotals(prices, params) }, params), + displayModels = buildDisplayModels(requestRows, prices, params), + ) + } } suspend fun rangeData( @@ -41,32 +41,50 @@ object TokenStatsQueryService { zone: ZoneId, ): TokenStatsRangeData { val repository = TokenUsageRepository.getInstance(context) - repository.ensureInitialized() - val dao = repository.dao - val modelSettings = dao.getAllStatsModels() - val prices = modelSettings.toPriceSnapshot() - val modelRows = dao.aggregateModelsInRange( - startMs = range.startMs, - endMs = range.endMs, - providerModels = params.providerModels.queryValues(), - allModels = params.providerModels == null, - categories = params.categories.namesForQuery(), - allCategories = params.categories == null, - statuses = params.statuses.namesForQuery(), - allStatuses = params.statuses == null, - ) - val displayModels = buildDisplayModels(modelRows, prices, params) - val summary = combineTotals(displayModels.map(TokenStatsDisplayModelBreakdown::totals), params) - val granularity = TokenStatsTimeRanges.granularityFor(range) - val starts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) - val buckets = starts.mapIndexed { index, bucketStart -> - val bucketEnd = minOf( - range.endMs, - TokenStatsTimeRanges.bucketEndMs(starts, index, granularity, zone), + return repository.withDao { dao -> + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + val modelRows = dao.aggregateModelsInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, ) - val bucketRows = dao.aggregateModelsInRange( - startMs = maxOf(range.startMs, bucketStart), - endMs = bucketEnd, + val displayModels = buildDisplayModels(modelRows, prices, params) + val summary = combineTotals(displayModels.map(TokenStatsDisplayModelBreakdown::totals), params) + val granularity = TokenStatsTimeRanges.granularityFor(range) + val starts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) + val buckets = starts.mapIndexed { index, bucketStart -> + val bucketEnd = minOf( + range.endMs, + TokenStatsTimeRanges.bucketEndMs(starts, index, granularity, zone), + ) + val bucketRows = dao.aggregateModelsInRange( + startMs = maxOf(range.startMs, bucketStart), + endMs = bucketEnd, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val models = buildDisplayModels(bucketRows, prices, params) + TokenStatsTrendBucket( + bucketStartMs = bucketStart, + bucketEndMs = bucketEnd, + totals = combineTotals(models.map(TokenStatsDisplayModelBreakdown::totals), params), + byModel = models.associate { it.displayModelId to it.totals.toModelBucket() }, + performance = performanceOf(bucketRows), + ) + } + val categoryRows = dao.aggregateCategoriesInRange( + startMs = range.startMs, + endMs = range.endMs, providerModels = params.providerModels.queryValues(), allModels = params.providerModels == null, categories = params.categories.namesForQuery(), @@ -74,56 +92,38 @@ object TokenStatsQueryService { statuses = params.statuses.namesForQuery(), allStatuses = params.statuses == null, ) - val models = buildDisplayModels(bucketRows, prices, params) - TokenStatsTrendBucket( - bucketStartMs = bucketStart, - bucketEndMs = bucketEnd, - totals = combineTotals(models.map(TokenStatsDisplayModelBreakdown::totals), params), - byModel = models.associate { it.displayModelId to it.totals.toModelBucket() }, - performance = performanceOf(bucketRows), + val statusRows = dao.aggregateStatusesInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + TokenStatsRangeData( + range = range, + granularity = granularity, + eventCount = summary.totalTokens.totalEventCount, + summary = summary, + performance = performanceOf(modelRows), + buckets = buckets, + displayModels = displayModels, + categories = categoryRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsCategoryBreakdown( + TokenStatCategory.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, + statuses = statusRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsStatusBreakdown( + TokenStatStatus.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, ) } - val categoryRows = dao.aggregateCategoriesInRange( - startMs = range.startMs, - endMs = range.endMs, - providerModels = params.providerModels.queryValues(), - allModels = params.providerModels == null, - categories = params.categories.namesForQuery(), - allCategories = params.categories == null, - statuses = params.statuses.namesForQuery(), - allStatuses = params.statuses == null, - ) - val statusRows = dao.aggregateStatusesInRange( - startMs = range.startMs, - endMs = range.endMs, - providerModels = params.providerModels.queryValues(), - allModels = params.providerModels == null, - categories = params.categories.namesForQuery(), - allCategories = params.categories == null, - statuses = params.statuses.namesForQuery(), - allStatuses = params.statuses == null, - ) - return TokenStatsRangeData( - range = range, - granularity = granularity, - eventCount = summary.totalTokens.totalEventCount, - summary = summary, - performance = performanceOf(modelRows), - buckets = buckets, - displayModels = displayModels, - categories = categoryRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> - TokenStatsCategoryBreakdown( - TokenStatCategory.fromName(key), - combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), - ) - }, - statuses = statusRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> - TokenStatsStatusBreakdown( - TokenStatStatus.fromName(key), - combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), - ) - }, - ) } internal suspend fun activitySnapshot( @@ -133,24 +133,25 @@ object TokenStatsQueryService { zone: ZoneId, ): TokenActivitySnapshot { val repository = TokenUsageRepository.getInstance(context) - repository.ensureInitialized() - val days = repository.dao.getActivityDaysInRange( - startMs = range.startMs, - endMs = range.endMs, - providerModels = params.providerModels.queryValues(), - allModels = params.providerModels == null, - categories = params.categories.namesForQuery(), - allCategories = params.categories == null, - statuses = params.statuses.namesForQuery(), - allStatuses = params.statuses == null, - ) - return TokenActivitySnapshot( - zone = zone, - dayTotals = - days.groupBy(TokenUsageActivityDayRow::localDate).mapValues { (_, rows) -> - rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } - }.mapKeys { (date, _) -> LocalDate.parse(date) }, - ) + return repository.withDao { dao -> + val days = dao.getActivityDaysInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + TokenActivitySnapshot( + zone = zone, + dayTotals = + days.groupBy(TokenUsageActivityDayRow::localDate).mapValues { (_, rows) -> + rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } + }.mapKeys { (date, _) -> LocalDate.parse(date) }, + ) + } } private fun buildDisplayModels( diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt index e97e13161..ccef5f39b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -40,7 +40,6 @@ data class TokenStatsPriceSetting( class TokenStatsSettingsManager(context: Context) { private val appContext = context.applicationContext private val repository = TokenUsageRepository.getInstance(appContext) - private val dao = repository.dao fun validatePriceValue(name: String, value: Double?): Double? { if (value == null) return null @@ -51,7 +50,6 @@ class TokenStatsSettingsManager(context: Context) { } suspend fun savePrice(draft: TokenStatsPriceDraft) { - repository.ensureInitialized() val provider = draft.provider.trim() val model = draft.model.trim() val configId = draft.configId?.trim().orEmpty() @@ -62,74 +60,79 @@ class TokenStatsSettingsManager(context: Context) { } val storageConfigId = if (draft.scope == TokenStatsPriceScope.PROVIDER_MODEL) "" else configId - val current = - dao.getStatsModel(storageConfigId, provider, model) - ?: TokenStatsModelEntity(storageConfigId, provider, model) - dao.upsertStatsModel( - current.copy( - billingMode = draft.billingMode.name, - currency = draft.currency.name, - inputPricePerMillion = - if (draft.billingMode == BillingMode.TOKEN) { - validatePriceValue("inputPrice", draft.inputPricePerMillion) - } else { - null - }, - cachedInputPricePerMillion = - if (draft.billingMode == BillingMode.TOKEN) { - validatePriceValue("cachedInputPrice", draft.cachedInputPricePerMillion) - } else { - null - }, - cacheWritePricePerMillion = - if (draft.billingMode == BillingMode.TOKEN) { - validatePriceValue("cacheWritePrice", draft.cacheWritePricePerMillion) - } else { - null - }, - outputPricePerMillion = - if (draft.billingMode == BillingMode.TOKEN) { - validatePriceValue("outputPrice", draft.outputPricePerMillion) - } else { - null - }, - pricePerRequest = - if (draft.billingMode == BillingMode.COUNT) { - validatePriceValue("pricePerRequest", draft.pricePerRequest) - } else { - null - }, + repository.withDao { dao -> + val current = + dao.getStatsModel(storageConfigId, provider, model) + ?: TokenStatsModelEntity(storageConfigId, provider, model) + dao.upsertStatsModel( + current.copy( + billingMode = draft.billingMode.name, + currency = draft.currency.name, + inputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("inputPrice", draft.inputPricePerMillion) + } else { + null + }, + cachedInputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cachedInputPrice", draft.cachedInputPricePerMillion) + } else { + null + }, + cacheWritePricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cacheWritePrice", draft.cacheWritePricePerMillion) + } else { + null + }, + outputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("outputPrice", draft.outputPricePerMillion) + } else { + null + }, + pricePerRequest = + if (draft.billingMode == BillingMode.COUNT) { + validatePriceValue("pricePerRequest", draft.pricePerRequest) + } else { + null + }, + ) ) - ) + } } suspend fun allPriceSettings(): List { - repository.ensureInitialized() - return dao.getAllStatsModels() - .filter(TokenStatsModelEntity::hasPriceSetting) - .map(TokenStatsModelEntity::toPriceSetting) - .sortedWith( - compareBy( - { it.providerModel.lowercase() }, - { it.scope.ordinal }, - { it.configId.orEmpty().lowercase() }, + return repository.withDao { dao -> + dao.getAllStatsModels() + .filter(TokenStatsModelEntity::hasPriceSetting) + .map(TokenStatsModelEntity::toPriceSetting) + .sortedWith( + compareBy( + { it.providerModel.lowercase() }, + { it.scope.ordinal }, + { it.configId.orEmpty().lowercase() }, + ) ) - ) + } } suspend fun restoreBuiltInPrice(providerModel: String) { - repository.ensureInitialized() val (provider, model) = splitProviderModel(providerModel) - dao.clearPricing("", provider, model) - dao.deleteEmptyStatsModels() + repository.withDao { dao -> + dao.clearPricing("", provider, model) + dao.deleteEmptyStatsModels() + } } suspend fun resetConfigPrice(providerModel: String, configId: String) { - repository.ensureInitialized() require(configId.isNotBlank()) { "configId must not be blank" } val (provider, model) = splitProviderModel(providerModel) - dao.clearPricing(configId, provider, model) - dao.deleteEmptyStatsModels() + repository.withDao { dao -> + dao.clearPricing(configId, provider, model) + dao.deleteEmptyStatsModels() + } } private fun splitProviderModel(providerModel: String): Pair { diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt index e0f3f99bc..acb9699fa 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt @@ -20,90 +20,102 @@ class TokenUsageRepository private constructor(context: Context) { @Volatile private var instance: TokenUsageRepository? = null + private val databaseAccessMutex = Mutex() fun getInstance(context: Context): TokenUsageRepository = instance ?: synchronized(this) { instance ?: TokenUsageRepository(context.applicationContext).also { instance = it } } + + /** + * Prevent token-statistics operations from opening or using Room while a restore replaces + * its database files. The initialization state must be reset before Room is closed. + */ + suspend fun withDatabaseRestore(block: suspend () -> T): T = + databaseAccessMutex.withLock { + instance?.initializationComplete = false + block() + } } private val appContext = context.applicationContext - internal val database = AppDatabase.getDatabase(appContext) - internal val dao: TokenUsageDao = database.tokenUsageDao() private val legacyDataSource = ApiPreferences.getInstance(appContext) private val statsPreferences = TokenStatsPreferences(appContext) - private val importMutex = Mutex() - - @Volatile private var initializationComplete = false - suspend fun ensureInitialized() { + suspend fun ensureInitialized() = databaseAccessMutex.withLock { + ensureInitializedLocked() + } + + /** Resolves the DAO only after the restore barrier and initialization have completed. */ + internal suspend fun withDao(block: suspend (TokenUsageDao) -> T): T = + databaseAccessMutex.withLock { + ensureInitializedLocked() + block(AppDatabase.getDatabase(appContext).tokenUsageDao()) + } + + suspend fun record(record: TokenUsageRecordEntity) { + withDao { dao -> dao.insertRecord(record) } + } + + private suspend fun ensureInitializedLocked() { if (initializationComplete) return - importMutex.withLock { - if (initializationComplete) return - if (statsPreferences.importedAtMs() == null) { - val snapshot = legacyDataSource.readTokenStatsMigrationSnapshot() - val importedAtMs = System.currentTimeMillis() - database.withTransaction { - dao.insertRecords(snapshot.totals.map { total -> - TokenUsageRecordEntity( - importKey = TokenUsageIdentity(null, total.provider, total.model).encode(), - occurredAtMs = null, - source = TokenUsageRecordSource.REQUEST, - configId = null, - provider = total.provider, - model = total.model, - category = null, - status = null, - requestCount = total.requestCount, - uncachedInputTokens = - (total.inputTokens - total.cachedInputTokens).coerceAtLeast(0L), - cachedInputTokens = total.cachedInputTokens, - cacheWriteTokens = null, - totalInputTokens = total.inputTokens, - outputTokens = total.outputTokens, - reasoningTokens = null, - ttftMs = null, - durationMs = null, + if (statsPreferences.importedAtMs() == null) { + val snapshot = legacyDataSource.readTokenStatsMigrationSnapshot() + val importedAtMs = System.currentTimeMillis() + val activeDatabase = AppDatabase.getDatabase(appContext) + val activeDao = activeDatabase.tokenUsageDao() + activeDatabase.withTransaction { + activeDao.insertRecords(snapshot.totals.map { total -> + TokenUsageRecordEntity( + importKey = TokenUsageIdentity(null, total.provider, total.model).encode(), + occurredAtMs = null, + source = TokenUsageRecordSource.REQUEST, + configId = null, + provider = total.provider, + model = total.model, + category = null, + status = null, + requestCount = total.requestCount, + uncachedInputTokens = + (total.inputTokens - total.cachedInputTokens).coerceAtLeast(0L), + cachedInputTokens = total.cachedInputTokens, + cacheWriteTokens = null, + totalInputTokens = total.inputTokens, + outputTokens = total.outputTokens, + reasoningTokens = null, + ttftMs = null, + durationMs = null, + ) + }) + snapshot.prices.forEach { price -> + val current = + activeDao.getStatsModel("", price.provider, price.model) + ?: TokenStatsModelEntity("", price.provider, price.model) + activeDao.upsertStatsModel( + current.copy( + billingMode = price.settings.billingMode?.name, + currency = price.settings.currency?.name, + inputPricePerMillion = price.settings.inputPricePerMillion, + cachedInputPricePerMillion = price.settings.cachedInputPricePerMillion, + cacheWritePricePerMillion = price.settings.cacheWritePricePerMillion, + outputPricePerMillion = price.settings.outputPricePerMillion, + pricePerRequest = price.settings.pricePerRequest, ) - }) - snapshot.prices.forEach { price -> - val current = - dao.getStatsModel("", price.provider, price.model) - ?: TokenStatsModelEntity("", price.provider, price.model) - dao.upsertStatsModel( - current.copy( - billingMode = price.settings.billingMode?.name, - currency = price.settings.currency?.name, - inputPricePerMillion = price.settings.inputPricePerMillion, - cachedInputPricePerMillion = - price.settings.cachedInputPricePerMillion, - cacheWritePricePerMillion = - price.settings.cacheWritePricePerMillion, - outputPricePerMillion = price.settings.outputPricePerMillion, - pricePerRequest = price.settings.pricePerRequest, - ) - ) - } + ) } - statsPreferences.completeMigration( - importedAtMs = importedAtMs, - releasedUsdToCnyRate = snapshot.usdToCnyRate, - ) - AppLogger.i( - TAG, - "Imported ${snapshot.totals.size} cumulative totals and " + - "${snapshot.prices.size} price settings", - ) } - legacyDataSource.clearMigratedTokenStatsData() - initializationComplete = true + statsPreferences.completeMigration( + importedAtMs = importedAtMs, + releasedUsdToCnyRate = snapshot.usdToCnyRate, + ) + AppLogger.i( + TAG, + "Imported ${snapshot.totals.size} cumulative totals and " + + "${snapshot.prices.size} price settings", + ) } + legacyDataSource.clearMigratedTokenStatsData() + initializationComplete = true } - - suspend fun record(record: TokenUsageRecordEntity) { - ensureInitialized() - dao.insertRecord(record) - } - } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt index 1a3067cb1..e2387abde 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -25,7 +25,6 @@ import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore import com.ai.assistance.operit.data.stats.TokenStatsTimeRange import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges -import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.time.ZoneId import kotlinx.coroutines.CancellationException @@ -81,7 +80,6 @@ class TokenUsageStatisticsViewModel( private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, ) : ViewModel() { private val appContext = context.applicationContext - private val repository = TokenUsageRepository.getInstance(appContext) private val manager = TokenStatsSettingsManager(appContext) private val modelConfigManager = ModelConfigManager(appContext) private val tag = "TokenUsageStatisticsViewModel" diff --git a/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md index 1131ce205..e21cc4c1e 100644 --- a/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md +++ b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md @@ -19,3 +19,17 @@ barrier semantics and the replacing marker. A failed atomic replacement reports failure without deleting the user's previously active database. A focused regression test injects the final move failure and verifies that the existing database remains intact. [DONE] + +## Statistics Repository Lifecycle + +`TokenUsageRepository` survives for the process lifetime, while both Room-only +restore and raw snapshot restore close and replace `AppDatabase`. Retaining a Room +DAO in that repository would leave token queries and request recording attached to +the closed database when a user chooses to restart later. + +The repository now uses one process-wide mutex for every Room-backed statistics +operation and for both restore entry points. A restore clears the initialization +state while holding that mutex, then keeps it until the database files have been +replaced. Each operation obtains the current DAO only after that barrier and the +one-time import have completed, so it cannot use a Room instance that a restore is +closing or has closed. [DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/3_verification.md b/docs/TODO/token_stats_922_review_20260811/3_verification.md index cfd1e876e..9e7ffafbb 100644 --- a/docs/TODO/token_stats_922_review_20260811/3_verification.md +++ b/docs/TODO/token_stats_922_review_20260811/3_verification.md @@ -81,3 +81,14 @@ Range controls, activity summaries, and the selected visualization share one card; the card-level labels do not compete with page-section headings. - No compilation, build, or test was run, per user instruction. + +## 2026-08-13 Mainline Merge And Restore Lifecycle + +- [DONE] Merged current `main`; resolved `MemoryLibrary` by retaining windowed + analysis semantics while keeping memory requests categorized for token statistics. +- [DONE] Retained the current snapshot package-prefix validation. +- [DONE] Statically traced both database restore entry points and all Room-backed + token-statistics reads and writes. They share one mutex which holds from + initialization through DAO use, or from clearing initialization through database + file replacement, so no statistics operation can retain or use a closed DAO. +- No compilation, build, or test was run, per user instruction. From 5235840be2ec22c329fb9b4ada6011a8f9fa11a9 Mon Sep 17 00:00:00 2001 From: AAswordman <1002153674@qq.com> Date: Thu, 13 Aug 2026 18:00:08 +0800 Subject: [PATCH 28/28] fix(stats): serialize database backup access --- .../operit/api/chat/library/MemoryLibrary.kt | 2 +- .../data/backup/RawSnapshotBackupManager.kt | 79 +++++++++---------- .../data/backup/RoomDatabaseBackupManager.kt | 13 ++- .../backup/RoomDatabaseBackupRestoreLock.kt | 7 -- .../data/backup/RoomDatabaseRestoreManager.kt | 74 +++++++++-------- .../operit/data/stats/TokenUsageRepository.kt | 9 ++- .../screens/ChatBackupSettingsScreen.kt | 1 - 7 files changed, 86 insertions(+), 99 deletions(-) delete mode 100644 app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt index 184420d28..b8da5dee3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt @@ -666,7 +666,7 @@ object MemoryLibrary { stream.collect { content -> result.append(content) } } - return parseAnalysisResult(context, ChatUtils.removeThinkingContent(result.toString())) + return parseAnalysisResult(ChatUtils.removeThinkingContent(result.toString())) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index dde3ad46a..48d7e1445 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -23,8 +23,6 @@ import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream import kotlin.system.measureTimeMillis import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -53,7 +51,6 @@ object RawSnapshotBackupManager { private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") - private val mutex = Mutex() private val mainHandler = Handler(Looper.getMainLooper()) @Serializable @@ -110,7 +107,7 @@ object RawSnapshotBackupManager { options: SnapshotOptions = SnapshotOptions(), onProgress: ((ExportProgressInfo) -> Unit)? = null ): File = withContext(Dispatchers.IO) { - mutex.withLock { + TokenUsageRepository.withDatabaseAccess { AppLogger.i(TAG, "export start (includeTerminalData=${options.includeTerminalData})") withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.PREPARING)) } val exportDir = OperitBackupDirs.rawSnapshotDir() @@ -268,7 +265,7 @@ object RawSnapshotBackupManager { uri: Uri, onProgress: ((RestoreProgress) -> Unit)? = null ) = withContext(Dispatchers.IO) { - mutex.withLock { + TokenUsageRepository.withDatabaseRestore { val cacheZip = File.createTempFile("raw_snapshot_restore_", ".zip", context.cacheDir) val workDir = File(context.cacheDir, "raw_snapshot_restore_work").apply { if (exists()) deleteRecursively() @@ -287,53 +284,51 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "restore cached zip: ${cacheZip.absolutePath} (${cacheZip.length()} bytes)") - TokenUsageRepository.withDatabaseRestore { - AppDatabase.closeDatabase() - ObjectBoxManager.closeAll() + AppDatabase.closeDatabase() + ObjectBoxManager.closeAll() - AppLogger.i(TAG, "restore closed databases (room + objectbox)") + AppLogger.i(TAG, "restore closed databases (room + objectbox)") - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } - val manifest = extractZipToWorkDir(cacheZip, workDir) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.EXTRACTING) } + val manifest = extractZipToWorkDir(cacheZip, workDir) - val payloadDir = File(workDir, "payload") - val externalFilesPayloadDir = File(payloadDir, "external_files") + val payloadDir = File(workDir, "payload") + val externalFilesPayloadDir = File(payloadDir, "external_files") - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() - val preserveTerminal = !manifest.includeTerminalData - val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() - val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> - File(payloadDir, "files/$dirName").exists() - }.toSet() - val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames + val preserveTerminal = !manifest.includeTerminalData + val preservedTerminalNames = if (preserveTerminal) terminalTopLevelDirNames else emptySet() + val preservedAlwaysExcludedNames = alwaysExcluded.filterNot { dirName -> + File(payloadDir, "files/$dirName").exists() + }.toSet() + val preservedNames = preservedTerminalNames + preservedAlwaysExcludedNames - AppLogger.i( - TAG, - "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" - ) + AppLogger.i( + TAG, + "restore manifest ok (formatVersion=${manifest.formatVersion}, includeTerminalData=${manifest.includeTerminalData})" + ) - AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") + AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } - replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) - if (externalFilesPayloadDir.exists()) { - val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { - "External files dir is unavailable" - } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } - replaceDirContents(externalFilesPayloadDir, externalFilesDir) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) + if (externalFilesPayloadDir.exists()) { + val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { + "External files dir is unavailable" } - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } - replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } - replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } - replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) - - withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } - AppLogger.i(TAG, "restore done: ${manifest.packageName}") + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_EXTERNAL_FILES) } + replaceDirContents(externalFilesPayloadDir, externalFilesDir) } + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_SHARED_PREFS) } + replaceDirContents(File(payloadDir, "shared_prefs"), File(context.dataDir, "shared_prefs")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATASTORE) } + replaceDirContents(File(payloadDir, "datastore"), File(context.dataDir, "datastore")) + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_DATABASES) } + replaceDirContents(File(payloadDir, "databases"), File(context.dataDir, "databases")) + + withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.FINALIZING) } + AppLogger.i(TAG, "restore done: ${manifest.packageName}") } catch (e: Exception) { AppLogger.e(TAG, "restore failed", e) throw e diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt index dd0802559..bb7ba1528 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.backup import android.content.Context import androidx.sqlite.db.SupportSQLiteDatabase import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -15,7 +16,6 @@ import java.time.format.DateTimeFormatter import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.withLock object RoomDatabaseBackupManager { @@ -31,7 +31,7 @@ object RoomDatabaseBackupManager { ) suspend fun pruneExcessBackups(context: Context) { - RoomDatabaseBackupRestoreLock.mutex.withLock { + TokenUsageRepository.withDatabaseAccess { val preferences = RoomDatabaseBackupPreferences.getInstance(context) val maxBackupCount = preferences.getMaxBackupCount() enforceMaxBackupCount(context, keepLatest = maxBackupCount) @@ -39,24 +39,24 @@ object RoomDatabaseBackupManager { } suspend fun backupIfNeeded(context: Context, force: Boolean): BackupResult { - return RoomDatabaseBackupRestoreLock.mutex.withLock { + return TokenUsageRepository.withDatabaseAccess { val preferences = RoomDatabaseBackupPreferences.getInstance(context) val enabled = preferences.isDailyBackupEnabled() val maxBackupCount = preferences.getMaxBackupCount() if (!enabled && !force) { - return@withLock BackupResult(performed = false, skippedReason = "disabled") + return@withDatabaseAccess BackupResult(performed = false, skippedReason = "disabled") } if (force) { val backupFile = createManualBackup(context) enforceMaxBackupCount(context, keepLatest = maxBackupCount) - return@withLock BackupResult(performed = true, backupFile = backupFile) + return@withDatabaseAccess BackupResult(performed = true, backupFile = backupFile) } val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE) val lastValue = preferences.lastBackupDayFlow.first() if (lastValue == today) { - return@withLock BackupResult(performed = false, skippedReason = "already_backed_up_today") + return@withDatabaseAccess BackupResult(performed = false, skippedReason = "already_backed_up_today") } val backupFile = createOrReplaceAutoBackup(context, today) @@ -213,4 +213,3 @@ object RoomDatabaseBackupManager { } } } - diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt deleted file mode 100644 index 163dae562..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ai.assistance.operit.data.backup - -import kotlinx.coroutines.sync.Mutex - -object RoomDatabaseBackupRestoreLock { - val mutex = Mutex() -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index b288fe39b..260f2bcf9 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -10,9 +10,12 @@ import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.zip.ZipInputStream import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext object RoomDatabaseRestoreManager { @@ -64,31 +67,27 @@ object RoomDatabaseRestoreManager { suspend fun restoreFromBackupUri(context: Context, uri: Uri) { withContext(Dispatchers.IO) { - RoomDatabaseBackupRestoreLock.mutex.withLock { - val cacheFile = File.createTempFile("room_db_restore_", ".zip", context.cacheDir) - try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(cacheFile).use { output -> - input.copyTo(output) - } - } ?: throw IllegalStateException("Failed to open uri") - - TokenUsageRepository.withDatabaseRestore { - restoreFromBackupFileInternal(context, cacheFile) + val cacheFile = File.createTempFile("room_db_restore_", ".zip", context.cacheDir) + try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> + input.copyTo(output) } - } finally { - cacheFile.delete() + } ?: throw IllegalStateException("Failed to open uri") + + TokenUsageRepository.withDatabaseRestore { + restoreFromBackupFileInternal(context, cacheFile) } + } finally { + cacheFile.delete() } } } suspend fun restoreFromBackupFile(context: Context, zipFile: File) { withContext(Dispatchers.IO) { - RoomDatabaseBackupRestoreLock.mutex.withLock { - TokenUsageRepository.withDatabaseRestore { - restoreFromBackupFileInternal(context, zipFile) - } + TokenUsageRepository.withDatabaseRestore { + restoreFromBackupFileInternal(context, zipFile) } } } @@ -151,23 +150,20 @@ object RoomDatabaseRestoreManager { throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") } - targetWal.delete() - targetShm.delete() - targetDb.delete() + if (targetWal.exists() && !extractedWal) { + throw IOException("Backup does not contain ${targetWal.name}") + } + if (targetShm.exists() && !extractedShm) { + throw IOException("Backup does not contain ${targetShm.name}") + } - replaceFile(tmpDb, targetDb) + atomicallyReplace(tmpDb, targetDb) if (extractedWal) { - replaceFile(tmpWal, targetWal) - } else { - tmpWal.delete() - targetWal.delete() + atomicallyReplace(tmpWal, targetWal) } if (extractedShm) { - replaceFile(tmpShm, targetShm) - } else { - tmpShm.delete() - targetShm.delete() + atomicallyReplace(tmpShm, targetShm) } } catch (e: Exception) { tmpDb.delete() @@ -188,14 +184,16 @@ object RoomDatabaseRestoreManager { } } - private fun replaceFile(from: File, to: File) { - if (to.exists()) { - to.delete() - } - if (!from.renameTo(to)) { - from.copyTo(to, overwrite = true) - from.delete() + private fun atomicallyReplace(from: File, to: File) { + try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (e: AtomicMoveNotSupportedException) { + throw IOException("Atomic database replacement is unavailable: ${from.name}", e) } } } - diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt index acb9699fa..6e97e6f4b 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt @@ -31,8 +31,11 @@ class TokenUsageRepository private constructor(context: Context) { * Prevent token-statistics operations from opening or using Room while a restore replaces * its database files. The initialization state must be reset before Room is closed. */ + suspend fun withDatabaseAccess(block: suspend () -> T): T = + databaseAccessMutex.withLock { block() } + suspend fun withDatabaseRestore(block: suspend () -> T): T = - databaseAccessMutex.withLock { + withDatabaseAccess { instance?.initializationComplete = false block() } @@ -43,13 +46,13 @@ class TokenUsageRepository private constructor(context: Context) { private val statsPreferences = TokenStatsPreferences(appContext) private var initializationComplete = false - suspend fun ensureInitialized() = databaseAccessMutex.withLock { + suspend fun ensureInitialized() = withDatabaseAccess { ensureInitializedLocked() } /** Resolves the DAO only after the restore barrier and initialization have completed. */ internal suspend fun withDao(block: suspend (TokenUsageDao) -> T): T = - databaseAccessMutex.withLock { + withDatabaseAccess { ensureInitializedLocked() block(AppDatabase.getDatabase(appContext).tokenUsageDao()) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt index 90e792c19..40f5bc3de 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatBackupSettingsScreen.kt @@ -1620,4 +1620,3 @@ private suspend fun importMemoriesFromUri( memoryRepository.importMemoriesFromJson(jsonString, strategy) } -