533db535f1
build / build (push) Successful in 1m12s
按已确认需求提供独立安全模型配置、真实探测、正式字典字段评估与原问题译中,持久批次支持只补空白、暂停恢复、有限重试、幂等和逐字段依据。覆盖固定规则、本地语言、组织关联与主表有效看板共用治理侧读模型,保留源生产和冻结历史。 验证:完整 make verify 退出0,702项Go与329项前端通过;默认118项外部/隔离集成明确跳过,另跑完整镜像及组织隔离PG竞态。12并发、输入漂移、过期租约、暂停/恢复及有限重试通过。用户授权的百炼 deepseek-v4.1-flash 完成真实后台与浏览器评估/翻译;552行、16列、11块看板及完整PNG、独立配置页测试/保存、重开恢复和零JS异常通过。仅非业务样本外发,自动处理关闭,原payload不变。 上线前治理库已做导出快照原生备份并真实恢复,60表行数与17个QA表内容哈希一致;不修改DobotAI生产或隧道。本提交时尚未部署,线上验收须独立完成。
209 lines
6.2 KiB
Go
209 lines
6.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"knowledge-governance/internal/qaopsai"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
func qaAIError(w http.ResponseWriter, err error) {
|
|
status, code, message := 503, "qa_ai_unavailable", "问答AI存储或安全配置不可用,请检查独立模型配置与执行器;未将失败伪装为成功或空结果"
|
|
switch {
|
|
case errors.Is(err, qaopsai.ErrInvalid):
|
|
status, code, message = 400, "qa_ai_invalid_request", "参数、正式标签或模型输出校验未通过;更换API地址时须重新录入密钥,不能把旧密钥发送到新地址"
|
|
case errors.Is(err, qaopsai.ErrConflict):
|
|
status, code, message = 409, "qa_ai_version_conflict", "配置、输入、人工复核或预览已变化,请刷新;现有结论未被覆盖"
|
|
default:
|
|
var remote *qaopsai.RemoteError
|
|
if errors.As(err, &remote) {
|
|
status, code, message = 422, "qa_ai_"+remote.Code, "模型服务明确拒绝或未返回合格结果,配置未启用;请核对密钥、模型与JSON输出支持"
|
|
}
|
|
}
|
|
writeJSON(w, status, map[string]string{"error": message, "code": code})
|
|
}
|
|
func qaAIReady(w http.ResponseWriter, r *http.Request, c Config) bool {
|
|
if !authorizeQAOps(w, r) {
|
|
return false
|
|
}
|
|
if r.Method != "GET" && !authorizeQAIntent(w, r, "qa-ai") {
|
|
return false
|
|
}
|
|
if c.QAAI == nil {
|
|
qaAIError(w, qaopsai.ErrUnavailable)
|
|
return false
|
|
}
|
|
if r.URL.RawQuery != "" {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
func qaAISecretTransport(r *http.Request, c Config) bool {
|
|
if r.TLS != nil {
|
|
return true
|
|
}
|
|
host := r.Host
|
|
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
|
host = parsed
|
|
}
|
|
if strings.EqualFold(host, "localhost") {
|
|
return true
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
|
return true
|
|
}
|
|
if c.QAAITrustTLSProxy && r.Header.Get("X-Forwarded-Proto") == "https" && r.Header.Get("Sec-Fetch-Site") == "same-origin" {
|
|
origin, err := url.Parse(r.Header.Get("Origin"))
|
|
return err == nil && origin.Scheme == "https"
|
|
}
|
|
return false
|
|
}
|
|
func registerQAAIRoutes(mux *http.ServeMux, c Config) {
|
|
mux.HandleFunc("GET /api/v1/qa-ops/ai/settings", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
data, err := c.QAAI.GetSettings(r.Context())
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
for _, test := range []bool{false, true} {
|
|
path := "POST /api/v1/qa-ops/ai/settings"
|
|
if test {
|
|
path += "/test"
|
|
}
|
|
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
var input qaopsai.SaveSettings
|
|
if decodeQualityJSON(w, r, &input, "base_revision", "base_url", "model", "key_action") != nil {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return
|
|
}
|
|
if input.APIKey != "" && !qaAISecretTransport(r, c) {
|
|
writeJSON(w, 400, map[string]string{"code": "qa_ai_secure_transport_required", "error": "密钥仅可通过有效HTTPS或本机回环地址录入;未保存、未发送密钥"})
|
|
return
|
|
}
|
|
identity, _ := sessionFrom(r.Context())
|
|
var data qaopsai.Settings
|
|
var err error
|
|
if test {
|
|
data, err = c.QAAI.TestSettings(r.Context(), input)
|
|
} else {
|
|
data, err = c.QAAI.SaveSettings(r.Context(), input, identity.Username)
|
|
}
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
}
|
|
mux.HandleFunc("POST /api/v1/qa-ops/ai/automation", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
var input struct {
|
|
Kind string `json:"kind"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if decodeQualityJSON(w, r, &input, "kind", "enabled") != nil {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return
|
|
}
|
|
identity, _ := sessionFrom(r.Context())
|
|
data, err := c.QAAI.SetAutomation(r.Context(), input.Kind, input.Enabled, identity.Username)
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
mux.HandleFunc("POST /api/v1/qa-ops/ai/previews", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
var input qaopsai.Scope
|
|
if decodeQualityJSON(w, r, &input, "kind") != nil {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return
|
|
}
|
|
identity, _ := sessionFrom(r.Context())
|
|
data, err := c.QAAI.Preview(r.Context(), input, identity.Username)
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
mux.HandleFunc("POST /api/v1/qa-ops/ai/batches", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
var input struct {
|
|
PreviewID string `json:"preview_id"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
if decodeQualityJSON(w, r, &input, "preview_id", "nonce") != nil {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return
|
|
}
|
|
identity, _ := sessionFrom(r.Context())
|
|
data, err := c.QAAI.Start(r.Context(), input.PreviewID, input.Nonce, identity.Username)
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 202, map[string]any{"data": data})
|
|
})
|
|
mux.HandleFunc("POST /api/v1/qa-ops/ai/batches/{id}/actions", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
var input struct {
|
|
Action string `json:"action"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
if decodeQualityJSON(w, r, &input, "action", "nonce") != nil || r.Header.Get("Idempotency-Key") != input.Nonce {
|
|
qaAIError(w, qaopsai.ErrInvalid)
|
|
return
|
|
}
|
|
identity, _ := sessionFrom(r.Context())
|
|
data, err := c.QAAI.ManageBatch(r.Context(), r.PathValue("id"), input.Action, input.Nonce, identity.Username)
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/qa-ops/ai/status", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
data, err := c.QAAI.GetStatus(r.Context())
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
mux.HandleFunc("GET /api/v1/qa-ops/ai/records/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
if !qaAIReady(w, r, c) {
|
|
return
|
|
}
|
|
data, err := c.QAAI.RecordEvidence(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
qaAIError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"data": data})
|
|
})
|
|
}
|