Files
knowledge-governance/internal/dingtalkdiscovery/docs.go
T
hermes 020cae1228
build / build (push) Successful in 1m9s
style: gofmt 新增的发现与路由文件
2026-10-26 17:23:27 +08:00

194 lines
6.6 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 dingtalkdiscovery
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// DocumentNode 是一篇钉钉在线文档/表格,用于「钉钉文档·表格」这一类数据源:
// 它不在知识库、也不在钉盘的目录树里,只能按节点单独登记。
type DocumentNode struct {
NodeID string `json:"node_id"`
NodeType string `json:"node_type"`
Name string `json:"name"`
URL string `json:"url"`
ContentType string `json:"content_type"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// DocResult 是一次文档发现的结果。Notice 说明「为什么没列全」,不把失败伪装成空列表。
type DocResult struct {
Documents []DocumentNode `json:"documents"`
Notice string `json:"notice,omitempty"`
FetchedAt time.Time `json:"fetched_at"`
}
// RecentDocsArgs 列出最近编辑的文档;没有关键词时的默认入口。
func RecentDocsArgs(limit int) []string {
if limit <= 0 || limit > 200 {
limit = 50
}
return []string{"drive", "+recent", "--operate-type", "1", "--limit", "20", "--page-all",
"--max-items", fmt.Sprint(limit), "--max-pages", "10"}
}
// SearchDocsArgs 按关键词在文档空间检索。
func SearchDocsArgs(query string, limit int) []string {
if limit <= 0 || limit > 200 {
limit = 50
}
return []string{"drive", "+search-docs", "--query", query, "--limit", fmt.Sprint(limit)}
}
// DocInfoArgs / SheetListArgs 是校验「这篇还能不能读」的只读命令:
// 文档取元信息,表格取工作表清单——都真实打一次接口,不用缓存谎报可读。
func DocInfoArgs(node string) []string { return []string{"doc", "info", "--node", node} }
func SheetListArgs(node string) []string { return []string{"sheet", "list", "--node", node} }
// IsSheet 判断节点是否表格:表格要用 sheet 校验,文档用 doc。
func IsSheet(contentType string) bool {
kind := strings.ToUpper(strings.TrimSpace(contentType))
return strings.Contains(kind, "SHEET") || strings.Contains(kind, "ABLE")
}
// DiscoverDocs 枚举可登记的在线文档:有关键词就检索,没有就给最近编辑的一批。
// 与 Discover 一样,读不到就返回 ErrUnavailable,而不是空结果。
func DiscoverDocs(ctx context.Context, runner Runner, query string, limit int) (DocResult, error) {
result := DocResult{FetchedAt: time.Now().UTC()}
args := RecentDocsArgs(limit)
if trimmed := strings.TrimSpace(query); trimmed != "" {
args = SearchDocsArgs(trimmed, limit)
}
raw, err := runner.RunDWS(ctx, args...)
if err != nil {
return result, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
}
docs, notice, err := parseDocs(raw)
if err != nil {
return result, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
}
result.Documents, result.Notice = docs, notice
return result, nil
}
// ValidateDocument 真实读一次节点,返回它的规范元信息;读不到就是不可登记。
func ValidateDocument(ctx context.Context, runner Runner, node string) (DocumentNode, error) {
node = strings.TrimSpace(node)
if node == "" {
return DocumentNode{}, fmt.Errorf("%w: empty node", ErrUnavailable)
}
raw, err := runner.RunDWS(ctx, DocInfoArgs(node)...)
if err != nil {
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
}
doc, err := parseDocInfo(raw)
if err != nil {
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
}
if doc.NodeID == "" {
doc.NodeID = node
}
if IsSheet(doc.ContentType) {
if _, err := runner.RunDWS(ctx, SheetListArgs(doc.NodeID)...); err != nil {
return DocumentNode{}, fmt.Errorf("%w: %s", ErrUnavailable, err.Error())
}
}
return doc, nil
}
// parseDocs 解析 `drive +recent` / `drive +search-docs`:
// {"ok":true,"data":{"items":[{nodeId,nodeType,name,docUrl,contentType,accessTime}]}}
// 第二个返回值是「结果不完整」的说明(服务端还有下一页或条目被截断)。
func parseDocs(raw []byte) ([]DocumentNode, string, error) {
var payload struct {
OK bool `json:"ok"`
Data struct {
Count int `json:"count"`
HasMore bool `json:"hasMore"`
Items []struct {
NodeID string `json:"nodeId"`
NodeType string `json:"nodeType"`
Name string `json:"name"`
DocURL string `json:"docUrl"`
ContentType string `json:"contentType"`
AccessTime string `json:"accessTime"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, "", fmt.Errorf("decode docs: %w", err)
}
if !payload.OK {
return nil, "", errors.New("docs listing reported failure")
}
out := make([]DocumentNode, 0, len(payload.Data.Items))
seen := map[string]bool{}
for _, item := range payload.Data.Items {
id := strings.TrimSpace(item.NodeID)
if id == "" || seen[id] {
continue
}
seen[id] = true
nodeType := strings.TrimSpace(item.NodeType)
if nodeType == "" {
nodeType = "file"
}
out = append(out, DocumentNode{
NodeID: id,
NodeType: nodeType,
Name: strings.TrimSpace(item.Name),
URL: strings.TrimSpace(item.DocURL),
ContentType: strings.TrimSpace(item.ContentType),
UpdatedAt: strings.TrimSpace(item.AccessTime),
})
}
notice := ""
if payload.Data.HasMore {
notice = fmt.Sprintf("只列出前 %d 篇,还有更多;用关键词检索可以找到其余的。", len(out))
}
return out, notice, nil
}
// parseDocInfo 解析 `dws doc info --node`。服务端字段是 camelCase,部分历史响应是
// snake_case,两种都认,避免因为命名差异把可读文档判成不可读。
func parseDocInfo(raw []byte) (DocumentNode, error) {
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return DocumentNode{}, fmt.Errorf("decode doc info: %w", err)
}
if ok, present := payload["ok"].(bool); present && !ok {
return DocumentNode{}, errors.New("doc info reported failure")
}
source := payload
if data, ok := payload["data"].(map[string]any); ok {
source = data
}
pick := func(keys ...string) string {
for _, key := range keys {
if value, ok := source[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
doc := DocumentNode{
NodeID: pick("nodeId", "node_id", "dentUuid", "dentryUuid", "id"),
NodeType: pick("nodeType", "node_type"),
Name: pick("name", "title", "docName"),
URL: pick("docUrl", "doc_url", "url"),
ContentType: pick("contentType", "content_type", "extension"),
UpdatedAt: pick("updateTime", "updatedAt", "modifiedTime"),
}
if doc.NodeType == "" {
doc.NodeType = "file"
}
if doc.Name == "" {
return DocumentNode{}, errors.New("doc info has no title")
}
return doc, nil
}