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"}) }