8d838d9cb4
build / build (push) Successful in 1m45s
执行时真实时钟与持久授权回读(撤销/过期即停并保留预备份)、写集合写前落盘、 租约与 attempt 身份、恢复队列与原发送身份分离、成功收尾必须释放维护后再登记终态。
136 lines
5.8 KiB
Go
136 lines
5.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"knowledge-governance/internal/governance"
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
)
|
|
|
|
// 建立目标权威基线:首次发布时目标已经有内容,却没有生效版本记录。
|
|
//
|
|
// 唯一诚实的解法是"维护隔离 + 真实整库捕获 + 逐类校验 + 登记版本":
|
|
// - 不手写 production_effective,不伪造验收;
|
|
// - 不清空目标换库绕过(那会把"非空但未知"变成"空");
|
|
// - 页面只投递作业,停写入者、捕获、校验都在 worker 里执行。
|
|
//
|
|
// 这个入口只建作业,不签发发布授权:基线不等于发布,同步仍然要单独的 sync 授权。
|
|
const libraryBaselineJobType = "library.baseline"
|
|
|
|
type libraryBaselineRequest struct {
|
|
Scope string `json:"scope"`
|
|
}
|
|
|
|
func libraryBaselineEnqueue(w http.ResponseWriter, r *http.Request, config Config) {
|
|
if r.Method != http.MethodPost {
|
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
|
return
|
|
}
|
|
identity, ok := sessionFrom(r.Context())
|
|
if !ok || roleRank(identity.Role) < roleRank(roleAdmin) {
|
|
writeJSON(w, http.StatusForbidden, map[string]any{"error": "建立目标权威基线仅管理员可发起"})
|
|
return
|
|
}
|
|
var request libraryBaselineRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
if request.Scope != "internal" && request.Scope != "external" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
|
|
return
|
|
}
|
|
store, ok := libraryStore(config)
|
|
if !ok {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"code": "library_baseline_unavailable",
|
|
"error": "版本登记存储不可用"})
|
|
return
|
|
}
|
|
// 建基线的前提是"这个目标还没有生效版本"。这是创建判据,必须精确查当前生效版本:
|
|
// 用"最近 N 条"会在窗口满时把已生效读成没有,放行一次会覆盖他人版本的基线捕获。
|
|
current, hasCurrent, err := store.LibraryCurrentVersion(r.Context(), request.Scope)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read current library version"})
|
|
return
|
|
}
|
|
if hasCurrent {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_baseline_exists",
|
|
"error": "该目标已有生效版本,不需要再建基线;换版本请走同步或恢复",
|
|
"snapshot_id": current.SnapshotID})
|
|
return
|
|
}
|
|
// 目标必须真的读得到:读不到就不是"非空无权威版本",而是另一个问题,
|
|
// 不允许用建基线去掩盖连接/配置故障。
|
|
probe := config.LibraryScopeProbe
|
|
if probe == nil {
|
|
probe = func(ctx context.Context, scope string) (bool, map[string]int64, error) {
|
|
return productionScopeState(ctx, config, scope)
|
|
}
|
|
}
|
|
empty, counts, probeErr := probe(r.Context(), request.Scope)
|
|
if probeErr != nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"code": "library_baseline_unprobeable",
|
|
"error": "无法读取目标整库状态,不能建立基线:" + probeErr.Error()})
|
|
return
|
|
}
|
|
if empty {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_baseline_target_empty",
|
|
"error": "目标整库为空,不需要基线:首次同步会在写入前生成完整预备份并登记生效版本",
|
|
"counts": counts})
|
|
return
|
|
}
|
|
// 维护互斥与同步一致:基线要在目标上开维护窗口,目标正在维护时直接拒绝。
|
|
if body, held := instanceMaintenanceGuard(r.Context(), config, request.Scope); held {
|
|
writeJSON(w, http.StatusConflict, body)
|
|
return
|
|
}
|
|
if maintenanceStore, ok := config.Store.(interface {
|
|
LibraryMaintenanceOf(ctx context.Context, scope string) (*postgresstore.LibraryMaintenance, error)
|
|
}); ok {
|
|
if held, err := maintenanceStore.LibraryMaintenanceOf(r.Context(), request.Scope); err == nil && held != nil && held.ReleasedAt == nil {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_target_in_maintenance",
|
|
"error": "目标正在维护中,验证完成前不接受新的写入请求",
|
|
"owner_job": held.OwnerJobID, "state": held.State})
|
|
return
|
|
}
|
|
}
|
|
digest, err := governance.LibraryRequestDigest(request.Scope, governance.LibraryJobBaseline,
|
|
map[string]any{"scope": request.Scope})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot digest request"})
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
job, reused, err := store.CreateLibraryDispatch(r.Context(), governance.LibraryJob{
|
|
ID: fmt.Sprintf("libbase-%s-%d", request.Scope, now.UnixNano()), Scope: request.Scope,
|
|
Kind: governance.LibraryJobBaseline,
|
|
IdempotencyKey: "library.baseline:" + request.Scope + ":" + now.Format("20060102T150405.000000000"),
|
|
RequestDigest: digest, Actor: identity.Username,
|
|
}, governance.BaselineStages(), false, now)
|
|
if errors.Is(err, governance.ErrLibraryJobActive) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_write_active",
|
|
"error": "该目标已有在飞的写作业(同步/恢复/基线互斥),等它结束再发起"})
|
|
return
|
|
}
|
|
if errors.Is(err, governance.ErrLibraryJobIdempotencyConflict) {
|
|
writeJSON(w, http.StatusConflict, map[string]any{"code": "library_baseline_conflict",
|
|
"error": "同一幂等键对应了不同的请求"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot create library baseline job"})
|
|
return
|
|
}
|
|
if reused {
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"job": job, "reused": true})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"job": job, "reused": false,
|
|
"target_counts": counts})
|
|
}
|