f91359f881
build / build (push) Successful in 1m15s
为什么:存储页原来只有占用数字,保留/依赖保护/清理预览写"未接线";这些都不需要生产写权限, 按"未设置就不做决定"的口径实现成只读计算。 - 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)。
202 lines
8.0 KiB
Go
202 lines
8.0 KiB
Go
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)
|
||
}
|