Files
knowledge-governance/internal/releaseadapter/v071/library_scope_remap.go
T
姚章浩 5defe238e0
build / build (push) Successful in 1m34s
fix(library): 跨范围同步按目标范围改写并校验残留
真实事故:源 KB(10000/0c84a098) 同步到目标 KB(10001/e64e82b5) 时,归档行被原样
插入目标库,落在来源租户/知识库;资源阶段随即报"生产缺少资源 ... 的存储路径"
(资源查询按目标租户过滤,看不见刚写进去的行)。回滚只按目标范围回放,于是页面
报"已恢复到预备份",目标却多出 4 文件/38 分块/21 资源/4 绑定。

改:
- 写入与校验共用 retargetRow:tenant_id / knowledge_base_id / 资源定位符(后端、
  provider、桶、租户前缀) / storage_backend_id / provider / embedding_model_id
  统一映射到目标范围;行里的来源身份对不上就失败,宁可不写。
- Verify 增加"本次写集合仍以来源身份残留"判定;Restore 增加整库指纹比对(预备份
  时刻 vs 恢复后)与孤儿资源清理,回滚后用 Verify 复核,恢复工具退出 0 不再等于
  恢复完成。
- 新增 cmd/libraryresidue:按归档身份精确、可备份、可复核地清理已知的范围外残留。

验证:make verify 全绿;真实 PG+ES 用例复现"源目标 KB 不同 + 目标非空 + 资源阶段
失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言
行落在目标范围且资源定位符指向目标存储。
2026-10-29 22:11:59 +08:00

263 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package v071
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// 整库同步是"把来源知识库的内容搬到目标的租户/知识库",但归档行带着来源身份
// (tenant_id / knowledge_base_id)。原样插入会让行落在来源范围里:目标范围的
// 删除、校验、预备份与恢复全都看不见它们(真实事故:同步失败后"已恢复到预备份",
// 目标却多出 4 文件 / 38 分块 / 21 资源 / 4 绑定,行所属租户还是来源租户)。
//
// 因此写入与校验必须共用这一个改写入口,并且对不上就失败——宁可不写,也不写
// 一行目标范围外的数据。
// tablesWithKB 是真实 WeKnora v0.7.1 里带 knowledge_base_id 的知识域表。
// resources / resource_bindings 是多知识库共享表,只有 tenant_id;
// knowledge_tag_relations 两列都没有,归属完全由父表 knowledges 决定。
var tablesWithKB = map[string]bool{
"knowledges": true, "chunks": true, "knowledge_tags": true,
"wiki_folders": true, "wiki_pages": true, "wiki_page_issues": true, "wiki_log_entries": true,
}
// tableHasTenant 报告这张表是否自带租户列。
func tableHasTenant(table string) bool { return table != "knowledge_tag_relations" }
// bindSnapshotScope 记下快照的来源身份。没有来源身份就无法判断"这行要搬到哪个
// 范围",宁可失败也不要按行内容猜。
func (t *ProductionLibraryTarget) bindSnapshotScope(manifest manifestScope) error {
if manifest.tenantID == 0 || !validSearchIdentifier(manifest.knowledgeBaseID) {
return errors.New("快照缺少来源范围身份(tenant_id / knowledge_base_id),无法确定目标范围映射")
}
t.snapshotTenant = manifest.tenantID
t.snapshotKB = manifest.knowledgeBaseID
return nil
}
// manifestScope 只是把快照的两个范围字段抽出来,避免调用点重复解构。
type manifestScope struct {
tenantID uint64
knowledgeBaseID string
}
// retargetRow 把一行归档数据改写成目标范围的数据:tenant_id / knowledge_base_id
// 换成目标身份,资源行还要把定位符、存储后端、提供方换成目标实例自己的值。
func (t *ProductionLibraryTarget) retargetRow(table string, row map[string]json.RawMessage) error {
if !tableHasTenant(table) {
return nil
}
if err := t.retargetTenantField(row); err != nil {
return fmt.Errorf("%s:%w", table, err)
}
if tablesWithKB[table] {
if err := t.retargetKnowledgeBaseField(row); err != nil {
return fmt.Errorf("%s:%w", table, err)
}
}
if table == "resources" {
return t.rewriteResourceIdentity(row)
}
if table == "knowledges" {
return t.retargetEmbeddingModelField(row)
}
return nil
}
// retargetEmbeddingModelField 把文档的 embedding 模型换成目标知识库绑定的那一个。
// 来源模型 id 在目标实例里通常不存在(目标 models 表可能是空的),留着它等于让
// 应用拿一个解析不到的模型去查——库里的向量与查询向量就不是同一套了。
func (t *ProductionLibraryTarget) retargetEmbeddingModelField(row map[string]json.RawMessage) error {
if t.targetEmbeddingModel == "" {
return nil
}
if _, ok := row["embedding_model_id"]; !ok {
return nil
}
encoded, err := json.Marshal(t.targetEmbeddingModel)
if err != nil {
return errors.New("编码目标 embedding 模型失败")
}
row["embedding_model_id"] = encoded
return nil
}
// resolveEmbeddingModel 读目标知识库自己的 embedding 模型 id。读不到不算错误
// (目标可能确实没绑模型),此时保持来源值不动,由契约校验去发现不一致。
func (t *ProductionLibraryTarget) resolveEmbeddingModel(ctx context.Context) {
var model string
if err := t.pool.QueryRow(ctx,
`SELECT coalesce(embedding_model_id,'') FROM knowledge_bases WHERE id=$1 AND tenant_id=$2`,
t.scope.KnowledgeBaseID, t.scope.TenantID).Scan(&model); err == nil {
t.targetEmbeddingModel = model
}
}
func (t *ProductionLibraryTarget) retargetTenantField(row map[string]json.RawMessage) error {
raw, ok := row["tenant_id"]
if !ok {
return errors.New("行缺少 tenant_id,无法确定要写入哪个范围")
}
var current uint64
if err := json.Unmarshal(raw, &current); err != nil {
return errors.New("行 tenant_id 不可解析")
}
if t.snapshotTenant == 0 {
return errors.New("快照来源租户未知,拒绝猜测目标租户")
}
if current != t.snapshotTenant {
return fmt.Errorf("行 tenant_id=%d 不属于快照来源租户 %d", current, t.snapshotTenant)
}
encoded, err := json.Marshal(t.scope.TenantID)
if err != nil {
return errors.New("编码目标租户失败")
}
row["tenant_id"] = encoded
return nil
}
func (t *ProductionLibraryTarget) retargetKnowledgeBaseField(row map[string]json.RawMessage) error {
raw, ok := row["knowledge_base_id"]
if !ok {
return errors.New("行缺少 knowledge_base_id,无法确定要写入哪个知识库")
}
var current string
if err := json.Unmarshal(raw, &current); err != nil || current == "" {
return errors.New("行 knowledge_base_id 不可解析")
}
if t.snapshotKB == "" {
return errors.New("快照来源知识库未知,拒绝猜测目标知识库")
}
if current != t.snapshotKB {
return fmt.Errorf("行 knowledge_base_id=%s 不属于快照来源知识库 %s", current, t.snapshotKB)
}
encoded, err := json.Marshal(t.scope.KnowledgeBaseID)
if err != nil {
return errors.New("编码目标知识库失败")
}
row["knowledge_base_id"] = encoded
return nil
}
// rewriteResourceIdentity 把资源行的定位符与提供方换成目标实例的值。
//
// 只换桶不够:来源行的 scheme(oss/minio…)与对象键里的租户段也是来源实例的,
// 目标实例按它取文件会落到别的提供方或别的租户前缀上(真跑:storage backend
// not found → invalid path → 500 code1007)。
func (t *ProductionLibraryTarget) rewriteResourceIdentity(row map[string]json.RawMessage) error {
raw, ok := row["physical_path"]
if !ok {
return errors.New("资源行缺少定位符")
}
var locator string
if err := json.Unmarshal(raw, &locator); err != nil {
return errors.New("资源定位符不可解析")
}
rewritten, err := retargetLocator(locator, t.storageBackend, t.storageProvider, t.config.ObjectsBucket,
t.snapshotTenant, t.scope.TenantID)
if err != nil {
return err
}
if rewritten != locator {
encoded, err := json.Marshal(rewritten)
if err != nil {
return errors.New("编码目标定位符失败")
}
row["physical_path"] = encoded
}
if t.storageBackend != "" {
if _, ok := row["storage_backend_id"]; ok {
encoded, err := json.Marshal(t.storageBackend)
if err != nil {
return errors.New("编码目标存储后端失败")
}
row["storage_backend_id"] = encoded
}
}
if t.storageProvider != "" {
if _, ok := row["provider"]; ok {
encoded, err := json.Marshal(t.storageProvider)
if err != nil {
return errors.New("编码目标提供方失败")
}
row["provider"] = encoded
}
}
return nil
}
// retargetLocator 把 "storage://<backend>/<scheme>://<bucket>/<key>" 整体换成目标
// 实例的真实取值:后端 id、scheme/提供方、桶名,以及对象键里的租户段。
// 桶名为空表示目标没有配置对象存储(测试夹具),此时不动定位符。
func retargetLocator(locator, backendID, provider, bucket string, sourceTenant, targetTenant uint64) (string, error) {
if bucket == "" || locator == "" {
return locator, nil
}
if locator != strings.TrimSpace(locator) {
return "", errors.New("资源定位符带首尾空白")
}
prefix := ""
rest := locator
if strings.HasPrefix(locator, "storage://") {
trimmed := strings.TrimPrefix(locator, "storage://")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 || !validSearchIdentifier(parts[0]) || parts[1] == "" {
return "", errors.New("资源定位符结构不可解析")
}
if backendID != "" {
if !validSearchIdentifier(backendID) {
return "", errors.New("目标存储后端 id 形状不合法")
}
parts[0] = backendID
}
prefix, rest = "storage://"+parts[0]+"/", parts[1]
}
separator := strings.Index(rest, "://")
if separator < 0 {
// 裸对象键没有桶与租户段:落盘按配置桶,改写无意义。
return locator, nil
}
scheme := rest[:separator]
if !validSearchIdentifier(scheme) {
return "", errors.New("资源定位符的提供方不可解析")
}
schemeless := rest[separator+3:]
boundary := strings.Index(schemeless, "/")
if boundary <= 0 || boundary == len(schemeless)-1 {
return "", errors.New("资源定位符缺少对象键")
}
key := schemeless[boundary+1:]
if provider != "" {
if !validSearchIdentifier(provider) {
return "", errors.New("目标提供方形状不合法")
}
scheme = provider
}
return prefix + scheme + "://" + bucket + "/" + retargetKeyTenant(key, sourceTenant, targetTenant), nil
}
// retargetKeyTenant 把对象键里的来源租户段换成目标租户,并丢掉来源实例自己的
// 路径前缀(例如 MINIO_PATH_PREFIX=weknora/),让键与目标实例的写法一致。
// 找不到来源租户段时原样返回:此时键本身不含跨租户引用。
func retargetKeyTenant(key string, sourceTenant, targetTenant uint64) string {
if sourceTenant == 0 || targetTenant == 0 || sourceTenant == targetTenant {
return key
}
source, target := strconv.FormatUint(sourceTenant, 10), strconv.FormatUint(targetTenant, 10)
segments := strings.Split(key, "/")
for index := 0; index < len(segments) && index < 2; index++ {
// 只有"后面还有内容"的段才是路径前缀;整键就是租户号时不该动它。
if index >= len(segments)-1 || segments[index] != source {
continue
}
kept := segments[index:]
kept[0] = target
return strings.Join(kept, "/")
}
return key
}