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") } } data := map[string]any{"state": state, "code": code, "warnings": warnings, "read_only": true, "source_version": "2.0", "channel": "dingtalk"} if mirror, ok := config.QAOps.(qaMirrorSyncStore); ok { cache, err := mirror.GetSyncStatus(r.Context()) if err != nil { qaOpsError(w, err) return } data["cache"] = cache } writeJSON(w, http.StatusOK, map[string]any{"data": data}) } 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, "projection": true} for _, key := range dobotaiops.QualityFilterKeys { allowed[key] = 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 } } filters := map[string]string{} for _, key := range dobotaiops.QualityFilterKeys { if value := values.Get(key); value != "" { filters[key] = value } } if !dobotaiops.ValidQualityFilters(filters) { qaOpsError(w, dobotaiops.ErrInvalidQuery) return } mode := values.Get("projection") if mode != "" && mode != "summary" && mode != "full" { 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, Summary: mode == "summary", Filters: filters}) if err != nil { qaOpsError(w, err) return } enrichQAPage(&page, projection) if err := enrichQAQualityPage(r.Context(), config, &page); err != nil { writeQualityError(w, err) return } if mode == "summary" { for i := range page.Items { page.Items[i] = qaRecordPreview(page.Items[i]) } } 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}) }