Files
姚章浩 4b3aaff321 feat(qa-ops): 完成复检留档与人工复核闭环
治理侧保存输入观测版本、自动复检及人工复核历史,输入变化失效、版本冲突和幂等重放不覆盖人工记录。批量质量数量使用完整筛选元数据,未采集原文不伪造快照,延迟旧扫描不回退新输入。

验证:全仓 make verify 退出码0,Go656及前端253通过;五包竞态、源/组织/质量隔离PG、真实只读源和隔离网页读回复核通过。默认91个可选集成跳过不计通过。未接AI模型,不写DobotAI源库,未推送、未合并、未部署。
2026-10-17 22:37:26 +08:00

96 lines
3.0 KiB
Go

package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"knowledge-governance/internal/qaopsorg"
"net/http"
"net/url"
"strings"
)
type QAOrgSyncStore interface {
GetSyncStatus(context.Context) (qaopsorg.SyncStatus, error)
EnqueueSync(context.Context, string, string) (qaopsorg.SyncReceipt, bool, error)
}
func authorizeQAOrganizationWrite(w http.ResponseWriter, r *http.Request) bool {
return authorizeQAIntent(w, r, "qa-organization")
}
func authorizeQAIntent(w http.ResponseWriter, r *http.Request, intent string) bool {
if r.Header.Get("X-Governance-Action") != intent || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
writeJSON(w, 403, map[string]string{"error": "此操作需要同源操作意图"})
return false
}
// Fetch Metadata survives same-origin proxies that rewrite Host; untrusted
// forwarded headers must not grant cross-origin write authority.
if raw := r.Header.Get("Origin"); raw != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin" {
origin, err := url.Parse(raw)
if err != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host != r.Host {
writeJSON(w, 403, map[string]string{"error": "拒绝跨站配置请求"})
return false
}
}
return true
}
func qaOrganizationSync(w http.ResponseWriter, r *http.Request, c Config) {
if !authorizeQAOps(w, r) {
return
}
if r.URL.RawQuery != "" {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodGet {
if c.QAOrgSync == nil {
writeJSON(w, 200, map[string]any{"data": qaopsorg.SyncStatus{}})
return
}
result, err := c.QAOrgSync.GetSyncStatus(r.Context())
if err != nil {
writeOrgError(w, err)
return
}
result.Configured = c.QAOrgSyncEnabled
writeJSON(w, 200, map[string]any{"data": result})
return
}
if !authorizeQAOrganizationWrite(w, r) {
return
}
key := r.Header.Get("Idempotency-Key")
if key == "" || len(key) > 128 || strings.TrimSpace(key) != key {
writeJSON(w, 400, map[string]string{"error": "同步请求需要有效 Idempotency-Key,重试须复用原标识"})
return
}
var body *struct{}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
decoder.DisallowUnknownFields()
if decoder.Decode(&body) != nil || body == nil || decoder.Decode(&struct{}{}) != io.EOF {
writeOrgError(w, qaopsorg.ErrInvalid)
return
}
if !c.QAOrgSyncEnabled || c.QAOrgSync == nil {
writeJSON(w, 503, map[string]string{"error": "通讯录后台同步未接线,未创建任务"})
return
}
identity, _ := sessionFrom(r.Context())
receipt, replayed, err := c.QAOrgSync.EnqueueSync(r.Context(), key, identity.Username)
if err != nil {
if errors.Is(err, qaopsorg.ErrSyncWorkerUnavailable) {
writeJSON(w, 503, map[string]string{"error": "通讯录执行器未在线,未创建任务;请检查后台同步配置"})
return
}
writeOrgError(w, err)
return
}
operation := "created"
if replayed {
operation = "replayed"
}
writeJSON(w, 202, map[string]any{"data": receipt, "operation": operation})
}