Files
knowledge-governance/internal/releaseadapter/v071/object_export.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环):

- 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口)
- 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入
  parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截
- 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过):
  * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id)
  * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配
  * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数
  * 回滚 job payload 支持 prior_execution_id(strict 解码)
- 三通道发布+回滚演练实测:publish 4/4 verified,检索命中,
  rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false
- 前端 A·Data-console 风格全站统一,120 用例通过
- 门禁:go vet / go test 全包通过,gofmt 干净
2026-09-30 10:54:06 +08:00

162 lines
4.9 KiB
Go

package v071
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"sort"
"strings"
)
type ObjectReader interface {
Open(ctx context.Context, provider, physicalPath string) (io.ReadCloser, error)
}
type ObjectExporterLimits struct {
MaxObjectBytes int64
MaxTotalBytes int64
}
type AssetObject struct {
ResourceID string
Handle string
Provider string
PhysicalPath string
Size int64
SHA256 string
Data []byte
}
type ObjectSnapshot struct {
Objects []AssetObject
TotalBytes int64
SHA256 string
}
type ObjectExporter struct {
reader ObjectReader
limits ObjectExporterLimits
}
func NewObjectExporter(reader ObjectReader, limits ObjectExporterLimits) (*ObjectExporter, error) {
if reader == nil {
return nil, errors.New("object reader is required")
}
if limits.MaxObjectBytes == 0 {
limits.MaxObjectBytes = 100 * 1024 * 1024
}
if limits.MaxTotalBytes == 0 {
limits.MaxTotalBytes = 1024 * 1024 * 1024
}
if limits.MaxObjectBytes < 1 || limits.MaxTotalBytes < limits.MaxObjectBytes {
return nil, errors.New("invalid object export limits")
}
return &ObjectExporter{reader: reader, limits: limits}, nil
}
func (exporter *ObjectExporter) Export(ctx context.Context, postgresSnapshot PostgresSnapshot) (ObjectSnapshot, error) {
resources := postgresSnapshot.Table("resources")
objects := make([]AssetObject, 0, len(resources.Rows))
var total int64
for _, raw := range resources.Rows {
var resource struct {
ID string `json:"id"`
Handle string `json:"handle"`
Provider string `json:"provider"`
PhysicalPath string `json:"physical_path"`
Size int64 `json:"size"`
ContentHash string `json:"content_hash"`
}
if json.Unmarshal(raw, &resource) != nil || !validSearchIdentifier(resource.ID) || len(resource.Handle) != 22 ||
!validObjectProvider(resource.Provider) || !validObjectKey(resource.PhysicalPath) || resource.Size < 0 || resource.Size > exporter.limits.MaxObjectBytes ||
(resource.ContentHash != "" && !validLowerSHA256(resource.ContentHash)) {
return ObjectSnapshot{}, errors.New("invalid release resource record")
}
reader, err := exporter.reader.Open(ctx, resource.Provider, resource.PhysicalPath)
if err != nil {
return ObjectSnapshot{}, errors.New("read release object")
}
data, readErr := io.ReadAll(io.LimitReader(reader, exporter.limits.MaxObjectBytes+1))
closeErr := reader.Close()
if readErr != nil || closeErr != nil || int64(len(data)) > exporter.limits.MaxObjectBytes {
return ObjectSnapshot{}, errors.New("read release object")
}
if resource.Size != int64(len(data)) {
return ObjectSnapshot{}, errors.New("release object size mismatch")
}
digest := sha256.Sum256(data)
contentSHA := hex.EncodeToString(digest[:])
if resource.ContentHash != "" && resource.ContentHash != contentSHA {
return ObjectSnapshot{}, errors.New("release object digest mismatch")
}
total += int64(len(data))
if total > exporter.limits.MaxTotalBytes {
return ObjectSnapshot{}, errors.New("release object total exceeds limit")
}
objects = append(objects, AssetObject{
ResourceID: resource.ID, Handle: resource.Handle, Provider: resource.Provider,
PhysicalPath: resource.PhysicalPath, Size: int64(len(data)), SHA256: contentSHA, Data: data,
})
}
sort.Slice(objects, func(left, right int) bool { return objects[left].Handle < objects[right].Handle })
return ObjectSnapshot{Objects: objects, TotalBytes: total, SHA256: objectSnapshotDigest(objects)}, nil
}
func objectSnapshotDigest(objects []AssetObject) string {
inventory := make([]struct {
Handle string `json:"handle"`
Key string `json:"key"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}, 0, len(objects))
for _, object := range objects {
inventory = append(inventory, struct {
Handle string `json:"handle"`
Key string `json:"key"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}{Handle: object.Handle, Key: object.PhysicalPath, Size: object.Size, SHA256: object.SHA256})
}
encoded, _ := json.Marshal(inventory)
digest := sha256.Sum256(encoded)
return hex.EncodeToString(digest[:])
}
func shaBytes(data []byte) string {
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:])
}
func validObjectProvider(provider string) bool {
switch provider {
case "minio", "oss", "s3", "cos":
return true
default:
return false
}
}
func validObjectKey(value string) bool {
if strings.HasPrefix(value, "storage://") {
rest := strings.TrimPrefix(value, "storage://")
parts := strings.SplitN(rest, "/", 2)
return len(parts) == 2 && validSearchIdentifier(parts[0]) && validObjectLocator(parts[1])
}
return validObjectLocator(value)
}
func validLowerSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, char := range value {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}