98294b9583
build / build (push) Failing after 24s
why:审计 19 项(12 P1 / 7 P2)来自同一批根因,逐点试错会把协议撕成十几份:
· 意图与执行混为一谈(F01/F15):`library.sync:<scope>:<快照>` 是终身幂等键,
A→B→A 的第三次发布拿到第一次的历史成功作业,什么都不做;
· 版本行既是实体又是事件(F02/F09):主键只由 (kind,scope,快照) 决定、写入是
upsert,第二个作业覆盖 `source_job_id`/`evidence`/`created_at`;
· 接管没有代次(F13):两个 worker 都能认领同一 pending 作业,失租的旧执行还能
写业务状态、释放别人的维护锁;
· 早退与终态没有统一政策(F03/F04/F05/F06):半写窗口的前置检查失败会重新开门、
服务起不来也照样开入口、journal 终态后收尾不可重建、恢复成功不回写 current;
· 数据身份与准入各写一套(F07/F08/F14/F17):接管丢了复用语义、全库搬运不改
location_hash、通用入口可造 library.* 作业、目标模型从没被问过;
· 存储/容量/UI/CI 判据各自为政(F10/F11/F12/F16/F18/F19)。
what:按批准的一套协议集中实施(详见 handoffs/release-consolidated-implementation/
current-contract.md):
· operation(一次明确意图)/ attempt(epoch 代次)/ 不可变版本事件 / current 指针;
治理库迁移 76,只动治理库,不碰 WeKnora Schema/源码/镜像;
· 所有业务状态、阶段、版本事件、维护锁写入带 epoch 守卫(失租代次写不进去);
心跳失租立刻取消 handler;
· 半写窗口早退保持关闭;服务未全部健康绝不开入口;journal 终态可从持久事实重建
收尾;恢复成功登记 recovery 事件并推进 current;
· worker 在动维护前核对业务行/kind/scope/队列关联;通用入口拒绝 library.*;
· 全库搬运重算 location_hash 并与文档级导入共用实现;接管消费写集合的复用决定;
发布前核目标实时模型契约、容量门禁前置;
· 存储保护闭包改为全量依赖、类型只认显式登记、保护未知则不出可删候选;
· 页面核 HTTP 状态与结构、恢复纳入轮询、Wiki 差异覆盖标题/摘要/父级。
how:`make verify` rc=0(16 项必需检查全绿,含 go test 全量与前端 485 例);
`go vet ./...` rc=0;`npx tsc -p tsconfig.json --noEmit` rc=0;逐项负例见各包测试
(F03/F04/F08/F12/F13/F14/F16/F18/F19 都有先红后绿记录)。
未执行:治理库迁移到任何已有库、真实 PG/ES/S3/WeKnora 集成、故障注入、部署。
287 lines
10 KiB
Go
287 lines
10 KiB
Go
// 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
|
||
// summary / parentSlug 是持久业务字段:只改它们同样是"内容变了",
|
||
// 旧的比较结构根本没读这两个字段,于是发布前预览会漏报(F18)。
|
||
summary string
|
||
parentSlug 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")
|
||
}
|
||
// 标题/摘要/父级是页面身份与导航的一部分:它们在持久列里,改了就是差异,
|
||
// 不能被"正文没变"掩掉(F18)。
|
||
if previous.title != next.title {
|
||
change.Changed = append(change.Changed, "title")
|
||
}
|
||
if previous.summary != next.summary {
|
||
change.Changed = append(change.Changed, "summary")
|
||
}
|
||
if previous.parentSlug != next.parentSlug {
|
||
change.Changed = append(change.Changed, "parent_slug")
|
||
}
|
||
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)
|
||
page.summary, _ = row["summary"].(string)
|
||
page.parentSlug, _ = row["parent_slug"].(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
|
||
}
|