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

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

168 lines
5.1 KiB
Go

package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"knowledge-governance/internal/dobotaiops"
)
// No raw SQL, source credential, arbitrary tenant or write method crosses this boundary.
type QAOpsReader interface {
ListRecords(context.Context, dobotaiops.RecordQuery) (dobotaiops.RecordPage, error)
GetRecord(context.Context, string, string) (dobotaiops.Record, error)
Available(context.Context) error
Safety() dobotaiops.SourceSafety
}
func authorizeQAOps(w http.ResponseWriter, r *http.Request) bool {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
identity, ok := sessionFrom(r.Context())
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "问答运营需要登录后访问"})
return false
}
// Until the employee-data visibility scope is approved, do not widen access
// to all pre-existing viewers/engineers merely because this endpoint is GET.
if identity.Role != roleAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "尚未配置员工问答可见范围,当前仅管理员可访问"})
return false
}
return true
}
func qaOpsStatus(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
state := "disabled"
code := "source_not_configured"
warnings := []string{}
if config.QAOpsEnabled {
state = "unavailable"
code = "source_unavailable"
}
if config.QAOpsProblem != "" {
state = "blocked"
code = "source_configuration_invalid"
}
if config.QAOps != nil && config.QAOps.Available(r.Context()) == nil {
state = "connected"
code = ""
safety := config.QAOps.Safety()
if safety.DatabaseTemp {
warnings = append(warnings, "database_temp_enabled")
}
if !safety.SSL {
warnings = append(warnings, "database_transport_unencrypted")
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"}})
}
func listQAOpsRecords(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
if config.QAOps == nil || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
values := r.URL.Query()
allowed := map[string]bool{"start": true, "end": true, "keyword": true, "cursor": true, "limit": true}
for key, items := range values {
if !allowed[key] || len(items) != 1 {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
window, err := dobotaiops.NewWindow(config.QAOpsTenantKey, values.Get("start"), values.Get("end"), 90*24*time.Hour, config.Now())
if err != nil {
qaOpsError(w, err)
return
}
limit := 20
if raw := values.Get("limit"); raw != "" {
limit, err = strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 100 {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
}
projection, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
page, err := config.QAOps.ListRecords(r.Context(), dobotaiops.RecordQuery{Window: window, Keyword: values.Get("keyword"), Cursor: values.Get("cursor"), Limit: limit})
if err != nil {
qaOpsError(w, err)
return
}
enrichQAPage(&page, projection)
if err := enrichQAQualityPage(r.Context(), config, &page); err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": page})
}
func getQAOpsRecord(w http.ResponseWriter, r *http.Request, config Config) {
if !authorizeQAOps(w, r) {
return
}
if config.QAOps == nil || config.QAOpsTenantKey == "" {
qaOpsError(w, dobotaiops.ErrUnavailable)
return
}
if r.URL.RawQuery != "" {
qaOpsError(w, dobotaiops.ErrInvalidQuery)
return
}
projection, err := recordAttribution(r.Context(), config)
if err != nil {
qaOpsError(w, err)
return
}
item, err := config.QAOps.GetRecord(r.Context(), config.QAOpsTenantKey, r.PathValue("id"))
if err != nil {
qaOpsError(w, err)
return
}
enrichQARecord(&item, projection)
if err := enrichQAQualityRecord(r.Context(), config, &item); err != nil {
writeQualityError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": item})
}
func qaOpsError(w http.ResponseWriter, err error) {
status := http.StatusServiceUnavailable
code := "source_unavailable"
message := "问答数据源不可用,请检查独立只读连接;原有知识治理功能不受影响"
if errors.Is(err, errQAOrganization) {
code = "organization_unavailable"
message = "通讯录或区域配置读取失败,未将不可用数据伪装成空列,请重试"
}
if errors.Is(err, dobotaiops.ErrInvalidQuery) {
status = http.StatusBadRequest
code = "invalid_query"
message = "查询参数无效:请检查时间范围、关键词长度或重新查询后翻页"
}
if errors.Is(err, dobotaiops.ErrNotReadOnly) {
code = "source_not_read_only"
message = "问答连接权限校验未通过,已阻止读取,请联系管理员"
}
if errors.Is(err, dobotaiops.ErrRecordNotFound) {
status = http.StatusNotFound
code = "record_not_found"
message = "未找到当前授权范围内的问答记录"
}
writeJSON(w, status, map[string]string{"error": message, "code": code})
}