From f9457fab52eb84c7af0edad492aad23fa59658ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Tue, 3 Nov 2026 11:14:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(library):=20=E6=89=93=E9=80=9A=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=AA=8C=E8=AF=81=E7=9A=84=E6=AD=A3=E5=B8=B8=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E9=A1=B5=E9=9D=A2=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1/B2:演练在同一维护切点做两次真实捕获(写前镜像与来源身份), 写集合在写第一笔之前落盘并登记;恢复走限定范围的 RestoreDrill, 复用同一份 DS01 身份合同与 Apply 原语整段回放,不再让同快照必被拒绝。 worker 接治理维护生命周期(实例+scope 行、真实入口状态、成功才释放), held 保留行并由同 job 的 library.recover 收口(kind 过滤贯通 verify)。 B3:证据优先,failed/invalid 与读取错误即使 env=true 也拒绝。 B4/B5:GET 投影在飞作业,子面板回调父页面轮询;观测绑定 scope 与代次, 刷新失败明确过期,不再显示旧的"已验证"。 迁移 79 不自动 down;未部署、未调用业务目标。 --- CHANGELOG.md | 34 +++ cmd/librarydrill/main.go | 7 +- cmd/worker/library_recovery.go | 14 +- cmd/worker/library_verify.go | 92 ++++++- cmd/worker/library_verify_test.go | 19 ++ internal/httpapi/library_verify.go | 91 +++++-- internal/httpapi/library_verify_test.go | 76 ++++++ internal/httpapi/server.go | 7 +- internal/libraryrelease/writeset.go | 26 ++ internal/libraryverify/canary_test.go | 98 +++++--- .../libraryverify/restore_identity_test.go | 77 ++++++ internal/libraryverify/verify.go | 228 +++++++++++++++--- internal/platform/postgres/library_jobs.go | 2 +- .../postgres/library_recovery_identity.go | 2 +- .../v071/library_production_target.go | 80 ++++-- web/src/ReleaseTargets.test.tsx | 1 + web/src/ReleaseTargets.tsx | 48 +++- ...estoreVerificationSection.mounted.test.tsx | 171 +++++++++++++ web/src/RestoreVerificationSection.test.tsx | 43 +++- web/src/RestoreVerificationSection.tsx | 109 +++++++-- web/src/restoreVerification.test.ts | 28 ++- web/src/restoreVerification.ts | 28 ++- 22 files changed, 1145 insertions(+), 136 deletions(-) create mode 100644 internal/libraryverify/restore_identity_test.go create mode 100644 web/src/RestoreVerificationSection.mounted.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f74aa38..fc36ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 开发快照(GPT-6 正常 UI 发布审核 B1–B5 修复) + +### Fixed + +- **B1 恢复验证的恢复身份/计划**:共享演练不再把同一份快照同时当作来源与写前镜像 + (真实恢复合同会在任何写之前拒绝)。同一维护切点内做两次**真实**捕获:写前镜像 + (恢复实际回放的预备份)与来源身份(独立密封、内容相同、身份不同);两者与作业/ + 目标/tenant/KB 一起持久化,写集合在第一笔写入之前落盘并登记。演练恢复走限定范围的 + 安全入口 `RestoreDrill`:复用同一份 DS01 身份合同与同一批 Apply 原语,四个阶段按 + 写前镜像整段回放(先清 scope 再插入归档行)并做整实例指纹校验,不是"只删 canary"; + 有恢复派发闸口的目标拒绝该入口。 +- **B2 演练接进治理维护生命周期**:worker 在开窗口前写实例级 + scope 级维护行, + 写入边界更新真实入口状态;成功或"未写内容的安全收尾"才释放占用并开入口,held 时 + 保留行(入口 closed、状态 needs_restore)。同 job 的现有恢复入口贯通 verify: + 恢复队列准入、中断接管与 `library.recover` 的 kind 过滤都接受 verify;预备份引用与 + 写集合摘要按 (scope, job, 写前镜像) 精确登记,恢复入口能取回。失败文案由报告的 + restore_check 与窗口终态产生,不再用"有快照"冒充"已恢复并复核"。 +- **B3 证据优先级统一**:API 与 worker 都以证据为先——明确 failed/invalid 或读取错误 + 必拒绝,即使 `PRODUCTION_BACKUP_RESTORE_VERIFIED=true`;历史 env 兜底只保留在 + "明确没有证据行/存储无证据能力"时。未读取证据的旧调用点语义不变。 +- **B4 恢复验证作业联动父页面**:GET `/api/v1/library/verifications` 从真实业务行 + 投影在飞 verify 作业(running/job_id/state/stage);子面板接受作业后回调父页面 + `onAccepted`,父页面立即 load 并纳入轮询,终态回调 `onComplete` 再做一次读回, + 正常一次点击无需手工刷新即可同步进度/终态/闸口。 +- **B5 观测绑定 scope 与请求代次**:切 scope 立即丢弃旧观测;晚到的旧响应按代次丢弃; + HTTP 失败/不可解析/响应 scope 不一致都明确显示读取失败,保留旧数据时必须标成 + "上次观测已过期(刷新失败)",不再显示"已验证(当前目标)"。 + +### Notes + +- 迁移 79 仍只向上应用;`migrator` 没有自动 down 路径,部署回滚保留 79 与既有证据, + 不执行有损 `down.sql`。F1(备份主机连接/命令状态细分)仍为非阻塞后续。 +- 未部署、未调用业务目标、未切换服务;本轮只做隔离验证与固定 SHA 的候选提交。 + ## 开发快照(DS01 收口:CAS 点实时租约、恢复投递归属、治理权威域与绑定冲突分类) ### Fixed diff --git a/cmd/librarydrill/main.go b/cmd/librarydrill/main.go index 8af1685..fe4a5a5 100644 --- a/cmd/librarydrill/main.go +++ b/cmd/librarydrill/main.go @@ -78,8 +78,11 @@ func run(scope, jobID string) (*drillReport, error) { if jobID == "" { jobID = fmt.Sprintf("drill-%s-%d", scope, time.Now().UTC().UnixNano()) } - return libraryverify.Run(context.TODO(), config, scope, jobID, - func(stage, state, detail string) { log.Printf("stage=%s state=%s %s", stage, state, detail) }) + // 主机侧 CLI 不持有治理库连接:维护记录由调用它的作业/运维流程负责。 + // 页面发起的验证走 worker,那里 Options 同时接治理生命周期与写集合登记。 + return libraryverify.Run(context.TODO(), config, scope, jobID, libraryverify.Options{ + Progress: func(stage, state, detail string) { log.Printf("stage=%s state=%s %s", stage, state, detail) }, + }) } func writeReport(path string, report *drillReport) error { diff --git a/cmd/worker/library_recovery.go b/cmd/worker/library_recovery.go index 55ff8c6..77b1502 100644 --- a/cmd/worker/library_recovery.go +++ b/cmd/worker/library_recovery.go @@ -127,7 +127,10 @@ func runLibraryRecovery(ctx context.Context, store *postgresstore.Store, config defer control.Close() defer coordinator.Close() - restorer := recoveryRestorer{target: target, expected: prebackup, writeSet: ledger} + restorer := recoveryRestorer{target: target, expected: prebackup, writeSet: ledger, + // 恢复验证写的是"隔离 canary",不是发布计划:同一 job 的收尾走受限的演练 + // 恢复入口(同样的身份合同 + 整段回放),不能拿发布计划合同去回放它。 + drill: windowJob.Kind == governance.LibraryJobVerify} verifier := recoveryVerifier{target: target, expected: prebackup} result, recoverErr := coordinator.RecoverWindow(ctx, payload.WindowJobID, restorer, verifier) now := time.Now().UTC() @@ -171,6 +174,8 @@ type recoveryRestorer struct { target *v071.ProductionLibraryTarget expected string writeSet *libraryrelease.WriteSet + // drill 表示窗口属于恢复演练:它的写集合是 canary,不是发布执行计划。 + drill bool } func (r recoveryRestorer) Restore(ctx context.Context, backupID string) error { @@ -183,8 +188,11 @@ func (r recoveryRestorer) Restore(ctx context.Context, backupID string) error { if r.writeSet.BackupSnapshotID != backupID { return fmt.Errorf("写集合记录绑定预备份 %s,不是 %s", r.writeSet.BackupSnapshotID, backupID) } - return r.target.Restore(ctx, libraryrelease.BackupRef{ID: "backup-" + backupID, SnapshotID: backupID, - WriteSet: r.writeSet}) + ref := libraryrelease.BackupRef{ID: "backup-" + backupID, SnapshotID: backupID, WriteSet: r.writeSet} + if r.drill { + return r.target.RestoreDrill(ctx, ref) + } + return r.target.Restore(ctx, ref) } // recoveryVerifier 做真实的内容校验,并把目标当前状态摘要作为"校验过什么"的凭证。 diff --git a/cmd/worker/library_verify.go b/cmd/worker/library_verify.go index 44e6015..b5bbae5 100644 --- a/cmd/worker/library_verify.go +++ b/cmd/worker/library_verify.go @@ -11,7 +11,9 @@ import ( "knowledge-governance/internal/appconfig" "knowledge-governance/internal/governance" "knowledge-governance/internal/jobs" + "knowledge-governance/internal/librarymaintenance" "knowledge-governance/internal/libraryverify" + "knowledge-governance/internal/pgdsn" postgresstore "knowledge-governance/internal/platform/postgres" ) @@ -71,7 +73,11 @@ func runLibraryVerifyAttempt(ctx context.Context, writer *libraryAttemptWriter, "", "", "", "", now) report, runErr := libraryverify.Run(ctx, config, payload.Scope, payload.LibraryJobID, - func(stage, state, detail string) { recorder.Stage(ctx, stage, state, detail) }) + libraryverify.Options{ + Progress: func(stage, state, detail string) { recorder.Stage(ctx, stage, state, detail) }, + Governance: verifyGovernance{store: store, config: config}, + Ledger: recorder, + }) // 证据只由真实执行产生:失败也照实登记 failure 与报告,绝不写成 passed。 evidence := governance.LibraryRestoreVerification{ @@ -113,11 +119,16 @@ func runLibraryVerifyAttempt(ctx context.Context, writer *libraryAttemptWriter, held := report != nil && report.Status == "maintenance_held" message := "恢复验证未通过:" + evidence.Failure if held { - message += "(目标仍处于维护窗口,需要先按预备份恢复再重试)" + message += "(目标仍在维护窗口,入口关闭、写入者已停)" } + // 失败出口只写真实结果:报告里的 restore_check 与窗口终态说明目标此刻是 + // 仍在维护,还是已经按原运行状态安全收尾。不能拿"有快照"冒充"已恢复并复核"。 recovery := "未对目标执行任何写入,无需恢复" - if report != nil && report.SnapshotID != "" { - recovery = "目标已按预备份 " + report.SnapshotID + " 恢复并复核;下次验证会重新捕获" + switch { + case held: + recovery = "目标仍在维护窗口:在同一作业上执行「按预备份恢复」(library.recover),恢复与逐类校验通过后才会开入口并释放占用" + case report != nil && report.WindowState != "": + recovery = "维护窗口已按原运行状态收尾(window=" + report.WindowState + "):目标内容未写入或已恢复并复核,无需再恢复" } snapshot := "" if report != nil { @@ -141,6 +152,67 @@ func runLibraryVerifyAttempt(ctx context.Context, writer *libraryAttemptWriter, return nil } +// verifyGovernance 把演练接进**既有**维护生命周期:实例行 + scope 行都由 worker 的 +// 真实 attempt 写入,入口状态与状态文字来自实际执行。页面读的维护接口只认这些行, +// 所以只写磁盘 journal 的窗口在 UI 上等于不存在(审核 B2 的真实缺口)。 +type verifyGovernance struct { + store *libraryAttemptWriter + config appconfig.Config +} + +func (g verifyGovernance) targetIdentity(scope string) string { + return fmt.Sprintf("scope=%s db=%s index=%s bucket=%s", scope, + pgdsn.Endpoint(g.config.LibraryProductionDatabaseURL), g.config.LibraryProductionIndex, g.config.LibraryProductionS3Bucket) +} + +func (g verifyGovernance) Acquire(ctx context.Context, jobID, scope string, target libraryverify.TargetIdentity, originalServiceState string) error { + now := time.Now().UTC() + note := "实例级维护:关闭目标入口并停止全部写入者,恢复验证完成并复核后开放" + if _, err := g.store.AcquireInstanceMaintenance(ctx, postgresstore.LibraryInstanceMaintenance{ + Instance: target.Instance, OwnerJobID: jobID, OwnerScope: scope, + TargetFingerprint: target.Fingerprint, Level: librarymaintenance.LevelInstance, + Services: target.Writers, IngressState: "unknown", State: "acquired", + OriginalServiceState: originalServiceState, Note: note, + }, now); err != nil { + return err + } + if _, err := g.store.AcquireLibraryMaintenance(ctx, postgresstore.LibraryMaintenance{ + Scope: scope, OwnerJobID: jobID, TargetIdentity: g.targetIdentity(scope), + Level: librarymaintenance.LevelInstance, TargetFingerprint: target.Fingerprint, + Services: target.Writers, IngressState: "unknown", State: "acquired", + OriginalServiceState: originalServiceState, Note: note, + }, now); err != nil { + // scope 行没拿到时目标还没被停用:释放实例占用,避免留下一个空窗口。 + _ = g.store.ReleaseInstanceMaintenance(ctx, target.Instance, jobID, now) + return err + } + return nil +} + +func (g verifyGovernance) Ingress(ctx context.Context, jobID, scope, state, note string) error { + now := time.Now().UTC() + if err := g.store.SetLibraryMaintenanceIngress(ctx, scope, jobID, state, note, now); err != nil { + return err + } + return g.store.SetInstanceMaintenanceIngress(ctx, g.config.LibraryTargetInstance, jobID, state, note, now) +} + +func (g verifyGovernance) State(ctx context.Context, jobID, scope, state, note string) error { + now := time.Now().UTC() + if err := g.store.UpdateLibraryMaintenanceState(ctx, scope, jobID, state, note, now); err != nil { + return err + } + return g.store.UpdateInstanceMaintenanceState(ctx, g.config.LibraryTargetInstance, jobID, state, note, now) +} + +func (g verifyGovernance) Release(ctx context.Context, jobID, scope, note string) error { + now := time.Now().UTC() + if err := g.store.ReleaseLibraryMaintenance(ctx, scope, jobID, now); err != nil { + return err + } + return g.store.ReleaseInstanceMaintenance(ctx, g.config.LibraryTargetInstance, jobID, now) +} + // verificationReportObject 把报告转成可落库的 JSON 对象(保留 canary/漂移校验等证据字段)。 func verificationReportObject(report *libraryverify.Report) map[string]any { if report == nil { @@ -161,19 +233,21 @@ func verificationReportObject(report *libraryverify.Report) map[string]any { // // 证据是第一来源:library_restore_verifications 按当前 target_id 读取,换库/换索引/换桶 // 之后旧证据自动失效,不需要谁记得关开关。历史部署里的 PRODUCTION_BACKUP_RESTORE_VERIFIED -// 仍作为显式覆盖保留(不删除旧部署能力),但页面不再需要它,也不再由页面写入。 +// 仍作为显式覆盖保留(不删除旧部署能力),但只允许在**明确没有证据行**时兜底; +// 明确 failed/invalid 与读取错误一律拒绝(审核 B3:不能把"不知道"当"满足")。 func libraryRestoreEvidenceSatisfied(ctx context.Context, store interface { LatestLibraryRestoreVerification(context.Context, string) (governance.LibraryRestoreVerification, bool, error) }, config appconfig.Config, scope string) (bool, string) { + // 存储没有证据能力(nil/迁移前形状)等同于"没有证据行":历史 env 覆盖可兜底; + // 但只要读得出证据行,failed/invalid 与读取错误都优先拒绝。 + missing := true if store != nil { evidence, found, err := store.LatestLibraryRestoreVerification(ctx, scope) switch { case err != nil: - if config.ProductionBackupRestoreVerified { - return true, "" - } return false, "读取恢复验证证据失败:" + err.Error() case found: + missing = false if ok, reason := governance.LibraryVerificationSatisfied(evidence, scope); ok { return true, "" } else { @@ -181,7 +255,7 @@ func libraryRestoreEvidenceSatisfied(ctx context.Context, store interface { } } } - if config.ProductionBackupRestoreVerified { + if missing && config.ProductionBackupRestoreVerified { return true, "" } return false, "当前目标还没有恢复验证证据:先跑一次恢复验证(备份 → canary → 恢复 → 复核)" diff --git a/cmd/worker/library_verify_test.go b/cmd/worker/library_verify_test.go index 2c35918..d44e5d9 100644 --- a/cmd/worker/library_verify_test.go +++ b/cmd/worker/library_verify_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "strings" "testing" "time" @@ -22,6 +23,24 @@ func (s verifyEvidenceStore) LatestLibraryRestoreVerification(context.Context, s return s.evidence, s.found, nil } +// 审核 B3:读取失败不能被 env=true 洗成"满足"。 +type unreadableEvidenceStore struct{} + +func (unreadableEvidenceStore) LatestLibraryRestoreVerification(context.Context, string) (governance.LibraryRestoreVerification, bool, error) { + return governance.LibraryRestoreVerification{}, false, errors.New("evidence lookup failed") +} + +func TestWorkerUnreadableEvidenceBeatsEnv(t *testing.T) { + ok, reason := libraryRestoreEvidenceSatisfied(context.Background(), unreadableEvidenceStore{}, + appconfig.Config{ProductionBackupRestoreVerified: true}, "internal") + if ok { + t.Fatalf("unreadable evidence must block even with env=true; reason=%q", reason) + } + if !strings.Contains(reason, "读取恢复验证证据失败") { + t.Fatalf("reason=%q", reason) + } +} + func TestLibraryRestoreEvidenceSatisfied(t *testing.T) { now := time.Now().UTC() passed := verifyEvidenceStore{found: true, evidence: governance.LibraryRestoreVerification{ diff --git a/internal/httpapi/library_verify.go b/internal/httpapi/library_verify.go index 4ce1bc8..1e72d9c 100644 --- a/internal/httpapi/library_verify.go +++ b/internal/httpapi/library_verify.go @@ -31,6 +31,8 @@ type libraryVerificationView struct { History []governance.LibraryRestoreVerification `json:"history"` Running bool `json:"running"` JobID string `json:"job_id,omitempty"` + State string `json:"state,omitempty"` + Stage string `json:"stage,omitempty"` } type libraryVerificationTarget struct { @@ -166,49 +168,110 @@ func libraryVerifications(w http.ResponseWriter, r *http.Request, config Config) view.Verified = false view.Reason = "还没有对当前目标做过恢复验证:点「跑一次恢复验证」(真实备份 → canary → 恢复 → 复核)" } + // 在飞作业必须从真实业务行读出来:旧实现只构造 running 字段却从不赋值, + // 页面因此在验收里看不到"已接受/正在执行",按钮也不会进入执行态(审核 B4)。 + if jobs, ok := config.Store.(interface { + ListLibraryJobs(context.Context, string, int) ([]governance.LibraryJob, error) + }); ok { + if items, err := jobs.ListLibraryJobs(r.Context(), scope, 20); err == nil { + var active *governance.LibraryJob + for index := range items { + item := items[index] + if item.Kind != governance.LibraryJobVerify || string(item.State) == "" { + continue + } + if !libraryJobActiveState(item.State) { + continue + } + if active == nil || item.CreatedAt.After(active.CreatedAt) { + active = &item + } + } + if active != nil { + view.Running = true + view.JobID = active.ID + view.State = string(active.State) + view.Stage = active.Stage + view.Reason = "恢复验证作业 " + active.ID + " 正在执行(" + active.Stage + ");终态会在这里显示证据,页面会自动刷新" + } + } + } writeJSON(w, http.StatusOK, view) } +// libraryJobActiveState 只认仍在执行的业务状态:终态不是"在飞"。 +func libraryJobActiveState(state governance.LibraryJobState) bool { + switch state { + case governance.LibraryJobPending, governance.LibraryJobRunning: + return true + } + return false +} + // withRestoreEvidence 把"当前目标是否有真实恢复证据"注入 Config 的副本, // 让同步/恢复闸口在同一个请求里按证据判断,而不是读一个可能被手填的 env。 -// 读不到证据时不改写任何东西:闸口照旧因为缺证据而拒绝。 +// +// 证据优先级是不可交换的(审核 B3):明确 failed/invalid 或读取失败都必须拒绝, +// 即使 env=true;历史 env 覆盖只在**明确没有证据行**(not-found)时兜底。 +// 把读取失败混进 not-found 等于把"不知道"说成"满足"。 func (c Config) withRestoreEvidence(ctx context.Context, scope string) Config { - if ok, err := c.restoreEvidence(ctx, scope); err == nil { - c.restoreVerified = ok - c.restoreEvidenceReason = "" - } else { + satisfied, missing, err := c.restoreEvidence(ctx, scope) + c.restoreEvidenceChecked = true + switch { + case err != nil: c.restoreVerified = false c.restoreEvidenceReason = err.Error() + case satisfied: + c.restoreVerified = true + c.restoreEvidenceReason = "" + case missing && c.ProductionBackupRestoreVerified: + c.restoreVerified = true + c.restoreEvidenceReason = "" + default: + c.restoreVerified = false + if missing { + c.restoreEvidenceReason = "当前目标还没有恢复演练证据:先在「生产保护与恢复」里跑一次恢复验证" + } else { + c.restoreEvidenceReason = "没有已核验的生产恢复演练证据" + } } return c } // restoreEvidence 读当前目标的最近一条证据并给出它能不能支撑"可恢复"。 -func (c Config) restoreEvidence(ctx context.Context, scope string) (bool, error) { +// missing 只在"查询成功且确实没有这一行"时为 true;读取错误单独返回。 +func (c Config) restoreEvidence(ctx context.Context, scope string) (satisfied bool, missing bool, err error) { store, ok := c.Store.(interface { LatestLibraryRestoreVerification(context.Context, string) (governance.LibraryRestoreVerification, bool, error) }) if !ok { - return false, errors.New("没有已核验的生产恢复演练证据") + // 存储没有证据能力(迁移前的部署形状)等同于"没有证据行": + // 历史 env 覆盖仍可兜底,但一旦读得出证据行,失败/无效/读取错误都优先拒绝。 + return false, true, nil } latest, found, err := store.LatestLibraryRestoreVerification(ctx, scope) if err != nil { - return false, fmt.Errorf("读取恢复验证证据失败:%w", err) + return false, false, fmt.Errorf("读取恢复验证证据失败:%w", err) } if !found { - return false, errors.New("当前目标还没有恢复验证证据:先在「生产保护与恢复」里跑一次恢复验证") + return false, true, nil } ok, reason := governance.LibraryVerificationSatisfied(latest, scope) if !ok { - return false, errors.New(reason) + return false, false, errors.New(reason) } - return true, nil + return true, false, nil } -// restoreGateReasons 是闸口对"恢复可验证"的统一判断:证据优先, -// 历史 env 覆盖仅在证据缺失时兜底(页面不再写入它)。 +// restoreGateReasons 是闸口对"恢复可验证"的统一判断:只看 withRestoreEvidence 的结论。 +// 这里不再 OR env:env 兜底只在证据缺失时、且已经在 withRestoreEvidence 里判定过。 func restoreGateReasons(config Config) []string { - if config.restoreVerified || config.ProductionBackupRestoreVerified { + if config.restoreVerified { + return nil + } + // 没有走过证据读取的调用点(历史路径/单元夹具)保持原有 env 语义; + // 一旦读过证据,上面的 withRestoreEvidence 已经把优先级判定完毕。 + if !config.restoreEvidenceChecked && config.ProductionBackupRestoreVerified { return nil } reason := config.restoreEvidenceReason diff --git a/internal/httpapi/library_verify_test.go b/internal/httpapi/library_verify_test.go index 7fd2670..0e15593 100644 --- a/internal/httpapi/library_verify_test.go +++ b/internal/httpapi/library_verify_test.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -157,6 +158,81 @@ func TestLibraryVerificationsFailedEvidenceIsNotVerified(t *testing.T) { // 同步闸口必须消费同一 target 的证据:有 passed 证据即放行, // 只有失败证据时仍然拒绝并给出失败原因(不是笼统"没有证据")。 +// 审核 B3 反例(GPT-6 overlay)转成通过断言:env=true 不能覆盖明确失败或读取失败。 +type evidenceReadErrorStore struct{ verificationStore } + +func (*evidenceReadErrorStore) LatestLibraryRestoreVerification(context.Context, string) (governance.LibraryRestoreVerification, bool, error) { + return governance.LibraryRestoreVerification{}, false, errors.New("evidence lookup failed") +} + +func TestRestoreGateFailedEvidenceBeatsEnv(t *testing.T) { + store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{ + Scope: "internal", Status: governance.LibraryVerificationFailed, Failure: "restore mismatch", + FinishedAt: time.Now().UTC(), + }}} + config := Config{Store: store, ProductionBackupRestoreVerified: true}.withRestoreEvidence(context.Background(), "internal") + reasons := restoreGateReasons(config) + if len(reasons) == 0 || !strings.Contains(strings.Join(reasons, ";"), "restore mismatch") { + t.Fatalf("failed evidence must beat env=true: %v", reasons) + } +} + +func TestRestoreGateUnreadableEvidenceBeatsEnv(t *testing.T) { + config := Config{Store: &evidenceReadErrorStore{}, ProductionBackupRestoreVerified: true}. + withRestoreEvidence(context.Background(), "internal") + reasons := restoreGateReasons(config) + if len(reasons) == 0 || !strings.Contains(strings.Join(reasons, ";"), "读取恢复验证证据失败") { + t.Fatalf("unreadable evidence must beat env=true: %v", reasons) + } +} + +// env 兜底只允许在"明确没有证据行"时生效。 +func TestRestoreGateEnvFallbackOnlyWhenEvidenceMissing(t *testing.T) { + config := Config{Store: &verificationStore{}, ProductionBackupRestoreVerified: true}. + withRestoreEvidence(context.Background(), "internal") + if reasons := restoreGateReasons(config); len(reasons) != 0 { + t.Fatalf("explicit not-found with legacy env must still allow: %v", reasons) + } +} + +// 审核 B4:GET 必须从真实业务行投影在飞验证作业,而不是永远 running=false。 +func TestLibraryVerificationsProjectsActiveVerifyJob(t *testing.T) { + store := &verificationStore{} + store.jobs = []governance.LibraryJob{{ + ID: "verify-running", Scope: "internal", Kind: governance.LibraryJobVerify, + State: governance.LibraryJobRunning, Stage: "restore", CreatedAt: time.Now().UTC(), + }} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil) + libraryVerifications(recorder, request, Config{Store: store}) + body := decodeBody(t, recorder) + if body["running"] != true { + t.Fatalf("running verify job must be projected: %v", body) + } + if body["job_id"] != "verify-running" { + t.Fatalf("job_id=%v", body["job_id"]) + } + if body["stage"] != "restore" { + t.Fatalf("stage=%v", body["stage"]) + } +} + +// 终态作业不是"在飞":页面不能一直显示执行中。 +func TestLibraryVerificationsIgnoresTerminalVerifyJob(t *testing.T) { + store := &verificationStore{} + store.jobs = []governance.LibraryJob{{ + ID: "verify-done", Scope: "internal", Kind: governance.LibraryJobVerify, + State: governance.LibraryJobSucceeded, CreatedAt: time.Now().UTC(), + }} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal", nil) + libraryVerifications(recorder, request, Config{Store: store}) + body := decodeBody(t, recorder) + if body["running"] != false { + t.Fatalf("terminal verify job must not be projected as running: %v", body) + } +} + func TestLibrarySyncGateConsumesRestoreEvidence(t *testing.T) { base := Config{ProductionReleaseRequestsEnabled: true, ProductionBackupRestoreVerified: false, LibraryProductionDatabaseURL: "postgres://unused.invalid/db", LibraryCapacityBudgetBytes: 1 << 40} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 87ac1ca..1e77aed 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -206,8 +206,11 @@ type Config struct { // restoreVerified / restoreEvidenceReason 由请求期的证据读取填入(withRestoreEvidence): // 闸口按"当前目标是否有真实恢复验证证据"判断,而不是按可手填的 env。 // 它们不可由配置注入,避免把证据变成另一个开关。 - restoreVerified bool - restoreEvidenceReason string + restoreVerified bool + restoreEvidenceReason string + // restoreEvidenceChecked 表示这个请求真的读过证据行:只有读过之后才适用 + // "失败/读取错误优先于 env"。从未读过证据的旧调用点仍按历史 env 语义兜底。 + restoreEvidenceChecked bool ProductionReleaseAuthorizerRef string ProductionReleaseAuthorizationToken string // DingtalkSources 枚举「我账号下能看到」的钉钉知识库与钉盘空间(只读,dws)。 diff --git a/internal/libraryrelease/writeset.go b/internal/libraryrelease/writeset.go index 3d64067..7ef2620 100644 --- a/internal/libraryrelease/writeset.go +++ b/internal/libraryrelease/writeset.go @@ -111,6 +111,32 @@ type WriteSet struct { Digest string `json:"digest"` } +// ValidateRestoreBinding 是恢复身份合同(DS01):写集合必须绑定**本次**预备份、 +// 同一目标 tenant/KB,且来源快照(要回放到目标的内容身份)与写前镜像不能是同一份。 +// 它是 ProductionLibraryTarget.Restore 在建任何派发绑定之前调用的同一个判定: +// 演练的写前/来源身份也必须过这一关,不能靠调用方约定。 +func (w WriteSet) ValidateRestoreBinding(snapshotID string, tenantID uint64, knowledgeBaseID string) error { + if snapshotID == "" { + return errors.New("恢复需要绑定的预备份快照") + } + if w.BackupSnapshotID != snapshotID { + return fmt.Errorf("写集合记录绑定的是预备份 %s,不是 %s", w.BackupSnapshotID, snapshotID) + } + if w.TenantID != tenantID || w.KnowledgeBaseID != knowledgeBaseID { + return fmt.Errorf("写集合记录绑定的是 %d/%s,与目标 %d/%s 不一致", + w.TenantID, w.KnowledgeBaseID, tenantID, knowledgeBaseID) + } + // 派发绑定只能来自原锚点:来源快照是发布基线,backup 是这份写集的写前镜像。 + // 二者缺失或相同都说明装配没有拿到真实身份,必须在任何写之前失败关闭。 + if w.TargetSnapshotID == "" || snapshotID == "" { + return errors.New("恢复缺少来源快照或写前镜像,拒绝建立派发绑定") + } + if w.TargetSnapshotID == snapshotID { + return errors.New("来源快照与写前镜像不能是同一份快照,拒绝建立派发绑定") + } + return nil +} + // Seal 把行序规范化为可复现的顺序并写入摘要。调用方在落盘与登记之前必须先 Seal。 // 排序在这里落地(而不是只在算摘要时排),否则落盘字节与摘要覆盖的内容不一致。 func (w *WriteSet) Seal() error { diff --git a/internal/libraryverify/canary_test.go b/internal/libraryverify/canary_test.go index 6ad69f1..af59222 100644 --- a/internal/libraryverify/canary_test.go +++ b/internal/libraryverify/canary_test.go @@ -3,37 +3,60 @@ package libraryverify import ( "context" "errors" - "knowledge-governance/internal/libraryrelease" "os" "path/filepath" "testing" "time" + + "knowledge-governance/internal/libraryrelease" ) -func TestCanaryPersistsRecoveryEvidenceBeforeWrite(t *testing.T) { +func drillLedger() libraryrelease.WriteSet { + return libraryrelease.WriteSet{Version: 1, JobID: "drill", Target: "internal", TenantID: 10000, KnowledgeBaseID: "kb", + BackupSnapshotID: "backup", TargetSnapshotID: "source", CreatedAt: time.Now().UTC(), + Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} +} + +// 写集合必须先落盘并登记,再标记内容风险,最后才写目标: +// 顺序颠倒会让恢复入口在"已经写过"时读不到 ledger。 +func TestCanaryPersistsAndRegistersEvidenceBeforeWrite(t *testing.T) { root := t.TempDir() - ledger := libraryrelease.WriteSet{Version: 1, JobID: "drill", Target: "internal", TenantID: 10000, KnowledgeBaseID: "kb", BackupSnapshotID: "backup", TargetSnapshotID: "backup", CreatedAt: time.Now().UTC(), Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} - marked := false - written := false - err := writeDrillCanary(context.Background(), root, &ledger, "canary-id", func(context.Context) error { marked = true; return nil }, func(context.Context) error { - if !marked { - t.Fatal("write preceded risk marker") - } - if len(ledger.CreatedIDs("knowledge_tags")) != 1 { - t.Fatal("canary absent from write set") - } - matches, e := filepath.Glob(filepath.Join(root, "writesets", "*.json")) - if e != nil || len(matches) != 1 { - t.Fatalf("durable ledger missing: %v %v", matches, e) - } - if _, e := os.Stat(matches[0]); e != nil { - t.Fatal(e) - } - written = true - return nil - }) - if err != nil || !written { - t.Fatalf("err=%v written=%v", err, written) + ledger := drillLedger() + registered, marked, written := false, false, false + err := writeDrillCanary(context.Background(), root, &ledger, "canary-id", + func(context.Context) error { + if !registered { + t.Fatal("risk marker preceded durable registration") + } + marked = true + return nil + }, + func(_ context.Context) error { + if len(ledger.CreatedIDs("knowledge_tags")) != 1 { + t.Fatal("canary absent from write set") + } + if ledger.Digest == "" { + t.Fatal("write set was not sealed before registration") + } + matches, e := filepath.Glob(filepath.Join(root, "writesets", "*.json")) + if e != nil || len(matches) != 1 { + t.Fatalf("durable ledger missing: %v %v", matches, e) + } + if _, e := os.Stat(matches[0]); e != nil { + t.Fatal(e) + } + registered = true + return nil + }, + func(context.Context) error { + if !registered || !marked { + t.Fatal("write preceded registration/risk marker") + } + written = true + return nil + }) + if err != nil || !written || !registered { + t.Fatalf("err=%v written=%v registered=%v", err, written, registered) } } @@ -42,18 +65,37 @@ func TestCanaryNeverWritesWithoutDurableLedger(t *testing.T) { if err := os.WriteFile(root, []byte("not a directory"), 0600); err != nil { t.Fatal(err) } - ledger := libraryrelease.WriteSet{Version: 1, JobID: "drill", Target: "internal", TenantID: 10000, KnowledgeBaseID: "kb", BackupSnapshotID: "backup", TargetSnapshotID: "backup", CreatedAt: time.Now().UTC(), Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} + ledger := drillLedger() marked, written := false, false - err := writeDrillCanary(context.Background(), root, &ledger, "canary-id", func(context.Context) error { marked = true; return nil }, func(context.Context) error { written = true; return nil }) + err := writeDrillCanary(context.Background(), root, &ledger, "canary-id", + func(context.Context) error { marked = true; return nil }, + func(context.Context) error { marked = true; return nil }, + func(context.Context) error { written = true; return nil }) + if err == nil || marked || written { + t.Fatalf("err=%v marked=%v written=%v", err, marked, written) + } +} + +// 登记失败 = 恢复入口读不到写集合:此时一笔都不许写。 +func TestCanaryNeverWritesWhenRegistrationFails(t *testing.T) { + ledger := drillLedger() + marked, written := false, false + err := writeDrillCanary(context.Background(), t.TempDir(), &ledger, "canary-id", + func(context.Context) error { marked = true; return nil }, + func(context.Context) error { return errors.New("governance registration unavailable") }, + func(context.Context) error { written = true; return nil }) if err == nil || marked || written { t.Fatalf("err=%v marked=%v written=%v", err, marked, written) } } func TestCanaryNeverWritesWhenRiskMarkerFails(t *testing.T) { - ledger := libraryrelease.WriteSet{Version: 1, JobID: "drill", Target: "internal", TenantID: 10000, KnowledgeBaseID: "kb", BackupSnapshotID: "backup", TargetSnapshotID: "backup", CreatedAt: time.Now().UTC(), Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} + ledger := drillLedger() written := false - err := writeDrillCanary(context.Background(), t.TempDir(), &ledger, "canary-id", func(context.Context) error { return errors.New("journal unavailable") }, func(context.Context) error { written = true; return nil }) + err := writeDrillCanary(context.Background(), t.TempDir(), &ledger, "canary-id", + func(context.Context) error { return errors.New("journal unavailable") }, + func(context.Context) error { return nil }, + func(context.Context) error { written = true; return nil }) if err == nil || written { t.Fatalf("err=%v written=%v", err, written) } diff --git a/internal/libraryverify/restore_identity_test.go b/internal/libraryverify/restore_identity_test.go new file mode 100644 index 0000000..d125cc4 --- /dev/null +++ b/internal/libraryverify/restore_identity_test.go @@ -0,0 +1,77 @@ +package libraryverify + +import ( + "strings" + "testing" + + "knowledge-governance/internal/libraryrelease" + v071 "knowledge-governance/internal/releaseadapter/v071" +) + +// B1 的定向回归:共享 Run 用 PlanWrite 产出的写前/来源身份与写集合, +// 必须通过真实恢复入口调用的**同一份**身份合同;用同一份快照冒充两个身份 +// 的旧形状必须被拒绝。这里不连接任何业务目标(隔离 fixture)。 +func TestSharedDrillPlanPassesRealRestoreIdentityContract(t *testing.T) { + preimage := libraryrelease.BackupRef{SnapshotID: strings.Repeat("a", 64), + Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} + source := libraryrelease.BackupRef{SnapshotID: strings.Repeat("b", 64), + Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} + + set, err := PlanWrite("libverify-internal-fixture", "internal", 10000, "fixture-kb", preimage, source) + if err != nil { + t.Fatalf("shared plan rejected: %v", err) + } + if err := set.Seal(); err != nil { + t.Fatal(err) + } + // ProductionLibraryTarget.Restore 与 RestoreDrill 都在写之前调用这一判定。 + if err := set.ValidateRestoreBinding(preimage.SnapshotID, 10000, "fixture-kb"); err != nil { + t.Fatalf("real restore identity contract rejected the shared plan: %v", err) + } + // 演练恢复入口的写集合守卫(只允许可归因的 canary 行)也必须接受它。 + canary := set + canary.Rows = append(canary.Rows, libraryrelease.WriteSetRow{Table: "knowledge_tags", ID: "canary-1"}) + if err := canary.Seal(); err != nil { + t.Fatal(err) + } + if err := v071.ValidateDrillWriteSet(canary); err != nil { + t.Fatalf("drill write set guard rejected the shared plan: %v", err) + } + + // 旧缺陷形状:来源快照 = 写前镜像。同一判定必须拒绝。 + legacy := set + legacy.TargetSnapshotID = preimage.SnapshotID + legacy.Digest = "" + if err := legacy.Seal(); err != nil { + t.Fatal(err) + } + err = legacy.ValidateRestoreBinding(preimage.SnapshotID, 10000, "fixture-kb") + if err == nil || !strings.Contains(err.Error(), "同一份快照") { + t.Fatalf("legacy same-snapshot plan must be rejected, got %v", err) + } +} + +func TestSharedPlanRejectsSameSnapshotIdentity(t *testing.T) { + preimage := libraryrelease.BackupRef{SnapshotID: strings.Repeat("c", 64), + Universe: map[string]libraryrelease.TableFingerprint{"knowledge_tags": {Count: 0, Digest: "empty"}}} + if _, err := PlanWrite("job", "internal", 10000, "kb", preimage, preimage); err == nil { + t.Fatal("plan must refuse to use one snapshot as both source and pre-image") + } + if _, err := PlanWrite("job", "internal", 10000, "kb", preimage, libraryrelease.BackupRef{}); err == nil { + t.Fatal("plan must refuse a missing source identity") + } +} + +// 演练恢复入口只回放"本次写的隔离 canary 行";任何其它行都不是演练产物。 +func TestDrillWriteSetGuardRejectsForeignRows(t *testing.T) { + set := libraryrelease.WriteSet{Version: libraryrelease.WriteSetVersion, Rows: []libraryrelease.WriteSetRow{ + {Table: "knowledges", ID: "doc-1"}, + }} + if err := v071.ValidateDrillWriteSet(set); err == nil { + t.Fatal("drill guard must reject non-canary rows") + } + set.Rows = []libraryrelease.WriteSetRow{{Table: "knowledge_tags", ID: "tag-1", Existing: true}} + if err := v071.ValidateDrillWriteSet(set); err == nil { + t.Fatal("drill guard must reject rows that existed before the write") + } +} diff --git a/internal/libraryverify/verify.go b/internal/libraryverify/verify.go index 8c88ea2..34d9d02 100644 --- a/internal/libraryverify/verify.go +++ b/internal/libraryverify/verify.go @@ -37,21 +37,24 @@ type Verification struct { } type Report struct { - Status string `json:"status"` // passed | failed | maintenance_held - Scope string `json:"scope"` - Target string `json:"target"` - SnapshotID string `json:"snapshot_id"` - Project string `json:"project"` - Counts map[string]int64 `json:"counts"` - BaselineCheck *Verification `json:"baseline_check"` - DriftCheck *Verification `json:"drift_check"` // 必须失败:证明确实能发现差异 - RestoreCheck *Verification `json:"restore_check"` - Canary string `json:"canary"` - Failure string `json:"failure,omitempty"` - Stage string `json:"stage,omitempty"` - StartedAt time.Time `json:"started_at"` - FinishedAt time.Time `json:"finished_at"` - Until map[string]string `json:"until,omitempty"` + Status string `json:"status"` // passed | failed | maintenance_held + Scope string `json:"scope"` + Target string `json:"target"` + SnapshotID string `json:"snapshot_id"` // 写前镜像:恢复实际回放的那份预备份 + SourceSnapshot string `json:"source_snapshot_id,omitempty"` + WriteSetDigest string `json:"writeset_digest,omitempty"` + WindowState string `json:"window_state,omitempty"` + Project string `json:"project"` + Counts map[string]int64 `json:"counts"` + BaselineCheck *Verification `json:"baseline_check"` + DriftCheck *Verification `json:"drift_check"` // 必须失败:证明确实能发现差异 + RestoreCheck *Verification `json:"restore_check"` + Canary string `json:"canary"` + Failure string `json:"failure,omitempty"` + Stage string `json:"stage,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Until map[string]string `json:"until,omitempty"` } // Progress 把演练的阶段推进交给调用方(worker 写 library_job_stages,CLI 打日志)。 @@ -63,12 +66,46 @@ func (p Progress) report(stage, state, detail string) { } } +// TargetIdentity 是治理侧维护记录必须钉住的目标事实(与 librarymaintenance.Target 同源)。 +type TargetIdentity struct { + Instance string + Fingerprint string + Writers []string +} + +// Governance 是演练必须持有的**治理侧**维护生命周期:实例/scope 占用、入口真实状态、 +// 释放。为什么不能让调用方只写磁盘 journal:页面与恢复入口读的是治理行,只写 journal +// 时"正在维护"不会出现在 UI,失败后也没有同 job 的恢复锚点(2026-11 审核 B2)。 +type Governance interface { + Acquire(ctx context.Context, jobID, scope string, target TargetIdentity, originalServiceState string) error + Ingress(ctx context.Context, jobID, scope, state, note string) error + State(ctx context.Context, jobID, scope, state, note string) error + Release(ctx context.Context, jobID, scope, note string) error +} + +// Ledger 把预备份身份与写集合摘要持久化到**同一个作业**(worker 直接传 +// libraryStageRecorder:它已经是 BackupObserver + WriteSetObserver)。 +// 恢复入口按 (scope, job, 写前镜像) 精确读这条记录,所以两件事必须发生在写第一笔之前。 +type Ledger interface { + BackupPrepared(ctx context.Context, backup libraryrelease.BackupRef) error + WriteSetPlanned(ctx context.Context, set libraryrelease.WriteSet) error +} + +// Options 是演练的可选接线。Governance/Ledger 为 nil 只允许主机侧 CLI 使用: +// 页面发起的作业必须两者都在,否则演练结论不会出现在治理记录里。 +type Options struct { + Progress Progress + Governance Governance + Ledger Ledger +} + // Run 执行一次演练。jobID 同时是维护窗口的持有者标识,必须与业务作业 id 一致, // 否则崩溃恢复时窗口与作业对不上。 -func Run(ctx context.Context, config appconfig.Config, scope, jobID string, progress Progress) (*Report, error) { +func Run(ctx context.Context, config appconfig.Config, scope, jobID string, opts Options) (*Report, error) { if scope != "internal" && scope != "external" { return nil, errors.New("scope must be internal or external") } + progress := opts.Progress report := &Report{Status: "failed", Scope: scope, Project: config.LibraryTargetProject, Target: config.LibraryTargetInstance, StartedAt: time.Now().UTC()} fail := func(stage string, cause error, held bool) (*Report, error) { @@ -96,8 +133,25 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog if err := manager.VerifyTopology(ctx); err != nil { return fail("capture", err, false) } + originalState, err := manager.OriginalState(ctx) + if err != nil { + return fail("capture", errors.New("读不到目标写入者的原运行状态,不能开维护窗口:"+err.Error()), false) + } + identity := manager.Target() + // 治理占用必须在任何目标动作之前:拿不到就没有"入口已关/写入者已停"的权威记录, + // 失败后也没有可恢复的同 job 锚点。 + if opts.Governance != nil { + targetIdentity := TargetIdentity{Instance: identity.Instance, Fingerprint: identity.Fingerprint, Writers: identity.Writers} + if err := opts.Governance.Acquire(ctx, jobID, scope, targetIdentity, originalState); err != nil { + return fail("capture", errors.New("目标实例正在维护中或无法登记维护占用:"+err.Error()), false) + } + } coordinator, control, err := manager.Open(config.LibraryTargetJournalRoot) if err != nil { + if opts.Governance != nil { + // 还没进窗口、目标未被停用:释放占用是诚实的;释放失败就报告出来。 + _ = opts.Governance.Release(ctx, jobID, scope, "维护协调器不可用,未进入窗口") + } return fail("capture", err, false) } defer control.Close() @@ -105,6 +159,8 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog var ( snapshotID string + sourceID string + writesetDigest string counts map[string]int64 canary string contentTouched bool @@ -114,13 +170,21 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog ) work := func(ctx context.Context, window *libraryfreeze.Window) (string, error) { start := time.Now() + // 窗口已关入口、已停写入者并证明排空:此时才敢把 UI 的入口状态写成 closed。 + if opts.Governance != nil { + if err := opts.Governance.Ingress(ctx, jobID, scope, "closed", + "恢复验证已关闭目标入口并停止写入者"); err != nil { + return "", errors.New("登记入口关闭失败:" + err.Error()) + } + } current, err := target.Current(ctx) if err != nil { return "", errors.New("读取目标当前整库状态失败:" + err.Error()) } counts = current.Counts report.Counts = counts - // 1) 真预备份(落归档、内容寻址、导出后复核内容未变)。 + // 1) 写前镜像:真预备份(落归档、内容寻址、导出后复核内容未变)。 + // 它是恢复阶段实际回放的唯一内容来源。 backup, err := backups.Prepare(ctx, "", current) if err != nil { return "", errors.New("捕获目标整库失败:" + err.Error()) @@ -130,11 +194,32 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog if err := window.SetBackupSnapshot(ctx, snapshotID); err != nil { return "", errors.New("记录预备份身份失败:" + err.Error()) } + // 2) 来源身份:DS01 合同要求"来源快照 ≠ 写前镜像",演练不能用同一份快照冒充 + // 两个身份(真实 Restore 会在任何写之前拒绝)。这里再做一次**真实的**独立捕获, + // 只作为这次写入的来源身份;两次捕获都在同一维护切点内、由同一备份存储密封, + // 不伪造快照、也不放宽 Restore 的校验。 + source, err := backups.Prepare(ctx, "", current) + if err != nil { + return "", errors.New("捕获来源身份失败:" + err.Error()) + } + if source.SnapshotID == "" || source.SnapshotID == snapshotID { + return "", errors.New("来源身份与写前镜像相同:拒绝用一份快照冒充两个恢复身份") + } + sourceID = source.SnapshotID + report.SourceSnapshot = sourceID + // 预备份引用必须在任何写入之前落进同一个作业:恢复入口按它取写前镜像。 + if opts.Ledger != nil { + if err := opts.Ledger.BackupPrepared(ctx, backup); err != nil { + return "", errors.New("预备份身份无法持久化,未对目标执行任何写入:" + err.Error()) + } + } progress.report("capture", "succeeded", - fmt.Sprintf("已捕获目标整库快照 %s(%d 字节,用时 %s)", snapshotID, backup.Bytes, time.Since(start).Round(time.Second))) - log.Printf("verify: snapshot captured snapshot=%s bytes=%d in=%s", snapshotID, backup.Bytes, time.Since(start)) + fmt.Sprintf("已捕获写前镜像 %s 与来源身份 %s(%d 字节,用时 %s)", + short(snapshotID), short(sourceID), backup.Bytes, time.Since(start).Round(time.Second))) + log.Printf("verify: snapshot captured preimage=%s source=%s bytes=%d in=%s", + snapshotID, sourceID, backup.Bytes, time.Since(start)) - // 2) 基线校验:预备份必须与目标完全一致。 + // 3) 基线校验:预备份必须与目标完全一致。 progress.report("verify", "running", "逐表/索引/资源比对目标与预备份") baseline = Verification{SnapshotID: snapshotID, At: time.Now().UTC()} if err := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: snapshotID}); err != nil { @@ -160,10 +245,10 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog if exists { return "", errors.New("canary identity collision; no write attempted") } - ledger := libraryrelease.WriteSet{Version: libraryrelease.WriteSetVersion, JobID: jobID, Target: scope, - TenantID: identityValue.TenantID, KnowledgeBaseID: identityValue.KnowledgeBaseID, - BackupSnapshotID: snapshotID, TargetSnapshotID: snapshotID, - CreatedAt: time.Now().UTC(), Universe: backup.Universe} + ledger, planErr := PlanWrite(jobID, scope, identityValue.TenantID, identityValue.KnowledgeBaseID, backup, source) + if planErr != nil { + return "", planErr + } report.Canary = canary progress.report("canary", "running", "写入一个隔离 canary,验证校验能发现差异") if err := writeDrillCanary(ctx, config.LibraryArchiveRoot, &ledger, canary, @@ -173,12 +258,21 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog } contentTouched = true return nil + }, func(ctx context.Context) error { + if opts.Ledger == nil { + return nil + } + // 写集合摘要必须在第一笔写入之前登记到同一个作业的预备份证据里: + // 恢复入口按 (scope, job, 写前镜像) 精确读它,读不到就保持维护。 + return opts.Ledger.WriteSetPlanned(ctx, *ledgerClone(&ledger)) }, func(ctx context.Context) error { _, err := target.Pool().Exec(ctx, `INSERT INTO knowledge_tags(id,tenant_id,knowledge_base_id,name) VALUES($1,$2,$3,$4)`, canary, identityValue.TenantID, identityValue.KnowledgeBaseID, "drill-canary-"+canary) return err }); err != nil { return "", fmt.Errorf("canary preparation/write failed: %w", err) } + writesetDigest = ledger.Digest + report.WriteSetDigest = writesetDigest drift = Verification{SnapshotID: snapshotID, At: time.Now().UTC()} if verifyErr := target.Verify(ctx, libraryrelease.Expectation{SnapshotID: snapshotID}); verifyErr == nil { drift.Error = "写入 canary 后校验仍然通过:校验没有真正比对内容" @@ -191,9 +285,12 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog progress.report("canary", "succeeded", "校验按预期发现了 canary 差异") log.Printf("verify: drift detected as expected (%s)", drift.Error) - // 4) 按预备份恢复。恢复只删掉持久写集合里那个新 canary 身份。 + // 4) 按写前镜像恢复:四个阶段整段回放(与真实恢复同一批 Apply 原语), + // 而不是只删 canary。发布计划形态的 Restore 不能接受"只写过一行 canary"的 + // 写集合(v2 执行计划合同),所以演练走限定的安全恢复入口;它复用同一份 + // DS01 身份合同与整实例指纹校验。 progress.report("restore", "running", "按预备份恢复目标整库") - if err := target.Restore(ctx, libraryrelease.BackupRef{ID: "backup-" + snapshotID, SnapshotID: snapshotID, + if err := target.RestoreDrill(ctx, libraryrelease.BackupRef{ID: "backup-" + snapshotID, SnapshotID: snapshotID, WriteSet: &ledger}); err != nil { return "", errors.New("按预备份恢复失败:" + err.Error()) } @@ -218,15 +315,42 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog func(error) bool { return !contentTouched || restored.Verified }) report.Until = map[string]string{"job": jobID, "window_state": window.State} + report.WindowState = window.State + // 服务真的回来、入口验证过(succeeded / 未写内容的安全失败)才释放治理占用; + // held 时保留行,页面才能显示真实入口状态并从同一 job 走恢复入口。 + released := false + if opts.Governance != nil { + switch { + case window.State == "succeeded" || (window.State == "failed" && !contentTouched): + if err := opts.Governance.Ingress(ctx, jobID, scope, "open", + "维护窗口已按原运行状态恢复,入口已验证可用"); err != nil { + return fail("finalize", errors.New("恢复后登记入口状态失败:"+err.Error()), false) + } + if err := opts.Governance.Release(ctx, jobID, scope, + "恢复验证结束,服务与入口已按原状态恢复"); err != nil { + return fail("finalize", errors.New("恢复验证结束但维护占用释放失败:"+err.Error()), false) + } + released = true + default: + cause := "维护窗口保持关闭" + if windowErr != nil { + cause = windowErr.Error() + } + _ = opts.Governance.Ingress(ctx, jobID, scope, "closed", "目标内容状态未验证,入口保持关闭") + _ = opts.Governance.State(ctx, jobID, scope, "needs_restore", + "恢复验证失败且目标内容未验证:"+cause) + } + } if windowErr != nil || window.State != "succeeded" { cause := windowErr if cause == nil { cause = fmt.Errorf("维护窗口状态 %s(未成功)", window.State) } - if window.State == "held" { - return fail("finalize", cause, true) + held := window.State == "held" + if held && released { + held = false } - return fail("finalize", cause, false) + return fail("finalize", cause, held) } report.Status = "passed" report.FinishedAt = time.Now().UTC() @@ -234,7 +358,46 @@ func Run(ctx context.Context, config appconfig.Config, scope, jobID string, prog return report, nil } -func writeDrillCanary(ctx context.Context, root string, ledger *libraryrelease.WriteSet, id string, mark func(context.Context) error, write func(context.Context) error) error { +// PlanWrite 构造演练的写前镜像/来源身份与写集合。Run 与定向回归共用这一份: +// 只有来源身份 ≠ 写前镜像、且两者都是同一次维护切点里密封的真实捕获时, +// 恢复入口(同一份 DS01 身份合同)才接受这次演练。 +func PlanWrite(jobID, scope string, tenantID uint64, knowledgeBaseID string, + preimage, source libraryrelease.BackupRef) (libraryrelease.WriteSet, error) { + if preimage.SnapshotID == "" || source.SnapshotID == "" { + return libraryrelease.WriteSet{}, errors.New("演练缺少写前镜像或来源身份快照") + } + if preimage.SnapshotID == source.SnapshotID { + return libraryrelease.WriteSet{}, errors.New("来源身份与写前镜像相同:拒绝用一份快照冒充两个恢复身份") + } + set := libraryrelease.WriteSet{Version: libraryrelease.WriteSetVersion, JobID: jobID, Target: scope, + TenantID: tenantID, KnowledgeBaseID: knowledgeBaseID, + BackupSnapshotID: preimage.SnapshotID, TargetSnapshotID: source.SnapshotID, + CreatedAt: time.Now().UTC(), Universe: preimage.Universe} + if err := set.ValidateRestoreBinding(preimage.SnapshotID, tenantID, knowledgeBaseID); err != nil { + return libraryrelease.WriteSet{}, err + } + return set, nil +} + +func short(id string) string { + if len(id) <= 12 { + return id + } + return id[:12] +} + +// ledgerClone 只用于把台账交给登记方:Seal 会重排 Rows,调用方随后还要用同一份 +// 内容去恢复,不能让登记方持有可变的同一指针。 +func ledgerClone(set *libraryrelease.WriteSet) *libraryrelease.WriteSet { + copy := *set + copy.Rows = append([]libraryrelease.WriteSetRow(nil), set.Rows...) + return © +} + +// writeDrillCanary 是"写入前先留痕"的唯一实现:seal → 落盘 → 登记 → 标风险 → 写。 +// 任何一步失败都不写目标;登记在标风险之前,恢复入口才拿得到这份写集合。 +func writeDrillCanary(ctx context.Context, root string, ledger *libraryrelease.WriteSet, id string, + mark func(context.Context) error, record func(context.Context) error, write func(context.Context) error) error { ledger.Rows = append(ledger.Rows, libraryrelease.WriteSetRow{Table: "knowledge_tags", ID: id, Existing: false}) if err := ledger.Seal(); err != nil { return err @@ -242,6 +405,11 @@ func writeDrillCanary(ctx context.Context, root string, ledger *libraryrelease.W if _, err := v071.PersistWriteSet(root, ledger); err != nil { return err } + if record != nil { + if err := record(ctx); err != nil { + return err + } + } if err := mark(ctx); err != nil { return err } diff --git a/internal/platform/postgres/library_jobs.go b/internal/platform/postgres/library_jobs.go index 7a4c3fa..abea57d 100644 --- a/internal/platform/postgres/library_jobs.go +++ b/internal/platform/postgres/library_jobs.go @@ -421,7 +421,7 @@ func (s *Store) InterruptedLibraryJobs(ctx context.Context) ([]governance.Librar rows, err := s.db.Query(ctx, ` SELECT lj.id FROM library_jobs lj LEFT JOIN jobs j ON j.id=lj.queue_job_id -WHERE lj.kind IN ('sync','restore','baseline') +WHERE lj.kind IN ('sync','restore','baseline','verify') AND ( -- 业务作业在跑、队列已经没了/停了/租约过期 (lj.state='running' AND (j.id IS NULL OR j.state IN ('failed','completed','cancelled','dead') diff --git a/internal/platform/postgres/library_recovery_identity.go b/internal/platform/postgres/library_recovery_identity.go index e89522e..384ef3a 100644 --- a/internal/platform/postgres/library_recovery_identity.go +++ b/internal/platform/postgres/library_recovery_identity.go @@ -31,7 +31,7 @@ func (s *Store) ValidateLibraryRecoveryQueue(ctx context.Context, q jobs.Job, sc AND q.payload=$3::jsonb AND q.payload->>'window_job_id'=j.id AND q.payload->>'scope'=j.scope AND q.idempotency_key='library.recover:'||j.id AND q.lease_owner=$4 AND q.attempt=$5 AND q.lease_until>clock_timestamp() - AND j.scope=$6 AND j.target_id=$7 AND j.kind IN ('sync','restore','baseline'))`, q.ID, window, q.Payload, q.LeaseOwner, q.Attempt, scope, s.libraryTargetID).Scan(&valid) + AND j.scope=$6 AND j.target_id=$7 AND j.kind IN ('sync','restore','baseline','verify'))`, q.ID, window, q.Payload, q.LeaseOwner, q.Attempt, scope, s.libraryTargetID).Scan(&valid) if e != nil { return e } diff --git a/internal/releaseadapter/v071/library_production_target.go b/internal/releaseadapter/v071/library_production_target.go index 9b3b7a7..d37b275 100644 --- a/internal/releaseadapter/v071/library_production_target.go +++ b/internal/releaseadapter/v071/library_production_target.go @@ -968,6 +968,68 @@ func (t *ProductionLibraryTarget) AdoptWriteSet(set libraryrelease.WriteSet) err return nil } +// ValidateDrillWriteSet 只接受"恢复演练写的隔离 canary 行":表必须是 knowledge_tags、 +// 写前不存在、不是复用。任何其它行都说明这份写集合不是演练产生的,演练入口拒绝回放。 +func ValidateDrillWriteSet(set libraryrelease.WriteSet) error { + if len(set.Rows) == 0 { + return errors.New("演练写集合为空:没有可恢复的 canary") + } + for _, row := range set.Rows { + if row.Table != "knowledge_tags" || row.Existing || row.Reused || row.ID == "" { + return fmt.Errorf("演练写集合包含非 canary 行(表=%s id=%s existing=%v reused=%v):拒绝回放", + row.Table, row.ID, row.Existing, row.Reused) + } + } + return nil +} + +// RestoreDrill 是恢复演练**限定范围**的安全恢复入口。 +// +// 为什么不是直接调用 Restore:Restore 的写集合是"发布执行计划"(v2 合同 + 索引/对象/ +// 本地 ID 映射 + 源快照清单全部行),演练只写过一行隔离 canary,按发布计划构造会伪造 +// 一份从未发生过的发布。这里保留同一份 DS01 身份合同(来源快照 ≠ 写前镜像、同一作业/ +// 目标/预备份)与同一批 Apply 原语:四个阶段都按写前镜像**整段回放**(先清 scope 再 +// 插入归档行),不是"只删 canary";回放后再用整实例指纹证明目标与预备份一致。 +// 有派发闸口的目标不走这条入口:那条路径必须有过派发证据的恢复计划。 +func (t *ProductionLibraryTarget) RestoreDrill(ctx context.Context, backup libraryrelease.BackupRef) error { + if backup.SnapshotID == "" { + return errors.New("恢复需要绑定的预备份快照") + } + if backup.WriteSet == nil { + return errors.New("演练恢复缺少持久化的写集合记录") + } + if err := backup.WriteSet.Verify(); err != nil { + return fmt.Errorf("写集合记录校验失败,拒绝演练恢复:%w", err) + } + if err := backup.WriteSet.ValidateRestoreBinding(backup.SnapshotID, t.scope.TenantID, t.scope.KnowledgeBaseID); err != nil { + return err + } + if err := ValidateDrillWriteSet(*backup.WriteSet); err != nil { + return err + } + if t.config.RestoreDispatch != nil { + return errors.New("目标配置了恢复派发闸口:演练不能用绕过派发证据的回放入口") + } + // 幂等:恢复收尾可能重放(上次恢复到一半崩溃)。这里不要求"目标当前仍与镜像不同"—— + // "写确实发生"由发起方在写入后的 drift 校验证明,而不是由重放入口猜。 + previous := t.preserveChunkSequence + t.preserveChunkSequence = true + defer func() { t.preserveChunkSequence = previous }() + for _, stage := range libraryrelease.Stages() { + if err := t.Apply(ctx, stage, backup.SnapshotID); err != nil { + return fmt.Errorf("演练恢复阶段 %s 失败: %w", stage, err) + } + } + after, err := universeFingerprint(ctx, t.pool) + if err != nil { + return fmt.Errorf("演练恢复后读取整库指纹失败: %w", err) + } + if diff := universeDiff(backup.WriteSet.Universe, after); diff != "" { + return fmt.Errorf("演练恢复后目标与预备份不一致:%s", diff) + } + return nil +} + // Restore 用预备份把生产整库恢复回去:走同一条 Apply 路径,可重复执行。 // // 恢复必须带着"本次写集合 + 写前整实例证据"(写第一笔之前落盘、摘要登记在治理库)。 @@ -983,20 +1045,10 @@ func (t *ProductionLibraryTarget) Restore(ctx context.Context, backup libraryrel if err := backup.WriteSet.Verify(); err != nil { return fmt.Errorf("写集合记录校验失败,拒绝恢复:%w", err) } - if backup.WriteSet.BackupSnapshotID != backup.SnapshotID { - return fmt.Errorf("写集合记录绑定的是预备份 %s,不是 %s", backup.WriteSet.BackupSnapshotID, backup.SnapshotID) - } - if backup.WriteSet.TenantID != t.scope.TenantID || backup.WriteSet.KnowledgeBaseID != t.scope.KnowledgeBaseID { - return fmt.Errorf("写集合记录绑定的是 %d/%s,与目标 %d/%s 不一致", - backup.WriteSet.TenantID, backup.WriteSet.KnowledgeBaseID, t.scope.TenantID, t.scope.KnowledgeBaseID) - } - // 派发绑定只能来自原锚点:来源快照是发布基线,backup 是这份写集的写前镜像。 - // 二者缺失或相同都说明装配没有拿到真实身份,必须在任何写之前失败关闭。 - if backup.WriteSet.TargetSnapshotID == "" || backup.SnapshotID == "" { - return errors.New("恢复缺少来源快照或写前镜像,拒绝建立派发绑定") - } - if backup.WriteSet.TargetSnapshotID == backup.SnapshotID { - return errors.New("来源快照与写前镜像不能是同一份快照,拒绝建立派发绑定") + // 身份合同与演练共用同一份判定(libraryrelease.WriteSet.ValidateRestoreBinding): + // 来源快照 ≠ 写前镜像、同一预备份、同一 tenant/KB,缺一不可。 + if err := backup.WriteSet.ValidateRestoreBinding(backup.SnapshotID, t.scope.TenantID, t.scope.KnowledgeBaseID); err != nil { + return err } t.restoreScope = t.config.RestoreScope t.restoreSourceSnapshot = backup.WriteSet.TargetSnapshotID diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index c3a8815..66766d7 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -154,6 +154,7 @@ const base: ViewProps = { canRestore: false, sync: { canSign: false, pendingGrant: false, onGrant: vi.fn(), canStart: false, pending: false, onStart: vi.fn() }, baseline: { canStart: false, pending: false, onStart: vi.fn() }, + verification: { onAccepted: vi.fn(), onComplete: vi.fn() }, restore: null, restoreNotice: null, pendingRestore: false, diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index 443d69e..c951b18 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -117,6 +117,12 @@ interface ViewProps { sync: ReleaseSyncActions // 目标非空又没有生效版本时,唯一入口是"建立目标权威基线"(管理员)。 baseline: ReleaseBaselineActions + // 恢复验证(真实作业)的接受/终态要回到父页面:父级据此启动轮询并在终态读回 + // overview/维护/闸口,不能只在子面板里刷新自己。 + verification: { + onAccepted: (jobId: string) => void + onComplete: (jobId: string) => void + } // 按版本恢复是管理员动作:工程师看得到条件,但拿不到恢复入口。 canRestore: boolean restore: LibraryRestoreAvailability | null @@ -724,6 +730,7 @@ function ProtectionCard({ baseline, scope, onOpen, + verification, }: { production: LibraryOverview['production'] | null // 没有目标观测时显示的原因(加载中 / 整体读取失败 / 当前范围没数据)。 @@ -737,6 +744,10 @@ function ProtectionCard({ baseline: ReleaseBaselineActions scope: Scope onOpen: () => void + verification: { + onAccepted: (jobId: string) => void + onComplete: (jobId: string) => void + } }) { const backupKnown = prebackup.state === 'known' && prebackup.version // 基线入口只在"目标非空、却没有生效版本"时才是答案;空库走首次同步(预备份+登记), @@ -804,7 +815,12 @@ function ProtectionCard({
恢复条件
{gate.reasons.length === 0 ? '已满足' : `缺 ${gate.reasons.length} 项`}
- + {gate.reasons.length > 0 ? (
    {gate.reasons.map((reason) => ( @@ -853,6 +869,10 @@ interface ReadyProps { onOpen: () => void sync: ReleaseSyncActions baseline: ReleaseBaselineActions + verification: { + onAccepted: (jobId: string) => void + onComplete: (jobId: string) => void + } } function ReadyBody({ @@ -866,6 +886,7 @@ function ReadyBody({ authorization, sync, baseline, + verification, canCapture, canWrite, pendingCapture, @@ -889,6 +910,7 @@ function ReadyBody({ baseline={baseline} scope={view.scope} onOpen={onOpen} + verification={verification} />
    @@ -1412,6 +1434,7 @@ export function ReleaseTargetsView(props: ViewProps) { canWrite, sync, baseline, + verification, canRestore, restore, restoreNotice, @@ -1635,6 +1658,7 @@ export function ReleaseTargetsView(props: ViewProps) { authorization={authorization} sync={sync} baseline={baseline} + verification={verification} canCapture={canCapture} canWrite={canWrite} pendingCapture={pendingCapture} @@ -1793,6 +1817,9 @@ export function ReleaseTargets() { // 只靠 jobs/overview 判断会让页面在投递后立刻停止轮询,恢复完成也停在旧状态(F12)。 const [pendingRecovery, setPendingRecovery] = useState(false) const recoveryStartedAt = useRef(0) + // 恢复验证是独立业务作业:投递后父页面必须自己轮询 overview/维护/闸口, + // 只在子面板刷新证据会让页面停在点击前的状态(审核 B4)。 + const [activeVerifyJob, setActiveVerifyJob] = useState('') const [pendingGrant, setPendingGrant] = useState(false) // 轮询覆盖"投递 → 终态读回":投递中的动作也算在跑(点完到 worker 落库之间 // 有一次空窗,只看已有作业会让页面停在"尚未启动")。终态(成功/失败/取消) @@ -1800,7 +1827,14 @@ export function ReleaseTargets() { const polling = libraryPollingActive({ captures: jobs, overviews, - pending: pendingCapture || pendingSync || pendingBaseline || pendingRestore || pendingSyncGrant || pendingGrant, + pending: + pendingCapture || + pendingSync || + pendingBaseline || + pendingRestore || + pendingSyncGrant || + pendingGrant || + activeVerifyJob !== '', recoveryPending: pendingRecovery, maintenances, }) @@ -1949,6 +1983,16 @@ export function ReleaseTargets() { pending: pendingBaseline, onStart: () => void startBaseline(), }} + verification={{ + onAccepted: (jobId) => { + setActiveVerifyJob(jobId) + void load() + }, + onComplete: () => { + setActiveVerifyJob('') + void load() + }, + }} canRestore={canRestore} restore={restore} restoreNotice={restoreNotice} diff --git a/web/src/RestoreVerificationSection.mounted.test.tsx b/web/src/RestoreVerificationSection.mounted.test.tsx new file mode 100644 index 0000000..7cbf9e2 --- /dev/null +++ b/web/src/RestoreVerificationSection.mounted.test.tsx @@ -0,0 +1,171 @@ +// @vitest-environment jsdom +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import type { Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { RestoreVerificationSection } from './RestoreVerificationSection' + +// 审核 B4/B5 的挂载级回归(GPT-6 的离线挂载反例转成通过断言): +// 全部 fetch 都是 fake,不发任何 8088/云请求;这不是页面验收。 +const target = { + instance: 'fixture', + project: 'fixture', + internal_kb: 'fixture-internal', + external_kb: 'fixture-external', + index: 'fixture-index', + s3_bucket: 'fixture-bucket', + tenant_id: 1, + database_configured: true, +} + +const passed = { + id: 'e', + scope: 'internal', + status: 'passed', + snapshot_id: 'a'.repeat(64), + job_id: 'old-internal-job', + started_at: '2026-01-01T00:00:00Z', + finished_at: '2026-01-01T00:01:00Z', +} + +const internalView = { + scope: 'internal', + verified: true, + reason: '', + target, + latest: passed, + history: [passed], + running: false, +} + +const externalView = { + scope: 'external', + verified: false, + reason: '还没有对当前目标做过恢复验证', + target, + latest: null, + history: [], + running: false, +} + +let container: HTMLDivElement +let root: Root + +const jsonResponse = (body: unknown, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) + +const press = async (label: string) => { + const button = [...container.querySelectorAll('button')].find((item) => item.textContent === label) + if (!button) throw new Error(`button ${label} not found: ${container.textContent}`) + await act(async () => { + button.click() + }) +} + +beforeEach(() => { + ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('RestoreVerificationSection mounted (offline fixtures)', () => { + it('刷新失败时旧证据标成过期,不再显示"已验证(当前目标)"', async () => { + let mode: 'ok' | 'fail' = 'ok' + vi.stubGlobal('fetch', async () => { + if (mode === 'fail') throw new Error('offline fixture read failed') + return jsonResponse(internalView) + }) + await act(async () => { + root.render() + }) + expect(container.textContent).toContain('已验证(当前目标)') + mode = 'fail' + await press('刷新验证状态') + expect(container.textContent).not.toContain('已验证(当前目标)') + expect(container.textContent).toContain('上次观测已过期') + expect(container.textContent).toContain('刷新失败') + expect(container.textContent).toContain('offline fixture read failed') + }) + + it('切 scope 不沿用旧 scope 的通过证据', async () => { + vi.stubGlobal('fetch', async (url: string) => { + if (String(url).includes('scope=external')) return new Promise(() => undefined) + return jsonResponse(internalView) + }) + await act(async () => { + root.render() + }) + expect(container.textContent).toContain('old-internal-job') + await act(async () => { + root.render() + }) + // 新 scope 的观测还没回来:宁可显示"还没读到",也不能沿用旧 scope 的证据。 + expect(container.textContent).toContain('还没有读到验证状态') + expect(container.textContent).not.toContain('fixture-internal') + expect(container.textContent).not.toContain('old-internal-job') + expect(container.textContent).not.toContain('已验证(当前目标)') + }) + + it('接受作业后父页面收到 onAccepted,终态收到 onComplete', async () => { + const onAccepted = vi.fn() + const onComplete = vi.fn() + let started = false + vi.stubGlobal('fetch', async (url: string, init?: { method?: string }) => { + if (init?.method === 'POST') { + started = true + return jsonResponse({ job: { id: 'libverify-internal-1', scope: 'internal' }, reused: false }, 202) + } + if (String(url).includes('scope=internal')) { + if (!started) return jsonResponse({ ...internalView, verified: false, latest: null, history: [] }) + if (container.textContent?.includes('验证执行中')) { + return jsonResponse({ + ...externalView, + scope: 'internal', + latest: { ...passed, status: 'passed', job_id: 'libverify-internal-1' }, + running: false, + }) + } + return jsonResponse({ + ...externalView, + scope: 'internal', + running: true, + job_id: 'libverify-internal-1', + stage: 'restore', + }) + } + return jsonResponse(externalView) + }) + await act(async () => { + root.render( + + ) + }) + await press('跑一次恢复验证(维护隔离)') + expect(onAccepted).toHaveBeenCalledWith('libverify-internal-1') + expect(container.textContent).toContain('验证执行中') + await press('刷新验证状态') + expect(onComplete).toHaveBeenCalledWith('libverify-internal-1') + }) + + it('HTTP 失败按失败显示,不把响应当通过证据', async () => { + vi.stubGlobal('fetch', async () => + jsonResponse({ scope: 'internal', verified: true, reason: '证据读取失败', target, history: [] }, 503) + ) + await act(async () => { + root.render() + }) + expect(container.textContent).toContain('读取验证状态失败') + expect(container.textContent).not.toContain('已验证(当前目标)') + }) +}) diff --git a/web/src/RestoreVerificationSection.test.tsx b/web/src/RestoreVerificationSection.test.tsx index b16d991..1f729e3 100644 --- a/web/src/RestoreVerificationSection.test.tsx +++ b/web/src/RestoreVerificationSection.test.tsx @@ -25,7 +25,13 @@ const view = (overrides: Partial = {}): LibraryVerifica ...overrides, }) -const render = (value: LibraryVerificationView | null, isAdmin = true, pending = false, error: string | null = null) => +const render = ( + value: LibraryVerificationView | null, + isAdmin = true, + pending = false, + error: string | null = null, + stale = false +) => renderToStaticMarkup( undefined} onReload={() => undefined} /> @@ -96,6 +103,40 @@ describe('RestoreVerificationView', () => { expect(html).toContain('跑一次恢复验证') }) + // 审核 B5:刷新失败时旧证据必须明确标成过期,不能再显示"已验证(当前目标)"。 + it('stale 观测不得显示当前已通过,并给出刷新失败说明', () => { + const html = render( + view({ + verified: true, + reason: '', + latest: { + id: 'libverify-internal-job1', + scope: 'internal', + status: 'passed', + snapshot_id: '0f9b39a16738', + job_id: 'libverify-internal-1', + started_at: '2026-11-03T01:00:00Z', + finished_at: '2026-11-03T01:02:00Z', + }, + }), + true, + false, + '读取验证状态失败:offline fixture read failed', + true + ) + expect(html).not.toContain('已验证(当前目标)') + expect(html).toContain('上次观测已过期') + expect(html).toContain('刷新失败') + expect(html).toContain('offline fixture read failed') + }) + + // 审核 B4:在飞作业必须体现在按钮/状态上。 + it('running 时显示执行中并禁用发起按钮', () => { + const html = render(view({ running: true, job_id: 'libverify-internal-9', stage: 'restore' })) + expect(html).toContain('验证执行中') + expect(html).toContain('disabled') + }) + it('投递被拒时显示后端逐条原因', () => { const html = render(view(), true, false, '恢复验证被拒绝:生产写入闸口关闭') expect(html).toContain('生产写入闸口关闭') diff --git a/web/src/RestoreVerificationSection.tsx b/web/src/RestoreVerificationSection.tsx index 85d8233..5055711 100644 --- a/web/src/RestoreVerificationSection.tsx +++ b/web/src/RestoreVerificationSection.tsx @@ -22,6 +22,8 @@ export interface RestoreVerificationViewProps { pending: boolean error: string | null notice: string + // stale 表示这份 view 是上一次成功读取的结果,本次刷新失败,不能当当前结论。 + stale: boolean onRun: () => void onReload: () => void } @@ -33,17 +35,22 @@ const stampText = (value: string): string => { } export function RestoreVerificationView(props: RestoreVerificationViewProps) { - const { view, scope, isAdmin, pending, error, notice, onRun, onReload } = props - const status = verificationStatusText(view) + const { view, scope, isAdmin, pending, error, notice, stale, onRun, onReload } = props + const status = verificationStatusText(view, stale) const latest = view?.latest ?? null const running = Boolean(view?.running) - const tone = view?.verified ? 'green' : latest?.status === 'failed' ? 'red' : 'gray' + const tone = stale ? 'gray' : view?.verified ? 'green' : latest?.status === 'failed' ? 'red' : 'gray' return (

    恢复验证(当前整库目标)

    {status}
    + {stale ? ( +

    + 刷新失败:下面是上一次成功读取的观测,已过期,不能代表当前目标;在刷新成功之前不得据此判断"已验证"。 +

    + ) : null} {view ? (
      {verificationTargetLines(view.target, scope).map((line) => ( @@ -81,36 +88,89 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) { ) } -// 容器:挂载即读一次,之后每 5 秒刷新(验证作业在 worker 里跑,页面只读证据)。 -export function RestoreVerificationSection({ scope, isAdmin }: { scope: string; isAdmin: boolean }) { +// 容器:挂载即读一次,之后每 3 秒刷新(验证作业在 worker 里跑,页面只读证据)。 +// +// 两个身份必须分开: +// - 观测身份 = scope + 请求代次:切 scope 立即清旧结果,晚到的旧响应被丢弃; +// - 作业身份 = 投递返回的 job_id:它驱动父页面的 load/轮询,终态再通知一次。 +export function RestoreVerificationSection({ + scope, + isAdmin, + onAccepted, + onComplete, +}: { + scope: string + isAdmin: boolean + onAccepted?: (jobId: string) => void + onComplete?: (jobId: string) => void +}) { const [view, setView] = useState(null) + const [stale, setStale] = useState(false) const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [notice, setNotice] = useState('') const mounted = useRef(true) + const generation = useRef(0) + const viewScopeRef = useRef('') + const acceptedJob = useRef('') + const sawRunning = useRef(false) + const onCompleteRef = useRef(onComplete) + onCompleteRef.current = onComplete - const reload = useCallback( - (signal?: AbortSignal) => { - fetchLibraryVerification(scope, signal) - .then((next) => { - if (mounted.current && next) setView(next) - }) - .catch(() => undefined) - }, - [scope] - ) + const reload = useCallback((targetScope: string, signal?: AbortSignal) => { + const request = ++generation.current + return fetchLibraryVerification(targetScope, signal) + .then((next) => { + // 晚到的旧 scope/旧代次响应一律丢弃:它不能改写当前 scope 的观测。 + if (!mounted.current || request !== generation.current) return + setView(next) + viewScopeRef.current = next.scope + setStale(false) + setError(null) + const job = acceptedJob.current + if (!job) return + if (next.running) { + sawRunning.current = true + return + } + // 终态:本作业不再出现在在飞集合里(或被证据行点名),通知父页面做最后一次读回。 + if (sawRunning.current || next.job_id === job || next.latest?.job_id === job) { + acceptedJob.current = '' + sawRunning.current = false + onCompleteRef.current?.(job) + } + }) + .catch((err: unknown) => { + if (!mounted.current || request !== generation.current) return + if ((err as Error)?.name === 'AbortError') return + // 刷新失败:同 scope 的旧结果保留但明确标成过期;别的 scope 的旧结果直接丢掉。 + setStale(true) + setView((current) => (viewScopeRef.current === targetScope ? current : null)) + setError(err instanceof Error ? err.message : '读取恢复验证状态失败') + }) + }, []) useEffect(() => { mounted.current = true + // 切 scope:旧 scope 的证据不能继续显示为"当前目标已验证"。 + setView(null) + viewScopeRef.current = '' + setStale(false) + setError(null) + setNotice('') + acceptedJob.current = '' + sawRunning.current = false const controller = new AbortController() - reload(controller.signal) - const timer = setInterval(() => reload(controller.signal), 5000) + void reload(scope, controller.signal) + const timer = setInterval(() => void reload(scope, controller.signal), 3000) return () => { mounted.current = false controller.abort() clearInterval(timer) } - }, [reload]) + // reload 依赖 viewScope(用于判断旧结果是否同 scope),不能让它的身份变化重启轮询。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scope]) const onRun = useCallback(() => { setPending(true) @@ -118,12 +178,16 @@ export function RestoreVerificationSection({ scope, isAdmin }: { scope: string; setNotice('') enqueueLibraryVerification(scope) .then((accepted) => { - setNotice(`已投递恢复验证作业 ${accepted.job_id};进度在作业进度里可见,完成后这里显示证据。`) - reload() + acceptedJob.current = accepted.job_id + sawRunning.current = false + setNotice(`已投递恢复验证作业 ${accepted.job_id};页面会自动刷新进度与终态,父级作业/维护/闸口同步更新。`) + // 父页面必须立刻知道"有一个验证在跑":否则它不会轮询 overview/维护。 + onAccepted?.(accepted.job_id) + void reload(scope) }) .catch((err: unknown) => setError(err instanceof Error ? err.message : '投递恢复验证失败')) .finally(() => setPending(false)) - }, [scope, reload]) + }, [scope, reload, onAccepted]) return ( reload()} + onReload={() => void reload(scope)} /> ) } diff --git a/web/src/restoreVerification.test.ts b/web/src/restoreVerification.test.ts index 02f0689..1995b87 100644 --- a/web/src/restoreVerification.test.ts +++ b/web/src/restoreVerification.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { + fetchLibraryVerification, parseVerificationRecord, parseVerificationView, verificationStatusText, @@ -29,6 +30,31 @@ const passed = { finished_at: '2026-11-03T01:02:00Z', } +const response = (body: unknown, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}) + +afterEach(() => vi.unstubAllGlobals()) + +describe('fetchLibraryVerification 的失败与身份', () => { + it('HTTP 错误必须抛错,不能把响应当证据', async () => { + vi.stubGlobal('fetch', async () => response({ scope: 'internal', reason: '存储不可用' }, 503)) + await expect(fetchLibraryVerification('internal')).rejects.toThrow('存储不可用') + }) + + it('响应 scope 与请求不一致时拒绝', async () => { + vi.stubGlobal('fetch', async () => response({ scope: 'external', verified: true, target, history: [] })) + await expect(fetchLibraryVerification('internal')).rejects.toThrow('不一致') + }) + + it('不可解析的响应按失败处理', async () => { + vi.stubGlobal('fetch', async () => response({ nope: true })) + await expect(fetchLibraryVerification('internal')).rejects.toThrow('不可解析') + }) +}) + describe('restoreVerification parsing', () => { it('只把后端明确 true 当作已验证', () => { const view = parseVerificationView({ scope: 'internal', target, verified: 'true', reason: '', history: [] }) diff --git a/web/src/restoreVerification.ts b/web/src/restoreVerification.ts index 3adc142..4502e6b 100644 --- a/web/src/restoreVerification.ts +++ b/web/src/restoreVerification.ts @@ -35,6 +35,8 @@ export interface LibraryVerificationView { history: LibraryVerificationRecord[] running?: boolean job_id?: string + state?: string + stage?: string } function record(value: unknown): value is Record { @@ -97,19 +99,29 @@ export function parseVerificationView(value: unknown): LibraryVerificationView | history, running: bool(value.running), job_id: text(value.job_id) || undefined, + state: text(value.state) || undefined, + stage: text(value.stage) || undefined, } } -export async function fetchLibraryVerification( - scope: string, - signal?: AbortSignal -): Promise { +// fetchLibraryVerification 只返回**这份 scope** 的观测: +// HTTP 非 2xx、响应不可解析、或响应 scope 与请求不一致都抛错。 +// 调用方据此把"读失败"和"没有证据"分开显示,不能拿旧绿色遮住失败(审核 B5)。 +export async function fetchLibraryVerification(scope: string, signal?: AbortSignal): Promise { const response = await fetch(`/api/v1/library/verifications?scope=${encodeURIComponent(scope)}`, { headers: { Accept: 'application/json' }, signal, }) const value = (await response.json().catch(() => null)) as unknown - return parseVerificationView(value) + if (!response.ok) { + if (record(value) && text(value.reason)) throw new Error(`读取验证状态失败:${text(value.reason)}`) + throw new Error(`读取验证状态失败(HTTP ${response.status})`) + } + const parsed = parseVerificationView(value) + if (!parsed) throw new Error('读取验证状态失败:响应不可解析') + if (parsed.scope !== scope) + throw new Error(`读取验证状态失败:响应 scope=${parsed.scope || '空'} 与请求 ${scope} 不一致`) + return parsed } export interface LibraryVerificationAccepted { @@ -150,8 +162,12 @@ export function verificationTargetLines(target: LibraryVerificationTarget, scope ] } -export function verificationStatusText(view: LibraryVerificationView | null): string { +// verificationStatusText 只在**当前 scope 的新鲜观测**上允许说"已验证"。 +// stale 表示这次刷新失败、显示的是上一次结果:它既不是当前结论,也不能是绿灯。 +export function verificationStatusText(view: LibraryVerificationView | null, stale = false): string { if (!view) return '还没有读到验证状态' + if (stale) return '上次观测已过期(刷新失败)' + if (view.running) return '验证执行中' if (view.verified) return '已验证(当前目标)' if (view.latest) return view.latest.status === 'failed' ? '最近一次未通过' : '未通过' return '还没有验证记录'