e508abd958
build / build (push) Successful in 1m18s
同步目标与封存源常常不是同一个实例(kgseal vs kgmaint);用目标名去绑来源死信,
真实观测的 project 永远对不上,解除会被一律拒绝。新增 LIBRARY_SOURCE_INSTANCE,
留空回落到目标实例名,API 写记录与 worker 取指纹共用同一个来源实例名。
试过:真实 API GET/POST、go test ./internal/{httpapi,appconfig} 与 make verify 全绿。
105 lines
4.1 KiB
Go
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})
|
|
}
|