Skip to content

EntityGraph 数据完整性加固方案

目的

本文档是对 entity-graph-implementation-plan.md 的安全补充,聚焦于当前 entitygraph 模块已发现的 7 个数据完整性/性能漏洞的修复方案。

审查基准

  • 审查范围:entitygraph/ 模块全部 16 个 Kotlin 源文件 + 相关 migration + DAO
  • 审查日期:2026-06-07
  • 审查者:Pi Coding Agent(自动化架构分析)
  • 总漏洞数:7(严重 2、高危 2、中危 2、低危 1)

根因分析

entity 表的设计文档将其定位为 cache-like and deletable 的本地缓存层,但实现缺少缓存应有的核心属性:

缓存属性期望行为当前实现缺陷
幂等性同一 key 多次写入 = 同一个实体仅 binding 层有 PK,实体层无唯一约束
去重同名实体自动合并依赖脆弱的 Levenshtein 启发式(阈值 0.72)
引用完整性删除实体时级联清理关联数据binding/relation 表无 FK 约束,依赖手动代码顺序
并发安全并发写入不产生重复或损坏resolveOrCreateEntity 分阶段读写,写偏斜窗口存在

漏洞修复方案

VULN-1: entity_bindingrelation 表缺少外键约束 [严重: 8/10]

位置Migration36To37.kt — DDL 中未声明 FOREIGN KEY

现状

sql
-- Migration36To37.kt: 无 FK 的当前 DDL
CREATE TABLE `entity_binding` (
    `entity_id` INTEGER NOT NULL,
    `source` TEXT NOT NULL,
    `external_id` TEXT NOT NULL,
    `confidence` REAL NOT NULL,
    `is_primary` INTEGER NOT NULL,
    PRIMARY KEY(`source`, `external_id`)
);

CREATE TABLE `relation` (
    `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    `from_entity_id` INTEGER NOT NULL,   -- 无 FK 约束
    `to_entity_id` INTEGER NOT NULL,     -- 无 FK 约束
    `type` TEXT NOT NULL,
    `weight` REAL NOT NULL,
    `created_at` INTEGER NOT NULL
);

修复方案

Room 实体注解是首选方案——类型安全、可被 KSP 验证、与现有代码风格一致:

kotlin
// EntityGraphEntities.kt — 修改后

@Entity(
    tableName = TABLE_ENTITY_GRAPH_BINDING,
    primaryKeys = ["source", "external_id"],
    foreignKeys = [
        ForeignKey(
            entity = EntityRecord::class,
            parentColumns = ["id"],
            childColumns = ["entity_id"],
            onDelete = ForeignKey.CASCADE,
        ),
    ],
    indices = [
        Index(name = "idx_binding_entity", value = ["entity_id"]),
        Index(name = "idx_binding_external", value = ["source", "external_id"]),
    ],
)
data class EntityBindingRecord(
    @ColumnInfo(name = "entity_id") val entityId: Long,
    @ColumnInfo(name = "source") val source: String,
    @ColumnInfo(name = "external_id") val externalId: String,
    @ColumnInfo(name = "confidence") val confidence: Float,
    @ColumnInfo(name = "is_primary") val isPrimary: Boolean,
)

@Entity(
    tableName = TABLE_ENTITY_GRAPH_RELATION,
    foreignKeys = [
        ForeignKey(
            entity = EntityRecord::class,
            parentColumns = ["id"],
            childColumns = ["from_entity_id"],
            onDelete = ForeignKey.CASCADE,
        ),
        ForeignKey(
            entity = EntityRecord::class,
            parentColumns = ["id"],
            childColumns = ["to_entity_id"],
            onDelete = ForeignKey.CASCADE,
        ),
    ],
    indices = [
        Index(name = "idx_relation_from", value = ["from_entity_id"]),
        Index(name = "idx_relation_to", value = ["to_entity_id"]),
        Index(name = "idx_relation_unique", value = ["from_entity_id", "to_entity_id", "type"], unique = true),
    ],
)
data class RelationRecord(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id") val id: Long = 0L,
    @ColumnInfo(name = "from_entity_id") val fromEntityId: Long,
    @ColumnInfo(name = "to_entity_id") val toEntityId: Long,
    @ColumnInfo(name = "type") val type: String,
    @ColumnInfo(name = "weight") val weight: Float,
    @ColumnInfo(name = "created_at") val createdAt: Long,
)

配套修改

kotlin
// MangaDatabase.kt — 添加 FK 迁移
// 新的 MigrationXXToXX 类:
class MigrationFkConstraints : Migration(from, to) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // SQLite 不支持 ALTER TABLE ADD CONSTRAINT,需要重建表
        db.execSQL("""
            CREATE TABLE entity_binding_new (
                entity_id INTEGER NOT NULL,
                source TEXT NOT NULL,
                external_id TEXT NOT NULL,
                confidence REAL NOT NULL,
                is_primary INTEGER NOT NULL,
                PRIMARY KEY(source, external_id),
                FOREIGN KEY(entity_id) REFERENCES entity(id) ON DELETE CASCADE
            )
        """)
        db.execSQL("INSERT INTO entity_binding_new SELECT * FROM entity_binding")
        db.execSQL("DROP TABLE entity_binding")
        db.execSQL("ALTER TABLE entity_binding_new RENAME TO entity_binding")
        db.execSQL("CREATE INDEX idx_binding_entity ON entity_binding(entity_id)")
        db.execSQL("CREATE INDEX idx_binding_external ON entity_binding(source, external_id)")
        
        // relation 表同理
        // ...
    }
}

风险:迁移需要锁表 + 全表复制,在实体积大的设备上可能耗时数百毫秒。考虑在后台 migration worker 中执行。

可简化替代方案:不做 FK 迁移,改为在 pruneStaleEntities 前后增加验证步骤:

kotlin
suspend fun verifyIntegrity(): List<String> {
    val orphanBindings = db.query("""
        SELECT b.rowid FROM entity_binding b 
        LEFT JOIN entity e ON b.entity_id = e.id 
        WHERE e.id IS NULL
    """)
    val orphanRelations = db.query("""
        SELECT r.id FROM relation r 
        LEFT JOIN entity e1 ON r.from_entity_id = e1.id
        LEFT JOIN entity e2 ON r.to_entity_id = e2.id
        WHERE e1.id IS NULL OR e2.id IS NULL
    """)
    // log and clean up
}

建议:采用 FK 注解方案(Room 原生支持,安全且可维护),耦合一个新的 migration 版本号。


VULN-2: mergeEntities 中的 binding 静默覆盖 [严重: 8/10]

位置EntityGraphRepository.ktmergeEntities()mergeLocalWorkEntities()

问题代码

kotlin
// 第 254-258 行
distinctSourceIds.forEach { sourceEntityId ->
    dao.findBindingsByEntity(sourceEntityId).forEach { binding ->
        dao.upsertBinding(
            binding.copy(
                entityId = targetEntityId,
                isPrimary = false,
            ),
        )
    }
}

entity_binding 的 PK 是 (source, external_id)。如果 target 实体已经有一个同 key 的 binding,source 实体的旧值(包括 confidence, isPrimary)会静默覆盖。

场景复现

  1. 作品 A 从 Bangumi 导入 → entity_1, binding(confidence=1.0, source=bangumi, extId=42)
  2. 作品 A 也从 MAL 导入,通过 AnimeOffline 解析到同一 entity_1 → binding(confidence=0.98, source=mal, extId=7)
  3. 用户手动合并 entity_1 和 entity_2
  4. entity_2 的旧 binding(confidence=0.5, source=bangumi, extId=42) 覆盖了 entity_1 的好 binding

修复方案

kotlin
// EntityGraphRepository.kt — mergeEntities() 内

distinctSourceIds.forEach { sourceEntityId ->
    dao.findBindingsByEntity(sourceEntityId).forEach { sourceBinding ->
        val existingTargetBinding = dao.findBinding(sourceBinding.source, sourceBinding.externalId)
        when {
            existingTargetBinding == null -> {
                // Target 没有这个 binding——直接迁移
                dao.upsertBinding(
                    sourceBinding.copy(entityId = targetEntityId, isPrimary = false)
                )
            }
            existingTargetBinding.confidence < sourceBinding.confidence -> {
                // Target 的 binding 置信度更低——用 source 的值覆盖
                dao.upsertBinding(
                    sourceBinding.copy(
                        entityId = targetEntityId,
                        isPrimary = existingTargetBinding.isPrimary, // 保留 target 的 isPrimary
                    )
                )
            }
            else -> {
                // Target 的 binding 置信度相等或更高——保留 target,仅记录日志
                // source binding 将被丢弃(source entity 本身也会被删除)
            }
        }
    }
}

额外保护:在 mergeEntities 方法头部添加快速检查:

kotlin
// 不要合并自己
val distinctSourceIds = sourceEntityIds
    .asSequence()
    .filter { it != targetEntityId }
    .distinct()
    .toList()
if (distinctSourceIds.isEmpty()) return@withTransaction targetEntityId

VULN-3: 并发实体创建竞态条件 [高危: 7/10]

位置EntityGraphRepository.ktresolveOrCreateEntity()

问题描述

两个并发请求(如同时打开 Bangumi 和 MAL 的同一作品详情页)可能:

  1. 同时发现 entity_binding 中不存在对应记录
  2. 同时进入 pickCandidate 扫描 → 都返回 IGNORE(名称差异导致匹配分 < 0.60)
  3. 各自创建独立的实体 → 同一作品生成了两个树根

修复方案 A(推荐):添加实体层面的唯一约束——名称 hash 列

kotlin
// EntityRecord 增加 name_hash 列
@Entity(
    tableName = TABLE_ENTITY_GRAPH_ENTITY,
    indices = [
        Index(name = "idx_entity_name", value = ["primary_name"]),
        Index(name = "idx_entity_name_hash", value = ["type", "name_hash"], unique = true),
    ],
)
data class EntityRecord(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "id") val id: Long = 0L,
    @ColumnInfo(name = "type") val type: String,
    @ColumnInfo(name = "primary_name") val primaryName: String,
    @ColumnInfo(name = "name_hash") val nameHash: Long,  // 新增
    @ColumnInfo(name = "aliases") val aliases: String?,
    @ColumnInfo(name = "created_at") val createdAt: Long,
    @ColumnInfo(name = "last_accessed") val lastAccessed: Long,
    @ColumnInfo(name = "access_count") val accessCount: Int,
)

name_hash 的生成规则:MurmurHash3(normalizeName(primaryName).toByteArray()) 或利用已有的 String.longHashCode()

kotlin
// 在 createEntity 中使用 INSERT OR IGNORE 语义
private suspend fun createEntity(...): Entity {
    val dao = db.getEntityGraphDao()
    val nameHash = normalizeName(primaryName.trim()).longHashCode()
    
    // 改用 insertOrIgnore,捕获主键冲突
    val id = dao.insertEntityOrIgnore(
        EntityRecord(
            type = type.name,
            primaryName = primaryName.trim(),
            nameHash = nameHash,
            aliases = encodeStringList(mergeAliases(primaryName, aliases).drop(1)),
            createdAt = now,
            lastAccessed = now,
            accessCount = 1,
        ),
    )
    if (id == -1L) {
        // 冲突了——另一个并发请求已经创建了这个实体,回退到查询
        val existing = dao.findEntityByTypeAndNameHash(type.name, nameHash)
        if (existing != null) {
            return mergeIntoResolvedEntity(
                entity = existing.toModel(),
                primaryName = primaryName,
                aliases = aliases,
                source = source,
                externalId = externalId,
                confidence = confidence,
                now = now,
            )
        }
        // 理论上不应该到这里,如果到了说明 hash 冲突
    }
    // ... 插入 binding
}

修复方案 B(轻量):使用 INSERT OR IGNORE + 事后重查

不引入新列,在 createEntity 中用事务级别的重试:

kotlin
// EntityGraphDao.kt
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insertEntityIgnore(entity: EntityRecord): Long

如果返回 -1(IGNORE 生效),就在同一事务内重新查询并返回已有实体。

推荐:方案 A,因为 name_hash 还能显著加速 pickCandidate 的预过滤(见 VULN-5)。


VULN-4: 无名称级唯一性约束 [高危: 7/10]

位置Migration36To37.kt — 仅创建了普通索引

修复方案:与 VULN-3 的方案 A 合并处理——通过 (type, name_hash) UNIQUE 约束同时解决两个问题。

如果不想引入 hash 列,可以复用 VULN-3 的方案 B(幂等 insert)。

核心原则ensureLocalWorkEntities 已通过 existingBindings 做了去重,但 resolveOrCreateEntity 的非 binding 路径没有。统一在 createEntity 层做防护。


VULN-5: pickCandidate 在事务内 O(n×m×k) 扫描 [中危: 5/10]

位置EntityGraphRepository.ktpickCandidate()

kotlin
private suspend fun pickCandidate(...): CandidateMatch? {
    // ...
    return db.getEntityGraphDao().findEntitiesByType(type.name, ENTITY_SCAN_LIMIT) // 120 条
        .map { it.toModel() }
        .map { entity ->
            val confidence = bindingMatcher.tryBindEntities(probe, entity)  // Levenshtein
            CandidateMatch(entity, confidence, bindingMatcher.classify(confidence))
        }
        .filter { it.strength != EntityBindingStrength.IGNORE }
        .maxWithOrNull(...)
}

开销分析

  • scoreNames 对每个候选实体执行 namesA.size × namesB.size 次 Levenshtein
  • 单次 Levenshtein 复杂度 O(len1×len2),日文/中文作品名长度 10-30 字符
  • scoreContext 对 CHARACTER 和 PERSON 类型额外执行 2 次 DB 查询
  • 总计:最多 120 × (3×3) × 900 = ~972,000 次字符比较 + 最多 240 次 DB 查询

修复方案:两阶段匹配

kotlin
// DefaultEntityBindingMatcher.kt
override suspend fun tryBindEntities(entityA: Entity, entityB: Entity): Float {
    if (entityA.type != entityB.type) return 0f
    
    // Phase 1: 快速名称精确匹配(hash 预过滤)
    val nameScore = scoreNames(entityA, entityB)
    if (nameScore < WEAK_BIND_THRESHOLD) return 0f  // 提前终止,避免无意义的 context 查询
    
    // Phase 2: 仅在名称匹配通过后才查询 context
    val contextScore = scoreContext(entityA, entityB)
    return (nameScore + contextScore).coerceIn(0f, 1f)
}

scoreNames 内部优化——优先快速路径:

kotlin
private fun scoreNames(entityA: Entity, entityB: Entity): Float {
    val namesA = mergeAliases(entityA.primaryName, entityA.aliases)
    val namesB = mergeAliases(entityB.primaryName, entityB.aliases)
    
    // 快速路径 1: exact match
    val aSet = namesA.toSet()
    if (namesB.any { it in aSet }) return 1f
    
    // 快速路径 2: lowercase exact match
    val aLower = namesA.map { it.lowercase() }.toSet()
    if (namesB.any { it.lowercase() in aLower }) return 0.9f
    
    // 快速路径 3: normalized match
    val aNormalized = namesA.map { normalizeName(it) }.toSet()
    if (namesB.any { normalizeName(it) in aNormalized }) return 0.9f
    
    // 慢速路径 4: Levenshtein(仅在上述路径都不匹配时执行)
    var best = 0f
    for (left in namesA) {
        for (right in namesB) {
            val score = scoreLevenshtein(left, right)
            if (score > best) best = score
            if (best >= 0.88f) return best  // 达到上限,提前结束
        }
    }
    return best
}

配套索引:见 VULN-7。


VULN-6: Levenshtein 匹配假阳性风险 [中危: 6/10]

位置DefaultEntityBindingMatcher.ktscoreName()

问题

kotlin
private fun normalizeName(value: String): String {
    return value.lowercase()
        .replace(Regex("\\s+"), "")
        .replace(Regex("[^a-z0-9\\u4e00-\\u9fff\\u3040-\\u30ff\\u31f0-\\u31ff\\uff66-\\uff9d]"), "")
}

归一化去掉了所有标点和特殊字符。短名称发生碰撞的风险较高。

修复方案

kotlin
private const val MIN_LENGTH_FOR_FUZZY = 5

private fun scoreName(left: String, right: String): Float {
    // 精确匹配
    if (left == right) return 1f
    if (left.equals(right, ignoreCase = true)) return 0.9f
    
    val normalizedLeft = normalizeName(left)
    val normalizedRight = normalizeName(right)
    
    if (normalizedLeft.isEmpty() || normalizedRight.isEmpty()) return 0f
    if (normalizedLeft == normalizedRight) return 0.9f
    
    // 新增:短名称保护
    val minNormLength = minOf(normalizedLeft.length, normalizedRight.length)
    if (minNormLength < MIN_LENGTH_FOR_FUZZY) {
        // 短名称(< 5 个规范化字符)不允许模糊匹配
        // 必须精确匹配才会走上面的分支
        return 0f
    }
    
    // 原有 Levenshtein 逻辑
    val maxLength = maxOf(normalizedLeft.length, normalizedRight.length).coerceAtLeast(1)
    val similarity = 1f - normalizedLeft.levenshteinDistance(normalizedRight).toFloat() / maxLength.toFloat()
    if (similarity < 0.72f) return 0f
    return (0.7f + ((similarity - 0.72f) / 0.28f) * 0.18f).coerceIn(0.7f, 0.88f)
}

阈值调优建议

  • AUTO_BIND_THRESHOLD: 从 0.85 提高到 0.90(减少自动合并误判)
  • WEAK_BIND_THRESHOLD: 从 0.60 提高到 0.65(减少弱绑定噪声)
kotlin
private const val AUTO_BIND_THRESHOLD = 0.90f   // was 0.85
private const val WEAK_BIND_THRESHOLD = 0.65f   // was 0.60

验证数据:在 production DB 上运行 dumpEntities() 导出全量数据,用新旧阈值分别计算 AUTOBIND/WEAK/IGNORE 分布,确保改动不破坏已有合理绑定。


VULN-7: 缺少 (type, access_count, last_accessed) 复合索引 [低危: 4/10]

位置EntityGraphDao.ktfindEntitiesByType()pickCandidate()

问题

sql
-- findEntitiesByType 的 WHERE/ORDER BY 缺少匹配的复合索引
SELECT * FROM entity
WHERE type = ?
ORDER BY access_count DESC, last_accessed DESC, id DESC
LIMIT ?

当前仅有 idx_entity_name (primary_name),此查询会全表扫描 + filesort。

修复方案

kotlin
// EntityGraphEntities.kt — EntityRecord 注解修改
@Entity(
    tableName = TABLE_ENTITY_GRAPH_ENTITY,
    indices = [
        Index(name = "idx_entity_name", value = ["primary_name"]),
        Index(name = "idx_entity_type_access", value = ["type", "access_count", "last_accessed", "id"]),  // 新增
        Index(name = "idx_entity_name_hash", value = ["type", "name_hash"], unique = true),  // VULN-3 新增
    ],
)
data class EntityRecord(
    // ...
)

对应的 migration

sql
CREATE INDEX idx_entity_type_access 
ON entity (type, access_count, last_accessed, id);

由于是新列+新索引,只需在 migration 中执行 CREATE INDEX,代价极低。

Query plan 验证:部署后用 EXPLAIN QUERY PLAN 确认索引被命中:

sql
EXPLAIN QUERY PLAN 
SELECT * FROM entity 
WHERE type = 'WORK' 
ORDER BY access_count DESC, last_accessed DESC, id DESC 
LIMIT 120;
-- 期望:SEARCH TABLE entity USING INDEX idx_entity_type_access

实施计划

Phase 1: P0 修复(目标:消除数据损坏风险)

步骤内容文件
1aEntityBindingRecord 添加 @ForeignKeyEntityGraphEntities.kt
1bRelationRecord 添加 @ForeignKeyEntityGraphEntities.kt
1c新建 Migration 添加 FK 约束migrations/MigrationXXToXX.kt
1dmergeEntities 添加 binding 覆盖保护EntityGraphRepository.kt
1emergeLocalWorkEntities 添加 binding 覆盖保护EntityGraphRepository.kt
1f单元测试:FK 约束生效、merge 不覆盖高置信度 bindingEntityGraphRepositoryTest.kt

Phase 2: P1 修复(目标:消除竞态 + 重复实体)

步骤内容文件
2aEntityRecord 添加 name_hash 列 + UNIQUE 索引EntityGraphEntities.kt
2b新建 Migration 添加列和索引,回填已有数据migrations/MigrationXXToXX.kt
2ccreateEntity 改为 INSERT OR IGNORE + 冲突重查EntityGraphRepository.kt
2dEntityGraphDao 添加 insertEntityIgnorefindEntityByTypeAndNameHashEntityGraphDao.kt
2e单元测试:并发创建同名实体不产生重复EntityGraphRepositoryTest.kt

Phase 3: P2 修复(目标:性能 + 匹配质量)

步骤内容文件
3a添加 idx_entity_type_access 复合索引EntityGraphEntities.kt + migration
3bscoreNames 分层快速路径优化DefaultEntityBindingMatcher.kt
3c短名称保护(MIN_LENGTH_FOR_FUZZY=5DefaultEntityBindingMatcher.kt
3dAUTO_BIND_THRESHOLD 调整为 0.90DefaultEntityBindingMatcher.kt
3epickCandidate 增加 name_hash 预过滤EntityGraphRepository.kt
3f基准测试:ingestion 耗时对比EntityGraphBenchmarkTest.kt

测试策略

单元测试

kotlin
class EntityGraphRepositoryTest {
    
    @Test
    fun `mergeEntities preserves higher confidence binding`() = runTest {
        // Given: target 已有 binding(bangumi, 42, confidence=1.0)
        // And: source 有 binding(bangumi, 42, confidence=0.5)
        // When: mergeEntities(target, source)
        // Then: target 的 binding(bangumi, 42) confidence 仍为 1.0
    }
    
    @Test
    fun `concurrent entity creation deduplicates by name hash`() = runTest {
        // Given: 两个协程同时创建同名 WORK 实体
        // When: 两者都执行 resolveOrCreateEntity
        // Then: 只创建了一个实体,第二个请求返回已有实体
    }
    
    @Test
    fun `foreign key cascade deletes bindings and relations`() = runTest {
        // Given: entity A 有 2 个 bindings 和 1 个 relation
        // When: 删除 entity A
        // Then: bindings 和 relations 都被级联删除
    }
}

class DefaultEntityBindingMatcherTest {
    
    @Test
    fun `short names below 5 chars require exact match`() = runTest {
        val left = entity(name = "A")
        val right = entity(name = "B")
        val confidence = matcher.tryBindEntities(left, right)
        assertEquals(0f, confidence)
    }
    
    @Test
    fun `normalized match is case and whitespace insensitive`() = runTest {
        val left = entity(name = "Sword Art Online")
        val right = entity(name = "swordartonline")
        val confidence = matcher.tryBindEntities(left, right)
        assertEquals(0.9f, confidence)
    }
}

集成测试 / 属性测试

kotlin
class EntityGraphPropertyTest {
    
    @PropertyTest
    fun `no duplicate entities for same name across all supported scripts`() {
        // 用 property-based testing 生成随机名称组合
        // 验证 dedup 在各种 Unicode 块下正确工作
    }
}

性能基准

kotlin
class EntityGraphBenchmarkTest {
    
    @Test
    fun `ingestion latency under 50 entities`() {
        // 预热:插入 50 个 WORK 实体 + 随机 aliases
        // 测量:ingestWorkFromTracking 的 p50/p95/p99 耗时
        // 预期:p95 < 100ms(移动设备 SQLite)
    }
}

回滚安全

所有修改均为增量式

  • FK 约束:仅影响将来的删除操作,不影响现有读取路径。如果迁移失败,回退 migration 版本即可
  • name_hash 列:新增列,不影响已有代码的读写。hash 回填在 migration 中一次性完成
  • 阈值调整:可在运行时通过 AppSettings 控制,允许 A/B 测试
  • merge 保护mergeEntities 的修改不改变 API 签名——仅内部逻辑更保守

未覆盖的风险(后续追踪)

风险当前状态计划
JSON 别名解析失败静默丢弃decodeStringList 使用 getOrElse长期考虑引入结构化别名表
binding source key 格式不一致bindingSourceKeys() 做了兼容,但依赖代码约定定义 source key 枚举
EntityGraphMigrationWorker 重复运行无幂等保护添加 migration 标记位
无关系类型校验RelationType 直接 store as String保持现状,Room 实体层不感知 enum
EntityGraphMigrationWorker 未更新 name_hash 回填现有实体 name_hash 使用 row-id 占位,未回填真正 normalized hash已在 Phase 4-2b 中通过 worker 更新实现
多份重复的 normalizeName 实现MergeFavoriteEntitiesUseCase, BindTrackingToEntitiesUseCase 各有独立实现已在 Phase 4-4 中统一

第二轮排查:跨模块集成漏洞(2026-06-07 第二轮)

分析范围:entitygraph 模块被 33 个外部模块引用。本轮深入审查了 6 个关键集成点。

VULN-8: Backup 恢复创建全零 name_hash,违反 UNIQUE 约束 [严重: 10/10]

位置backups/data/BackupRepository.ktrestoreEntityRecord()

问题代码

kotlin
val localId = if (existing == null) {
    dao.insertEntity(
        remote.copy(
            id = 0L,
            aliases = encodeStringList(...),
            // ❌ name_hash 使用默认值 0,导致备份恢复失败
        ),
    )
}

根因backups 模块直接使用 EntityGraphDao.insertEntity 绕过 EntityGraphRepository,不经过 createEntity 逻辑。旧备份 JSON 反序列化后 name_hash 字段为 0L(data class 默认值),第二个同 type 实体恢复时触发 UNIQUE (type, name_hash) 约束冲突。

修复方案

kotlin
// 方案:restoreEntityRecord 中计算 name_hash
private suspend fun MangaDatabase.restoreEntityRecord(
    remote: EntityRecord,
    entityIdMapping: MutableMap<Long, Long>,
) {
    val dao = getEntityGraphDao()
    val trimmedName = remote.primaryName.trim()
    val computedHash = computeNameHash(trimmedName)
    val existing = dao.findEntity(remote.id)
        ?.takeIf { it.type == remote.type }
        ?: dao.findEntityByTypeAndPrimaryName(remote.type, trimmedName)
    val localId = if (existing == null) {
        dao.insertEntityIgnore(
            EntityRecord(
                type = remote.type,
                primaryName = trimmedName,
                nameHash = computedHash,  // ✅ 使用 normalized hash
                aliases = encodeStringList(mergeAliases(trimmedName, decodeStringList(remote.aliases)).drop(1)),
                createdAt = remote.createdAt.coerceAtLeast(0L),
                lastAccessed = remote.lastAccessed.coerceAtLeast(0L),
                accessCount = remote.accessCount.coerceAtLeast(1),
            ),
        ).takeIf { it != -1L } ?: run {
            // Conflict — another entity already has this name hash. Try to merge.
            dao.findEntityByTypeAndNameHash(remote.type, computedHash)?.id
                ?: dao.insertEntity(  // Fallback: force insert if hash collision
                    EntityRecord(
                        type = remote.type,
                        primaryName = trimmedName,
                        nameHash = remote.id,  // Use remote ID as hash to guarantee uniqueness
                        aliases = encodeStringList(mergeAliases(trimmedName, decodeStringList(remote.aliases)).drop(1)),
                        createdAt = remote.createdAt.coerceAtLeast(0L),
                        lastAccessed = remote.lastAccessed.coerceAtLeast(0L),
                        accessCount = remote.accessCount.coerceAtLeast(1),
                    ),
                )
        }
    } else {
        val mergedNames = mergeAliases(
            existing.primaryName,
            decodeStringList(existing.aliases) + listOf(trimmedName) + decodeStringList(remote.aliases),
        )
        val newPrimary = mergedNames.firstOrNull() ?: existing.primaryName
        val merged = existing.copy(
            primaryName = newPrimary,
            nameHash = computeNameHash(newPrimary),
            aliases = encodeStringList(mergedNames.drop(1)),
            createdAt = minOf(existing.createdAt, remote.createdAt.coerceAtLeast(0L)),
            lastAccessed = maxOf(existing.lastAccessed, remote.lastAccessed.coerceAtLeast(0L)),
            accessCount = maxOf(existing.accessCount, remote.accessCount.coerceAtLeast(1)),
        )
        dao.upsertEntityRecord(merged)
        existing.id
    }
    entityIdMapping[remote.id] = localId
}

VULN-9: AttachReadingSourceToEntityUseCase 绕过 Repository 创建实体 [严重: 9/10]

位置favourites/domain/AttachReadingSourceToEntityUseCase.ktresolveOrCreateEntityId()

问题代码

kotlin
private suspend fun resolveOrCreateEntityId(content: Content): Long {
    findLocalBinding(content.id)?.let { return it.entityId }
    val now = System.currentTimeMillis()
    val entityId = database.getEntityGraphDao().insertEntity(
        EntityRecord(
            type = EntityType.WORK.name,
            primaryName = content.title.trim(),
            aliases = null,       // ❌ 不合并别名
            // ❌ name_hash=0,第二个调用冲突
            ...
        ),
    )
    ...
}

修复方案:完全替换为 EntityGraphRepository.ensureLocalWorkEntity(),不再直接操作 DAO:

kotlin
private suspend fun resolveOrCreateEntityId(content: Content): Long {
    return entityGraphRepository.ensureLocalWorkEntity(content).id
}

VULN-10: 多份重复的 normalizeName/Levenshtein 实现 [中危: 5/10]

文件函数阈值
EntityGraphMapping.ktnormalizeName()—(共享实现)
MergeFavoriteEntitiesUseCasenormalizeTitle()FUZZY_MATCH_THRESHOLD=0.82
BindTrackingToEntitiesUseCasenormalizeTitle()EXACT_MATCH_THRESHOLD=0.995

修复方案:两个 UseCase 改用 entitygraph.data.normalizeName(同包 internal 可访问),删除各自的 normalizeTitlelevenshteinsimilarity 私有方法。

VULN-11: Backup 恢复静默丢弃无映射的 binding/relation [中危: 6/10]

位置backups/data/BackupRepository.kt

kotlin
private suspend fun MangaDatabase.restoreEntityBinding(
    remote: EntityBindingRecord,
    entityIdMapping: Map<Long, Long>,
) {
    val localEntityId = entityIdMapping[remote.entityId] ?: return  // ❌ 静默丢弃
    ...
}

修复方案:添加计数器跟踪跳过的 binding/relation,记录日志。

VULN-12: MigrateUseCase 直接操作 DAO [中危: 5/10]

位置alternatives/domain/MigrateUseCase.kt

直接使用 entityGraphDao.upsertBinding() 维护 source migration 的 binding 关联。虽已有前置 lookup 保护,但仍绕过了 Repository 的 upsertBindingForSource 逻辑。

修复方案:改为通过 entityGraphRepository 间接操作(或确认无风险后保持现状并添加注释说明)。


Phase 4: 跨模块集成修复

步骤

步骤内容文件数据库变更
4aBackup restoreEntityRecord 计算 name_hash + insertEntityIgnoreBackupRepository.kt
4bEntityGraphMigrationWorker 添加 name_hash 回填步骤EntityGraphMigrationWorker.kt无(运行时回填)
4cAttachReadingSourceToEntityUseCase 改用 EntityGraphRepositoryAttachReadingSourceToEntityUseCase.kt
4d统一 normalizeName 引用,删除重复实现MergeFavoriteEntitiesUseCase.kt, BindTrackingToEntitiesUseCase.kt
4eBackup 恢复添加丢失 binding/relation 日志BackupRepository.kt
4fMigrateUseCase 审查和修正MigrateUseCase.kt

数据库版本

Phase 4 不引入新的 migration(无需新增列/索引),所有修改均为应用层逻辑修正。当前 DB 版本保持在 52。


第五轮:EntityWorkbench UI 架构重构(统一表格方案)

设计评审结论

实体整理功能的核心设计——统一工作台表格 + 三阶段操作维度——在概念上是合理的:

  • 每行是一个实体组,MERGE/TRACKING/READING 是同一行的三组列操作
  • 跨阶段筛选器(ACTION_REQUIRED)和排序(ACTION_FIRST)需要统一表格上下文
  • EntityWorkbenchRowWorkbenchRowStageSnapshot 数据模型正确表达了这种同表多维度语义

问题不在设计概念,而在实现分层

反模式具体表现
ViewModel 混合表格元数据和 UI-local 操作moveTrackingServiceUp/DowntoggleXxxContentType/TagsetConcurrency 等纯 UI 状态操作不应该在 VM 层
Compose 文件 4427 行单文件60+ 个 @Composable 函数堆在一个文件,表格行渲染和阶段面板和对话框全混在一起
工作台状态关闭即丢失表格选中状态只在 rememberSaveable 中,Activity 重建后丢失

重构目标

  1. 保留统一表格语义 — 不拆 stage,不拆 ViewModel(表格状态需要集中管理)
  2. 拆分 Compose 文件 — 按组件类型分文件,不改变任何行为
  3. 分离 UI-local 状态 — 纯 UI 操作(上下移动列表项、切换筛选器)从 VM 移出

Compose 文件拆分方案

favourites/ui/migration/compose/
├── SourceMigrationPanel.kt          ← 面板主入口 + HeaderSection + 顶层 LazyColumn 组装
├── EntityWorkbenchTable.kt          ← EntityWorkbenchSection + 表头 + 行渲染 + 工具栏
├── EntityWorkbenchCells.kt          ← MergeCandidateSection / TrackingPreviewCard /
│                                       ReadingPreviewCard / ProjectionSummaryCard
├── EntityWorkbenchDialogs.kt        ← TrackingServiceSelectorDialog /
│                                       SourceSelectorDialog / SourceSearchDialog
├── StageConfigCard.kt               ← StageConfigCard + TrackingBindingSection +
│                                       TargetSourcesSection + SourceFilterSection
├── DatasetBridgeCard.kt             ← DatasetBridgeCard + DatasetMetaChip
├── SharedComponents.kt              ← CompactInfoChip, ButtonLabel, SearchPillTextField,
│                                       FilterDropdown, ConcurrencyDropdown, etc.
└── EntityWorkbenchModels.kt         ← data classes (EntityWorkbenchRow, WorkbenchStageSnapshot,
                                        WorkbenchColumnWidths, etc.) + 工具函数

拆分后每个文件预计 200-500 行,可读性大幅提升。

ViewModel 瘦身方案

将以下纯 UI-local 操作从 SourceMigrationViewModel 移到 Compose 层 remember + mutableStateListOf

kotlin
// 从 ViewModel 移除,在 Compose 用 remember { mutableStateListOf<ScrobblerService>() } 替代
fun moveTrackingServiceUp(service)
fun moveTrackingServiceDown(service)

// 从 ViewModel 移除,在 Compose 用 remember { mutableStateListOf<ContentSource>() } 替代
fun moveTargetSourceUp(sourceKey)
fun moveTargetSourceDown(sourceKey)
fun toggleTargetSource(source)
fun removeTargetSource(sourceKey)

// 从 ViewModel 移除,在 Compose 用 remember { mutableStateOf() } 替代
fun toggleFromContentType(tab)
fun toggleFromSourceTag(tag)
fun toggleToContentType(tab)
fun toggleToSourceTag(tag)
fun setConcurrency(value)

这些操作都是瞬时的 UI 排列变化,不涉及数据层,不需要在 ViewModel 中管理。

实施步骤

步骤内容文件变更
6a提取 SharedComponents.kt(CompactInfoChip、ButtonLabel、SearchPillTextField、FilterDropdown、ConcurrencyDropdown)新建 1 文件,SourceMigrationPanel.kt 删除对应代码
6b提取 EntityWorkbenchModels.kt(data class + 工具函数)新建 1 文件
6c提取 EntityWorkbenchCells.kt(MergeCandidateSection、TrackingPreviewCard 等)新建 1 文件
6d提取 EntityWorkbenchDialogs.kt(所有 Dialog composable)新建 1 文件
6e提取 EntityWorkbenchTable.kt(EntityWorkbenchSection + 表头 + 行 + 工具栏)新建 1 文件
6f提取 StageConfigCard.kt新建 1 文件
6g提取 DatasetBridgeCard.kt新建 1 文件
6h将 UI-local 状态从 VM 移到 Compose 层SourceMigrationViewModel.kt 删方法,Compose 加 remember

数据库版本

不引入 migration。当前 DB 版本保持在 52。

Documentation for Kototoro