017cbcb851
build / build (push) Failing after 16s
GET /source-catalog/available 四类同一形状(wiki/drive/doc/pp),类型差异只放在 content_type; POST /source-catalog/validate 真读一次:文档表格校验可读、知识库钉盘校验账号仍可见,失败不让添加。
201 lines
7.4 KiB
Go
201 lines
7.4 KiB
Go
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"})
|
||
}
|