Files
knowledge-governance/internal/httpapi/library_capture.go
姚章浩 dc9e827656
build / build (push) Failing after 17s
feat(library): 整库版本/作业持久化与状态读取契约,生产同步默认拒绝
- 新增 library_versions:测试封存、生产生效、生产预备份三类分开登记,
  生产生效版本唯一(旧版本必须先 supersede),测试快照不能顶替生产版本
- 新增 library_jobs + library_job_stages:业务作业身份、阶段、错误与恢复说明,
  分项计数用 NULL 表示"总量未知";互斥与幂等由数据库唯一索引保证
- 新增 GET /api/v1/library/overview、/jobs、/jobs/{id};POST /syncs 闸口未齐
  返回 503 缺项且不建作业
- 封存作业落库:先建业务作业再入队,worker 推进 export 阶段并登记 test_sealed
- 发布管理读真实状态:生产未知不再顶替、差异 unknown、启动同步按缺项禁用、
  同步明细/四类分项来自持久阶段;阶段勾选只认当前作业证据
- 准备信息标注封存时间与范围,不声称实时测试库已就绪
2026-10-28 13:58:51 +08:00

195 lines
7.9 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
)
// 页面发起封存:只投递一个作业,真正的停机/导出/恢复发生在主机侧 agent。
// 这里没有任何 Docker 访问,也没有来源凭据 —— 调用方只能选择范围与作业号。
const libraryCaptureJobType = "library.capture"
var libraryCaptureJobPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
type libraryCaptureRequest struct {
Scope string `json:"scope"`
Job string `json:"job"`
}
func libraryCaptureEnqueue(w http.ResponseWriter, r *http.Request, config Config) {
if config.LibraryAgentSocket == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"code": "library_capture_unwired",
"reason": "未接线:主机侧封存 agent 没有挂载,页面无法发起封存",
})
return
}
var request libraryCaptureRequest
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
}
// The job id is the idempotency key handed to the agent: repeating the same
// scope+job returns the already sealed version instead of sealing twice.
captureJob := strings.TrimSpace(request.Job)
if captureJob == "" {
captureJob = "lib-" + request.Scope + "-" + time.Now().UTC().Format("20060102T150405")
}
if !libraryCaptureJobPattern.MatchString(captureJob) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid job id"})
return
}
requestedBy := ""
if identity, ok := sessionFrom(r.Context()); ok {
requestedBy = identity.Username
}
payload, err := json.Marshal(map[string]string{
"scope": request.Scope, "capture_job": captureJob, "requested_by": requestedBy,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot build capture job"})
return
}
now := time.Now().UTC()
// 业务作业先建:互斥由 library_jobs 的唯一索引保证,这样不会出现"队列里排着两个封存"。
// 队列投递失败或请求冲突时把业务作业取消,不留下永远 pending 的占位。
libraryJobID := "libseal-" + captureJob
createdSealJob := false
if store, ok := libraryStore(config); ok {
digest, digestErr := governance.LibraryRequestDigest(request.Scope, governance.LibraryJobSeal,
map[string]any{"capture_job": captureJob})
if digestErr == nil {
sealJob, reusedSeal, sealErr := store.CreateLibraryJob(r.Context(), governance.LibraryJob{
ID: libraryJobID, Scope: request.Scope, Kind: governance.LibraryJobSeal,
IdempotencyKey: "library.seal:" + captureJob, RequestDigest: digest, Actor: requestedBy,
}, governance.SealStages(), now)
switch {
case errors.Is(sealErr, governance.ErrLibraryJobActive):
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_seal_active", "error": "该目标已有在飞的封存作业,等它结束再发起",
})
return
case errors.Is(sealErr, governance.ErrLibraryJobIdempotencyConflict):
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_seal_scope_conflict", "error": "同一作业号已按另一个范围封存",
})
return
case sealErr != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot create seal job"})
return
case !reusedSeal:
createdSealJob = true
default:
libraryJobID = sealJob.ID
}
}
}
requestedID := fmt.Sprintf("library-capture-%s-%d", request.Scope, now.UnixNano())
// The idempotency key is the capture job id: the same logical capture must map
// to one queue row. Binding the scope into the key would silently create a
// second job instead of reporting a conflicting request, so the conflict is
// detected by comparing the stored payload below.
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
ID: requestedID,
Type: libraryCaptureJobType,
Payload: payload,
AvailableAt: now,
MaxAttempts: 2,
IdempotencyKey: "library.capture:" + captureJob,
}, now)
if err != nil || job == nil {
if store, ok := libraryStore(config); ok && createdSealJob {
_ = store.CancelLibraryJob(r.Context(), libraryJobID, "队列投递失败,未开始封存", now)
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot enqueue capture job"})
return
}
if store, ok := libraryStore(config); ok {
if attachErr := store.AttachLibraryQueueJob(r.Context(), libraryJobID, job.ID, now); attachErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot attach capture job"})
return
}
}
// Enqueue returns the existing row for a reused key. Answer from that row, not
// from the current request: a reused job id with another scope must be refused
// instead of being reported as a job for the new scope.
var stored struct {
Scope string `json:"scope"`
CaptureJob string `json:"capture_job"`
RequestedBy string `json:"requested_by"`
}
cancelSeal := func(reason string) {
if store, ok := libraryStore(config); ok && createdSealJob {
_ = store.CancelLibraryJob(r.Context(), libraryJobID, reason, now)
}
}
if err = json.Unmarshal(job.Payload, &stored); err != nil || stored.CaptureJob != captureJob {
cancelSeal("请求冲突,未开始封存")
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_capture_conflict", "error": "该作业号已被另一个封存请求占用",
"existing_scope": stored.Scope, "requested_scope": request.Scope,
})
return
}
if stored.Scope != request.Scope {
cancelSeal("同一作业号已按另一个范围投递")
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_capture_scope_conflict", "error": "同一作业号已按另一个范围投递",
"existing_scope": stored.Scope, "requested_scope": request.Scope,
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"job_id": job.ID, "capture_job": stored.CaptureJob, "scope": stored.Scope,
"state": job.State, "reused": job.ID != requestedID, "requested_by": stored.RequestedBy,
})
}
// libraryCaptureJobs lists recent capture jobs so the page can show real state
// instead of an optimistic spinner. It reads the queue, not the archive.
func libraryCaptureJobs(w http.ResponseWriter, r *http.Request, config Config) {
// Ask the store for this type only. Reading the global queue and filtering in
// the handler would hide capture jobs behind unrelated traffic and make the
// page claim "idle" while a capture is still queued.
byType, ok := config.Store.(interface {
ListByType(ctx context.Context, jobType string, limit int) ([]jobs.Job, error)
})
if !ok {
writeJSON(w, http.StatusOK, map[string]any{"available": false,
"reason": "任务存储不支持按类型查询,页面不会据此判断空闲", "jobs": []any{}})
return
}
queue, err := byType.ListByType(r.Context(), libraryCaptureJobType, 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read capture jobs"})
return
}
views := make([]map[string]any, 0, len(queue))
for _, job := range queue {
var payload struct {
Scope string `json:"scope"`
CaptureJob string `json:"capture_job"`
RequestedBy string `json:"requested_by"`
}
_ = json.Unmarshal(job.Payload, &payload)
views = append(views, map[string]any{"job_id": job.ID, "capture_job": payload.CaptureJob,
"scope": payload.Scope, "state": job.State, "attempt": job.Attempt,
"last_error": job.LastError, "requested_by": payload.RequestedBy,
"created_at": job.CreatedAt, "updated_at": job.UpdatedAt})
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "jobs": views})
}