为什么:删除一篇与别人共同贡献同一 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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user