fix(library): 首次同步可复用同租户已存在资源
build / build (push) Successful in 1m39s

取消资源复用必须属于本目标历史成功同步/预备份的自加门槛。
仍要求目标行逐列一致与实际对象字节一致;旧库资源行/绑定/对象不删不改。
新增 reuse evidence v3:无 provenance,保留行摘要与逐对象字节证据。
This commit is contained in:
姚章浩
2026-11-04 14:22:51 +08:00
parent 0d9091e464
commit 537a6df46d
4 changed files with 134 additions and 30 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 开发快照 0.1.350(首次复用取消本库历史来源门槛)
### Changed
- **首次同步可复用同 tenant 已有资源**:取消“资源必须在本目标历史成功同步/预备份范围内”的
额外阻断;目标为空、资源已被同 tenant 旧知识库绑定时,只要目标行逐列等于本次映射行、
且对象实际字节与归档摘要一致,即可复用。旧库 resource 行、绑定与对象不删不改。
- **保留安全边界**:行内容不一致、对象字节不同、跨 tenant 仍拒绝;复用证据新增 v3
(无历史 provenance,但仍记录行摘要与逐对象字节证据),v1/v2 旧证据语义不变。
### Notes
- 更完整的资源归属审计(谁在何时首次写入、跨库引用关系)记入 `docs/BACKLOG.md`,后续补充。
## 开发快照(目标库独立 ID 映射,保留旧库)
### Changed
+6 -1
View File
@@ -18,4 +18,9 @@
1. 跑 `make verify`(本地全绿不等于远端跑通,需要一次真实 run 的证据)。
2. 把**使用者可见**的变化写成 `CHANGELOG.md` 的 `[Unreleased]` 条目(按四类,别写过程)。
3. 从本文件划掉该项;过程记录归档到 `docs/archive/`。
3. 从本文件划掉该项;过程记录归档到 `docs/archive/`。
## 资源首次复用的归属审计(后续)
- 当前首次复用只依赖“目标行逐列一致 + 对象字节一致”,不再要求本目标历史成功同步/预备份来源。
- 后续可补:资源首次写入者/首次绑定 KB、跨 KB 引用计数、孤儿资源与同 tenant 共享资源的可审计归属视图;
不以此重新阻断正常的同内容首次复用。
+17 -5
View File
@@ -34,6 +34,7 @@ import (
const (
reuseDirName = "reuse"
reuseEvidenceVersion = 1
reuseEvidenceNoPrior = 3
resourceReuseTableName = "resources"
)
@@ -93,11 +94,17 @@ func (e *ResourceReuseEvidence) Seal() error {
// Verify 校验版本、必备身份与摘要;缺任何一项都不许拿它去放行覆盖。
func (e *ResourceReuseEvidence) Verify() error {
if e.Version != reuseEvidenceVersion && e.Version != 2 {
if e.Version != reuseEvidenceVersion && e.Version != 2 && e.Version != reuseEvidenceNoPrior {
return fmt.Errorf("复用证据版本 %d 不受支持(当前 %d)", e.Version, reuseEvidenceVersion)
}
if e.JobID == "" || e.Target == "" || e.TargetSnapshotID == "" || e.ResourceID == "" ||
e.ProvenanceJobID == "" || e.ProvenanceSnapshotID == "" || e.ProvenanceKind == "" {
if e.JobID == "" || e.Target == "" || e.TargetSnapshotID == "" || e.ResourceID == "" {
return errors.New("复用证据缺少作业/目标/快照/资源身份")
}
// v3:目标首次复用,本目标没有历史成功同步/预备份来源。归属改由
// “目标行 == 本次映射行”与“对象字节 == 归档对象”证明,不再要求 provenance。
// v1/v2 仍必须是完整历史来源,旧证据语义不变。
if e.Version != reuseEvidenceNoPrior &&
(e.ProvenanceJobID == "" || e.ProvenanceSnapshotID == "" || e.ProvenanceKind == "") {
return errors.New("复用证据缺少作业/目标/快照/来源身份")
}
if e.TenantID == 0 || e.KnowledgeBaseID == "" || e.SourceTenantID == 0 || e.SourceKnowledgeBaseID == "" {
@@ -113,6 +120,10 @@ func (e *ResourceReuseEvidence) Verify() error {
if e.Version == 2 && (e.ProvenanceKind != libraryrelease.PriorSnapshotPrebackup || e.ProvenanceRowDigest != e.RetargetedRowDigest) {
return errors.New("prebackup reuse proof differs from target projection")
}
if e.Version == reuseEvidenceNoPrior && (e.ProvenanceJobID != "" || e.ProvenanceSnapshotID != "" || e.ProvenanceKind != "" ||
len(e.Objects) == 0) {
return errors.New("无历史来源的复用证据不能夹带历史身份,且必须有对象字节证据")
}
copied := *e
copied.Digest = ""
raw, err := json.Marshal(copied)
@@ -395,8 +406,9 @@ func (t *ProductionLibraryTarget) planReusableResource(ctx context.Context, arch
}
}
if provenance.JobID == "" {
return libraryrelease.WriteSetRow{}, fmt.Errorf(
"资源 ID 冲突:%s 已存在于目标租户但不在预备份范围内,且没有可审计的持久来源(成功同步/预备份)证明它属于本目标,拒绝覆盖", id)
// 用户批准:首次同步不要求本目标历史归属。行内容一致已在上方核对;
// 下面仍逐对象核对实际字节,任一不一致即拒绝,且不删/不改旧库资源与绑定。
evidenceVersion = reuseEvidenceNoPrior
}
provider, path, err := resourceLocatorField(retargeted)
if err != nil {
@@ -179,29 +179,23 @@ func TestLibraryResyncAfterRestoreReusesProvenResourcesIsolated(t *testing.T) {
}
}
func TestLibraryResyncAfterRestoreRejectsReuseWithoutProvenanceIsolated(t *testing.T) {
func TestLibraryResyncAfterRestoreReusesTargetRowWithoutProvenanceIsolated(t *testing.T) {
fx := openWriteSetFixture(t)
seedTargetData(t, fx)
snapshotID := sealSourceSnapshot(t, fx, nil)
target, backups, first, _ := syncThenRestore(t, fx, snapshotID)
before := universeDump(t, fx.pool)
beforeXmin, beforeRow := resourceFingerprint(t, fx.pool, "res-src")
// 已登记的来源里没有 res-src(只有这次同步之前的预备份):即使目标那一行
// 看起来一模一样,也不许放行。
// 用户批准:没有本目标历史来源也可以复用,条件退回到“目标行与本次映射行逐列一致
// + 对象字节与归档一致”。这里的 res-src 是上一次同步写进目标、恢复后留下的行。
noSource := []libraryrelease.PriorSnapshot{{JobID: first.Backup.WriteSet.JobID,
SnapshotID: first.Backup.SnapshotID, Kind: libraryrelease.PriorSnapshotPrebackup}}
outcome := libraryrelease.Run(fx.ctx, requestForWithPrior(snapshotID, "job-sync-no-provenance", noSource), target, backups, nil)
if outcome.State != "failed" {
t.Fatalf("没有来源证据时必须拒绝:%+v", outcome)
if outcome.State != "succeeded" {
t.Fatalf("无本目标历史来源、但行与字节一致时必须复用成功:%+v", outcome)
}
if outcome.FailedAt != "" {
t.Fatalf("冲突必须在任何阶段写入之前被拒绝,failed_at=%s", outcome.FailedAt)
}
if !strings.Contains(outcome.Error, "res-src") || !strings.Contains(outcome.Error, "可审计") {
t.Fatalf("拒绝原因必须点名资源并说明缺来源证据:%s", outcome.Error)
}
if diff := diffUniverse(before, universeDump(t, fx.pool)); diff != "" {
t.Fatalf("被拒绝的同步不许动目标:%s", diff)
if xmin, row := resourceFingerprint(t, fx.pool, "res-src"); xmin != beforeXmin || row != beforeRow {
t.Fatalf("复用不得改写共享资源行:\n%s\n->\n%s", beforeRow, row)
}
}
@@ -231,9 +225,10 @@ func TestLibraryResyncAfterRestoreRejectsReuseWhenContentDiffersIsolated(t *test
}
}
// 同租户、同 ID、同内容,但来源快照属于另一个知识库:仍然不许复用。
// "归属无法证明"包括"来源不同",不能因为字节一样就把别人的行当成自己的。
func TestLibraryReuseRequiresSameSourceScopeIsolated(t *testing.T) {
// 同租户、同 ID、同内容,来源快照属于另一个知识库:历史归属不再参与放行,
// 但仍必须以“目标行逐列一致 + 对象字节一致”为前提,且不得改写旧库行。
// 若内容或字节不同,下面的负例必须继续拒绝。
func TestLibraryReuseAcrossSourceScopeKeepsTargetRowsIsolated(t *testing.T) {
fx := openWriteSetFixture(t)
seedTargetData(t, fx)
snapshotID := sealSourceSnapshot(t, fx, nil)
@@ -241,14 +236,12 @@ func TestLibraryReuseRequiresSameSourceScopeIsolated(t *testing.T) {
otherSource := sealSourceSnapshotScope(t, fx, wsSourceTenant+1, "kb-source-other", nil, nil)
before := universeDump(t, fx.pool)
outcome := libraryrelease.Run(fx.ctx, requestForWithPrior(otherSource, "job-sync-other-source", priors), target, backups, nil)
if outcome.State != "failed" {
t.Fatalf("来源范围不同必须拒绝复用:%+v", outcome)
if outcome.State != "succeeded" {
t.Fatalf("同内容同字节的跨来源复用应被允许:%+v", outcome)
}
if !strings.Contains(outcome.Error, "可审计") {
t.Fatalf("拒绝原因必须说明来源不可审计:%s", outcome.Error)
}
if diff := diffUniverse(before, universeDump(t, fx.pool)); diff != "" {
t.Fatalf("被拒绝的同步不许动目标:%s", diff)
if diff := diffUniverse(before, universeDump(t, fx.pool)); diff == "" {
// 允许成功但必须有实际导入结果;目标已有行可能被保留,不能空跑成功。
t.Fatal("复用后目标应出现新的导入结果")
}
}
@@ -313,3 +306,83 @@ func TestLibraryHalfWriteRollbackKeepsReusedResourcesIsolated(t *testing.T) {
t.Fatalf("回滚必须与预备份一致:%v", err)
}
}
// 新目标库首次同步:本目标没有任何历史成功同步/预备份来源,但同 tenant 的
// 另一个知识库已经绑定了一个 ID、整行内容与对象字节都完全相同的资源。
// 用户已批准取消"必须有本目标历史来源"这一自加门槛;仍须保留旧库行/绑定/对象,
// 且内容不同或字节不同必须继续拒绝。
func TestLibraryFirstSyncReusesResourceOwnedByOtherKBIsolated(t *testing.T) {
fx := openWriteSetFixture(t)
// 同 tenant 另一个 KB 的文档 + 资源 + 绑定;res-src 与源快照同 ID 同内容。
otherDoc := "doc-first-other"
statements := []string{
`INSERT INTO knowledges VALUES('` + otherDoc + `',10001,'` + wsOtherKB + `','completed',0,'',NULL)`,
`INSERT INTO resources VALUES('res-src','` + wsHandleSource + `',10001,'` + wsLocator("backend-target", "minio", wsBucket, 10001, "res-src") + `',NULL,'minio',8,'backend-target')`,
`INSERT INTO resource_bindings VALUES('bind-first-other',10001,'res-src','knowledge','` + otherDoc + `',NULL)`,
}
for _, statement := range statements {
if _, err := fx.pool.Exec(fx.ctx, statement); err != nil {
t.Fatalf("seed other-KB resource: %v\n%s", err, statement)
}
}
if err := fx.objects.s3.Put(fx.ctx, "minio", wsLocator("backend-target", "minio", wsBucket, 10001, "res-src"),
strings.NewReader("12345678"), 8); err != nil {
t.Fatal(err)
}
beforeXmin, beforeRow := resourceFingerprint(t, fx.pool, "res-src")
snapshotID := sealSourceSnapshot(t, fx, nil)
target := fx.target(t)
backups := fx.backups(t, target)
outcome := libraryrelease.Run(fx.ctx, requestForWithPrior(snapshotID, "job-first-no-provenance", nil), target, backups, nil)
if outcome.State != "succeeded" {
t.Fatalf("首次同步必须完成(无本目标历史来源,但同 tenant 同内容资源可复用):%+v", outcome)
}
if xmin, row := resourceFingerprint(t, fx.pool, "res-src"); xmin != beforeXmin || row != beforeRow {
t.Fatalf("复用不得改写共享资源行:\n%s\n->\n%s", beforeRow, row)
}
var binding int64
if err := fx.pool.QueryRow(fx.ctx, `SELECT count(*) FROM resource_bindings WHERE id='bind-first-other' AND resource_id='res-src'`).Scan(&binding); err != nil || binding != 1 {
t.Fatalf("旧库绑定必须保留:%d %v", binding, err)
}
var targetDocs int64
if err := fx.pool.QueryRow(fx.ctx, `SELECT count(*) FROM knowledges WHERE knowledge_base_id=$1 AND id<>$2`,
wsTargetKB, otherDoc).Scan(&targetDocs); err != nil || targetDocs == 0 {
t.Fatalf("目标 KB 必须完成导入:%d %v", targetDocs, err)
}
}
// 首次同步复用仍必须核对实际对象字节:行投影一致、但目标对象字节不同 -> 拒绝,
// 且不得改写资源行/绑定或留下写入。
func TestLibraryFirstSyncRejectsReuseWhenObjectBytesDifferIsolated(t *testing.T) {
fx := openWriteSetFixture(t)
otherDoc := "doc-byte-other"
statements := []string{
`INSERT INTO knowledges VALUES('` + otherDoc + `',10001,'` + wsOtherKB + `','completed',0,'',NULL)`,
`INSERT INTO resources VALUES('res-src','` + wsHandleSource + `',10001,'` + wsLocator("backend-target", "minio", wsBucket, 10001, "res-src") + `',NULL,'minio',8,'backend-target')`,
`INSERT INTO resource_bindings VALUES('bind-byte-other',10001,'res-src','knowledge','` + otherDoc + `',NULL)`,
}
for _, statement := range statements {
if _, err := fx.pool.Exec(fx.ctx, statement); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := fx.objects.s3.Put(fx.ctx, "minio", wsLocator("backend-target", "minio", wsBucket, 10001, "res-src"),
strings.NewReader("87654321"), 8); err != nil {
t.Fatal(err)
}
beforeXmin, beforeRow := resourceFingerprint(t, fx.pool, "res-src")
before := universeDump(t, fx.pool)
snapshotID := sealSourceSnapshot(t, fx, nil)
target := fx.target(t)
backups := fx.backups(t, target)
outcome := libraryrelease.Run(fx.ctx, requestForWithPrior(snapshotID, "job-byte-diff", nil), target, backups, nil)
if outcome.State != "failed" || !strings.Contains(outcome.Error, "字节") {
t.Fatalf("对象字节不同必须拒绝:%+v", outcome)
}
if xmin, row := resourceFingerprint(t, fx.pool, "res-src"); xmin != beforeXmin || row != beforeRow {
t.Fatal("拒绝时不得改写共享资源行")
}
if diff := diffUniverse(before, universeDump(t, fx.pool)); diff != "" {
t.Fatalf("拒绝时不得写入目标:%s", diff)
}
}