Files
knowledge-governance/internal/httpapi/source_available.go
T
hermes 017cbcb851
build / build (push) Failing after 16s
feat(api): 数据源统一列举与添加前校验
GET /source-catalog/available 四类同一形状(wiki/drive/doc/pp),类型差异只放在 content_type;
POST /source-catalog/validate 真读一次:文档表格校验可读、知识库钉盘校验账号仍可见,失败不让添加。
2026-10-26 17:22:09 +08:00

201 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"knowledge-governance/internal/dingtalkdiscovery"
)
// dingtalkDocLister 是本路由需要的文档能力面:列在线文档/表格、并真实读一次确认可读。
// 与知识库/钉盘共用同一份 dws 凭据,注入方在 cmd/api。
type dingtalkDocLister interface {
ListDingtalkDocuments(ctx context.Context, query string, limit int) (dingtalkdiscovery.DocResult, error)
ValidateDingtalkDocument(ctx context.Context, node string) (dingtalkdiscovery.DocumentNode, error)
}
// availableItem 是「添加数据源」向导第一步展示的候选条目,四类来源同一形状。
type availableItem struct {
Ref string `json:"ref"`
Name string `json:"name"`
Kind string `json:"kind"`
ContentType string `json:"content_type,omitempty"`
URL string `json:"url,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
var availableSourceTypes = map[string]bool{"wiki": true, "drive": true, "doc": true, "pp": true}
// listAvailableSources 处理 GET /api/v1/source-catalog/available?type=&q=&limit=:
// 向导用它列「可加入的数据源」。类型不同,形状相同——前端不需要为每类写一套。
func listAvailableSources(w http.ResponseWriter, r *http.Request, config Config) {
if _, ok := requireSessionIdentity(w, r); !ok {
return
}
kind := strings.TrimSpace(r.URL.Query().Get("type"))
if !availableSourceTypes[kind] {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type 必须是 wiki、drive、doc 或 pp"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
limit := 50
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 200 {
limit = parsed
}
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
items := make([]availableItem, 0, 16)
notice := ""
switch kind {
case "wiki", "drive":
lister := config.DingtalkSources
if lister == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "dingtalk source discovery is not wired"})
return
}
result, err := lister.ListAvailableDingtalkSources(ctx)
if err != nil {
writeDiscoveryFailure(w, err)
return
}
if kind == "wiki" {
for _, base := range result.KnowledgeBases {
items = append(items, availableItem{Ref: base.URL, Name: base.Name, Kind: "wiki", URL: base.URL})
}
notice = result.KnowledgeNotice
} else {
for _, space := range result.DriveSpaces {
items = append(items, availableItem{Ref: dingtalkdiscovery.SpaceURL(space.SpaceID), Name: space.Name, Kind: "drive", URL: dingtalkdiscovery.SpaceURL(space.SpaceID)})
}
notice = result.DriveNotice
}
case "doc":
lister := config.DingtalkDocuments
if lister == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "dingtalk document discovery is not wired"})
return
}
result, err := lister.ListDingtalkDocuments(ctx, query, limit)
if err != nil {
writeDiscoveryFailure(w, err)
return
}
for _, doc := range result.Documents {
items = append(items, availableItem{
Ref: doc.NodeID, Name: doc.Name, Kind: "doc",
ContentType: doc.ContentType, URL: doc.URL, UpdatedAt: doc.UpdatedAt,
})
}
notice = result.Notice
case "pp":
// PP 是公开只读目录:没有可枚举的账号级列表,向导要求直接给资料中心地址,
// 由第 4 步的校验去真打一次。这里返回空列表并说明原因,不伪装成"没权限"。
notice = "PP 资料下载中心没有可列举的账号级列表,请在第 2 步填写资料中心地址。"
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"type": kind, "items": items, "notice": notice, "fetched_at": time.Now().UTC(),
}})
}
// validateSourceSelection 处理 POST /api/v1/source-catalog/validate:
// 添加前真读一次,读不到就不让进下一步——避免加入一堆永远同步不了的来源。
func validateSourceSelection(w http.ResponseWriter, r *http.Request, config Config) {
if _, ok := requireSessionIdentity(w, r); !ok {
return
}
var body struct {
Type string `json:"type"`
Refs []string `json:"refs"`
}
if decodeJSONStrict(r, &body) != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request_body"})
return
}
kind := strings.TrimSpace(body.Type)
if !availableSourceTypes[kind] || len(body.Refs) == 0 || len(body.Refs) > 50 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "type 无效,或一次校验的条目数不在 1–50 之间"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
switch kind {
case "doc":
lister := config.DingtalkDocuments
if lister == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "dingtalk document discovery is not wired"})
return
}
verified := make([]map[string]string, 0, len(body.Refs))
for _, ref := range body.Refs {
doc, err := lister.ValidateDingtalkDocument(ctx, ref)
if err != nil {
writeDiscoveryFailure(w, err)
return
}
verified = append(verified, map[string]string{"ref": doc.NodeID, "name": doc.Name, "content_type": doc.ContentType})
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"ok": true, "type": kind, "items": verified, "discovered": len(verified),
}})
case "wiki", "drive":
lister := config.DingtalkSources
if lister == nil {
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "dingtalk source discovery is not wired"})
return
}
result, err := lister.ListAvailableDingtalkSources(ctx)
if err != nil {
writeDiscoveryFailure(w, err)
return
}
known := map[string]string{}
if kind == "wiki" {
for _, base := range result.KnowledgeBases {
known[base.URL] = base.Name
}
} else {
for _, space := range result.DriveSpaces {
known[dingtalkdiscovery.SpaceURL(space.SpaceID)] = space.Name
}
}
verified := make([]map[string]string, 0, len(body.Refs))
for _, ref := range body.Refs {
name, ok := known[strings.TrimSpace(ref)]
if !ok {
writeJSON(w, http.StatusConflict, map[string]string{"error": "当前钉钉账号已看不到该来源,请刷新后再选"})
return
}
verified = append(verified, map[string]string{"ref": ref, "name": name})
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
"ok": true, "type": kind, "items": verified, "discovered": len(verified),
}})
case "pp":
// PP 的可用性由真正的下载探测在登记流程里判定,这里只做形式校验。
for _, ref := range body.Refs {
if !strings.HasPrefix(strings.TrimSpace(ref), "http") {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "PP 需要 http(s) 资料中心地址"})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"ok": true, "type": kind, "discovered": len(body.Refs)}})
}
}
func writeDiscoveryFailure(w http.ResponseWriter, err error) {
if errors.Is(err, dingtalkdiscovery.ErrUnavailable) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "dingtalk_discovery_unavailable",
"hint": "dws 未登录或缺少读权限;请刷新钉钉会话,或让管理员检查 dws 凭据。",
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list sources"})
}