diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c5d00..1bba9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 开发快照(Wiki 撤销贡献:逐页语义比对 + 维护窗口真实呈现) + +### Added + +- **Wiki 贡献级差异**(`internal/librarydiff/wiki.go`):以 slug 为身份,把每页拆成 + 正文 / 来源文档 / 引用分块 / 出边 / 入边 / 目录/状态 分别取集合比较,输出 + `sources_added` / `sources_removed` / `citations_before→after` / `changed`。 + 删除一篇与别人共同贡献同一页的文档后,页面通常仍是「更新」,只看页面级增删改会显示成 + 「无变化」——现在能指出这份贡献是从哪一页、哪几类语义上被摘掉的。两端同时接受 JSON 数组 + 与字符串化数组,读不出来直接报错,不把「读不到」当成「没有来源」。 +- **接口 `GET /api/v1/library/overview` 的 `diff.wiki` 增加逐页明细**:`pages`(最多 50 页, + 超出置 `pages_truncated`)、`sources_added/removed`、`base_count/target_count`;页面级计数 + 保留,说明文字明确「重算发生在源侧,本平台只如实比对与保留结果」。 +- **发布管理页面:目标维护面板**(`MaintenanceCard`):显示维护级别(实例级/库级)、持有 + scope+作业、阶段、入口开/关、服务、开始/释放时间、作业进度(阶段名中文化,未映射原样显示)、 + 恢复错误与处理记录;只有窗口仍持有且作业失败时才出现「按预备份恢复」,动作走 + `POST /api/v1/library/maintenance/recover`。没有维护记录时明确写「未接入 + fail closed」, + 不画成已就绪。 +- **变更页展示逐页贡献差异**:每条页面一行,直接写「更新 发布闸门 · 正文/来源文档/引用分块 · + 来源 -1 · 引用 2→1」。 + +### Fixed + +- `LibraryJobRecord.kind` 之前只有 `seal | sync`,恢复作业(restore)在类型上不存在; + 补上后页面不再把恢复作业当成未知类型。 + +### Notes + +- 本次真实验收(kgmaint 专用实例 + 原版 WeKnora API)与本快照之外的部署计划见 + `docs/library-acceptance-matrix.md` 与交付报告;矩阵按「已完成 / 缺代码 / 未跑 / 需授权」 + 重排,不再在顶部叠报告。 + ## 开发快照(后台静默必须在关入口之后证明,且要能复核) ### Fixed diff --git a/internal/httpapi/library_flow.go b/internal/httpapi/library_flow.go index d9ba354..b35d328 100644 --- a/internal/httpapi/library_flow.go +++ b/internal/httpapi/library_flow.go @@ -106,6 +106,58 @@ type libraryWikiDiffView struct { // Sample 只列前若干页,避免把整库 Wiki 列表塞进接口。 Sample []string `json:"sample"` Note string `json:"note"` + // Pages 是逐页的**贡献级**明细:哪一页、变了哪几类语义、掉/加了哪些来源文档。 + // 页面级计数只看增删改;撤销一份共享贡献时页面通常仍是"更新", + // 只有这里的 sources_removed / changed 才能证明那份贡献确实被摘掉了。 + Pages []libraryWikiPageView `json:"pages,omitempty"` + PagesTruncated bool `json:"pages_truncated,omitempty"` + SourcesAdded int `json:"sources_added"` + SourcesRemoved int `json:"sources_removed"` + BaseCount int `json:"base_count"` + TargetCount int `json:"target_count"` +} + +type libraryWikiPageView struct { + Slug string `json:"slug"` + Title string `json:"title"` + PageType string `json:"page_type,omitempty"` + Status string `json:"status"` // added | updated | removed + Changed []string `json:"changed"` + SourcesAdded []string `json:"sources_added,omitempty"` + SourcesRemoved []string `json:"sources_removed,omitempty"` + CitationsBefore int `json:"citations_before"` + CitationsAfter int `json:"citations_after"` +} + +// wikiPageLimit 是接口一次最多返回的页数;超出部分只给计数,不给半截明细。 +const wikiPageLimit = 50 + +func wikiViewFrom(result librarydiff.WikiResult, limit int) *libraryWikiDiffView { + if limit <= 0 { + limit = wikiPageLimit + } + view := &libraryWikiDiffView{State: result.State, Added: result.Added, Updated: result.Updated, + Removed: result.Removed, Sample: []string{}, SourcesAdded: result.SourcesAdded, + SourcesRemoved: result.SourcesRemoved, BaseCount: result.BaseCount, TargetCount: result.TargetCount, + Note: "逐页比对正文、来源文档、引用分块、出入边与目录;语义重算发生在源侧,本平台只如实比对与保留结果"} + for index, page := range result.Pages { + if index >= limit { + view.PagesTruncated = true + break + } + view.Pages = append(view.Pages, libraryWikiPageView{Slug: page.Slug, Title: page.Title, + PageType: page.PageType, Status: page.Status, Changed: librarydiff.ChangedLabels(page.Changed), + SourcesAdded: page.SourcesAdded, SourcesRemoved: page.SourcesRemoved, + CitationsBefore: page.CitationsBefore, CitationsAfter: page.CitationsAfter}) + } + for _, page := range view.Pages { + if len(view.Sample) >= 20 { + break + } + label := map[string]string{"added": "新增", "updated": "更新", "removed": "撤销"}[page.Status] + view.Sample = append(view.Sample, label+" "+page.Title) + } + return view } // wikiDiffOf 比较两份快照的 wiki_pages 行数组(页面级)。 @@ -148,32 +200,11 @@ func wikiDiffOf(archive *librarysnapshot.Archive, baseID, targetID string) *libr if reason != "" { return unknown("测试版本:" + reason) } - result, err := librarydiff.Compare(base, target) + result, err := librarydiff.CompareWiki(base, target) if err != nil { - return unknown("Wiki 页面比对失败:" + err.Error()) + return unknown("Wiki 逐页比对失败:" + err.Error()) } - view := &libraryWikiDiffView{State: "computed", Added: len(result.Added), Updated: len(result.Updated), - Removed: len(result.Removed), Sample: []string{}, - Note: "页面级差异:只说明页面被增/改/撤,不代表实体、概念、边或引用已重算"} - for _, item := range result.Added { - if len(view.Sample) >= 20 { - break - } - view.Sample = append(view.Sample, "新增 "+item.Title) - } - for _, item := range result.Updated { - if len(view.Sample) >= 20 { - break - } - view.Sample = append(view.Sample, "更新 "+item.Title) - } - for _, item := range result.Removed { - if len(view.Sample) >= 20 { - break - } - view.Sample = append(view.Sample, "撤销 "+item.Title) - } - return view + return wikiViewFrom(result, wikiPageLimit) } // computeLibraryDiff 用归档里的 knowledges 行数组比较两个快照: diff --git a/internal/httpapi/library_wiki_diff_test.go b/internal/httpapi/library_wiki_diff_test.go new file mode 100644 index 0000000..0464d42 --- /dev/null +++ b/internal/httpapi/library_wiki_diff_test.go @@ -0,0 +1,51 @@ +package httpapi + +import ( + "strings" + "testing" + + "knowledge-governance/internal/librarydiff" +) + +func TestWikiViewFromKeepsContributionDetail(t *testing.T) { + result := librarydiff.WikiResult{ + State: "computed", Added: 0, Updated: 1, Removed: 1, SourcesAdded: 0, SourcesRemoved: 2, + BaseCount: 2, TargetCount: 1, + Pages: []librarydiff.WikiContribution{ + {Slug: "entity/gate", Title: "发布闸门", PageType: "entity", Status: "updated", + Changed: []string{"content", "sources", "citations"}, SourcesRemoved: []string{"doc-a"}, + CitationsBefore: 2, CitationsAfter: 1}, + {Slug: "entity/only-a", Title: "只属于 A", PageType: "entity", Status: "removed", + Changed: []string{"removed"}, SourcesRemoved: []string{"doc-a"}}, + }, + } + view := wikiViewFrom(result, 0) + if view.State != "computed" || view.Updated != 1 || view.Removed != 1 || view.SourcesRemoved != 2 { + t.Fatalf("view: %+v", view) + } + if len(view.Pages) != 2 { + t.Fatalf("pages: %+v", view.Pages) + } + kept := view.Pages[0] + if kept.Status != "updated" || len(kept.Changed) != 3 || kept.Changed[0] != "正文" { + t.Fatalf("changed labels: %+v", kept) + } + if len(kept.SourcesRemoved) != 1 || kept.SourcesRemoved[0] != "doc-a" { + t.Fatalf("sources: %+v", kept) + } + if !strings.Contains(view.Note, "来源") { + t.Fatalf("note should explain what is compared: %q", view.Note) + } +} + +func TestWikiViewFromCapsPagesAndSaysSo(t *testing.T) { + result := librarydiff.WikiResult{State: "computed", BaseCount: 0} + for _, slug := range []string{"a", "b", "c"} { + result.Pages = append(result.Pages, librarydiff.WikiContribution{Slug: slug, Status: "added"}) + result.Added++ + } + view := wikiViewFrom(result, 2) + if len(view.Pages) != 2 || !view.PagesTruncated { + t.Fatalf("cap: pages=%d truncated=%v", len(view.Pages), view.PagesTruncated) + } +} diff --git a/internal/librarydiff/wiki.go b/internal/librarydiff/wiki.go new file mode 100644 index 0000000..40b3839 --- /dev/null +++ b/internal/librarydiff/wiki.go @@ -0,0 +1,269 @@ +// Wiki 贡献级差异:页面身份是 slug,语义拆成「正文 / 来源文档 / 引用分块 / 出边 / 入边 / 目录」。 +// +// 为什么不能只比页面级:删除一篇与别人共同贡献同一页的文档后,页面还在、名字还在, +// 只有那篇文档的正文与引用被摘掉。页面级差异会把这种"语义重算"显示成"无变化", +// 操作员据此会误判撤销没有生效。这里把每类语义单独取集合再比,撤销的那份贡献 +// 必须能在 sources/citations/links/content 上被指出来。 +// +// 边界:本包只读归档里的 wiki_pages 行,**不重算** WeKnora 的实体/概念/边。 +// 语义重算发生在源侧,治理侧能做的是把源侧算完的结果如实比对与保留。 +package librarydiff + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" +) + +// volatileWikiFields 是每次保存都会变、但不代表页面语义变化的列。 +var volatileWikiFields = []string{"updated_at", "updated_by", "created_at", "view_count"} + +type WikiContribution struct { + Slug string `json:"slug"` + Title string `json:"title"` + PageType string `json:"page_type"` + Status string `json:"status"` // added | updated | removed + // Changed 是发生变化(或被删除)的语义类别:content / sources / citations / + // out_links / in_links / category / status / page_type。撤销一份贡献时, + // sources 与 citations 必须出现,正文没变也要标出来。 + Changed []string `json:"changed"` + SourcesAdded []string `json:"sources_added"` + SourcesRemoved []string `json:"sources_removed"` + CitationsBefore int `json:"citations_before"` + CitationsAfter int `json:"citations_after"` + CitationsAdded []string `json:"citations_added,omitempty"` + CitationsRemoved []string `json:"citations_removed,omitempty"` + LinksAdded []string `json:"links_added,omitempty"` + LinksRemoved []string `json:"links_removed,omitempty"` + // ContentDigestBefore/After 让证据可以复核"正文真的变了",而不是只凭长度。 + ContentDigestBefore string `json:"content_digest_before,omitempty"` + ContentDigestAfter string `json:"content_digest_after,omitempty"` +} + +type WikiResult struct { + State string `json:"state"` // computed + Added int `json:"added"` + Updated int `json:"updated"` + Removed int `json:"removed"` + // SourcesAdded/Removed 是所有页面来源变化的合计,便于一眼看到"撤销了谁的贡献"。 + SourcesAdded int `json:"sources_added"` + SourcesRemoved int `json:"sources_removed"` + BaseCount int `json:"base_count"` + TargetCount int `json:"target_count"` + Pages []WikiContribution `json:"pages"` +} + +type wikiPage struct { + slug string + title string + pageType string + status string + content string + sources []string + citations []string + outLinks []string + inLinks []string + category string +} + +// CompareWiki 以 base 为基准(当前生产版本),target 为拟发布版本,逐页比对语义。 +func CompareWiki(base, target []map[string]any) (WikiResult, error) { + basePages, err := indexWiki(base) + if err != nil { + return WikiResult{}, err + } + targetPages, err := indexWiki(target) + if err != nil { + return WikiResult{}, err + } + result := WikiResult{State: "computed", Pages: []WikiContribution{}, + BaseCount: len(basePages), TargetCount: len(targetPages)} + for slug, next := range targetPages { + previous, ok := basePages[slug] + if !ok { + result.Added++ + result.Pages = append(result.Pages, WikiContribution{ + Slug: next.slug, Title: next.title, PageType: next.pageType, Status: "added", + Changed: []string{"added"}, SourcesAdded: next.sources, + CitationsAfter: len(next.citations), ContentDigestAfter: digestOf(next.content), + }) + result.SourcesAdded += len(next.sources) + continue + } + change, changed := diffWikiPage(previous, next) + if !changed { + continue + } + result.Updated++ + result.SourcesAdded += len(change.SourcesAdded) + result.SourcesRemoved += len(change.SourcesRemoved) + result.Pages = append(result.Pages, change) + } + for slug, previous := range basePages { + if _, ok := targetPages[slug]; ok { + continue + } + result.Removed++ + result.Pages = append(result.Pages, WikiContribution{ + Slug: previous.slug, Title: previous.title, PageType: previous.pageType, Status: "removed", + Changed: []string{"removed"}, SourcesRemoved: previous.sources, + CitationsBefore: len(previous.citations), ContentDigestBefore: digestOf(previous.content), + }) + result.SourcesRemoved += len(previous.sources) + } + sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug }) + return result, nil +} + +func diffWikiPage(previous, next wikiPage) (WikiContribution, bool) { + change := WikiContribution{Slug: next.slug, Title: next.title, PageType: next.pageType, Status: "updated", + CitationsBefore: len(previous.citations), CitationsAfter: len(next.citations), + ContentDigestBefore: digestOf(previous.content), ContentDigestAfter: digestOf(next.content)} + change.SourcesAdded, change.SourcesRemoved = setDiff(next.sources, previous.sources), setDiff(previous.sources, next.sources) + change.CitationsAdded, change.CitationsRemoved = setDiff(next.citations, previous.citations), setDiff(previous.citations, next.citations) + change.LinksAdded, change.LinksRemoved = setDiff(next.outLinks, previous.outLinks), setDiff(previous.outLinks, next.outLinks) + if previous.content != next.content { + change.Changed = append(change.Changed, "content") + } + if len(change.SourcesAdded)+len(change.SourcesRemoved) > 0 { + change.Changed = append(change.Changed, "sources") + } + if len(change.CitationsAdded)+len(change.CitationsRemoved) > 0 { + change.Changed = append(change.Changed, "citations") + } + if len(change.LinksAdded)+len(change.LinksRemoved) > 0 { + change.Changed = append(change.Changed, "out_links") + } + if added, removed := setDiff(next.inLinks, previous.inLinks), setDiff(previous.inLinks, next.inLinks); len(added)+len(removed) > 0 { + change.Changed = append(change.Changed, "in_links") + } + if previous.category != next.category { + change.Changed = append(change.Changed, "category") + } + if previous.status != next.status { + change.Changed = append(change.Changed, "status") + } + if previous.pageType != next.pageType { + change.Changed = append(change.Changed, "page_type") + } + return change, len(change.Changed) > 0 +} + +func indexWiki(rows []map[string]any) (map[string]wikiPage, error) { + pages := make(map[string]wikiPage, len(rows)) + for _, row := range rows { + slug, _ := row["slug"].(string) + slug = strings.TrimSpace(slug) + if slug == "" { + return nil, errors.New("wiki row without slug") + } + if _, exists := pages[slug]; exists { + return nil, fmt.Errorf("duplicate wiki slug %s", slug) + } + page := wikiPage{slug: slug} + page.title, _ = row["title"].(string) + page.pageType, _ = row["page_type"].(string) + page.status, _ = row["status"].(string) + page.content, _ = row["content"].(string) + var err error + if page.sources, err = stringList(row["source_refs"]); err != nil { + return nil, fmt.Errorf("wiki %s source_refs: %w", slug, err) + } + if page.citations, err = stringList(row["chunk_refs"]); err != nil { + return nil, fmt.Errorf("wiki %s chunk_refs: %w", slug, err) + } + if page.outLinks, err = stringList(row["out_links"]); err != nil { + return nil, fmt.Errorf("wiki %s out_links: %w", slug, err) + } + if page.inLinks, err = stringList(row["in_links"]); err != nil { + return nil, fmt.Errorf("wiki %s in_links: %w", slug, err) + } + category, err := stringList(row["category_path"]) + if err != nil { + return nil, fmt.Errorf("wiki %s category_path: %w", slug, err) + } + if folder, ok := row["folder_id"].(string); ok && folder != "" { + category = append(category, folder) + } + page.category = strings.Join(category, "/") + pages[slug] = page + } + return pages, nil +} + +// stringList 同时接受 JSON 数组与字符串化数组;其他形状一律报错, +// 避免"读不出来就当空集合"把撤销误判成没有来源。 +func stringList(value any) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case []string: + return append([]string{}, typed...), nil + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + text, ok := item.(string) + if !ok { + return nil, fmt.Errorf("non-string item %T", item) + } + out = append(out, text) + } + return out, nil + case string: + if strings.TrimSpace(typed) == "" { + return nil, nil + } + var out []string + if err := json.Unmarshal([]byte(typed), &out); err != nil { + return nil, err + } + return out, nil + default: + return nil, fmt.Errorf("unsupported value %T", value) + } +} + +// setDiff 返回 left 有、right 没有的元素(升序)。 +func setDiff(left, right []string) []string { + seen := make(map[string]bool, len(right)) + for _, item := range right { + seen[item] = true + } + out := make([]string, 0) + for _, item := range left { + if seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + sort.Strings(out) + return out +} + +func digestOf(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) +} + +// changedLabels 把语义类别翻成给操作员看的词,界面不用各自维护一份。 +func ChangedLabels(changed []string) []string { + labels := map[string]string{ + "content": "正文", "sources": "来源文档", "citations": "引用分块", + "out_links": "出边", "in_links": "入边", "category": "目录", + "status": "状态", "page_type": "页面类型", "added": "新增", "removed": "整页撤销", + } + out := make([]string, 0, len(changed)) + for _, name := range changed { + if label, ok := labels[name]; ok { + out = append(out, label) + } else { + out = append(out, name) + } + } + return out +} diff --git a/internal/librarydiff/wiki_test.go b/internal/librarydiff/wiki_test.go new file mode 100644 index 0000000..1c755e1 --- /dev/null +++ b/internal/librarydiff/wiki_test.go @@ -0,0 +1,114 @@ +package librarydiff + +import ( + "encoding/json" + "testing" +) + +// 两篇文档共同贡献一页:撤销一篇后,页面保留、只掉撤销那篇的来源与引用, +// 并且能说清是哪几类语义变了——这正是"页面级差异"做不到的部分。 +func TestCompareWikiReportsContributionRemoval(t *testing.T) { + base := []map[string]any{ + row(t, "entity/gate", "gate", []string{"doc-a", "doc-b"}, []string{"chunk-a", "chunk-b"}, + "闸门:灰度 30 分钟 ZEBRA-A-7731;阈值 5% ZEBRA-B-4492", []string{"entity/only-a"}, []string{"entity/gate"}), + row(t, "entity/only-a", "only-a", []string{"doc-a"}, []string{"chunk-a"}, "只属于 A", nil, nil), + } + after := []map[string]any{ + row(t, "entity/gate", "gate", []string{"doc-b"}, []string{"chunk-b"}, + "闸门:阈值 5% ZEBRA-B-4492", []string{"entity/gate"}, []string{"entity/gate"}), + } + result, err := CompareWiki(base, after) + if err != nil { + t.Fatalf("compare: %v", err) + } + if result.Updated != 1 || result.Removed != 1 || result.Added != 0 { + t.Fatalf("counts: %+v", result) + } + var kept WikiContribution + for _, page := range result.Pages { + if page.Slug == "entity/gate" { + kept = page + } + } + if kept.Status != "updated" { + t.Fatalf("kept page status = %q", kept.Status) + } + if len(kept.SourcesRemoved) != 1 || kept.SourcesRemoved[0] != "doc-a" { + t.Fatalf("sources_removed = %v", kept.SourcesRemoved) + } + if len(kept.SourcesAdded) != 0 { + t.Fatalf("sources_added = %v", kept.SourcesAdded) + } + want := map[string]bool{"content": true, "sources": true, "citations": true, "out_links": true} + got := map[string]bool{} + for _, name := range kept.Changed { + got[name] = true + } + for name := range want { + if !got[name] { + t.Fatalf("changed missing %q: %v", name, kept.Changed) + } + } + if kept.CitationsAfter != 1 || kept.CitationsBefore != 2 { + t.Fatalf("citations %d -> %d", kept.CitationsBefore, kept.CitationsAfter) + } + if len(kept.LinksRemoved) != 1 || kept.LinksRemoved[0] != "entity/only-a" { + t.Fatalf("links_removed = %v", kept.LinksRemoved) + } + // 合计口径:共享页掉 1 个来源 + 整页撤销的 only-a 带走 1 个来源 = 2。 + if result.SourcesRemoved != 2 { + t.Fatalf("result sources_removed = %d", result.SourcesRemoved) + } +} + +func TestCompareWikiIgnoresVolatileFieldsAndUnchangedPages(t *testing.T) { + base := []map[string]any{row(t, "concept/x", "x", []string{"doc-a"}, []string{"c1"}, "正文", nil, nil)} + after := []map[string]any{row(t, "concept/x", "x", []string{"doc-a"}, []string{"c1"}, "正文", nil, nil)} + after[0]["updated_at"] = "2026-10-29T00:00:00Z" + after[0]["updated_by"] = "someone" + result, err := CompareWiki(base, after) + if err != nil { + t.Fatalf("compare: %v", err) + } + if result.Updated+result.Added+result.Removed != 0 || len(result.Pages) != 0 { + t.Fatalf("noise produced changes: %+v", result) + } + if result.State != "computed" || result.BaseCount != 1 || result.TargetCount != 1 { + t.Fatalf("result header: %+v", result) + } +} + +func TestCompareWikiRejectsRowsWithoutSlug(t *testing.T) { + if _, err := CompareWiki([]map[string]any{{"title": "no slug"}}, nil); err == nil { + t.Fatal("expected error for row without slug") + } +} + +func row(t *testing.T, slug, title string, sources, chunks []string, content string, + outLinks, inLinks []string) map[string]any { + t.Helper() + return map[string]any{ + "slug": slug, "title": title, "page_type": "entity", "status": "published", + "content": content, "summary": "", + "source_refs": sources, "chunk_refs": chunks, + "out_links": outLinks, "in_links": inLinks, + "category_path": []string{"根"}, "folder_id": "", + } +} + +func TestCompareWikiAcceptsJSONEncodedArrays(t *testing.T) { + // 归档行来自 to_jsonb,数组是 JSON 数组;但历史归档里也出现过字符串化的数组, + // 两种都要能读,读不出就报错,不能静默当成空集合。 + base := []map[string]any{{"slug": "entity/y", "source_refs": `["doc-a","doc-b"]`, + "chunk_refs": `["c1"]`, "content": "a", "out_links": `["entity/y"]`}} + after := []map[string]any{{"slug": "entity/y", "source_refs": `["doc-b"]`, + "chunk_refs": `["c1"]`, "content": "a", "out_links": `["entity/y"]`}} + result, err := CompareWiki(base, after) + if err != nil { + t.Fatalf("compare: %v", err) + } + if len(result.Pages) != 1 || len(result.Pages[0].SourcesRemoved) != 1 { + raw, _ := json.Marshal(result) + t.Fatalf("string arrays not parsed: %s", raw) + } +} diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index d2ab4c9..2a967cf 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -149,9 +149,11 @@ const base: ViewProps = { pendingCapture: false, canCapture: true, canWrite: true, + maintenance: null, now: new Date('2026-10-28T03:35:00Z').getTime(), onRefresh: vi.fn(), onCapture: vi.fn(), + onRecover: vi.fn(), } const render = (overrides: Partial = {}) => @@ -227,7 +229,7 @@ describe('ReleaseTargetsView(确认稿布局)', () => { expect(html).toContain('生产 prod00000000 → 测试 test00000000') expect(html).not.toContain('变更清单来自生产与测试版本对比') expect(html).toContain('Wiki 页面:新增 2 · 更新 1 · 撤销 3') - expect(html).toContain('不代表实体/边/引用重算') + expect(html).toContain('重算发生在源侧') }) it('准备信息注明快照时间与范围,不声称实时测试库已就绪', () => { diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index 1f87cae..1c4f952 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -1,13 +1,16 @@ import { useCallback, useEffect, useState } from 'react' import { enqueueLibraryCapture, + enqueueLibraryRecovery, fetchLibraryCaptures, + fetchLibraryMaintenance, fetchLibraryOverview, fetchLibrarySnapshots, formatBytes, scopeLabel, type LibraryCaptureJob, type LibraryJobRecord, + type LibraryMaintenanceView, type LibraryOverview, type LibrarySnapshot, type LibrarySnapshotList, @@ -83,9 +86,11 @@ interface ViewProps { canCapture: boolean canWrite: boolean overview: LibraryOverview | null + maintenance: LibraryMaintenanceView | null now: number onRefresh: () => void onCapture: () => void + onRecover: (windowJobID: string) => void } function scopeView( @@ -157,6 +162,170 @@ function jobStateText(job: LibraryJobRecord): string { return map[job.state] ?? job.state } +// ---- 目标维护窗口:级别 / 进度 / 恢复错误 ---- +// +// 维护窗口有两种级别:实例级(跨 scope 共用一把锁,真正挡住写入者)和库级。 +// 页面必须说清是哪一级、入口是开还是关、作业走到哪、恢复失败的具体错误—— +// 这些都来自 /api/v1/library/maintenance 的真实记录,没有记录就说"未接入"。 +export interface MaintenanceSummary { + level: string + held: boolean + owner: string + state: string + ingress: string + services: string + acquiredAt: string + releasedAt: string + progress: string + recoveryError: string + recoveryNote: string + canRecover: boolean +} + +// 阶段名给操作员看的映射;没映射到的阶段原样显示,不猜。 +const stageLabel: Record = { + prebackup: '预备份', + database: '数据库', + resources: '资源', + index: '索引', + wiki: 'Wiki', + verify: '校验', + finalize: '登记版本', + rollback: '回滚', + export: '导出', + acquire: '开窗口', + drain: '排空', + stop: '停写入者', +} + +const ingressText = (state?: string) => + state === 'closed' ? '已关闭(排空后等待写入)' : state === 'open' ? '打开' : state ? state : '未知' + +export function maintenanceSummary( + maintenance: LibraryMaintenanceView | null | undefined, + job: LibraryJobRecord | undefined +): MaintenanceSummary { + const record = maintenance?.instance_maintenance ?? maintenance?.maintenance ?? null + const level = !record + ? '未接入' + : record.level === 'instance' + ? '实例级' + : record.level === 'library' + ? '库级' + : record.level || '未知' + const held = Boolean(maintenance?.held) + const failed = job?.state === 'failed' + const ownerScope = maintenance?.instance_maintenance?.owner_scope ?? record?.scope ?? '—' + return { + level, + held, + owner: record ? `${ownerScope} · ${record.owner_job_id || '—'}` : '—', + state: record?.state || '—', + ingress: record ? ingressText(record.ingress_state) : '—', + services: (record?.services ?? []).join('、') || '—', + acquiredAt: record?.acquired_at ? stamp(record.acquired_at) : '—', + releasedAt: record?.released_at ? stamp(record.released_at) : '', + progress: job ? `${stageLabel[job.stage] ?? job.stage ?? '—'} · ${jobStateText(job)}` : '尚无作业进度', + recoveryError: failed ? job?.error || '作业失败但没有留下错误明细' : '', + recoveryNote: job?.recovery || '', + canRecover: held && (failed || !job || job.state === 'cancelled'), + } +} + +export function MaintenanceCard({ + maintenance, + job, + canWrite, + onRecover, +}: { + maintenance: LibraryMaintenanceView | null | undefined + job: LibraryJobRecord | undefined + canWrite: boolean + onRecover: (windowJobID: string) => void +}) { + const summary = maintenanceSummary(maintenance, job) + const record = maintenance?.instance_maintenance ?? maintenance?.maintenance + return ( +
+
+
+

目标维护

+

同步/恢复期间目标写入者被停住;级别与入口状态来自维护记录本身

+
+ + {summary.held ? `${summary.level} · 占用中` : summary.level === '未接入' ? '未接入' : '未占用'} + +
+ {summary.level === '未接入' ? ( +

+ 暂无维护窗口记录,也没有维护能力配置;维护类操作保持 fail closed,不会在此状态下写目标。 +

+ ) : ( + <> +
    +
  • + 级别 + {summary.level} +
  • +
  • + 持有 + {summary.owner} +
  • +
  • + 阶段 + {summary.state} +
  • +
  • + 入口 {summary.ingress} + {summary.services} +
  • +
  • + 开始 + {summary.acquiredAt} +
  • +
  • + 释放 + {summary.releasedAt || '未释放'} +
  • +
+

+ 进度:{summary.progress} + {summary.releasedAt ? ` · 窗口已于 ${summary.releasedAt} 释放` : ''} +

+ {summary.recoveryError ? ( +
+ 恢复错误 +

{summary.recoveryError}

+ {summary.recoveryNote ?

处理记录:{summary.recoveryNote}

: null} +
+ ) : null} + {canWrite && summary.canRecover && record ? ( + + ) : null} + + )} +
+ ) +} + +// 逐页贡献级差异:一条页面一行,直接写清来源少了几份、引用从几变几。 +export function wikiContributionLines(diff: LibraryOverview['diff'] | undefined): string[] { + const wiki = diff?.wiki + if (!wiki || wiki.state !== 'computed' || !wiki.pages || wiki.pages.length === 0) return [] + const label: Record = { added: '新增', updated: '更新', removed: '整页撤销' } + return wiki.pages.map((page) => { + const parts = [`${label[page.status] ?? page.status} ${page.title || page.slug}`] + if (page.changed.length > 0) parts.push(page.changed.join('、')) + if ((page.sources_removed ?? []).length > 0) parts.push(`来源 -${page.sources_removed!.length}`) + if ((page.sources_added ?? []).length > 0) parts.push(`来源 +${page.sources_added!.length}`) + if (page.citations_before !== page.citations_after) + parts.push(`引用 ${page.citations_before}→${page.citations_after}`) + return parts.join(' · ') + }) +} + function StageBar({ view }: { view: ScopeView }) { const job = view.job const sealJob = job?.kind === 'seal' ? job : undefined @@ -312,10 +481,23 @@ function ChangeTable({ diff }: { diff: LibraryOverview['diff'] }) { {diff.wiki && diff.wiki.state === 'computed' ? ( -

- Wiki 页面:新增 {diff.wiki.added} · 更新 {diff.wiki.updated} · 撤销 {diff.wiki.removed} - (页面级,不代表实体/边/引用重算) -

+
+

+ Wiki 页面:新增 {diff.wiki.added} · 更新 {diff.wiki.updated} · 撤销 {diff.wiki.removed} + {typeof diff.wiki.sources_removed === 'number' + ? ` · 来源文档变化 −${diff.wiki.sources_removed} / +${diff.wiki.sources_added ?? 0}` + : ''} + (逐页比对来源、引用、出入边与目录;重算发生在源侧) +

+ {wikiContributionLines(diff).length > 0 ? ( +
    + {wikiContributionLines(diff).map((line) => ( +
  • {line}
  • + ))} +
+ ) : null} + {diff.wiki.pages_truncated ?

逐页明细超过 50 条,只列出前 50 条。

: null} +
) : null} ) @@ -866,8 +1048,10 @@ export function ReleaseTargetsView(props: ViewProps) { canCapture, canWrite, now, + maintenance, onRefresh, onCapture, + onRecover, } = props const internal = scopeView(page, jobs, 'internal', overview?.scope === 'internal' ? overview : null) const external = scopeView(page, jobs, 'external', overview?.scope === 'external' ? overview : null) @@ -1028,6 +1212,8 @@ export function ReleaseTargetsView(props: ViewProps) {

启动后先固定测试版本,再备份生产。备份失败或恢复条件不满足,不进入生产写入。

+ + {active ? ( onDrawer(true)} /> ) : ( @@ -1073,6 +1259,10 @@ export function ReleaseTargets() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) + const [maintenances, setMaintenances] = useState>({ + internal: null, + external: null, + }) const [scope, setScope] = useState('internal') const [drawerOpen, setDrawerOpen] = useState(false) const [expandedSnapshot, setExpandedSnapshot] = useState(null) @@ -1082,15 +1272,19 @@ export function ReleaseTargets() { const load = useCallback(async (signal?: AbortSignal) => { setLoading(true) try { - const [snapshots, captures, internalOverview, externalOverview] = await Promise.all([ - fetchLibrarySnapshots(signal), - fetchLibraryCaptures(signal), - fetchLibraryOverview('internal', signal), - fetchLibraryOverview('external', signal), - ]) + const [snapshots, captures, internalOverview, externalOverview, internalMaintenance, externalMaintenance] = + await Promise.all([ + fetchLibrarySnapshots(signal), + fetchLibraryCaptures(signal), + fetchLibraryOverview('internal', signal), + fetchLibraryOverview('external', signal), + fetchLibraryMaintenance('internal', signal), + fetchLibraryMaintenance('external', signal), + ]) setPage(snapshots) setJobs(captures) setOverviews({ internal: internalOverview, external: externalOverview }) + setMaintenances({ internal: internalMaintenance, external: externalMaintenance }) setNow(Date.now()) setError(null) } catch (loadError) { @@ -1139,6 +1333,17 @@ export function ReleaseTargets() { } } + const recover = async (windowJobID: string) => { + setNotice(null) + try { + const accepted = await enqueueLibraryRecovery(scope, windowJobID) + setNotice(`已投递按预备份恢复:${accepted.recovery_job || '(未返回作业号)'}`) + await load() + } catch (recoveryError) { + setNotice(recoveryError instanceof Error ? recoveryError.message : '恢复请求失败') + } + } + return ( void load()} onCapture={() => void startCapture()} + onRecover={(windowJobID) => void recover(windowJobID)} /> ) } diff --git a/web/src/librarySnapshots.ts b/web/src/librarySnapshots.ts index cfe5916..679c3ca 100644 --- a/web/src/librarySnapshots.ts +++ b/web/src/librarySnapshots.ts @@ -250,7 +250,11 @@ export interface LibraryDiff { /** 这次比对用的两个整库版本(生产 → 测试),避免把历史差异当成当前。 */ base_snapshot?: string target_snapshot?: string - /** 页面级 Wiki 差异;不代表实体、概念、边或引用被重算。 */ + /** + * Wiki 逐页贡献级差异:来源文档 / 引用分块 / 出入边 / 目录分别比。 + * 语义重算发生在源侧,这里只如实反映重算后的结果——撤销一份共享贡献时 + * 页面通常还是"更新",要看 pages[].sources_removed 才知道那份贡献被摘掉了。 + */ wiki?: { state: 'computed' | 'unknown' reason?: string @@ -259,9 +263,92 @@ export interface LibraryDiff { removed: number sample: string[] note: string + pages?: LibraryWikiPageChange[] + pages_truncated?: boolean + sources_added?: number + sources_removed?: number + base_count?: number + target_count?: number } } +export interface LibraryWikiPageChange { + slug: string + title: string + page_type?: string + status: 'added' | 'updated' | 'removed' + changed: string[] + sources_added?: string[] + sources_removed?: string[] + citations_before: number + citations_after: number +} + +// ---- 目标维护窗口:级别、阶段、入口状态与恢复错误 ---- +export interface LibraryMaintenanceRecord { + scope: string + owner_job_id: string + target_identity?: string + level: string + target_fingerprint?: string + services?: string[] + ingress_state: string + state: string + original_service_state?: string + note?: string + acquired_at: string + updated_at: string + released_at?: string | null +} + +export interface LibraryInstanceMaintenanceRecord extends LibraryMaintenanceRecord { + instance: string + owner_scope: string +} + +export interface LibraryMaintenanceView { + held: boolean + maintenance?: LibraryMaintenanceRecord | null + instance?: string + instance_maintenance?: LibraryInstanceMaintenanceRecord | null +} + +export async function fetchLibraryMaintenance( + scope: string, + signal?: AbortSignal +): Promise { + const response = await fetch(`/api/v1/library/maintenance?scope=${encodeURIComponent(scope)}`, { + headers: { Accept: 'application/json' }, + signal, + }) + if (!response.ok) return null + const value = (await response.json().catch(() => null)) as unknown + if (!record(value)) return null + return value as unknown as LibraryMaintenanceView +} + +export interface LibraryRecoveryAccepted { + accepted?: boolean + recovery_job?: string + state?: string + code?: string + error?: string +} + +export async function enqueueLibraryRecovery(scope: string, windowJobID: string): Promise { + const response = await fetch('/api/v1/library/maintenance/recover', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ scope, window_job_id: windowJobID }), + }) + const value = (await response.json().catch(() => ({}))) as unknown + const body = record(value) ? (value as LibraryRecoveryAccepted) : {} + if (!response.ok) { + throw new Error(body.error || `恢复请求被拒绝(HTTP ${response.status})`) + } + return body +} + export interface LibraryStageRow { stage: string state: string @@ -276,7 +363,7 @@ export interface LibraryStageRow { export interface LibraryJobRecord { id: string scope: string - kind: 'seal' | 'sync' + kind: 'seal' | 'sync' | 'restore' state: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled' stage: string request_digest: string diff --git a/web/src/releaseTargets.css b/web/src/releaseTargets.css index 1075650..187e433 100644 --- a/web/src/releaseTargets.css +++ b/web/src/releaseTargets.css @@ -635,3 +635,40 @@ font-variant-numeric: tabular-nums; word-break: break-all; } + +/* 目标维护:级别/持有/入口/阶段用两列小表,数字与时间可对齐比较。 */ +.rel-maintenance-rows { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 18px; + margin: 8px 14px 0; + padding: 0; + list-style: none; +} +.rel-maintenance-rows li { + display: flex; + justify-content: space-between; + gap: 10px; + border-bottom: 1px dashed var(--rel-line, #dce6ed); + padding: 4px 0; + font-size: 12px; +} +.rel-maintenance-rows span { + color: var(--rel-muted, #7b8d9a); +} +.rel-maintenance-rows b { + font-variant-numeric: tabular-nums; + word-break: break-all; + text-align: right; +} + +/* Wiki 逐页贡献级差异:每条一行,直接写清来源/引用怎么变。 */ +.rel-wiki-pages { + margin: 4px 14px 10px; + padding-left: 18px; + font-size: 12px; + line-height: 1.6; +} +.rel-wiki-pages li { + font-variant-numeric: tabular-nums; +} diff --git a/web/src/releaseTargetsMaintenance.test.tsx b/web/src/releaseTargetsMaintenance.test.tsx new file mode 100644 index 0000000..92ceafc --- /dev/null +++ b/web/src/releaseTargetsMaintenance.test.tsx @@ -0,0 +1,139 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { MaintenanceCard, maintenanceSummary, wikiContributionLines } from './ReleaseTargets' +import type { LibraryDiff, LibraryJobRecord, LibraryMaintenanceView } from './librarySnapshots' + +const job: LibraryJobRecord = { + id: 'libsync-internal-1', + scope: 'internal', + kind: 'sync', + state: 'failed', + stage: 'rollback', + request_digest: 'x', + queue_job_id: 'q1', + snapshot_id: '', + target_snapshot_id: '', + actor: 'admin', + error: '写入生产 chunks 失败:连接被拒绝', + recovery: '已按预备份回滚并校验', + created_at: '2026-10-29T04:00:00Z', + updated_at: '2026-10-29T04:02:00Z', + finished_at: '2026-10-29T04:02:00Z', + stages: [], +} + +const heldInstance: LibraryMaintenanceView = { + held: true, + instance: 'kgmaint-weknora', + instance_maintenance: { + instance: 'kgmaint-weknora', + owner_scope: 'internal', + scope: 'internal', + owner_job_id: 'libsync-internal-1', + level: 'instance', + services: ['app'], + ingress_state: 'closed', + state: 'held', + acquired_at: '2026-10-29T04:00:00Z', + updated_at: '2026-10-29T04:02:00Z', + released_at: null, + }, +} + +describe('维护级别/进度/恢复错误', () => { + it('实例级持有:显示级别、入口状态、进度与恢复错误,并允许按预备份恢复', () => { + const summary = maintenanceSummary(heldInstance, job) + expect(summary.level).toBe('实例级') + expect(summary.held).toBe(true) + expect(summary.ingress).toContain('已关闭') + expect(summary.progress).toContain('回滚') + expect(summary.progress).toContain('失败') + expect(summary.recoveryError).toContain('写入生产 chunks 失败') + expect(summary.recoveryNote).toContain('按预备份回滚') + expect(summary.canRecover).toBe(true) + + const html = renderToStaticMarkup( + undefined} /> + ) + for (const text of ['目标维护', '实例级', '入口 已关闭', '按预备份恢复', '写入生产 chunks 失败']) { + expect(html).toContain(text) + } + }) + + it('没有窗口时如实说没有,不画成"已就绪"', () => { + const summary = maintenanceSummary(null, undefined) + expect(summary.level).toBe('未接入') + expect(summary.held).toBe(false) + expect(summary.canRecover).toBe(false) + const html = renderToStaticMarkup( + undefined} /> + ) + expect(html).toContain('暂无维护窗口') + expect(html).not.toContain('按预备份恢复') + }) + + it('已释放的窗口不再提供恢复入口', () => { + const released: LibraryMaintenanceView = { + held: false, + instance_maintenance: { + ...heldInstance.instance_maintenance!, + released_at: '2026-10-29T04:05:00Z', + state: 'recovered', + }, + } + const summary = maintenanceSummary(released, job) + expect(summary.held).toBe(false) + expect(summary.canRecover).toBe(false) + expect(summary.releasedAt).not.toBe('') + }) +}) + +describe('Wiki 贡献级差异展示', () => { + const diff: LibraryDiff = { + state: 'computed', + added: [], + updated: [], + removed: [], + wiki: { + state: 'computed', + added: 0, + updated: 1, + removed: 1, + sample: [], + note: '逐页比对', + sources_removed: 2, + pages: [ + { + slug: 'entity/fa-bu-zha-men', + title: '发布闸门', + status: 'updated', + changed: ['正文', '来源文档', '引用分块'], + sources_removed: ['doc-a'], + citations_before: 2, + citations_after: 1, + }, + { + slug: 'entity/tu-pu-hui-tian-fu-wu', + title: '图谱回填服务', + status: 'removed', + changed: ['整页撤销'], + citations_before: 1, + citations_after: 0, + }, + ], + }, + } + + it('共享页被更新时列出来源减少与引用变化,而不是只说"更新 1 页"', () => { + const lines = wikiContributionLines(diff) + expect(lines[0]).toContain('发布闸门') + expect(lines[0]).toContain('来源 -1') + expect(lines[0]).toContain('引用 2→1') + expect(lines[1]).toContain('整页撤销') + }) + + it('没有逐页明细时不编造明细', () => { + expect(wikiContributionLines({ state: 'unknown', added: [], updated: [], removed: [] })).toEqual([]) + }) +})