This commit is contained in:
liangxiaoqin.lxq
2026-08-11 10:57:44 +08:00
parent ee0c3507a5
commit ae9caa06af
4 changed files with 111 additions and 202 deletions
+16 -57
View File
@@ -1325,7 +1325,7 @@ ORG 类型授权不会出现在查询结果中。`,
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
使用 --exclude-file 可排除文件相关动态(文档创建/文档更新等),仅保留文档操作(创建/更新/评论/点赞等)。
使用 --exclude-file 可排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞等)。
当用户要求"只看文档操作""排除文件上传"等意图时,必须使用此 flag,
禁止在客户端自行过滤。`,
Example: ` dws wiki feed list --workspace <workspaceId>
@@ -1357,8 +1357,12 @@ ORG 类型授权不会出现在查询结果中。`,
if err != nil {
return err
}
deps.Out.PrintJSON(formatFeedTime(text))
return nil
// --format raw: 输出原始 MCP 文本,不做后处理
if deps.Caller.Format() == "raw" {
deps.Out.PrintRaw(text)
return nil
}
return deps.Out.PrintJSON(formatFeedTime(text))
},
}
DeclareLeafMetadata(feedListCmd, LeafSpec{
@@ -1406,7 +1410,7 @@ ORG 类型授权不会出现在查询结果中。`,
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10")
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
feedListCmd.Flags().Bool("exclude-file", false, "排除文件相关动态(文档创建/更新/评论/点赞等),仅保留文档操作。用户要求排除文件/只看文档创建/更新/评论/点赞时必须使用此flag,禁止客户端过滤")
feedListCmd.Flags().Bool("exclude-file", false, "排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作(创建/更新/评论/点赞)。用户要求排除文件/只看文档操作时必须使用此 flag,禁止客户端过滤")
feedListCmd.Flags().String("workspace-id", "", "")
_ = feedListCmd.Flags().MarkHidden("workspace-id")
RegisterCrossProductAliases(feedListCmd)
@@ -1448,9 +1452,8 @@ ORG 类型授权不会出现在查询结果中。`,
var beijingLoc = time.FixedZone("CST", 8*3600)
// formatFeedTime 将知识库动态 feeds[].time 毫秒时间戳转为可读时间字符串,
// 同时保留原始毫秒值至 timeMs 字段。解析失败时原样返回,不阻断输出。
// 同时裁剪冗余字段(avatarUrl、docKey、parentDoc 等),减小输出体积,
// 避免 Agent 因 JSON 过大而在后续处理中产生长耗时 echo 命令。
// 写入 timeFormatted 字段,同时保留原始 time 毫秒时间戳不变(避免破坏已有脚本)。
// 解析失败时原样返回,不阻断输出。
func formatFeedTime(raw string) any {
var result map[string]any
if err := json.Unmarshal([]byte(raw), &result); err != nil {
@@ -1469,65 +1472,21 @@ func formatFeedTime(raw string) any {
if !ok || ms <= 0 {
continue
}
item["timeMs"] = ms
item["time"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
trimFeedFields(item)
// 保留原始 time 毫秒时间戳,新增 timeFormatted 可读字段
item["timeFormatted"] = time.UnixMilli(int64(ms)).In(beijingLoc).Format("2006-01-02 15:04")
enrichFeedFields(item)
}
return result
}
// trimFeedFields 裁剪单条 feed 中对 Agent 无用的冗余字段,
// 大幅减小 JSON 输出体积,避免 Agent 二次处理时产生超长 echo 命令。
func trimFeedFields(item map[string]any) {
// 移除大块冗余字段
delete(item, "parentDoc")
delete(item, "userInfo")
// enrichFeedFields 为单条 feed 补充可读字段(typeLabel),
func enrichFeedFields(item map[string]any) {
// 将 type 数字映射为可读标签,使 Agent 无需查表即可直接展示
if typeNum, ok := item["type"].(float64); ok {
if label, known := feedTypeLabels[int(typeNum)]; known {
item["typeLabel"] = label
}
}
// 裁剪 users:仅保留 nick
if users, ok := item["users"].([]any); ok {
for _, u := range users {
if user, ok := u.(map[string]any); ok {
nick := user["nick"]
for k := range user {
delete(user, k)
}
if nick != nil {
user["nick"] = nick
}
}
}
}
// 裁剪 content:仅保留 content.doc.name 和 content.doc.extension
// extension 区分文档类型(adoc=文档, asheet=表格等),Agent 需要用来区分更新对象
if content, ok := item["content"].(map[string]any); ok {
if doc, ok := content["doc"].(map[string]any); ok {
name := doc["name"]
extension := doc["extension"]
for k := range doc {
delete(doc, k)
}
if name != nil {
doc["name"] = name
}
if extension != nil {
doc["extension"] = extension
}
}
// 移除 content 中除 doc 外的其他字段(如 dentryKey / workspaceKey 等)
for k := range content {
if k != "doc" {
delete(content, k)
}
}
}
}
// feedTypeLabels 将知识库动态 type 数字映射为可读中文标签,
@@ -1543,7 +1502,7 @@ var feedTypeLabels = map[int]string{
7: "上传普通文件",
8: "上传媒体文件",
9: "上传文件夹",
10: "上传文件夹",
10: "上传文件夹 V2",
11: "加入团队",
12: "创建知识库",
}
+91 -141
View File
@@ -5,9 +5,7 @@ import (
"encoding/json"
"io"
"os"
"strings"
"testing"
"time"
)
// ---------- formatFeedTime unit tests ----------
@@ -104,16 +102,10 @@ func TestCrossPlatformCoverageFormatFeedTimeTimeMissingOrInvalid(t *testing.T) {
}
func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
// 正常路径:毫秒时间戳被格式化,原始值保留到 timeMs
// 2025-06-15 10:30:00 UTC = 2025-06-15 18:30:00 CST (UTC+8)
// 正常路径:time 保留原始毫秒时间戳,timeFormatted 被格式化为北京时间
// 1750067400000 ms = 2025-06-16 17:50 Beijing time (UTC+8)
tsMs := float64(1750067400000)
raw := `{"feeds":[{"time":` + json.Number(strings.TrimRight(strings.TrimRight(
strings.Replace(
string(mustMarshal(t, map[string]any{"v": tsMs})),
`{"v":`, "", 1),
"}"), "")).String() + `,"type":1,"id":"f1"}]}`
// Build a cleaner JSON payload
raw = buildFeedJSON(t, []map[string]any{
raw := buildFeedJSON(t, []map[string]any{
{"time": tsMs, "type": float64(1), "id": "f1"},
})
@@ -128,19 +120,18 @@ func TestCrossPlatformCoverageFormatFeedTimeNormalPath(t *testing.T) {
}
item := feeds[0].(map[string]any)
// timeMs 保留原始毫秒值
if ms, ok := item["timeMs"].(float64); !ok || ms != tsMs {
t.Fatalf("timeMs = %v, want %v", item["timeMs"], tsMs)
// time 保留原始毫秒时间戳不变
if ms, ok := item["time"].(float64); !ok || ms != tsMs {
t.Fatalf("time = %v (%T), want original ms %v", item["time"], item["time"], tsMs)
}
// time 被格式化为北京时间字符串
timeStr, ok := item["time"].(string)
// timeFormatted 被格式化为北京时间字符串(硬编码期望值)
timeStr, ok := item["timeFormatted"].(string)
if !ok {
t.Fatalf("time = %T, want string", item["time"])
t.Fatalf("timeFormatted = %T, want string", item["timeFormatted"])
}
expected := time.UnixMilli(int64(tsMs)).In(beijingLoc).Format("2006-01-02 15:04")
if timeStr != expected {
t.Fatalf("time = %q, want %q", timeStr, expected)
if timeStr != "2025-06-16 17:50" {
t.Fatalf("timeFormatted = %q, want %q", timeStr, "2025-06-16 17:50")
}
// typeLabel 被填充
@@ -166,124 +157,108 @@ func TestCrossPlatformCoverageFormatFeedTimeMultipleFeeds(t *testing.T) {
// 前两个有时间戳
for i := 0; i < 2; i++ {
item := feeds[i].(map[string]any)
if _, ok := item["timeMs"].(float64); !ok {
t.Fatalf("feed[%d] missing timeMs", i)
if _, ok := item["time"].(float64); !ok {
t.Fatalf("feed[%d] time should remain float64, got %T", i, item["time"])
}
if _, ok := item["time"].(string); !ok {
t.Fatalf("feed[%d] time not string", i)
if _, ok := item["timeFormatted"].(string); !ok {
t.Fatalf("feed[%d] timeFormatted not string", i)
}
}
// 第三个没有时间戳
item3 := feeds[2].(map[string]any)
if _, hasTimeMs := item3["timeMs"]; hasTimeMs {
t.Fatalf("feed[2] should not have timeMs")
if _, hasTimeFormatted := item3["timeFormatted"]; hasTimeFormatted {
t.Fatalf("feed[2] should not have timeFormatted")
}
}
// ---------- trimFeedFields unit tests ----------
// ---------- enrichFeedFields unit tests ----------
func TestCrossPlatformCoverageTrimFeedFieldsRemovesParentDocAndUserInfo(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsPreservesAllFields(t *testing.T) {
// enrichFeedFields 不删除任何字段,仅新增 typeLabel
item := map[string]any{
"parentDoc": map[string]any{"name": "parent"},
"userInfo": map[string]any{"nick": "user1"},
"id": "f1",
"type": float64(1),
}
trimFeedFields(item)
if _, ok := item["parentDoc"]; ok {
t.Fatal("parentDoc should be removed")
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := item["userInfo"]; ok {
t.Fatal("userInfo should be removed")
if _, ok := item["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
if item["typeLabel"] != "更新文档" {
t.Fatalf("typeLabel = %v, want 更新文档", item["typeLabel"])
}
}
func TestCrossPlatformCoverageTrimFeedFieldsTypeLabelKnown(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelKnown(t *testing.T) {
// 已知 type → 添加 typeLabel
for typeNum, expectedLabel := range feedTypeLabels {
item := map[string]any{"type": float64(typeNum)}
trimFeedFields(item)
enrichFeedFields(item)
if item["typeLabel"] != expectedLabel {
t.Fatalf("type %d: typeLabel = %v, want %q", typeNum, item["typeLabel"], expectedLabel)
}
}
}
func TestCrossPlatformCoverageTrimFeedFieldsTypeLabelUnknown(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsTypeLabelUnknown(t *testing.T) {
// 未知 type → 不添加 typeLabel
item := map[string]any{"type": float64(999)}
trimFeedFields(item)
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for unknown type 999")
}
}
func TestCrossPlatformCoverageTrimFeedFieldsTypeNotFloat(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsTypeNotFloat(t *testing.T) {
// type 不是 float64 → 不添加 typeLabel
item := map[string]any{"type": "string-type"}
trimFeedFields(item)
enrichFeedFields(item)
if _, ok := item["typeLabel"]; ok {
t.Fatal("typeLabel should not be set for non-float type")
}
}
func TestCrossPlatformCoverageTrimFeedFieldsUsersTrim(t *testing.T) {
// users 数组中的 user 对象只保留 nick
func TestCrossPlatformCoverageEnrichFeedFieldsUsersPreserved(t *testing.T) {
// users 数组中的 user 对象所有字段保留不变
item := map[string]any{
"users": []any{
map[string]any{"nick": "Alice", "avatarUrl": "http://img", "userId": "u1"},
map[string]any{"nick": "Bob", "email": "bob@test"},
},
}
trimFeedFields(item)
enrichFeedFields(item)
users := item["users"].([]any)
for i, u := range users {
user := u.(map[string]any)
if _, ok := user["nick"]; !ok {
t.Fatalf("users[%d] should have nick", i)
}
for k := range user {
if k != "nick" {
t.Fatalf("users[%d] unexpected field %q", i, k)
}
}
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://img" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if users[0].(map[string]any)["nick"] != "Alice" {
t.Fatalf("users[0].nick = %v, want Alice", users[0].(map[string]any)["nick"])
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
func TestCrossPlatformCoverageTrimFeedFieldsUsersNilNick(t *testing.T) {
// users 中 nick 为 nil → 删除后不回填
item := map[string]any{
"users": []any{
map[string]any{"userId": "u1"},
},
}
trimFeedFields(item)
user := item["users"].([]any)[0].(map[string]any)
if len(user) != 0 {
t.Fatalf("user should be empty after trimming nil nick, got %v", user)
}
}
func TestCrossPlatformCoverageTrimFeedFieldsUsersNonMapItem(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsUsersNonMapItem(t *testing.T) {
// users 数组中有非 map 元素 → 不 panic
item := map[string]any{
"users": []any{"not-a-map", 42, nil},
}
trimFeedFields(item) // 不应 panic
enrichFeedFields(item) // 不应 panic
users := item["users"].([]any)
if len(users) != 3 {
t.Fatalf("users length changed: %d", len(users))
}
}
func TestCrossPlatformCoverageTrimFeedFieldsContentDocTrim(t *testing.T) {
// content.doc 只保留 name 和 extension
func TestCrossPlatformCoverageEnrichFeedFieldsContentPreserved(t *testing.T) {
// content 和 content.doc 所有字段保留不变
item := map[string]any{
"content": map[string]any{
"doc": map[string]any{
@@ -296,78 +271,42 @@ func TestCrossPlatformCoverageTrimFeedFieldsContentDocTrim(t *testing.T) {
"workspaceKey": "ws1",
},
}
trimFeedFields(item)
enrichFeedFields(item)
content := item["content"].(map[string]any)
// content 中只保留 doc
for k := range content {
if k != "doc" {
t.Fatalf("content should only have doc, found %q", k)
}
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey = %v, want entry1", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey = %v, want ws1", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["name"] != "测试文档" {
t.Fatalf("doc.name = %v, want 测试文档", doc["name"])
}
if doc["extension"] != "adoc" {
t.Fatalf("doc.extension = %v, want adoc", doc["extension"])
}
for k := range doc {
if k != "name" && k != "extension" {
t.Fatalf("doc unexpected field %q", k)
}
if doc["docKey"] != "dk123" {
t.Fatalf("doc.docKey = %v, want dk123", doc["docKey"])
}
}
func TestCrossPlatformCoverageTrimFeedFieldsContentDocNilFields(t *testing.T) {
// content.doc 中 name 和 extension 缺失 → 不回填
item := map[string]any{
"content": map[string]any{
"doc": map[string]any{
"docKey": "dk123",
},
},
}
trimFeedFields(item)
doc := item["content"].(map[string]any)["doc"].(map[string]any)
if len(doc) != 0 {
t.Fatalf("doc should be empty, got %v", doc)
}
}
func TestCrossPlatformCoverageTrimFeedFieldsContentNoDoc(t *testing.T) {
// content 存在但没有 doc 子字段 → 移除其他字段
item := map[string]any{
"content": map[string]any{
"dentryKey": "entry1",
"workspaceKey": "ws1",
},
}
trimFeedFields(item)
content := item["content"].(map[string]any)
if len(content) != 0 {
t.Fatalf("content should be empty, got %v", content)
}
}
func TestCrossPlatformCoverageTrimFeedFieldsNoContentNoUsers(t *testing.T) {
// 没有 content / users / type → 仅删除 parentDoc / userInfo
func TestCrossPlatformCoverageEnrichFeedFieldsNoContentNoUsers(t *testing.T) {
// 没有 content / users / type → 所有字段保留
item := map[string]any{
"id": "f1",
"parentDoc": map[string]any{"x": 1},
}
trimFeedFields(item)
if _, ok := item["parentDoc"]; ok {
t.Fatal("parentDoc should be removed")
enrichFeedFields(item)
if _, ok := item["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if item["id"] != "f1" {
t.Fatal("id should be preserved")
}
}
func TestCrossPlatformCoverageTrimFeedFieldsEmptyItem(t *testing.T) {
func TestCrossPlatformCoverageEnrichFeedFieldsEmptyItem(t *testing.T) {
// 空 map → 不 panic
item := map[string]any{}
trimFeedFields(item)
enrichFeedFields(item)
if len(item) != 0 {
t.Fatalf("empty item changed: %v", item)
}
@@ -453,38 +392,49 @@ func TestCrossPlatformCoverageWikiFeedListFormatIntegration(t *testing.T) {
t.Fatalf("feeds length unexpected: %v", parsed["feeds"])
}
// 第一个 feed 验证 timeMs 保留、time 格式化、字段裁剪
// 第一个 feed 验证 time 保留、timeFormatted 格式化、所有原始字段保留
f0 := feeds[0].(map[string]any)
if f0["timeMs"] != ts {
t.Fatalf("timeMs = %v, want %v", f0["timeMs"], ts)
// time 保留原始毫秒时间戳
if f0["time"] != ts {
t.Fatalf("time = %v, want %v", f0["time"], ts)
}
if _, ok := f0["time"].(string); !ok {
t.Fatalf("time should be string, got %T", f0["time"])
// timeFormatted 被填充
if _, ok := f0["timeFormatted"].(string); !ok {
t.Fatalf("timeFormatted should be string, got %T", f0["timeFormatted"])
}
if _, ok := f0["parentDoc"]; ok {
t.Fatal("parentDoc should be trimmed")
// 所有原始字段保留(不删除任何字段)
if _, ok := f0["parentDoc"]; !ok {
t.Fatal("parentDoc should be preserved")
}
if _, ok := f0["userInfo"]; ok {
t.Fatal("userInfo should be trimmed")
if _, ok := f0["userInfo"]; !ok {
t.Fatal("userInfo should be preserved")
}
if f0["typeLabel"] != "创建文档" {
t.Fatalf("typeLabel = %v, want 创建文档", f0["typeLabel"])
}
// users 裁剪验证
// users 保留验证:avatarUrl / userId 均保留
if users, ok := f0["users"].([]any); ok {
for _, u := range users {
um := u.(map[string]any)
if _, ok := um["avatarUrl"]; ok {
t.Fatal("user avatarUrl should be trimmed")
}
u0 := users[0].(map[string]any)
if u0["avatarUrl"] != "http://avatar" {
t.Fatalf("users[0].avatarUrl should be preserved, got %v", u0["avatarUrl"])
}
if u0["userId"] != "u1" {
t.Fatalf("users[0].userId should be preserved, got %v", u0["userId"])
}
}
// content 裁剪验证
// content 结构保留验证
if content, ok := f0["content"].(map[string]any); ok {
if _, ok := content["dentryKey"]; ok {
t.Fatal("content.dentryKey should be trimmed")
if content["dentryKey"] != "entry1" {
t.Fatalf("content.dentryKey should be preserved, got %v", content["dentryKey"])
}
if content["workspaceKey"] != "ws1" {
t.Fatalf("content.workspaceKey should be preserved, got %v", content["workspaceKey"])
}
doc := content["doc"].(map[string]any)
if doc["docKey"] != "dk123" {
t.Fatalf("content.doc.docKey should be preserved, got %v", doc["docKey"])
}
}
}
+2 -2
View File
@@ -196,7 +196,7 @@ Flags:
--workspace string 知识库 ID 或 URL (必填)
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
--cursor string 分页游标 (首页留空)
--exclude-file 排除文件相关动态(上传/更新 office 等),仅保留文档操作 (默认 false)
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
```
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
@@ -225,7 +225,7 @@ Flags:
> **重要 — 展示规则(必须严格遵守)**:
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
> 2. **time 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `timeMs` 字段中备用。
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
### 列出知识库节点
```
@@ -186,7 +186,7 @@ Flags:
--workspace string 知识库 ID 或 URL (必填)
--limit int 每页数量 (默认 10,最大 20)。用户未明确要求条数时禁止加此 flag,让服务端走默认 10
--cursor string 分页游标 (首页留空)
--exclude-file 排除文件相关动态(上传/更新 office 等),仅保留文档操作 (默认 false)
--exclude-file 排除普通文件、媒体文件、文件夹及 Office 文件动态,仅保留在线文档操作 (默认 false)
```
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
@@ -215,7 +215,7 @@ Flags:
> **重要 — 展示规则(必须严格遵守)**:
> 1. **typeLabel 字段**:CLI 已自动将 `type` 数字映射为 `typeLabel` 中文标签(如 `更新文档`、`创建文档`),**直接使用 `typeLabel` 展示即可**,无需自行查表映射。
> 2. **time 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `timeMs` 字段中备用。
> 2. **timeFormatted 字段**:已是北京时间字符串(格式如 `2026-07-30 08:00`),直接展示即可,无需任何转换。原始毫秒时间戳保存在 `time` 字段中(数字类型)。
### 列出知识库节点
```