案例 #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 语言里的野指针——指针还在,但指向的内存已经被释放了。
影响面:
- 知识库侧:embedding_model、summary_model、vlm_model、asr_model、wiki_synthesis_model 共 5 个可能引用模型的字段
- Agent 侧:chat_model、rerank_model 以及 JSON config 中的嵌套模型字段
- 任何一个引用被破坏,对应功能都会静默失败或报不可理解的错误
修复前 / 修复后
| 操作 | 修复前 | 修复后 |
|---|---|---|
| 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 新增了”工作流”和”数据源”两种实体,也可能引用模型,你需要改哪些文件?请列出文件路径和大致改动。
问题 3:formatModelInUseMessage 目前只返回引用的数量。如果产品经理要求返回具体是哪些知识库和 Agent 在引用(名字列表),你会怎么改 repository 层的查询?是改 CountByModelID 还是新增一个方法?为什么?
8. 延伸阅读
- WeKnora Go 后端 handler/service/repository 分层 — 浏览 internal/ 目录结构,感受三层分层的实际布局
- GORM 官方文档 - 高级查询 — 理解
Where嵌套、Or链式调用、Count方法 - SQLite JSON 函数 / PostgreSQL JSON 函数 — 对比两种数据库的 JSON 查询语法差异
- 案例索引 — 回到总表查看其他案例
- Issue #1248 — 用户反馈功能的完整需求描述,思考本 PR 的模式如何复用