Files
knowledge-governance/internal/dingtalkdiscovery/exec_runner.go
T
姚章浩 e00cab2f70 feat(settings): 数据源配置改成「列出来、勾选、变成来源」,并去掉文件格式筛选
用户对 系统设置 → 数据源配置 的四条要求:
① 这里不要做文件格式筛选(格式归目录级标注决定);
② 列出我账号下能看到的知识库;③ 列出钉盘能看到哪些;④ 勾选那些作为来源。

① 来源卡片不再渲染 FilenameFilterEditor(目录级筛选仍在「来源与目录」里,按目录就近覆盖)。
②③ 新端点 GET /api/v1/dingtalk-spaces/available(管理员,只读):用 dws 列举
   - 知识库:wiki +space-list --type orgWikiSpace/myWikiSpace(实测 17 + 1 个)
   - 钉盘:wiki space list --type mySpace/orgSpace(实测 我的文件 + 6 个组织空间)
   实测约束写进命令形态:--limit 上限是 50;翻页必须 --page-all(--max-items 兜底),
   否则超过一页的知识库会被静默截断(被截断时页面显示"可能不全"而不是假装列全)。
   列不出来 ≠ 空列表:dws 未登录/权限不足 → 503 + 原因;某一类失败只丢那一类。
④ 面板勾选后走既有登记路径(/api/v1/dingtalk-spaces),首次目录扫描自动排队;
   已登记的按空间名标「已是来源」并禁用勾选;扫码会话失效时直接弹二维码,扫完自动重试。

基础设施:api 与 worker 共享同一份 dws 状态(bind mount 同一份,refresh token 是轮换制,
不能各留一份)。子进程 HOME 必须指向 DWS_HOME —— 这条是实测踩出来的:漏了会报"未登录",
已有回归测试钉住。dws 子进程沿用白名单环境,库口令等变量不进子进程。

测试:新包 internal/dingtalkdiscovery(fixture 用真实 dws 输出形状):paging 约束、
截断提示、一类失败不连带、全部失败→ErrUnavailable、子进程环境;httpapi 侧 401/501/503/500
与不泄漏内部错误;OpenAPI 契约(400/503 组件)通过。前端 189 条 + tsc 干净。

面板实测:知识库(18)/钉盘(7)都列出来,已有三条钉钉来源自动标「已是来源」;
勾选「视觉文档」→ 加入为来源 → 新来源 sc-8a4be5b8293d1c9a048c3c7e9b194155(dingtalk,enabled)
+ source.catalog.refresh 已完成。
2026-10-10 16:46:39 +08:00

84 lines
2.4 KiB
Go

package dingtalkdiscovery
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// ExecRunner 是真的 dws 运行器。它只传 dws 需要的白名单环境(与取件器一致):
// 漏掉 DWS_CONFIG_DIR / DWS_HOME 时 dws 会找不到配置目录与文件型 keychain,报"未登录"。
type ExecRunner struct {
// Binary 是 dws 可执行文件;空值取 DWS_BIN,再取 PATH 里的 dws。
Binary string
// Timeout 单条命令超时;为零取 25 秒。
Timeout time.Duration
}
// RunDWS 运行 `dws <args…>` 并返回 stdout。
func (r ExecRunner) RunDWS(ctx context.Context, args ...string) ([]byte, error) {
binary := r.Binary
if binary == "" {
binary = os.Getenv("DWS_BIN")
}
if binary == "" {
found, err := exec.LookPath("dws")
if err != nil {
return nil, fmt.Errorf("dws binary not found: %w", err)
}
binary = found
}
timeout := r.Timeout
if timeout <= 0 {
timeout = 25 * time.Second
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := exec.CommandContext(runCtx, binary, args...)
command.Env = dwsEnv()
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
if err := command.Run(); err != nil {
// 退出码非零时 dws 仍会把错误 JSON 写在 stdout 上,保留它便于上层判定。
if stdout.Len() > 0 {
return stdout.Bytes(), fmt.Errorf("dws %s: %w", strings.Join(args, " "), err)
}
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return nil, fmt.Errorf("dws %s: %s", strings.Join(args, " "), message)
}
return stdout.Bytes(), nil
}
// dwsEnv 是给 dws 子进程的环境白名单:不把平台的库口令等变量带进子进程。
func dwsEnv() []string {
env := make([]string, 0, 8)
for _, name := range []string{"PATH", "SSL_CERT_FILE", "LANG"} {
if value := os.Getenv(name); value != "" {
env = append(env, name+"="+value)
}
}
// dws 的文件型 keychain 固定在 $HOME/.local/share/dws-cli,而 keychain 是挂在 DWS_HOME
// 下面的(容器自身的 HOME 指向别处)。因此只给 dws 子进程换 HOME,不动整个进程。
home := os.Getenv("DWS_HOME")
if home == "" {
home = os.Getenv("HOME")
}
if home != "" {
env = append(env, "HOME="+home)
}
for _, name := range []string{"DWS_BIN", "DWS_CONFIG_DIR", "DWS_HOME", "DWS_DISABLE_KEYCHAIN"} {
if value := os.Getenv(name); value != "" {
env = append(env, name+"="+value)
}
}
return env
}