00c4c13283
build / build (push) Successful in 1m19s
列表正文改为按需读取;保留完整导出、版本隔离和有效标签筛选。 验证:前端全量、Go全量及隔离PG全mirror竞态通过;覆盖首批回调、续载失败与筛选分页。
200 lines
6.0 KiB
Go
200 lines
6.0 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")
|
|
}
|
|
}
|
|
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})
|
|
}
|