feat(library): Wiki 撤销按贡献比对,维护窗口在页面如实呈现
build / build (push) Successful in 1m18s

为什么:删除一篇与别人共同贡献同一 Wiki 页的文档后,页面还在、名字也在,
只有那篇文档的正文与引用被摘掉。原来的页面级差异会把这种语义重算显示成
"无变化",操作员据此会误判撤销没有生效。

怎么改:librarydiff 新增 CompareWiki,以 slug 为身份把正文/来源文档/引用分块/
出入边/目录分别取集合比较,输出 sources_removed、citations 变化与具体类别;
overview 的 diff.wiki 增加逐页明细(最多 50 页,超出置 pages_truncated),
说明文字明确"重算发生在源侧,本平台只如实比对与保留结果"。

页面:新增目标维护面板(实例级/库级、持有、入口开关、阶段、进度、恢复错误),
只有窗口仍持有且作业失败时才给"按预备份恢复";没有维护记录时写未接入 + fail closed。
变更页把 Wiki 逐页差异列出来源/引用怎么变。kind 补上 restore。

验过:go test ./internal/librarydiff ./internal/httpapi;web vitest 453 项;
make verify 全绿(15 项,无未运行项)。真实撤销语义验收见交付报告。
This commit is contained in:
姚章浩
2026-10-29 04:45:34 +08:00
parent 0ef79846e8
commit 1f482e4b49
10 changed files with 1006 additions and 37 deletions
+32
View File
@@ -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
+55 -24
View File
@@ -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 行数组比较两个快照:
@@ -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)
}
}
+269
View File
@@ -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
}
+114
View File
@@ -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)
}
}
+3 -1
View File
@@ -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<ViewProps> = {}) =>
@@ -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('准备信息注明快照时间与范围,不声称实时测试库已就绪', () => {
+217 -10
View File
@@ -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<string, string> = {
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 (
<div className="rel-panel rel-maintenance">
<header className="rel-panel-head">
<div>
<h2>目标维护</h2>
<p>同步/恢复期间目标写入者被停住;级别与入口状态来自维护记录本身</p>
</div>
<span className={`rel-pill ${summary.held ? 'blue' : summary.level === '未接入' ? 'gray' : 'green'}`}>
{summary.held ? `${summary.level} · 占用中` : summary.level === '未接入' ? '未接入' : '未占用'}
</span>
</header>
{summary.level === '未接入' ? (
<p className="rel-scope-note">
暂无维护窗口记录,也没有维护能力配置;维护类操作保持 fail closed,不会在此状态下写目标。
</p>
) : (
<>
<ul className="rel-maintenance-rows">
<li>
<span>级别</span>
<b>{summary.level}</b>
</li>
<li>
<span>持有</span>
<b>{summary.owner}</b>
</li>
<li>
<span>阶段</span>
<b>{summary.state}</b>
</li>
<li>
<span>入口 {summary.ingress}</span>
<b>{summary.services}</b>
</li>
<li>
<span>开始</span>
<b>{summary.acquiredAt}</b>
</li>
<li>
<span>释放</span>
<b>{summary.releasedAt || '未释放'}</b>
</li>
</ul>
<p className="rel-scope-note">
进度:{summary.progress}
{summary.releasedAt ? ` · 窗口已于 ${summary.releasedAt} 释放` : ''}
</p>
{summary.recoveryError ? (
<div className="rel-blocked">
<strong>恢复错误</strong>
<p>{summary.recoveryError}</p>
{summary.recoveryNote ? <p>处理记录:{summary.recoveryNote}</p> : null}
</div>
) : null}
{canWrite && summary.canRecover && record ? (
<button type="button" className="rel-secondary" onClick={() => onRecover(record.owner_job_id)}>
按预备份恢复
</button>
) : null}
</>
)}
</div>
)
}
// 逐页贡献级差异:一条页面一行,直接写清来源少了几份、引用从几变几。
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<string, string> = { 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'] }) {
</tbody>
</table>
{diff.wiki && diff.wiki.state === 'computed' ? (
<p className="rel-scope-note">
Wiki 页面:新增 {diff.wiki.added} · 更新 {diff.wiki.updated} · 撤销 {diff.wiki.removed}
(页面级,不代表实体/边/引用重算)
</p>
<div className="rel-wiki-diff">
<p className="rel-scope-note">
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}`
: ''}
(逐页比对来源、引用、出入边与目录;重算发生在源侧)
</p>
{wikiContributionLines(diff).length > 0 ? (
<ul className="rel-wiki-pages">
{wikiContributionLines(diff).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
) : null}
{diff.wiki.pages_truncated ? <p className="rel-scope-note">逐页明细超过 50 条,只列出前 50 条。</p> : null}
</div>
) : null}
</div>
)
@@ -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) {
<p className="rel-footnote">启动后先固定测试版本,再备份生产。备份失败或恢复条件不满足,不进入生产写入。</p>
</div>
<MaintenanceCard maintenance={maintenance} job={view.job} canWrite={canWrite} onRecover={onRecover} />
{active ? (
<ProgressBody view={view} now={now} onOpen={() => onDrawer(true)} />
) : (
@@ -1073,6 +1259,10 @@ export function ReleaseTargets() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [maintenances, setMaintenances] = useState<Record<Scope, LibraryMaintenanceView | null>>({
internal: null,
external: null,
})
const [scope, setScope] = useState<Scope>('internal')
const [drawerOpen, setDrawerOpen] = useState(false)
const [expandedSnapshot, setExpandedSnapshot] = useState<string | null>(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 (
<ReleaseTargetsView
page={page}
@@ -1159,9 +1364,11 @@ export function ReleaseTargets() {
pendingCapture={pendingCapture}
canCapture={canCapture}
canWrite={canWrite}
maintenance={maintenances[scope]}
now={now}
onRefresh={() => void load()}
onCapture={() => void startCapture()}
onRecover={(windowJobID) => void recover(windowJobID)}
/>
)
}
+89 -2
View File
@@ -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<LibraryMaintenanceView | null> {
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<LibraryRecoveryAccepted> {
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
+37
View File
@@ -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;
}
+139
View File
@@ -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(
<MaintenanceCard maintenance={heldInstance} job={job} canWrite onRecover={() => 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(
<MaintenanceCard maintenance={null} job={undefined} canWrite onRecover={() => 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([])
})
})