From b81fcd07e3fbbd7695397d8867a0cbee94422ef5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?=
Date: Thu, 5 Nov 2026 13:19:27 +0800
Subject: [PATCH] =?UTF-8?q?fix(release):=20=E4=BF=AE=E5=A4=8DtableState?=
=?UTF-8?q?=E6=96=87=E6=9C=AC=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=BF=AB=E7=85=A7?=
=?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=BD=92=E5=B1=9E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
tableState 非 resources 分支恢复直接扫描 text 身份,避免 queryRows 把普通
doc id/UUID/复合键/数字 id 当 JSON canonical 校验而失败;resources 仍使用
F01 精确引用闭包。隔离 PG 覆盖字符串、UUID、复合键与数字 ID。
F11:快照 HTTP 投影补 bindings/wiki_log_entries;作业统计按本作业
target_snapshot_id/snapshot_id 取对应快照,取不到显示未知,不借最新封存;
running/pending/failed/unknown 不再在无 detail 时显示完成。
F03:verifications 支持 snapshot_id 精确查询,返回 snapshot_latest 并只按
该快照回答 verified;抽屉“查看校验记录”按选中快照加载,不借用最新记录。
F08:显示主机探测时间;无真实 next-run 时说明尚未核验下一次执行;最近成功
单列未知,不以最近运行冒充。
验证:Go/前端全量、隔离 PG tableState/F01 对拍与 make verify 17 项通过。
实际 Current→Prepare→Verify ES 集成被共享 ES 磁盘 watermark 阻塞,未冒充。
---
CHANGELOG.md | 22 +++++
internal/httpapi/backup_schedule.go | 3 +
internal/httpapi/library_read_paths_test.go | 22 +++++
internal/httpapi/library_snapshots.go | 4 +
internal/httpapi/library_verify.go | 52 +++++++++---
internal/httpapi/library_verify_test.go | 32 +++++++
.../platform/postgres/library_verification.go | 23 +++++
.../v071/library_production_target.go | 43 ++++++----
.../v071/library_resource_scope_f01_test.go | 16 +++-
.../v071/library_table_state_text_id_test.go | 58 +++++++++++++
web/src/BackupScheduleSection.tsx | 17 +++-
web/src/ReleaseTargets.test.tsx | 43 ++++++++++
web/src/ReleaseTargets.tsx | 85 +++++++++++++++++--
web/src/backupSchedule.ts | 2 +
web/src/restoreVerification.ts | 23 +++++
15 files changed, 404 insertions(+), 41 deletions(-)
create mode 100644 internal/releaseadapter/v071/library_table_state_text_id_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8fcd83f..c86a0eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Changelog
+## 开发快照 0.1.360(tableState 文本身份、快照统计归属与精确验证记录)
+
+### Fixed
+
+- **tableState 文本身份回归**:非 resources 分支恢复直接扫描 `(id)::text`,不再把普通
+ doc id/UUID/复合键/数字 ID 送入 `queryRows` 的 JSON canonical 校验;resources 仍走
+ F01 精确引用快照路径。新增隔离 PG 的字符串、UUID、复合键、数字 ID 回归。
+- **F11 统计**:HTTP 快照投影补 `bindings`、`wiki_log_entries`;组件按当前作业自己的
+ `target_snapshot_id/snapshot_id` 选快照,取不到显示未知,不借最新 test_sealed;阶段
+ 无 detail 时按 running/pending/failed/unknown 展示,不把缺失 completed 当完成。
+- **F03 精确快照验证记录**:GET verifications 支持 `snapshot_id` 精确查询,返回
+ `snapshot_latest` 并只按该快照回答 verified;快照抽屉的“查看校验记录”按选中快照
+ 加载,不借用其他快照/最新 N 条记录。
+- **F08 主机计划展示**:显示主机探测时间;无真实 next-run 时明确“已配置每日…尚未核验
+ 下一次实际执行”;最近成功单列并显示未读取,不以最近运行或 manifest 无 failure 冒充。
+
+### Tests
+
+- 隔离 PG tableState 文本 ID、F01 20k 资源闭包对拍、HTTP totals/精确 snapshot verification
+ 回归;前端作业快照/阶段状态、恢复验证报告与确认块回归。实际 Current→Prepare→Verify
+ ES 集成尝试受共享 ES 磁盘 watermark 阻塞,未冒充通过。
+
## 开发快照 0.1.359(发布管理 F01–F11:资源闭包、状态真源与易用性)
### Fixed
diff --git a/internal/httpapi/backup_schedule.go b/internal/httpapi/backup_schedule.go
index 92e14fe..11918c4 100644
--- a/internal/httpapi/backup_schedule.go
+++ b/internal/httpapi/backup_schedule.go
@@ -98,6 +98,8 @@ type backupScheduleView struct {
HostCronLines []string `json:"host_cron_lines"`
HostCronService string `json:"host_cron_service"`
HostScriptPresent bool `json:"host_script_present"`
+ // HostCheckedAt 是本次真实主机探测时间;没有探测(只读库)时为空。
+ HostCheckedAt string `json:"host_checked_at,omitempty"`
LastRunStamp string `json:"last_run_stamp"`
LastRunCreatedAt string `json:"last_run_created_at"`
@@ -296,6 +298,7 @@ func (config Config) backupSchedulePayload(ctx context.Context, probeHost bool)
if !probeHost {
return view, nil
}
+ view.HostCheckedAt = formatTime(config.Now().UTC())
show, err := config.BackupHost.Show(ctx)
if err != nil {
view.HostReachable = false
diff --git a/internal/httpapi/library_read_paths_test.go b/internal/httpapi/library_read_paths_test.go
index 0944c5f..f54d3c9 100644
--- a/internal/httpapi/library_read_paths_test.go
+++ b/internal/httpapi/library_read_paths_test.go
@@ -266,3 +266,25 @@ func TestLibraryReadPathsRejectTamperedManifest(t *testing.T) {
t.Fatalf("storage must fail closed: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+
+// F11: the HTTP projection must include binding and Wiki log counts; the
+// previous projection silently omitted them and the UI turned absence into 0.
+func TestLibrarySnapshotTotalsIncludeBindingsAndWikiLogs(t *testing.T) {
+ view := librarySnapshotViewOfNoUsage("snap-totals", librarysnapshot.Manifest{
+ Scope: "internal", KnowledgeBaseID: "kb", TenantID: 10000,
+ Parts: []librarysnapshot.Part{
+ {Kind: "database", Name: "knowledges", Count: 4},
+ {Kind: "database", Name: "chunks", Count: 38},
+ {Kind: "database", Name: "resources", Count: 21},
+ {Kind: "database", Name: "resource_bindings", Count: 4},
+ {Kind: "wiki", Name: "wiki_pages", Count: 31},
+ {Kind: "wiki", Name: "wiki_folders", Count: 21},
+ {Kind: "wiki", Name: "wiki_log_entries", Count: 11},
+ },
+ })
+ for key, want := range map[string]int64{"documents": 4, "chunks": 38, "resources": 21, "bindings": 4, "wiki_pages": 31, "wiki_folders": 21, "wiki_log_entries": 11} {
+ if view.Totals[key] != want {
+ t.Fatalf("totals[%s]=%d want=%d (%v)", key, view.Totals[key], want, view.Totals)
+ }
+ }
+}
diff --git a/internal/httpapi/library_snapshots.go b/internal/httpapi/library_snapshots.go
index 12f1f63..096bd3d 100644
--- a/internal/httpapi/library_snapshots.go
+++ b/internal/httpapi/library_snapshots.go
@@ -261,10 +261,14 @@ func librarySnapshotViewOfNoUsage(id string, manifest librarysnapshot.Manifest)
}
case "resources":
snapshot.Totals["resources"] = part.Count
+ case "resource_bindings":
+ snapshot.Totals["bindings"] = part.Count
case "wiki_pages":
snapshot.Totals["wiki_pages"] = part.Count
case "wiki_folders":
snapshot.Totals["wiki_folders"] = part.Count
+ case "wiki_log_entries":
+ snapshot.Totals["wiki_log_entries"] = part.Count
}
}
sort.Slice(snapshot.Parts, func(i, j int) bool {
diff --git a/internal/httpapi/library_verify.go b/internal/httpapi/library_verify.go
index a742ab6..3ffdff7 100644
--- a/internal/httpapi/library_verify.go
+++ b/internal/httpapi/library_verify.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"time"
"knowledge-governance/internal/governance"
@@ -23,16 +24,19 @@ type libraryVerifyRequest struct {
// libraryVerificationView 是页面需要的全部事实:当前目标是谁、最近一次验证结果、
// 证据是否对当前目标成立、以及同步闸口能不能据此放行。
type libraryVerificationView struct {
- Scope string `json:"scope"`
- Target libraryVerificationTarget `json:"target"`
- Verified bool `json:"verified"`
- Reason string `json:"reason"`
- Latest *governance.LibraryRestoreVerification `json:"latest,omitempty"`
- 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"`
+ Scope string `json:"scope"`
+ Target libraryVerificationTarget `json:"target"`
+ Verified bool `json:"verified"`
+ Reason string `json:"reason"`
+ Latest *governance.LibraryRestoreVerification `json:"latest,omitempty"`
+ // SnapshotLatest 只在请求显式带 snapshot_id 时返回:快照报告不能拿 scope
+ // 最近一条代替。
+ SnapshotLatest *governance.LibraryRestoreVerification `json:"snapshot_latest,omitempty"`
+ 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 {
@@ -177,11 +181,39 @@ func libraryVerifications(w http.ResponseWriter, r *http.Request, config Config)
if history, err := store.LibraryRestoreVerifications(r.Context(), scope, 10); err == nil {
view.History = history
}
+ if snapshotID := strings.TrimSpace(r.URL.Query().Get("snapshot_id")); snapshotID != "" {
+ if exact, ok := config.Store.(interface {
+ LibraryRestoreVerificationForSnapshot(context.Context, string, string) (governance.LibraryRestoreVerification, bool, error)
+ }); ok {
+ item, found, exactErr := exact.LibraryRestoreVerificationForSnapshot(r.Context(), scope, snapshotID)
+ if exactErr != nil {
+ view.Reason = "读取该快照的恢复验证记录失败"
+ writeJSON(w, http.StatusServiceUnavailable, view)
+ return
+ }
+ if found {
+ view.SnapshotLatest = &item
+ }
+ }
+ }
view.Verified, view.Reason = governance.LibraryVerificationSatisfied(latest, scope)
if !found {
view.Verified = false
view.Reason = "还没有对当前目标做过恢复验证:点「跑一次恢复验证」(真实备份 → canary → 恢复 → 复核)"
}
+ if snapshotID := strings.TrimSpace(r.URL.Query().Get("snapshot_id")); snapshotID != "" {
+ // 显式按快照查询时,verified 只回答该快照;不能让 scope 最新通过记录
+ // 把另一个未演练的快照染绿。
+ view.Verified = false
+ if view.SnapshotLatest == nil {
+ view.Reason = "该快照没有真实恢复验证记录(未验证;不是读取失败)"
+ } else if view.SnapshotLatest.Status == governance.LibraryVerificationPassed {
+ view.Verified = true
+ view.Reason = ""
+ } else {
+ view.Reason = "该快照最近一次恢复验证未通过"
+ }
+ }
// 在飞作业必须从真实业务行读出来:旧实现只构造 running 字段却从不赋值,
// 页面因此在验收里看不到"已接受/正在执行",按钮也不会进入执行态(审核 B4)。
if jobs, ok := config.Store.(interface {
diff --git a/internal/httpapi/library_verify_test.go b/internal/httpapi/library_verify_test.go
index a34d8ba..f873bc7 100644
--- a/internal/httpapi/library_verify_test.go
+++ b/internal/httpapi/library_verify_test.go
@@ -34,6 +34,15 @@ func (s *verificationStore) LatestLibraryRestoreVerification(_ context.Context,
return governance.LibraryRestoreVerification{}, false, nil
}
+func (s *verificationStore) LibraryRestoreVerificationForSnapshot(_ context.Context, scope, snapshotID string) (governance.LibraryRestoreVerification, bool, error) {
+ for _, item := range s.verifications {
+ if item.Scope == scope && item.SnapshotID == snapshotID {
+ return item, true, nil
+ }
+ }
+ return governance.LibraryRestoreVerification{}, false, nil
+}
+
func (s *verificationStore) LibraryRestoreVerifications(_ context.Context, scope string, limit int) ([]governance.LibraryRestoreVerification, error) {
items := make([]governance.LibraryRestoreVerification, 0, len(s.verifications))
for _, item := range s.verifications {
@@ -131,6 +140,29 @@ func TestLibraryVerificationsWithoutEvidenceIsNotVerified(t *testing.T) {
}
}
+// F03: the selected snapshot must resolve its own evidence, never the scope's
+// newest passed record.
+func TestLibraryVerificationsExactSnapshotDoesNotBorrowLatest(t *testing.T) {
+ store := &verificationStore{verifications: []governance.LibraryRestoreVerification{
+ {ID: "verify-new", Scope: "internal", Status: governance.LibraryVerificationPassed,
+ SnapshotID: "snap-new", JobID: "job-new", FinishedAt: time.Now().UTC()},
+ {ID: "verify-old", Scope: "internal", Status: governance.LibraryVerificationFailed,
+ SnapshotID: "snap-old", JobID: "job-old", Failure: "old restore mismatch",
+ FinishedAt: time.Now().UTC().Add(-time.Hour)},
+ }}
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal&snapshot_id=snap-old", nil)
+ libraryVerifications(recorder, request, Config{Store: store})
+ body := decodeBody(t, recorder)
+ exact, ok := body["snapshot_latest"].(map[string]any)
+ if !ok || exact["snapshot_id"] != "snap-old" || exact["status"] != "failed" {
+ t.Fatalf("snapshot_latest=%v body=%v", body["snapshot_latest"], body)
+ }
+ if body["verified"] != false {
+ t.Fatalf("old failed snapshot must not inherit latest passed: %v", body["verified"])
+ }
+}
+
func TestLibraryVerificationsWithPassedEvidenceIsVerified(t *testing.T) {
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{
ID: "libverify-internal-job1", Scope: "internal", Status: governance.LibraryVerificationPassed,
diff --git a/internal/platform/postgres/library_verification.go b/internal/platform/postgres/library_verification.go
index e42ab37..86cefb9 100644
--- a/internal/platform/postgres/library_verification.go
+++ b/internal/platform/postgres/library_verification.go
@@ -71,6 +71,29 @@ LIMIT 1`, s.libraryTargetID, scope).Scan(&item.ID, &item.Scope, &item.Status, &i
}
// LibraryRestoreVerifications 是页面用的轻量清单(最近 N 条),按目标过滤。
+
+// LibraryRestoreVerificationForSnapshot 只按本次精确快照查证据,不能用最近 N 条
+// 扫描代替:旧快照的报告可能已经掉出列表,但仍是该快照真实的恢复演练记录。
+func (s *Store) LibraryRestoreVerificationForSnapshot(ctx context.Context, scope, snapshotID string) (governance.LibraryRestoreVerification, bool, error) {
+ var item governance.LibraryRestoreVerification
+ var report []byte
+ err := s.db.QueryRow(ctx, `
+SELECT id, scope, status, snapshot_id, job_id, failure, report, started_at, finished_at
+FROM library_restore_verifications
+WHERE target_id=$1 AND scope=$2 AND snapshot_id=$3
+ORDER BY finished_at DESC, created_at DESC
+LIMIT 1`, s.libraryTargetID, scope, snapshotID).Scan(&item.ID, &item.Scope, &item.Status,
+ &item.SnapshotID, &item.JobID, &item.Failure, &report, &item.StartedAt, &item.FinishedAt)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return governance.LibraryRestoreVerification{}, false, nil
+ }
+ return governance.LibraryRestoreVerification{}, false, fmt.Errorf("load snapshot restore verification: %w", err)
+ }
+ item.Report = decodeEvidenceObject(report)
+ return item, true, nil
+}
+
func (s *Store) LibraryRestoreVerifications(ctx context.Context, scope string, limit int) ([]governance.LibraryRestoreVerification, error) {
if limit <= 0 {
limit = 10
diff --git a/internal/releaseadapter/v071/library_production_target.go b/internal/releaseadapter/v071/library_production_target.go
index fc9670a..495006c 100644
--- a/internal/releaseadapter/v071/library_production_target.go
+++ b/internal/releaseadapter/v071/library_production_target.go
@@ -329,32 +329,39 @@ func (t *ProductionLibraryTarget) tableState(ctx context.Context, table string)
return 0, "", fmt.Errorf("读取生产 %s 失败: %w", table, err)
}
rows = items
- } else {
- exporter := PostgresExporter{db: t.pool}
- items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
+ }
+ // Non-resource identities are plain text (UUID, doc id, numeric id, or a
+ // composite key), not JSON. Scanning them through queryRows would run
+ // canonicalJSON on text and fail on every non-empty table.
+ if table != "resources" {
+ rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
"("+identityExpr(table)+")::text", table, scopeWhere(table)), identity.TenantID, identity.KnowledgeBaseID)
if err != nil {
return 0, "", fmt.Errorf("读取生产 %s 失败", table)
}
- rows = items
+ defer rows.Close()
+ ids := make([]string, 0, 64)
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return 0, "", err
+ }
+ ids = append(ids, id)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, "", err
+ }
+ return int64(len(ids)), digestIDs(ids), nil
}
ids := make([]string, 0, len(rows))
for _, raw := range rows {
- var id string
- if table == "resources" {
- var value struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(raw, &value); err != nil || value.ID == "" {
- return 0, "", fmt.Errorf("读取生产 %s 身份失败", table)
- }
- id = value.ID
- } else {
- if err := json.Unmarshal(raw, &id); err != nil {
- return 0, "", err
- }
+ var value struct {
+ ID string `json:"id"`
}
- ids = append(ids, id)
+ if err := json.Unmarshal(raw, &value); err != nil || value.ID == "" {
+ return 0, "", fmt.Errorf("读取生产 %s 身份失败", table)
+ }
+ ids = append(ids, value.ID)
}
return int64(len(ids)), digestIDs(ids), nil
}
diff --git a/internal/releaseadapter/v071/library_resource_scope_f01_test.go b/internal/releaseadapter/v071/library_resource_scope_f01_test.go
index 9e60938..ee8bc83 100644
--- a/internal/releaseadapter/v071/library_resource_scope_f01_test.go
+++ b/internal/releaseadapter/v071/library_resource_scope_f01_test.go
@@ -14,8 +14,10 @@ import (
// F01 regression: a small target library inside a large tenant must resolve
// resources from exact binding ids + body handles, not by scanning every
-// tenant resource with a per-row body predicate. The old predicate is kept
-// here only as an oracle for equality and timing; never as the product path.
+// tenant resource with a per-row body predicate. The fixture has one active
+// target document/chunk (plus one deleted and one foreign-tenant row), not the
+// historical 4/38 target; the 20,000 unrelated resources are the scale input.
+// The old predicate is kept only as an oracle for equality and timing.
func TestLibraryResourceClosureIsBoundedByTargetReferences(t *testing.T) {
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
if dsn == "" {
@@ -106,7 +108,15 @@ func TestLibraryResourceClosureIsBoundedByTargetReferences(t *testing.T) {
if len(newIDs) != 2 {
t.Fatalf("expected bound + body-only resources, got %v", newIDs)
}
- t.Logf("unrelated_resources=%d old_scan=%s new_export=%s resources=%v", unrelated, oldElapsed, newElapsed, newIDs)
+ var activeDocs, activeChunks int
+ if err := db.QueryRow(ctx, `SELECT count(*) FROM knowledges WHERE tenant_id=10000 AND knowledge_base_id='kb-safe' AND deleted_at IS NULL`).Scan(&activeDocs); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.QueryRow(ctx, `SELECT count(*) FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id WHERE k.tenant_id=10000 AND k.knowledge_base_id='kb-safe' AND k.deleted_at IS NULL AND c.deleted_at IS NULL`).Scan(&activeChunks); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("target_docs=%d target_chunks=%d unrelated_resources=%d old_scan=%s new_export=%s resources=%v",
+ activeDocs, activeChunks, unrelated, oldElapsed, newElapsed, newIDs)
if newElapsed >= oldElapsed {
t.Fatalf("F01 path did not improve: old=%s new=%s", oldElapsed, newElapsed)
}
diff --git a/internal/releaseadapter/v071/library_table_state_text_id_test.go b/internal/releaseadapter/v071/library_table_state_text_id_test.go
new file mode 100644
index 0000000..3924555
--- /dev/null
+++ b/internal/releaseadapter/v071/library_table_state_text_id_test.go
@@ -0,0 +1,58 @@
+package v071
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// F01/F02 regression: tableState reads normal text identities (doc id, UUID,
+// composite relation key, numeric local id) without treating them as JSON.
+// The fixture is session-local temp tables; it never writes a persistent table.
+func TestTableStateReadsTextIdentities(t *testing.T) {
+ dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
+ if dsn == "" {
+ t.Skip("requires explicitly isolated kg_library_test_ PostgreSQL database")
+ }
+ cfg, err := pgxpool.ParseConfig(dsn)
+ if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "kg_library_test_") {
+ t.Fatal("isolated database required")
+ }
+ cfg.MaxConns = 1
+ cfg.ConnConfig.RuntimeParams["search_path"] = "pg_temp"
+ cfg.ConnConfig.RuntimeParams["statement_timeout"] = "10000"
+ ctx := context.Background()
+ pool, err := pgxpool.NewWithConfig(ctx, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pool.Close()
+ _, err = pool.Exec(ctx, `
+CREATE TEMP TABLE knowledges(id text,tenant_id bigint,knowledge_base_id text);
+CREATE TEMP TABLE knowledge_tag_relations(knowledge_id text,tag_id text);
+CREATE TEMP TABLE wiki_log_entries(id bigint,tenant_id bigint,knowledge_base_id text);
+INSERT INTO knowledges VALUES ('doc-parent',10000,'kb-parent'),
+ ('f643a7e7-bbb9-4209-9033-fca7f76f011b',10000,'kb-parent');
+INSERT INTO knowledge_tag_relations VALUES ('doc-parent','tag-parent'),
+ ('f643a7e7-bbb9-4209-9033-fca7f76f011b','tag-two');
+INSERT INTO wiki_log_entries VALUES (1,10000,'kb-parent'),(2,10000,'kb-parent');`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ target := &ProductionLibraryTarget{pool: pool, scope: ProductionScope{TenantID: 10000, KnowledgeBaseID: "kb-parent"}}
+ for _, fixture := range []struct {
+ table string
+ count int64
+ }{{"knowledges", 2}, {"knowledge_tag_relations", 2}, {"wiki_log_entries", 2}} {
+ count, _, err := target.tableState(ctx, fixture.table)
+ if err != nil {
+ t.Fatalf("%s plain identity rejected: %v", fixture.table, err)
+ }
+ if count != fixture.count {
+ t.Fatalf("%s count=%d want=%d", fixture.table, count, fixture.count)
+ }
+ }
+}
diff --git a/web/src/BackupScheduleSection.tsx b/web/src/BackupScheduleSection.tsx
index 62d5684..b88f562 100644
--- a/web/src/BackupScheduleSection.tsx
+++ b/web/src/BackupScheduleSection.tsx
@@ -126,9 +126,20 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
备份脚本
{scriptLine}
主机 cron
- {schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
+
+ {schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
+ {schedule.host_checked_at
+ ? ` · 状态读取于 ${formatBackupMoment(schedule.host_checked_at)}`
+ : ' · 状态读取时间未知'}
+
下次计划(主机读回)
- {schedule.host_next_run || '未读取(不以历史下发时间推算)'}
+
+ {schedule.host_next_run
+ ? schedule.host_next_run
+ : schedule.host_schedule_enabled
+ ? `已配置每日 ${schedule.host_schedule_time};尚未核验下一次实际执行(不以历史下发时间推算)`
+ : '未启用定时或未从主机读到计划'}
+
最近一次运行
{schedule.last_run_stamp || '—'}
@@ -139,6 +150,8 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
: ' · 有失败/读取错误'
: ''}
+ 最近一次成功
+ 未从主机读取(不能用最近一次运行代替成功;失败库/读取错误见下方)
备份耗时 / 体积
{schedule.last_run_stamp
diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx
index cd59f74..a534bd1 100644
--- a/web/src/ReleaseTargets.test.tsx
+++ b/web/src/ReleaseTargets.test.tsx
@@ -680,6 +680,49 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
expect(drawer).toContain('当前筛选下没有已登记版本')
})
+ it('统计数据只取当前作业快照,并按真实阶段状态展示', () => {
+ const jobSnapshot: LibrarySnapshot = {
+ ...internalSnapshot,
+ snapshot_id: 'old-job-snapshot',
+ totals: {
+ documents: 4,
+ chunks: 38,
+ resources: 21,
+ bindings: 4,
+ wiki_pages: 31,
+ wiki_folders: 21,
+ wiki_log_entries: 11,
+ },
+ }
+ const job: LibraryJobRecord = {
+ ...persistedSeal,
+ id: 'libsync-old',
+ kind: 'sync',
+ target_snapshot_id: jobSnapshot.snapshot_id,
+ stage: 'database',
+ stages: [
+ {
+ stage: 'database',
+ state: 'running',
+ total: null,
+ completed: null,
+ bytes_total: null,
+ bytes_done: null,
+ detail: '',
+ updated_at: '2026-11-05T01:00:00Z',
+ },
+ ],
+ }
+ const html = render({
+ page: { ...page, snapshots: [internalSnapshot, jobSnapshot] },
+ overview: { ...overview, job, jobs: [job], test_sealed: internalSnapshot },
+ })
+ expect(html).toContain('文档 4 · 分块 38 · 资源 21 · 绑定 4')
+ expect(html).toContain('页面 31 · 目录 21 · 日志 11')
+ expect(html).toContain('执行中;执行器尚未提供该分项明细')
+ expect(html).not.toContain('已完成 0 / 0')
+ })
+
it('同步中布局显示四类数据卡与明细,且不编造百分比', () => {
const html = render({
jobs: [runningJob],
diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx
index a9fa67f..b556d06 100644
--- a/web/src/ReleaseTargets.tsx
+++ b/web/src/ReleaseTargets.tsx
@@ -31,6 +31,7 @@ import {
import { useOptionalAuth } from './auth'
import './releaseTargets.css'
import { RestoreVerificationSection } from './RestoreVerificationSection'
+import { fetchSnapshotVerification } from './restoreVerification'
// 发布管理:按已确认稿实现——顶部两个知识库页签,下面是**一个**作业区。
//
@@ -1064,9 +1065,20 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
const running = job?.state === 'running' || job?.state === 'pending'
const stage = (name: string) => dataJob?.stages.find((item) => item.stage === name)
- const sealed = view.overview?.test_sealed
+ // F11: counts must belong to the snapshot this job actually used. A newly
+ // sealed test version must not decorate an older sync/restore/verify job.
+ const jobSnapshotID = job?.target_snapshot_id || job?.snapshot_id || ''
+ const sealed = jobSnapshotID
+ ? view.history.find((item) => item.snapshot_id === jobSnapshotID)
+ : job
+ ? undefined
+ : view.overview?.test_sealed
const breakdown = (keys: [string, string][]) =>
- sealed ? keys.map(([key, label]) => `${label} ${sealed.totals[key] ?? 0}`).join(' · ') : ''
+ sealed
+ ? keys
+ .map(([key, label]) => `${label} ${typeof sealed.totals[key] === 'number' ? sealed.totals[key] : '未知'}`)
+ .join(' · ')
+ : ''
const cards = [
// F11: these totals are storage rows, not business document/page counts.
{
@@ -1185,9 +1197,17 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
{card.row?.detail
? card.row.detail
: card.row
- ? card.row.total != null
- ? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
- : '该分项已完成,执行器未提供可核对总量'
+ ? card.row.state === 'running'
+ ? '执行中;执行器尚未提供该分项明细'
+ : card.row.state === 'pending'
+ ? '等待执行;不能按完成展示'
+ : card.row.state === 'failed'
+ ? '失败;没有可核对完成量'
+ : card.row.state === 'succeeded'
+ ? card.row.total != null
+ ? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
+ : '该分项已完成,执行器未提供可核对总量'
+ : '状态未知;不按完成展示'
: dataJob
? '本次作业没有产生该分项阶段记录'
: '目标同步执行链未接通,分项不会产生数据'}
@@ -1267,6 +1287,57 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
)
}
+// F03: exact snapshot evidence. This component deliberately reads
+// snapshot_latest, not latest: a passed record for another snapshot must not
+// make this one look verified.
+function SnapshotVerification({ scope, snapshotID }: { scope: Scope; snapshotID: string }) {
+ const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading')
+ const [record, setRecord] = useState>['snapshot_latest']>(null)
+ const [error, setError] = useState('')
+ useEffect(() => {
+ const controller = new AbortController()
+ setState('loading')
+ setRecord(null)
+ setError('')
+ fetchSnapshotVerification(scope, snapshotID, controller.signal)
+ .then((value) => {
+ setRecord(value.snapshot_latest ?? null)
+ setState('loaded')
+ })
+ .catch((reason: unknown) => {
+ if ((reason as Error)?.name === 'AbortError') return
+ setError(reason instanceof Error ? reason.message : '读取该快照的恢复验证记录失败')
+ setState('error')
+ })
+ return () => controller.abort()
+ }, [scope, snapshotID])
+ return (
+
+ 查看校验记录
+ {state === 'loading' ? 正在读取该快照的恢复验证记录…
: null}
+ {state === 'error' ? {error}
: null}
+ {state === 'loaded' && !record ? (
+ 该快照没有真实恢复验证记录;未验证,原因不是加载失败。
+ ) : null}
+ {record ? (
+
+
+ 该快照最近一次:{record.status === 'passed' ? '通过' : '未通过'} · {record.finished_at} · 作业{' '}
+ {record.job_id}
+
+ {record.failure ?
原因:{record.failure}
: null}
+ {record.report ? (
+
+ 本次恢复验证报告
+ {JSON.stringify(record.report, null, 2)}
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
+
interface DrawerProps {
view: ScopeView
page: LibrarySnapshotList | null
@@ -1534,9 +1605,7 @@ function Drawer({
以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。
-
+
{canRestore && restore && restore.authorization.state !== 'active' ? (