Files
knowledge-governance/internal/httpapi/library_capture.go
T
姚章浩 f2d6f3f646
build / build (push) Successful in 1m17s
fix(httpapi): 封存队列作业 id 按 jobs.id 列宽拼短
真实请求 POST /api/v1/library/captures 一律 500:拼出来的
"library-capture-<scope>-<nano>" 是 45 个字符,而 jobs.id 是 varchar(36),
PG 直接拒绝(value too long)。此前页面一直停在"未接线",这条路径从没被
真实请求覆盖过,所以只有接上 agent 之后才暴露。

改为 libcap-<scope>-<nano>(35 字符),并用测试锁住列宽与形状。

试过:go test ./internal/httpapi -run LibraryCapture 全绿;修复前用真实
DATABASE_URL 复现 enqueue error: value too long for type character varying(36)。
2026-10-29 06:05:16 +08:00

198 lines
8.2 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 (
"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
}
}
}
// 队列作业 id 落进 jobs.id(varchar(36)):原来拼 "library-capture-<scope>-<nano>"
// 是 45 个字符,真实请求 100% 被 PG 拒(value too long)——只是页面一直停在
// "未接线"上,没人走到这里。这里按列宽拼短 id,并留出 scope 全称。
requestedID := fmt.Sprintf("libcap-%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})
}