fix(library): 预备份与归档类型按版本登记显示

两处契约错:prebackup 只认"与拟发布快照同内容的那一份",而恢复作业登记的是
恢复前备份(必然不同),于是刚备份完就显示"尚未创建";归档里同时有目标基线与
每次预备份,overview 不给登记类型,页面只能把每份都写成"测试封存"。

现在 prebackup 取最近一次已登记的那份,并给出所属作业与 bound_to_target;
overview 新增不含 evidence 的轻量版本清单供页面标注每份快照。闸口判定不变。
This commit is contained in:
姚章浩
2026-10-30 01:33:50 +08:00
parent 72ea9726e7
commit 402173263e
2 changed files with 105 additions and 14 deletions
+35 -9
View File
@@ -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}
}
}
}
+70 -5
View File
@@ -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)