fix(library): 预备份与归档类型按版本登记显示
两处契约错:prebackup 只认"与拟发布快照同内容的那一份",而恢复作业登记的是 恢复前备份(必然不同),于是刚备份完就显示"尚未创建";归档里同时有目标基线与 每次预备份,overview 不给登记类型,页面只能把每份都写成"测试封存"。 现在 prebackup 取最近一次已登记的那份,并给出所属作业与 bound_to_target; overview 新增不含 evidence 的轻量版本清单供页面标注每份快照。闸口判定不变。
This commit is contained in:
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user