From 402173263ecc2e18c2f216ead0b397b7c79f5d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Fri, 30 Oct 2026 01:33:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(library):=20=E9=A2=84=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E4=B8=8E=E5=BD=92=E6=A1=A3=E7=B1=BB=E5=9E=8B=E6=8C=89=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=99=BB=E8=AE=B0=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处契约错:prebackup 只认"与拟发布快照同内容的那一份",而恢复作业登记的是 恢复前备份(必然不同),于是刚备份完就显示"尚未创建";归档里同时有目标基线与 每次预备份,overview 不给登记类型,页面只能把每份都写成"测试封存"。 现在 prebackup 取最近一次已登记的那份,并给出所属作业与 bound_to_target; overview 新增不含 evidence 的轻量版本清单供页面标注每份快照。闸口判定不变。 --- internal/httpapi/library_flow.go | 44 ++++++++++++---- internal/httpapi/library_flow_test.go | 75 +++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 14 deletions(-) diff --git a/internal/httpapi/library_flow.go b/internal/httpapi/library_flow.go index 191b427..b938737 100644 --- a/internal/httpapi/library_flow.go +++ b/internal/httpapi/library_flow.go @@ -55,6 +55,10 @@ type libraryKnownView struct { // EmptyBaseline 表示"没有生效版本记录,但生产目标可读且确实是空的": // 这是首次发布的合法起点,和"生产非空却没有权威版本"必须区分。 EmptyBaseline bool `json:"empty_baseline,omitempty"` + // BoundToTarget 只对预备份有意义:这份备份的内容是否就是当前待发布目标。 + // 最近一次生产写入前的备份可能**不是**本次目标(例如刚按版本恢复过), + // 页面必须照实说是哪一次作业的备份,而不是一句"尚未创建"。 + BoundToTarget bool `json:"bound_to_target,omitempty"` } // productionScopeState 只读探测生产目标:真正连库数一遍,不用缓存或推断。 @@ -302,11 +306,14 @@ type libraryGateView struct { } type libraryOverview struct { - Scope string `json:"scope"` - TestSealed *librarySnapshotView `json:"test_sealed,omitempty"` - TestSealedReason string `json:"test_sealed_reason,omitempty"` - Production libraryKnownView `json:"production"` - Prebackup libraryKnownView `json:"prebackup"` + Scope string `json:"scope"` + TestSealed *librarySnapshotView `json:"test_sealed,omitempty"` + TestSealedReason string `json:"test_sealed_reason,omitempty"` + Production libraryKnownView `json:"production"` + Prebackup libraryKnownView `json:"prebackup"` + // Versions 是已登记版本的轻量清单(不含 evidence):页面要按快照判断 + // "这份归档是哪一类版本",不能把所有归档都写成测试封存。 + Versions []libraryVersionRef `json:"versions"` Diff libraryDiffView `json:"diff"` Job *governance.LibraryJob `json:"job,omitempty"` Jobs []governance.LibraryJob `json:"jobs"` @@ -318,6 +325,17 @@ type libraryOverview struct { Now time.Time `json:"now"` } +// libraryVersionRef 只回答"这个快照被登记成哪一类版本、由哪个作业登记"。 +type libraryVersionRef struct { + ID string `json:"id"` + Kind string `json:"kind"` + SnapshotID string `json:"snapshot_id"` + VersionLabel string `json:"version_label"` + SourceJobID string `json:"source_job_id"` + CreatedAt time.Time `json:"created_at"` + SupersededAt *time.Time `json:"superseded_at,omitempty"` +} + type libraryJobStore interface { CreateLibraryJob(ctx context.Context, job governance.LibraryJob, stages []string, now time.Time) (governance.LibraryJob, bool, error) LibraryJob(ctx context.Context, id string) (governance.LibraryJob, error) @@ -669,7 +687,7 @@ func libraryOverviewHandler(w http.ResponseWriter, r *http.Request, config Confi overview.TestSealed, overview.TestSealedReason = testSealedOf(versions, byID) } overview.Production = libraryKnownView{State: "unknown", Reason: "还没有生产验证通过的生效版本记录"} - overview.Prebackup = libraryKnownView{State: "unknown", Reason: "本次目标快照还没有生产预备份"} + overview.Prebackup = libraryKnownView{State: "unknown", Reason: "这个范围还没有任何生产预备份"} target := "" if overview.TestSealed != nil { target = overview.TestSealed.SnapshotID @@ -677,15 +695,23 @@ func libraryOverviewHandler(w http.ResponseWriter, r *http.Request, config Confi for index := range versions { item := versions[index] view := libraryVersionViewOf(item) + overview.Versions = append(overview.Versions, libraryVersionRef{ + ID: item.ID, Kind: string(item.Kind), SnapshotID: item.SnapshotID, VersionLabel: item.VersionLabel, + SourceJobID: item.SourceJobID, CreatedAt: item.CreatedAt, SupersededAt: item.SupersededAt, + }) switch item.Kind { case governance.LibraryVersionProductionEffective: if item.SupersededAt == nil { overview.Production = libraryKnownView{State: "known", Version: &view} } case governance.LibraryVersionProductionPrebackup: - // 只有与目标快照绑定的预备份才算"这次同步前的备份"。 - if target != "" && item.SnapshotID == target { - overview.Prebackup = libraryKnownView{State: "known", Version: &view} + // 页面要显示"最近一次生产写入前的备份",不是"与拟发布快照同内容的那一份": + // 恢复作业登记的是**恢复前**的备份,它和测试封存快照本来就不同; + // 只按目标匹配会让真登记过的预备份显示成"尚未创建"。 + // versions 已按时间倒序,第一份就是最近一次;是否与本次目标同快照另给标志。 + if overview.Prebackup.State != "known" { + overview.Prebackup = libraryKnownView{State: "known", Version: &view, + BoundToTarget: target != "" && item.SnapshotID == target} } } } diff --git a/internal/httpapi/library_flow_test.go b/internal/httpapi/library_flow_test.go index b7130b0..15a0df4 100644 --- a/internal/httpapi/library_flow_test.go +++ b/internal/httpapi/library_flow_test.go @@ -305,6 +305,62 @@ func TestLibrarySyncEnqueueCreatesJobOnlyWithGrant(t *testing.T) { } } +// 真实现场(2026-10-29 16:59 的按版本恢复):恢复作业登记了"恢复前备份", +// 但那份备份的内容就是**恢复前**的生产状态,永远不可能等于测试封存快照。 +// 旧逻辑只认"与测试封存同快照的预备份",于是刚备份完就显示"尚未创建/无记录"。 +func TestLibraryOverviewShowsLatestPrebackupWhenTargetDiffers(t *testing.T) { + root := libraryFixtureRoot(t) + sealed := sealSharedVersions(t, root, 1, 4)[0] + now := time.Now().UTC() + store := &fakeLibraryStore{versions: []governance.LibraryVersion{ + {ID: "ver-effective", Scope: "internal", Kind: governance.LibraryVersionProductionEffective, + SnapshotID: "prod-restored-snapshot", CreatedAt: now}, + {ID: "ver-prebackup", Scope: "internal", Kind: governance.LibraryVersionProductionPrebackup, + SnapshotID: "snap-before-restore", SourceJobID: "librestore-internal-1", CreatedAt: now.Add(-time.Second)}, + {ID: "ver-sealed", Scope: "internal", Kind: governance.LibraryVersionTestSealed, + SnapshotID: sealed, CreatedAt: now.Add(-2 * time.Hour)}, + }} + config := Config{Store: store, LibraryArchiveRoot: root, LibraryCapacityBudgetBytes: 1 << 30} + request := httptest.NewRequest(http.MethodGet, "/api/v1/library/overview?scope=internal", nil) + recorder := httptest.NewRecorder() + libraryOverviewHandler(recorder, request, config) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d: %s", recorder.Code, recorder.Body.String()) + } + var body struct { + Production libraryKnownView `json:"production"` + Prebackup libraryKnownView `json:"prebackup"` + Versions []libraryVersionRef `json:"versions"` + TestSealed *librarySnapshotView `json:"test_sealed"` + Gate struct { + SyncAllowed bool `json:"sync_allowed"` + Reasons []string `json:"reasons"` + } `json:"gate"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("decode overview: %v", err) + } + if body.TestSealed == nil || body.TestSealed.SnapshotID != sealed { + t.Fatalf("test sealed candidate must be the registered one: %+v", body.TestSealed) + } + if body.Prebackup.State != "known" || body.Prebackup.Version == nil || + body.Prebackup.Version.ID != "ver-prebackup" || body.Prebackup.Version.SourceJobID != "librestore-internal-1" { + t.Fatalf("latest registered prebackup must be visible with its job: %+v", body.Prebackup) + } + if body.Prebackup.BoundToTarget { + t.Fatal("a restore prebackup is not the publish target snapshot") + } + // 三类版本都要出现在轻量清单里,页面才能给每份归档写对类型。 + if len(body.Versions) != 3 { + t.Fatalf("versions = %+v", body.Versions) + } + for _, reason := range body.Gate.Reasons { + if strings.Contains(reason, "预备份") { + t.Fatalf("a registered prebackup must not be reported as missing: %q", reason) + } + } +} + // 三类版本必须分开读:production_effective 才是生产当前,测试封存不能顶替。 func TestLibraryOverviewKeepsVersionKindsSeparate(t *testing.T) { now := time.Now().UTC() @@ -323,8 +379,9 @@ func TestLibraryOverviewKeepsVersionKindsSeparate(t *testing.T) { t.Fatalf("status = %d: %s", recorder.Code, recorder.Body.String()) } var body struct { - Production libraryKnownView `json:"production"` - Prebackup libraryKnownView `json:"prebackup"` + Production libraryKnownView `json:"production"` + Prebackup libraryKnownView `json:"prebackup"` + Versions []libraryVersionRef `json:"versions"` Diff struct { State string `json:"state"` } `json:"diff"` @@ -338,9 +395,17 @@ func TestLibraryOverviewKeepsVersionKindsSeparate(t *testing.T) { if body.Production.State != "known" || body.Production.Version == nil || body.Production.Version.ID != "ver-prod" { t.Fatalf("production must be the effective version, got %+v", body.Production) } - // 预备份只对匹配目标快照的那一份生效;这里目标为空(归档不可用),所以是未知。 - if body.Prebackup.State != "unknown" { - t.Fatalf("prebackup must stay unknown without a matching target snapshot: %+v", body.Prebackup) + // 预备份按"最近一次生产写入前的备份"显示:归档不可用时没有可匹配的目标, + // 但登记过的预备份必须仍然可见(否则真备份了却显示"尚未创建"), + // 并且明确标出它并不是本次拟发布快照。 + if body.Prebackup.State != "known" || body.Prebackup.Version == nil || body.Prebackup.Version.ID != "ver-backup-other" { + t.Fatalf("registered prebackup must stay visible: %+v", body.Prebackup) + } + if body.Prebackup.BoundToTarget { + t.Fatalf("prebackup must not claim to be the publish target without one: %+v", body.Prebackup) + } + if len(body.Versions) != 3 { + t.Fatalf("overview must expose every registered version kind, got %+v", body.Versions) } if body.Diff.State != "unknown" { t.Fatalf("diff must be unknown until the comparison is wired, got %q", body.Diff.State)