犀牛鸟 2026 研究笔记 estelledc.github.io

案例 #1774:Go 后端引用检查与删除保护

PR:#1774 | 作者:lyingbug | MERGED 2026-06-23 | 11 文件 +437/-2


结论先行

这个 PR 解决的问题可以用一句话概括:删除模型前先查一圈”谁还在用它”,有人用就拒绝删除并告诉用户去哪里解除引用

模式本身并不复杂——就像图书馆系统,你不能在书还被借出的时候把它从馆藏里删掉。但实现层面有两个值得精读的细节:第一,模型可能被引用在普通数据库字段里,也可能藏在 JSON 配置的嵌套字段中(embedding_model、summary_model、vlm_model、asr_model……),查询方式不同;第二,WeKnora 的 Go 后端严格遵循 handler → service → repository 三层分离,这个 PR 是学习该分层模式的最小完整样本。

对 #1248(用户反馈功能)的直接价值:feedback 表需要关联 message 和 chunk,POST feedback API 的校验逻辑(”这条 message 存在吗?这个 chunk 属于这条 message 吗?”)和本 PR 的”这个模型还被谁引用?”是同一个编程模式——跨表关联查询 + 业务规则校验 + HTTP 错误返回


1. 问题现象(What)

这个 PR 没有对应的 issue,所以需要从 PR 描述和代码反推问题场景。

用户视角:管理员在 Settings 页面删除了一个自定义模型(比如一个 fine-tuned 的 Embedding 模型),删除成功。但之后某个知识库的向量化任务开始报错——它的配置里还写着这个已经不存在的模型 ID。更糟的情况是,某个 Agent 的聊天功能直接不可用了,因为它引用的 chat model 已经被删除。

技术视角DELETE /api/v1/models/:id 接口没有做引用检查,直接执行了数据库删除。模型被删除后,所有引用该模型的知识库和 Agent 配置变成了悬空引用(dangling reference),类似于 C 语言里的野指针——指针还在,但指向的内存已经被释放了。

影响面

修复前 / 修复后

操作 修复前 修复后
DELETE 被引用的模型 200,直接删除 400,提示 KB/Agent 引用数
删除后 KB 向量化 悬空 model ID,静默失败 不会发生(删除被拦截)
错误消息 无 / 500 可操作的定量中文提示

定位步骤

rg "DeleteModel|CountByModelID" internal/application/service/model.go
rg "CountByModelID" internal/application/repository/
rg "DeleteModel" internal/handler/model.go

阅读顺序handler/model.go DELETE 入口 → service/model.go 删除逻辑 → 新增 model_usage.go 聚合层。


2. 根因分析(Why)

根因很直接:删除操作缺少前置的引用完整性检查

想象一个更生活化的场景:公司的共享打印机要报废,但没有人检查哪些部门的电脑还配置着这台打印机。打印机搬走后,这些部门打印时才发现”打印机未找到”。正确的做法是先查一圈谁还在用,通知他们切换到别的打印机,然后再报废。

调用链

sequenceDiagram
    participant UI as Settings UI
    participant H as handler/model.go
    participant S as service/model.go
    participant R1 as repository/model_usage.go
    participant R2 as repository/knowledgebase.go
    participant R3 as repository/custom_agent.go
    participant DB as Database

    UI->>H: DELETE /api/v1/models/:id
    H->>S: DeleteModel(tenantID, modelID)
    S->>S: 检查是否为内置模型(拒绝删除)
    S->>R1: CountByModelID(tenantID, modelID)
    R1->>R2: CountByModelID(tenantID, modelID)
    R2->>DB: SELECT COUNT (标量字段 + JSON 字段)
    R2-->>R1: kbCount
    R1->>R3: CountByModelID(tenantID, modelID)
    R3->>DB: SELECT COUNT (标量字段 + JSON 字段)
    R3-->>R1: agentCount
    R1-->>S: UsageCount{KB, Agent}
    alt 有引用
        S-->>H: AppError{400, "模型被 N 个知识库和 M 个 Agent 引用"}
        H-->>UI: HTTP 400 + 错误消息
    else 无引用
        S->>DB: 执行删除
        S-->>H: nil
        H-->>UI: HTTP 200
    end

关键文件分层归类

文件 职责 改动量
接口定义 internal/types/interfaces/knowledgebase.go 为 KB repository 接口添加 CountByModelID 方法签名 +4/-0
接口定义 internal/types/interfaces/custom_agent.go 为 Agent repository 接口添加 CountByModelID 方法签名 +4/-0
数据访问 internal/application/repository/knowledgebase.go 实现 KB 的 CountByModelID,含 JSON 字段查询 +14/-0
数据访问 internal/application/repository/custom_agent.go 实现 Agent 的 CountByModelID,含 JSON 字段查询 +13/-0
数据访问 internal/application/repository/model_usage.go 新文件,聚合 KB + Agent 的计数结果 +49/-0
数据访问测试 internal/application/repository/model_usage_test.go 新文件,测试聚合逻辑 +113/-0
业务逻辑 internal/application/service/model.go DeleteModel 中加入引用检查 + 错误消息格式化 +55/-2
业务逻辑测试 internal/application/service/model_delete_test.go 新文件,测试删除保护的各种场景 +175/-0
HTTP 处理 internal/handler/model.go 将 service 返回的 AppError 转为 HTTP 400 +4/-0
既有测试修复 internal/application/service/knowledgebase_pr3_test.go mock 接口补齐新方法 +3/-0
既有测试修复 internal/application/service/vectorstore_test.go mock 接口补齐新方法 +3/-0

3 个新文件(model_usage.go、model_usage_test.go、model_delete_test.go)承担了 337 行(77%)的改动量,其余 8 个文件都是在已有代码上做小幅扩展。


3. 解决方案(How)

第一层:Repository——”谁在用这个模型?”

核心挑战在于模型 ID 的存储方式不统一。有些字段是普通的数据库列(如 embedding_model),直接用 WHERE embedding_model = ? 就能查到;但有些字段藏在 JSON 配置里(如 Agent 的 config 中嵌套的 chat_model),需要用数据库的 JSON 查询函数。

知识库侧的 CountByModelID 实现思路(伪代码):

// repository/knowledgebase.go
func (r *KnowledgeBaseRepo) CountByModelID(tenantID, modelID string) (int64, error) {
    var count int64
    err := r.db.Model(&KnowledgeBase{}).
        Where("tenant_id = ?", tenantID).
        Where(
            // 标量字段:直接比较
            r.db.Where("embedding_model = ?", modelID).
            // JSON 字段:用 JSON_EXTRACT 查嵌套值
            Or("JSON_EXTRACT(config, '$.summary_model') = ?", modelID).
            Or("JSON_EXTRACT(config, '$.vlm_model') = ?", modelID).
            Or("JSON_EXTRACT(config, '$.asr_model') = ?", modelID).
            Or("JSON_EXTRACT(config, '$.wiki_synthesis_model') = ?", modelID),
        ).
        Count(&count).Error
    return count, err
}

这里的关键技巧是混合查询:同一个 WHERE 子句中,用 OR 把标量字段和 JSON 字段的条件串起来。只要命中任意一个,就说明这个知识库引用了该模型。

Agent 侧的查询结构类似,但查的字段不同(chat_model、rerank_model 等)。

第二层:聚合——model_usage.go

这是一个新文件,职责很单一:调用 KB 和 Agent 两个 repository 的 CountByModelID,把结果汇总成一个结构体返回。

// repository/model_usage.go
type ModelUsageCount struct {
    KnowledgeBaseCount int64
    AgentCount         int64
}

func (r *ModelUsageRepo) CountByModelID(tenantID, modelID string) (*ModelUsageCount, error) {
    kbCount, err := r.kbRepo.CountByModelID(tenantID, modelID)
    if err != nil {
        return nil, err
    }
    agentCount, err := r.agentRepo.CountByModelID(tenantID, modelID)
    if err != nil {
        return nil, err
    }
    return &ModelUsageCount{
        KnowledgeBaseCount: kbCount,
        AgentCount:         agentCount,
    }, nil
}

为什么要单独建一个文件而不是直接在 service 层调用两个 repository?因为 WeKnora 的分层原则是 service 不直接依赖多个平级 repository。如果将来还要检查”模型是否被工作流引用”或”是否被数据源引用”,只需要在 model_usage.go 里加一行,service 层完全不用改。这就是”聚合层”的价值——把 N 个数据源的查询收拢到一个统一出口

第三层:Service——业务决策

service/model.go 的 DeleteModel 方法修改后的逻辑:

// service/model.go (简化)
func (s *ModelService) DeleteModel(tenantID, modelID string) error {
    // 1. 内置模型不可删除
    if isBuiltin(modelID) {
        return NewAppError(400, "内置模型不可删除")
    }

    // 2. 引用检查(本 PR 新增)
    usage, err := s.modelUsageRepo.CountByModelID(tenantID, modelID)
    if err != nil {
        return err
    }
    if usage.KnowledgeBaseCount > 0 || usage.AgentCount > 0 {
        msg := formatModelInUseMessage(usage)
        return NewAppError(400, msg)
    }

    // 3. 执行删除
    return s.modelRepo.Delete(tenantID, modelID)
}

错误消息格式化是个小但重要的细节:formatModelInUseMessage 会根据实际引用情况拼接可读的提示,比如”该模型被 3 个知识库和 1 个 Agent 引用,请先解除引用后再删除”。这个函数也有独立的单元测试覆盖。

第四层:Handler——HTTP 语义

handler 层的改动最小,只有 4 行。核心逻辑是检查 service 返回的 error 是否是 AppError 类型,如果是就用 AppError.Code 作为 HTTP 状态码(这里是 400),否则返回 500。

这种模式在 WeKnora 中已经是惯例——handler 不做业务判断,只做 HTTP 语义翻译。


4. Review 与合入(Maintainer 视角)

这个 PR 由 lyingbug 提交并直接合入,没有 review 评论。这并不意外:lyingbug 是 WeKnora 的主要维护者,而且 PR 的质量从几个维度看都比较扎实。

测试覆盖:PR 新增了 3 个测试文件共 291 行测试代码,覆盖率约占总改动的 67%。测试场景包括:模型未被引用时正常删除、被 KB 引用时拒绝、被 Agent 引用时拒绝、同时被两者引用时的错误消息格式、内置模型的删除保护。另外还修复了 2 个既有测试文件的 mock 接口,确保新增的 CountByModelID 方法在 mock 中有对应实现。

向后兼容:这个改动是纯增量的——原来不检查引用、直接删除;现在多了一步检查,不影响任何现有的成功路径。唯一的行为变化是”以前能删成功的某些情况现在会返回 400”,但这恰恰是期望的行为。

命名一致性:所有新增的方法都叫 CountByModelID,跨 repository 保持统一命名,降低认知负担。


5. 可复用模式(Pattern)

从这个 PR 中可以提炼出一个通用的删除保护模式

检查前置条件 → 查询引用关系 → 有引用则拒绝并给出可操作信息 → 无引用则执行删除

这个模式的三个关键设计决策:

决策 1:查询放在 repository 层,决策放在 service 层。repository 只回答”有多少个引用”,不判断”能不能删”。这样做的好处是 repository 的查询可以被其他 service 复用——比如将来做一个”模型引用详情”页面,同样需要 CountByModelID,但不涉及删除。

决策 2:聚合查询单独建文件。model_usage.go 虽然现在只有几十行,但它把”从哪些表查引用”这个知识集中到了一处。新增引用源时只改这一个文件,符合单一职责原则。

决策 3:错误消息包含定量信息。不是笼统地说”模型被引用了”,而是说”被 3 个知识库和 1 个 Agent 引用”。这帮助用户判断影响面——如果只被 1 个知识库引用,可能很快就能改完;如果被 20 个引用,可能需要先批量替换。


6. 常见误区

误区 1:在 handler 层做引用检查

错误认知:handler 直接查数据库判断能不能删,省得经过 service 层。

正确理解:handler 层的职责是 HTTP 协议适配(解析参数、设置状态码、序列化响应),业务规则应该在 service 层。如果在 handler 里写业务逻辑,单元测试就必须启动 HTTP 上下文,既慢又脆弱。WeKnora 的 model_delete_test.go 之所以能写得简洁,正是因为它直接测 service 方法,不需要构造 HTTP 请求。

误区 2:用外键约束代替应用层检查

错误认知:数据库加个外键 REFERENCES models(id) ON DELETE RESTRICT 就行了,不用写代码。

正确理解:外键约束能防止删除,但无法返回友好的业务错误消息。数据库只会报一个 foreign key constraint violation 错误,用户看到的可能是 500 Internal Server Error。而且 WeKnora 需要兼容 SQLite 和 PostgreSQL,两者的外键行为和错误消息格式不同。应用层检查能统一返回格式、提供可操作的中文提示。

误区 3:只检查标量字段,忽略 JSON 字段

错误认知:数据库表有 embedding_model 列,查这个就够了。

正确理解:WeKnora 的 Agent 和 KB 配置中,很多模型引用存储在 JSON 类型的 config 字段里。如果只查标量字段,一个 Agent 的 chat_model 在 JSON config 中引用了待删除的模型,检查会通过,删除后 Agent 就坏了。这也是为什么 repository 层的查询要用 JSON_EXTRACT——深入 JSON 结构去找嵌套的模型 ID。

误区 4:引用检查可以放在删除 SQL 的 CASCADE 里

错误认知:数据库级联或 ON DELETE RESTRICT 能替代 service 层检查,代码更省事。

正确理解:DB 约束无法返回 「被 3 个知识库和 1 个 Agent 引用」 这类可操作提示,且 SQLite/PostgreSQL 行为不一致。WeKnora 选择在 service 决策 + repository 计数 路径统一返回 HTTP 400 与中文说明。


7. 思考点

问题 1:假设你要为 #1248 设计 feedback 表,需要记录用户对某条 AI 回复的点赞/点踩和文字评价。这条 feedback 至少需要关联到哪些实体?请画出表结构(字段名 + 类型即可),并思考:当用户删除一个会话(conversation)时,关联的 feedback 记录应该怎么处理?你会选择级联删除还是保留 feedback 数据用于模型改进?

问题 2:本 PR 的 CountByModelID 在 KB 和 Agent 两个表中各执行一次查询。如果将来 WeKnora 新增了”工作流”和”数据源”两种实体,也可能引用模型,你需要改哪些文件?请列出文件路径和大致改动。

问题 3formatModelInUseMessage 目前只返回引用的数量。如果产品经理要求返回具体是哪些知识库和 Agent 在引用(名字列表),你会怎么改 repository 层的查询?是改 CountByModelID 还是新增一个方法?为什么?


8. 延伸阅读