e313ae12f2
build / build (push) Successful in 1m18s
为什么:发布管理的差异一直显示"未接入",业务作业也可能因队列丢作业而永久占锁; 本轮把这两件做成真实实现,并把 30 个验收项与 CI/版本身份落成可审计文档。 - internal/librarydiff:稳定文档 ID + 排除易变列的整行摘要做增改撤销;overview 在 生产权威版本与测试封存都在时给出 computed 差异并标注两侧快照,缺一侧仍是 unknown。 - 发布管理"本次变更"列文档身份与比对版本;无差异时明确写"没有文档差异"。 - ReconcileLibraryJobs:worker 启动清理队列已无对应作业的孤儿,避免永久互斥。 - docs/library-acceptance-matrix.md 与 docs/release-ledger.md 固定证据与版本事实。
125 lines
3.7 KiB
Go
125 lines
3.7 KiB
Go
// Package librarydiff 比较两个已归档整库版本的知识文档集合。
|
||
//
|
||
// 身份用稳定文档 ID(不是标题、不是顺序),内容用排除易变字段后的整行摘要:
|
||
// 只有真实内容/元数据变化才算"更新",updated_at 之类的噪声不会引发假差异。
|
||
// 撤销(生产有、目标没有)单列,不能当作"无变化"。
|
||
package librarydiff
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"sort"
|
||
)
|
||
|
||
// volatileFields 是每次保存都会变、但不代表知识内容变化的列。
|
||
var volatileFields = []string{"updated_at", "updated_by", "last_edited_at"}
|
||
|
||
type Document struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
Digest string `json:"digest"`
|
||
}
|
||
|
||
type Result struct {
|
||
Added []Document `json:"added"`
|
||
Updated []Document `json:"updated"`
|
||
Removed []Document `json:"removed"`
|
||
// BaseCount/TargetCount 是两侧文档数,便于核对"差异之和是否自洽"。
|
||
BaseCount int `json:"base_count"`
|
||
TargetCount int `json:"target_count"`
|
||
}
|
||
|
||
// ReadDocuments 读取归档里 knowledges 部件的行数组。
|
||
func ReadDocuments(r io.Reader) ([]map[string]any, error) {
|
||
decoder := json.NewDecoder(r)
|
||
token, err := decoder.Token()
|
||
if err != nil || token != json.Delim('[') {
|
||
return nil, errors.New("knowledge component must be a JSON row array")
|
||
}
|
||
rows := make([]map[string]any, 0, 64)
|
||
for decoder.More() {
|
||
var row map[string]any
|
||
if err = decoder.Decode(&row); err != nil {
|
||
return nil, fmt.Errorf("decode knowledge row: %w", err)
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
if _, err = decoder.Token(); err != nil {
|
||
return nil, fmt.Errorf("close knowledge array: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func documentOf(row map[string]any) (Document, error) {
|
||
id, _ := row["id"].(string)
|
||
if id == "" {
|
||
return Document{}, errors.New("knowledge row without id")
|
||
}
|
||
canonical := make(map[string]any, len(row))
|
||
for key, value := range row {
|
||
canonical[key] = value
|
||
}
|
||
for _, key := range volatileFields {
|
||
delete(canonical, key)
|
||
}
|
||
encoded, err := json.Marshal(canonical)
|
||
if err != nil {
|
||
return Document{}, fmt.Errorf("encode knowledge row: %w", err)
|
||
}
|
||
sum := sha256.Sum256(encoded)
|
||
title, _ := row["title"].(string)
|
||
return Document{ID: id, Title: title, Digest: hex.EncodeToString(sum[:])}, nil
|
||
}
|
||
|
||
// Compare 以 base 为基准(通常是生产当前版本),target 为拟发布版本。
|
||
func Compare(base, target []map[string]any) (Result, error) {
|
||
baseDocs, err := index(base)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
targetDocs, err := index(target)
|
||
if err != nil {
|
||
return Result{}, err
|
||
}
|
||
result := Result{Added: []Document{}, Updated: []Document{}, Removed: []Document{},
|
||
BaseCount: len(baseDocs), TargetCount: len(targetDocs)}
|
||
for id, doc := range targetDocs {
|
||
previous, ok := baseDocs[id]
|
||
if !ok {
|
||
result.Added = append(result.Added, doc)
|
||
continue
|
||
}
|
||
if previous.Digest != doc.Digest {
|
||
result.Updated = append(result.Updated, doc)
|
||
}
|
||
}
|
||
for id, doc := range baseDocs {
|
||
if _, ok := targetDocs[id]; !ok {
|
||
result.Removed = append(result.Removed, doc)
|
||
}
|
||
}
|
||
sort.Slice(result.Added, func(i, j int) bool { return result.Added[i].ID < result.Added[j].ID })
|
||
sort.Slice(result.Updated, func(i, j int) bool { return result.Updated[i].ID < result.Updated[j].ID })
|
||
sort.Slice(result.Removed, func(i, j int) bool { return result.Removed[i].ID < result.Removed[j].ID })
|
||
return result, nil
|
||
}
|
||
|
||
func index(rows []map[string]any) (map[string]Document, error) {
|
||
docs := make(map[string]Document, len(rows))
|
||
for _, row := range rows {
|
||
doc, err := documentOf(row)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, exists := docs[doc.ID]; exists {
|
||
return nil, fmt.Errorf("duplicate knowledge id %s", doc.ID)
|
||
}
|
||
docs[doc.ID] = doc
|
||
}
|
||
return docs, nil
|
||
}
|