Files
knowledge-governance/internal/httpapi/library_deadletters.go
T
姚章浩 e508abd958
build / build (push) Successful in 1m18s
fix(library): 死信解除绑定封存源实例而不是同步目标
同步目标与封存源常常不是同一个实例(kgseal vs kgmaint);用目标名去绑来源死信,
真实观测的 project 永远对不上,解除会被一律拒绝。新增 LIBRARY_SOURCE_INSTANCE,
留空回落到目标实例名,API 写记录与 worker 取指纹共用同一个来源实例名。

试过:真实 API GET/POST、go test ./internal/{httpapi,appconfig} 与 make verify 全绿。
2026-10-29 07:13:18 +08:00

105 lines
4.1 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/librarydeadletter"
)
// 死信解除:上游 task_dead_letters 没有"已解决"标记。这里只写治理库的审计记录,
// 永不删除上游行;解绑的是精确指纹,行一变就不再命中,封存照旧阻断。
type deadLetterReleaseStore interface {
RecordDeadLetterRelease(context.Context, governance.LibraryDeadLetterRelease) (governance.LibraryDeadLetterRelease, bool, error)
DeadLetterReleases(context.Context, string, int) ([]governance.LibraryDeadLetterRelease, error)
}
type deadLetterReleaseRequest struct {
DeadLetterID int64 `json:"dead_letter_id"`
Replay librarydeadletter.Replay `json:"replay"`
Note string `json:"note"`
Observation json.RawMessage `json:"observation"`
ObservationDigest string `json:"observation_digest"`
}
func deadLetterReleaseHandlers(w http.ResponseWriter, r *http.Request, config Config) {
identity, ok := requireSessionIdentity(w, r)
if !ok {
return
}
store, ok := config.Store.(deadLetterReleaseStore)
if !ok {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "死信解除存储不可用"})
return
}
instance := strings.TrimSpace(config.SourceInstance())
if instance == "" {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"code": "dead_letter_release_unwired", "reason": "未接线:没有配置来源实例身份,无法绑定解除记录",
})
return
}
if r.Method == http.MethodGet {
releases, err := store.DeadLetterReleases(r.Context(), instance, 100)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "读取死信解除记录失败"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"instance": instance, "releases": releases})
return
}
// 写操作需要工程师及以上,并要求显式意图头:避免普通点击意外解除。
if roleRank(identity.Role) < roleRank(roleEngineer) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
if r.Header.Get("X-Governance-Action") != "dead-letter-release" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "write intent required"})
return
}
var request deadLetterReleaseRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
now := time.Now().UTC()
release, err := librarydeadletter.Verify(librarydeadletter.Request{
Project: instance, DeadLetterID: request.DeadLetterID, Replay: request.Replay,
Note: request.Note, Observation: request.Observation, Digest: request.ObservationDigest,
}, instance, now)
if err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"code": "dead_letter_release_rejected", "error": err.Error()})
return
}
stored, reused, err := store.RecordDeadLetterRelease(r.Context(), governance.LibraryDeadLetterRelease{
ID: governance.LibraryDeadLetterReleaseID(instance, release.Fingerprint),
Instance: instance,
TenantID: release.TenantID,
KnowledgeBaseID: release.KnowledgeBaseID,
DeadLetterID: release.DeadLetterID,
Fingerprint: release.Fingerprint,
TaskType: release.TaskType,
Scope: release.Scope,
ScopeID: release.ScopeID,
RelatedID: release.RelatedID,
ReplayQueue: release.Replay.Queue,
ReplayTaskID: release.Replay.TaskID,
ReplayedAt: release.Replay.At,
ReplayEvidence: release.Replay.Evidence,
ObservationDigest: release.Digest,
Observation: release.Observation,
Note: release.Note,
Actor: identity.Username,
CreatedAt: now,
})
if err != nil {
writeJSON(w, http.StatusConflict, map[string]any{"code": "dead_letter_release_conflict", "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"release": stored, "reused": reused})
}