Files
knowledge-governance/internal/httpapi/library_storage.go
T
姚章浩 f91359f881
build / build (push) Successful in 1m15s
feat(library): 存储管理真实化——容量门禁、依赖保护与清理预览
为什么:存储页原来只有占用数字,保留/依赖保护/清理预览写"未接线";这些都不需要生产写权限,
按"未设置就不做决定"的口径实现成只读计算。

- internal/libraryretention:按保留数量/天数生成预览;生产生效、每库最近封存、每次生产预备份、
  在飞作业引用一律受保护;回收预览只算独占字节,共享 blob 不计。
- internal/libraryretention.CheckCapacity:预算未设置即不放行(无法证明装得下),超预算阻断;
  已并入 POST /library/syncs 的 gate 缺项。
- 新增 GET /api/v1/library/storage:真实归档用量 + 三类版本保护依据 + 策略 + 预览 + 容量,只读。
- 系统设置→存储管理改为读该接口:策略未设置时明确"全部保留、不生成候选",自动清理恒关闭。
- 配置项 LIBRARY_CAPACITY_BUDGET_BYTES / LIBRARY_RETENTION_VERSIONS / LIBRARY_RETENTION_DAYS(未设=0)。
2026-10-28 15:04:44 +08:00

202 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"net/http"
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/libraryretention"
"knowledge-governance/internal/librarysnapshot"
)
// 存储管理接口:容量、保留策略、依赖保护与清理预览。
//
// 全部只读:没有删除动作,自动清理恒为关闭。候选版本只是"按策略如果清理会考虑这些",
// 受保护版本(生产生效、每库最近封存、每次生产预备份、在飞作业引用)永不进入候选。
type storageVersionView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Scope string `json:"scope"`
CreatedAt time.Time `json:"created_at"`
Protected bool `json:"protected"`
ProtectedBy []string `json:"protected_by,omitempty"`
ExclusiveBytes int64 `json:"exclusive_bytes"`
SharedBytes int64 `json:"shared_bytes"`
}
type libraryStorageResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
ArchiveRoot string `json:"archive_root,omitempty"`
Versions []storageVersionView `json:"versions"`
ContentBytes int64 `json:"content_bytes"`
BlobFileBytes int64 `json:"blob_file_bytes"`
BlobAllocatedBytes int64 `json:"blob_allocated_bytes"`
UnreferencedBlobs int `json:"unreferenced_blobs"`
UnreferencedBytes int64 `json:"unreferenced_bytes"`
ManifestFileBytes int64 `json:"manifest_file_bytes"`
Policy libraryretention.Policy `json:"policy"`
Plan *libraryretention.Plan `json:"plan,omitempty"`
Capacity libraryretention.Capacity `json:"capacity"`
AutomaticCleanup bool `json:"automatic_cleanup"`
Notes []string `json:"notes"`
}
func libraryStorageHandler(w http.ResponseWriter, r *http.Request, config Config) {
response := libraryStorageResponse{Versions: []storageVersionView{}, Notes: []string{}, AutomaticCleanup: false,
Policy: libraryretention.Policy{KeepVersions: int(config.LibraryRetentionVersions), KeepDays: int(config.LibraryRetentionDays)}}
if strings.TrimSpace(config.LibraryArchiveRoot) == "" {
response.Reason = "未接线:LIBRARY_ARCHIVE_ROOT 未配置,没有可统计的归档"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
archive, err := librarysnapshot.Open(config.LibraryArchiveRoot, 256*1024*1024)
if err != nil {
response.Reason = "归档根不可用"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
defer archive.Close()
total, err := archive.ContentUsage()
if err != nil {
response.Reason = "归档用量不可读"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
response.Available = true
response.ArchiveRoot = config.LibraryArchiveRoot
response.ContentBytes = total.ContentBytes
response.BlobFileBytes = total.BlobFileBytes
response.BlobAllocatedBytes = total.BlobAllocatedBytes
response.UnreferencedBlobs = total.UnreferencedBlobs
response.UnreferencedBytes = total.UnreferencedBytes
response.ManifestFileBytes = total.ManifestFileBytes
usage, err := archive.Usage()
if err != nil {
response.Reason = "归档逐版本用量不可读"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
usageByID := map[string]librarysnapshot.Usage{}
ids := make([]string, 0, len(usage))
for _, item := range usage {
usageByID[item.VersionID] = item
ids = append(ids, item.VersionID)
}
sort.Strings(ids)
// 版本类型与保护依据来自持久登记与在飞作业,不靠文件时间猜。
kinds := map[string]governance.LibraryVersion{}
protectedBy := map[string][]string{}
store, storeOK := libraryStore(config)
if storeOK {
for _, scope := range []string{"internal", "external"} {
versions, listErr := store.LibraryVersions(r.Context(), scope, 100)
if listErr == nil {
for _, item := range versions {
kinds[item.SnapshotID] = item
if item.Kind == governance.LibraryVersionProductionEffective && item.SupersededAt == nil {
protectedBy[item.SnapshotID] = append(protectedBy[item.SnapshotID], "生产生效版本")
}
if item.Kind == governance.LibraryVersionProductionPrebackup {
protectedBy[item.SnapshotID] = append(protectedBy[item.SnapshotID], "生产预备份")
}
}
}
jobs, jobErr := store.ListLibraryJobs(r.Context(), scope, 20)
if jobErr == nil {
for _, job := range jobs {
if job.State != governance.LibraryJobPending && job.State != governance.LibraryJobRunning {
continue
}
for _, id := range []string{job.SnapshotID, job.TargetSnapshotID} {
if id != "" {
protectedBy[id] = append(protectedBy[id], "在飞作业引用")
}
}
}
}
}
} else {
response.Notes = append(response.Notes, "版本登记存储不可用:只按归档文件统计,无法识别受保护版本")
}
versions := make([]libraryretention.Version, 0, len(ids))
latestSealed := map[string]time.Time{}
var requiredBytes int64
for _, id := range ids {
manifest, loadErr := archive.Load(id)
if loadErr != nil {
response.Reason = "归档快照校验失败"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
kind := "unregistered"
if record, ok := kinds[id]; ok {
kind = string(record.Kind)
} else if manifest.ValidationPolicy != "" {
kind = "test_sealed"
}
if kind == "test_sealed" {
// 每库最近一次封存受保护:它是最新可发布的测试版本。
if current, seen := latestSealed[manifest.Scope]; !seen || manifest.CreatedAt.After(current) {
latestSealed[manifest.Scope] = manifest.CreatedAt
}
}
item := usageByID[id]
view := storageVersionView{ID: id, Kind: kind, Scope: manifest.Scope, CreatedAt: manifest.CreatedAt,
ExclusiveBytes: item.ExclusiveBytes, SharedBytes: item.SharedBytes}
versions = append(versions, libraryretention.Version{ID: id, Kind: kind, CreatedAt: manifest.CreatedAt,
ExclusiveBytes: item.ExclusiveBytes, SharedBytes: item.SharedBytes})
response.Versions = append(response.Versions, view)
}
// 用与上面相同的排序再定位一次最新测试封存,避免把时间映射回 ID 时出错。
for _, id := range ids {
manifest, loadErr := archive.Load(id)
if loadErr != nil {
continue
}
if kindOf := kinds[id].Kind; kindOf == governance.LibraryVersionTestSealed ||
(kindOf == "" && manifest.ValidationPolicy != "") {
if manifest.CreatedAt.Equal(latestSealed[manifest.Scope]) {
protectedBy[id] = append(protectedBy[id], "最近测试封存("+manifest.Scope+")")
}
}
}
for index := range versions {
if reasons := protectedBy[versions[index].ID]; len(reasons) > 0 {
versions[index].Protected = true
versions[index].ProtectedBy = reasons
}
}
for index := range response.Versions {
if reasons := protectedBy[response.Versions[index].ID]; len(reasons) > 0 {
response.Versions[index].Protected = true
response.Versions[index].ProtectedBy = reasons
}
}
now := time.Now().UTC()
plan := libraryretention.Build(versions, total.ContentBytes, response.Policy, now)
response.Plan = &plan
response.Notes = append(response.Notes, "候选版本只是清理预览;共享 blob 不会因删除单个版本释放")
response.Notes = append(response.Notes, "自动清理关闭,页面不提供删除动作;实际回收需要单独授权")
if !plan.PolicyConfigured {
response.Notes = append(response.Notes, "保留数量/天数未设置(LIBRARY_RETENTION_VERSIONS / LIBRARY_RETENTION_DAYS)")
}
for index := range response.Versions {
item := response.Versions[index]
if item.Kind == "test_sealed" && item.CreatedAt.Equal(latestSealed[item.Scope]) {
requiredBytes = item.ExclusiveBytes + item.SharedBytes
break
}
}
response.Capacity = libraryretention.CheckCapacity(total.ContentBytes, requiredBytes, config.LibraryCapacityBudgetBytes)
writeJSON(w, http.StatusOK, response)
}