dc9e827656
build / build (push) Failing after 17s
- 新增 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、启动同步按缺项禁用、
同步明细/四类分项来自持久阶段;阶段勾选只认当前作业证据
- 准备信息标注封存时间与范围,不声称实时测试库已就绪
195 lines
7.9 KiB
Go
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})
|
|
}
|