@allbluecn/web-app 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +26 -11
  2. package/public/favicon.ico +0 -0
  3. package/public/logo/allblue-logo-app-icon.svg +7 -0
  4. package/public/logo/allblue-logo-dark-square.svg +4 -0
  5. package/public/logo/allblue-logo-dark.svg +4 -0
  6. package/public/logo/allblue-logo-light-square.svg +4 -0
  7. package/public/logo/allblue-logo-light.svg +4 -0
  8. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  9. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  10. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  11. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  12. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  13. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  14. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  15. package/src/__tests__/setup.ts +9 -0
  16. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  17. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  18. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  19. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  20. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  21. package/src/components/error/global-error-boundary.tsx +2 -2
  22. package/src/components/knowledge/general-groups.ts +15 -0
  23. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  24. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  25. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  26. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  27. package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
  28. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  29. package/src/components/mdx.tsx +9 -0
  30. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  31. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  32. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  33. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  34. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  35. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  36. package/src/components/story/relations/general-groups.ts +1 -15
  37. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  38. package/src/components/story/story-list-view.tsx +7 -1
  39. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  40. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  41. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  42. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  43. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  44. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  45. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  46. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  47. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  48. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  49. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  50. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  51. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  52. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  53. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  54. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  55. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  56. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  57. package/src/lib/__tests__/pgvector.test.ts +2 -2
  58. package/src/lib/changelog/sdk-versions.ts +21 -21
  59. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  60. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  61. package/src/lib/fetch-json.ts +23 -0
  62. package/src/lib/fumadocs-i18n.ts +7 -0
  63. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  64. package/src/lib/layout.shared.tsx +40 -0
  65. package/src/lib/sanitize.ts +14 -1
  66. package/src/lib/session.ts +9 -1
  67. package/src/lib/source.ts +15 -0
  68. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  69. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  70. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  71. package/src/routeTree.gen.ts +65 -0
  72. package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
  73. package/src/routes/$lang/docs/$/route.tsx +52 -0
  74. package/src/routes/-tests/docs-redirect.test.ts +45 -0
  75. package/src/routes/__root.tsx +13 -7
  76. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  77. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  78. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  79. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  80. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  81. package/src/routes/_nav/route.tsx +21 -0
  82. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  83. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  84. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  85. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  86. package/src/routes/api/search.ts +13 -0
  87. package/src/routes/docs.tsx +9 -0
  88. package/src/server/__tests__/agent-crud.test.ts +4 -4
  89. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  90. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  91. package/src/server/__tests__/permissions.test.ts +1 -3
  92. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  93. package/src/server/__tests__/skill-crud.test.ts +3 -3
  94. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  95. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  96. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  97. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  98. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  99. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  100. package/src/server/ai-gateway/index.ts +5 -0
  101. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  102. package/src/server/ai-gateway/llm-fence.ts +82 -0
  103. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  104. package/src/server/ai-gateway/story-prompt.ts +4 -2
  105. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  106. package/src/server/ai-gateway/tools/server.ts +97 -54
  107. package/src/server/builtin-project.ts +21 -0
  108. package/src/server/email/sender.ts +8 -2
  109. package/src/server/fetch-guard.ts +170 -0
  110. package/src/server/middlewares/security-headers.ts +113 -0
  111. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  112. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  115. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  116. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  117. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  118. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  119. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  120. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  121. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  122. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  123. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  124. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  125. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  126. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  127. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  128. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  129. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  130. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  131. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  132. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  133. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  134. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  135. package/src/server/serverFns/kb-doc-search.ts +111 -0
  136. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  137. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  138. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  139. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  140. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  141. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  142. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  143. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  144. package/src/server/serverFns/story/kb-general.ts +30 -17
  145. package/src/server/serverFns/story/story-assoc.ts +13 -103
  146. package/src/server/serverFns/story/story-crud.ts +2 -5
  147. package/src/server/serverFns/story/story-groups.ts +4 -13
  148. package/src/server/serverFns/team.ts +1 -1
  149. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  150. package/src/server/serverFns/workspace-kb.ts +403 -0
  151. package/src/server/serverFns/workspace.ts +4 -1
  152. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  153. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  154. package/src/shared/ai-cli/adapter.ts +2 -2
  155. package/src/shared/ai-cli/cli-detector.ts +18 -9
  156. package/src/start.ts +2 -1
  157. package/src/styles/globals.css +116 -0
  158. package/src/types/jest-dom-vitest.d.ts +8 -7
  159. package/vite.shared.ts +18 -1
  160. package/public/file.svg +0 -1
  161. package/public/globe.svg +0 -1
  162. package/public/window.svg +0 -1
  163. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  164. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  165. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  166. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  167. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  168. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  169. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect } from "vitest";
19
+ import { quickDocItemSchema, quickDocItemKey, generalDocHref } from "../workspace-kb-utils";
20
+
21
+ describe("quickDocItemSchema 互斥校验", () => {
22
+ it("docId 单独存在合法", () => {
23
+ expect(quickDocItemSchema.parse({ docId: "d1", displayTitle: "标题" })).toMatchObject({ docId: "d1" });
24
+ });
25
+ it("externalGroup+externalId 合法", () => {
26
+ expect(
27
+ quickDocItemSchema.parse({ externalGroup: "legal", externalId: "x1", displayTitle: "标题" }),
28
+ ).toMatchObject({ externalGroup: "legal" });
29
+ });
30
+ it("docId 与 external 同时存在拒绝", () => {
31
+ expect(() =>
32
+ quickDocItemSchema.parse({ docId: "d1", externalGroup: "legal", externalId: "x1", displayTitle: "标题" }),
33
+ ).toThrow();
34
+ });
35
+ it("两者皆无拒绝", () => {
36
+ expect(() => quickDocItemSchema.parse({ displayTitle: "标题" })).toThrow();
37
+ });
38
+ it("空标题拒绝", () => {
39
+ expect(() => quickDocItemSchema.parse({ docId: "d1", displayTitle: "" })).toThrow();
40
+ });
41
+ });
42
+
43
+ describe("quickDocItemKey / generalDocHref", () => {
44
+ it("doc 态返回 docId", () => {
45
+ expect(quickDocItemKey({ docId: "d1", externalGroup: undefined, externalId: undefined })).toBe("d1");
46
+ });
47
+ it("ext 态返回 ext: 前缀 id", () => {
48
+ expect(quickDocItemKey({ docId: undefined, externalGroup: "legal", externalId: "x1" })).toBe("ext:legal:x1");
49
+ });
50
+ it("href 映射", () => {
51
+ expect(generalDocHref("legal", "9")).toBe("/knowledge/laws/9");
52
+ expect(generalDocHref("summary", "9")).toBe("/knowledge/summary");
53
+ });
54
+ });
@@ -0,0 +1,348 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@tanstack/react-start", () => ({
21
+ createServerFn: (config: any) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema;
26
+ return this;
27
+ },
28
+ handler(fn: Function) {
29
+ return async (args: any) => {
30
+ let data = args?.data;
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data;
33
+ }
34
+ return fn({ data });
35
+ };
36
+ },
37
+ };
38
+ return api;
39
+ },
40
+ }));
41
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
42
+
43
+ const mocks = vi.hoisted(() => ({
44
+ currentSession: vi.fn(),
45
+ wsFindUnique: vi.fn(),
46
+ wkbFindMany: vi.fn(),
47
+ wkbDeleteMany: vi.fn(),
48
+ wkbCreateMany: vi.fn(),
49
+ wqdFindMany: vi.fn(),
50
+ wqdFindUnique: vi.fn(),
51
+ wqdUpdate: vi.fn(),
52
+ wqdDelete: vi.fn(),
53
+ wqdAggregate: vi.fn(),
54
+ wqdCreate: vi.fn(),
55
+ wqdFindFirst: vi.fn(),
56
+ legalFindMany: vi.fn(),
57
+ kbFindMany: vi.fn(),
58
+ kbFindFirst: vi.fn(),
59
+ transaction: vi.fn(),
60
+ docFindMany: vi.fn(),
61
+ docFindFirst: vi.fn(),
62
+ articleFavoriteFindMany: vi.fn(),
63
+ }));
64
+
65
+ vi.mock("@allbluecn/database", () => {
66
+ const prisma = {
67
+ workspace: { findUnique: mocks.wsFindUnique },
68
+ workspaceKnowledgeBase: { findMany: mocks.wkbFindMany, deleteMany: mocks.wkbDeleteMany, createMany: mocks.wkbCreateMany },
69
+ workspaceQuickDoc: {
70
+ findMany: mocks.wqdFindMany,
71
+ findUnique: mocks.wqdFindUnique,
72
+ update: mocks.wqdUpdate,
73
+ delete: mocks.wqdDelete,
74
+ aggregate: mocks.wqdAggregate,
75
+ create: mocks.wqdCreate,
76
+ findFirst: mocks.wqdFindFirst,
77
+ },
78
+ legalRegulation: { findMany: mocks.legalFindMany },
79
+ knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
80
+ knowledgeDocument: { findMany: mocks.docFindMany, findFirst: mocks.docFindFirst },
81
+ articleFavorite: { findMany: mocks.articleFavoriteFindMany },
82
+ $transaction: mocks.transaction,
83
+ };
84
+ return { prisma, Prisma: {} };
85
+ });
86
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
87
+
88
+ // 默认 transaction mock:调用回调并透传 tx 对象
89
+ beforeEach(() => {
90
+ mocks.transaction.mockImplementation(async (fn: (tx: any) => any) =>
91
+ fn({ workspaceKnowledgeBase: { deleteMany: mocks.wkbDeleteMany, createMany: mocks.wkbCreateMany } }),
92
+ );
93
+ });
94
+
95
+ import {
96
+ getWorkspaceKbCardFn,
97
+ listWorkspaceAvailableKbsFn,
98
+ setWorkspaceKbsFn,
99
+ searchQuickDocCandidatesFn,
100
+ addQuickDocsFn,
101
+ renameQuickDocFn,
102
+ removeQuickDocFn,
103
+ reorderQuickDocsFn,
104
+ } from "../workspace-kb";
105
+
106
+ const MEMBER_WS = { ownerId: "u1", members: [] };
107
+ const MEMBER_AS_MEMBER = { ownerId: "other", members: [{ id: "m1" }] };
108
+ const NON_MEMBER = { ownerId: "other", members: [] };
109
+
110
+ beforeEach(() => {
111
+ vi.clearAllMocks();
112
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
113
+ });
114
+
115
+ describe("getWorkspaceKbCardFn", () => {
116
+ it("非成员拒绝", async () => {
117
+ mocks.wsFindUnique.mockResolvedValue(NON_MEMBER);
118
+ await expect(
119
+ getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } }),
120
+ ).rejects.toThrow("成员");
121
+ });
122
+
123
+ it("返回 kbs + quickDocs(doc 态带 kb 信息、ext 态标题兜底)", async () => {
124
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
125
+ mocks.wkbFindMany.mockResolvedValue([
126
+ { kb: { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" } },
127
+ ]);
128
+ mocks.wqdFindMany.mockResolvedValue([
129
+ {
130
+ id: "q1", displayTitle: "接入指南", sortOrder: 0, docId: "d1",
131
+ externalGroup: null, externalId: null,
132
+ doc: { title: "接入指南", icon: "doc", isDeleted: false, kb: { id: "kb1", name: "团队库" } },
133
+ },
134
+ {
135
+ id: "q2", displayTitle: "数据安全法", sortOrder: 1, docId: null,
136
+ externalGroup: "legal", externalId: "x9",
137
+ doc: null,
138
+ },
139
+ ]);
140
+ mocks.legalFindMany.mockResolvedValue([{ id: "x9", title: "数据安全法(现行)" }]);
141
+ const res = await getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } });
142
+ expect(res.kbs).toHaveLength(1);
143
+ expect(res.quickDocs[0]).toMatchObject({ source: "doc", kbId: "kb1", stale: false });
144
+ expect(res.quickDocs[1]).toMatchObject({
145
+ source: "general", group: "legal", externalId: "x9",
146
+ sourceTitle: "数据安全法(现行)", stale: false,
147
+ });
148
+ });
149
+
150
+ it("外部条目源已删除 → stale: true 且仍返回", async () => {
151
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_AS_MEMBER);
152
+ mocks.wkbFindMany.mockResolvedValue([]);
153
+ mocks.wqdFindMany.mockResolvedValue([
154
+ { id: "q3", displayTitle: "旧法条", sortOrder: 0, docId: null, externalGroup: "legal", externalId: "gone", doc: null },
155
+ ]);
156
+ mocks.legalFindMany.mockResolvedValue([]); // 源查不到
157
+ const res = await getWorkspaceKbCardFn({ data: { workspaceId: "ws1" } });
158
+ expect(res.quickDocs[0]).toMatchObject({ stale: true, displayTitle: "旧法条" });
159
+ });
160
+ });
161
+
162
+ describe("listWorkspaceAvailableKbsFn", () => {
163
+ it("返回已关联 ∪ 用户 team 库,带 linked 标记", async () => {
164
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
165
+ mocks.wkbFindMany.mockResolvedValue([{ kbId: "kbLinked" }]);
166
+ mocks.kbFindMany.mockResolvedValue([
167
+ { id: "kbLinked", name: "他人的库", icon: "notebook", visibility: "team" },
168
+ { id: "kbMine", name: "我的库", icon: "notebook", visibility: "team" },
169
+ ]);
170
+ const res = await listWorkspaceAvailableKbsFn({ data: { workspaceId: "ws1" } });
171
+ expect(res.find((k: any) => k.id === "kbLinked")?.linked).toBe(true);
172
+ expect(res.find((k: any) => k.id === "kbMine")?.linked).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe("setWorkspaceKbsFn", () => {
177
+ it("非 team 库拒绝且不写库", async () => {
178
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
179
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 2 个只查回 1 个
180
+ await expect(
181
+ setWorkspaceKbsFn({ data: { workspaceId: "ws1", kbIds: ["kb1", "kb2"] } }),
182
+ ).rejects.toThrow("团队可见");
183
+ expect(mocks.transaction).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it("全量同步:删差集 + 增新增", async () => {
187
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
188
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
189
+ await setWorkspaceKbsFn({ data: { workspaceId: "ws1", kbIds: ["kb1", "kb2"] } });
190
+ expect(mocks.wkbDeleteMany).toHaveBeenCalledWith({
191
+ where: { workspaceId: "ws1", kbId: { notIn: ["kb1", "kb2"] } },
192
+ });
193
+ expect(mocks.wkbCreateMany).toHaveBeenCalledWith({
194
+ data: [
195
+ { workspaceId: "ws1", kbId: "kb1" },
196
+ { workspaceId: "ws1", kbId: "kb2" },
197
+ ],
198
+ skipDuplicates: true,
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("searchQuickDocCandidatesFn", () => {
204
+ it("kbId 态返回该库文档树(须属用户 team 库)", async () => {
205
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
206
+ mocks.kbFindFirst.mockResolvedValue({ id: "kb1" });
207
+ mocks.docFindMany.mockResolvedValue([
208
+ { id: "d1", title: "接入指南", parentId: null, icon: "doc", kb: { id: "kb1", name: "团队库", icon: "notebook" } },
209
+ ]);
210
+ const res = await searchQuickDocCandidatesFn({
211
+ data: { workspaceId: "ws1", kbId: "kb1", query: "" },
212
+ });
213
+ expect(res.items[0]).toMatchObject({ id: "d1", kbId: "kb1", parentId: null });
214
+ });
215
+
216
+ it("generalGroup 态走通用表(含 ext 前缀 id)", async () => {
217
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
218
+ mocks.articleFavoriteFindMany.mockResolvedValue([]);
219
+ mocks.legalFindMany.mockResolvedValue([{ id: "x9", title: "数据安全法" }]);
220
+ const res = await searchQuickDocCandidatesFn({
221
+ data: { workspaceId: "ws1", generalGroup: "legal", query: "" },
222
+ });
223
+ expect(res.items[0]).toMatchObject({ id: "ext:legal:x9", title: "数据安全法" });
224
+ });
225
+ });
226
+
227
+ describe("addQuickDocsFn", () => {
228
+ it("互斥违规被 validator 拒绝", async () => {
229
+ await expect(
230
+ addQuickDocsFn({
231
+ data: {
232
+ workspaceId: "ws1",
233
+ items: [{ docId: "d1", externalGroup: "legal", externalId: "x1", displayTitle: "x" }],
234
+ },
235
+ }),
236
+ ).rejects.toThrow("二选一");
237
+ });
238
+
239
+ it("文档不存在 → failed outOfScope;存在 → 创建且落到最短列末尾", async () => {
240
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
241
+ // 现有布局:col0 有 2 行(max sortOrder 1)、col1 有 1 行(max sortOrder 0)
242
+ mocks.wqdFindMany.mockResolvedValue([
243
+ { columnIndex: 0, sortOrder: 0 },
244
+ { columnIndex: 0, sortOrder: 1 },
245
+ { columnIndex: 1, sortOrder: 0 },
246
+ ]);
247
+ mocks.docFindFirst.mockResolvedValueOnce(null) // d1 不存在
248
+ .mockResolvedValueOnce({ id: "d2" }); // d2 存在
249
+ mocks.wqdFindFirst.mockResolvedValue(null); // 无重复
250
+ mocks.wqdCreate.mockImplementation(async (args: any) => ({ id: "new-" + args.data.docId }));
251
+ const res = await addQuickDocsFn({
252
+ data: {
253
+ workspaceId: "ws1",
254
+ items: [
255
+ { docId: "d1", displayTitle: "失效文档" },
256
+ { docId: "d2", displayTitle: "有效文档" },
257
+ ],
258
+ },
259
+ });
260
+ expect(res.failed).toEqual([{ key: "d1", reason: "outOfScope" }]);
261
+ expect(res.created).toEqual(["d2"]);
262
+ // col2/3/4 为空列(0 行)比 col1(1 行)更短,新文档落到第一个空列 col2 末尾
263
+ expect(mocks.wqdCreate).toHaveBeenCalledWith(
264
+ expect.objectContaining({
265
+ data: expect.objectContaining({ docId: "d2", columnIndex: 2, sortOrder: 0 }),
266
+ }),
267
+ );
268
+ });
269
+
270
+ it("summary 组外部条目拒绝(无实体表)", async () => {
271
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
272
+ const res = await addQuickDocsFn({
273
+ data: { workspaceId: "ws1", items: [{ externalGroup: "summary", externalId: "s1", displayTitle: "周报" }] },
274
+ });
275
+ expect(res.failed).toEqual([{ key: "ext:summary:s1", reason: "outOfScope" }]);
276
+ });
277
+ });
278
+
279
+ describe("renameQuickDocFn", () => {
280
+ it("非本工作空间成员拒绝", async () => {
281
+ mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
282
+ mocks.wsFindUnique.mockResolvedValue(NON_MEMBER);
283
+ await expect(
284
+ renameQuickDocFn({ data: { quickDocId: "q1", displayTitle: "新标题" } }),
285
+ ).rejects.toThrow("成员");
286
+ expect(mocks.wqdUpdate).not.toHaveBeenCalled();
287
+ });
288
+ it("成员重命名成功", async () => {
289
+ mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
290
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
291
+ await renameQuickDocFn({ data: { quickDocId: "q1", displayTitle: "新标题" } });
292
+ expect(mocks.wqdUpdate).toHaveBeenCalledWith({
293
+ where: { id: "q1" },
294
+ data: { displayTitle: "新标题" },
295
+ });
296
+ });
297
+ });
298
+
299
+ describe("removeQuickDocFn", () => {
300
+ it("成员删除成功", async () => {
301
+ mocks.wqdFindUnique.mockResolvedValue({ workspaceId: "ws1" });
302
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_AS_MEMBER);
303
+ await removeQuickDocFn({ data: { quickDocId: "q1" } });
304
+ expect(mocks.wqdDelete).toHaveBeenCalledWith({ where: { id: "q1" } });
305
+ });
306
+ });
307
+
308
+ describe("reorderQuickDocsFn", () => {
309
+ it("包含非本工作空间 id 拒绝", async () => {
310
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
311
+ mocks.wqdFindMany.mockResolvedValue([{ id: "q1" }]); // 请求 2 个只属 1 个
312
+ await expect(
313
+ reorderQuickDocsFn({
314
+ data: {
315
+ workspaceId: "ws1",
316
+ items: [
317
+ { id: "q1", columnIndex: 0, sortOrder: 0 },
318
+ { id: "qX", columnIndex: 1, sortOrder: 0 },
319
+ ],
320
+ },
321
+ }),
322
+ ).rejects.toThrow("不属于");
323
+ });
324
+ it("按传入的列归属与顺序重写 columnIndex/sortOrder", async () => {
325
+ mocks.wsFindUnique.mockResolvedValue(MEMBER_WS);
326
+ mocks.wqdFindMany.mockResolvedValue([{ id: "q1" }, { id: "q2" }]);
327
+ const updates: any[] = [];
328
+ mocks.wqdUpdate.mockImplementation(async (args: any) => { updates.push(args); return args; });
329
+ mocks.transaction.mockImplementation(async (ops: any[]) => Promise.all(ops.map((op) => op)));
330
+ await reorderQuickDocsFn({
331
+ data: {
332
+ workspaceId: "ws1",
333
+ items: [
334
+ { id: "q2", columnIndex: 1, sortOrder: 0 },
335
+ { id: "q1", columnIndex: 2, sortOrder: 3 },
336
+ ],
337
+ },
338
+ });
339
+ expect(mocks.wqdUpdate).toHaveBeenNthCalledWith(1, {
340
+ where: { id: "q2" },
341
+ data: { columnIndex: 1, sortOrder: 0 },
342
+ });
343
+ expect(mocks.wqdUpdate).toHaveBeenNthCalledWith(2, {
344
+ where: { id: "q1" },
345
+ data: { columnIndex: 2, sortOrder: 3 },
346
+ });
347
+ });
348
+ });
@@ -68,6 +68,7 @@ const mocks = vi.hoisted(() => ({
68
68
  knowledgeBaseDeleteMany: vi.fn(),
69
69
  knowledgeBaseUpdateMany: vi.fn(),
70
70
  knowledgeBaseCreate: vi.fn(),
71
+ wkbCreate: vi.fn(),
71
72
  usageRecordDeleteMany: vi.fn(),
72
73
  aiUsageLogDeleteMany: vi.fn(),
73
74
  ssoProviderDeleteMany: vi.fn(),
@@ -132,6 +133,9 @@ vi.mock("@allbluecn/database", () => ({
132
133
  deleteMany: mocks.knowledgeBaseDeleteMany,
133
134
  updateMany: mocks.knowledgeBaseUpdateMany,
134
135
  },
136
+ workspaceKnowledgeBase: {
137
+ create: mocks.wkbCreate,
138
+ },
135
139
  usageRecord: {
136
140
  deleteMany: mocks.usageRecordDeleteMany,
137
141
  },
@@ -161,6 +165,9 @@ vi.mock("@allbluecn/database", () => ({
161
165
  deleteMany: mocks.knowledgeBaseDeleteMany,
162
166
  updateMany: mocks.knowledgeBaseUpdateMany,
163
167
  },
168
+ workspaceKnowledgeBase: {
169
+ create: mocks.wkbCreate,
170
+ },
164
171
  usageRecord: {
165
172
  deleteMany: mocks.usageRecordDeleteMany,
166
173
  },
@@ -215,6 +222,7 @@ describe("createWorkspaceFn", () => {
215
222
  it("创建工作空间后 owner 即为所有者", async () => {
216
223
  const ws = { id: "ws1", name: "测试空间", icon: "folder", ownerId: "u1" };
217
224
  mocks.workspaceCreate.mockResolvedValue(ws);
225
+ mocks.knowledgeBaseCreate.mockResolvedValue({ id: "kb1" });
218
226
 
219
227
  const result = await createWorkspaceFn({
220
228
  data: { name: "测试空间", icon: "folder", description: "介绍" },
@@ -231,7 +239,7 @@ describe("createWorkspaceFn", () => {
231
239
 
232
240
  await expect(
233
241
  createWorkspaceFn({ data: { name: "测试" } }),
234
- ).rejects.toMatchObject({ message: expect.stringContaining("未认证") });
242
+ ).rejects.toThrow("未认证");
235
243
  });
236
244
 
237
245
  it("名称为空时 zod validator 拦截", async () => {
@@ -239,6 +247,18 @@ describe("createWorkspaceFn", () => {
239
247
  createWorkspaceFn({ data: { name: "", icon: "folder" } }),
240
248
  ).rejects.toThrow();
241
249
  });
250
+
251
+ it("创建工作空间时事务内自动关联同步创建的知识库", async () => {
252
+ mocks.knowledgeBaseCreate.mockResolvedValue({ id: "kb1" });
253
+ const ws = { id: "ws1", name: "测试空间", icon: "folder", ownerId: "u1" };
254
+ mocks.workspaceCreate.mockResolvedValue(ws);
255
+ mocks.wkbCreate.mockResolvedValue({ id: "wkb1" });
256
+
257
+ await createWorkspaceFn({ data: { name: "测试空间" } });
258
+ expect(mocks.wkbCreate).toHaveBeenCalledWith({
259
+ data: { workspaceId: "ws1", kbId: "kb1" },
260
+ });
261
+ });
242
262
  });
243
263
 
244
264
  describe("listWorkspacesFn", () => {
@@ -312,7 +332,7 @@ describe("getWorkspaceDetailFn", () => {
312
332
 
313
333
  await expect(
314
334
  getWorkspaceDetailFn({ data: { workspaceId: "ws1" } }),
315
- ).rejects.toMatchObject({ message: expect.stringContaining("工作空间不存在") });
335
+ ).rejects.toThrow("工作空间不存在");
316
336
  expect(mocks.workspaceFindUnique).toHaveBeenCalledTimes(1);
317
337
  });
318
338
 
@@ -321,7 +341,7 @@ describe("getWorkspaceDetailFn", () => {
321
341
 
322
342
  await expect(
323
343
  getWorkspaceDetailFn({ data: { workspaceId: "ws1" } }),
324
- ).rejects.toMatchObject({ message: expect.stringContaining("仅工作空间所有者可访问") });
344
+ ).rejects.toThrow("仅工作空间所有者可访问");
325
345
  });
326
346
  });
327
347
 
@@ -350,7 +370,7 @@ describe("listWorkspaceProjectsFn", () => {
350
370
 
351
371
  await expect(
352
372
  listWorkspaceProjectsFn({ data: { workspaceId: "ws1" } }),
353
- ).rejects.toMatchObject({ message: expect.stringContaining("仅工作空间所有者可访问") });
373
+ ).rejects.toThrow("仅工作空间所有者可访问");
354
374
  });
355
375
  });
356
376
 
@@ -443,7 +463,7 @@ describe("deleteWorkspaceFn", () => {
443
463
 
444
464
  await expect(
445
465
  deleteWorkspaceFn({ data: { workspaceId: "ws1", mode: "transfer" } }),
446
- ).rejects.toMatchObject({ message: expect.stringContaining("转移模式必须指定目标工作空间") });
466
+ ).rejects.toThrow("转移模式必须指定目标工作空间");
447
467
  expect(mocks.workspaceDelete).not.toHaveBeenCalled();
448
468
  });
449
469
 
@@ -455,7 +475,7 @@ describe("deleteWorkspaceFn", () => {
455
475
  deleteWorkspaceFn({
456
476
  data: { workspaceId: "ws1", mode: "transfer", targetWorkspaceId: "ws1" },
457
477
  }),
458
- ).rejects.toMatchObject({ message: expect.stringContaining("不可转移到自身") });
478
+ ).rejects.toThrow("不可转移到自身");
459
479
  expect(mocks.workspaceDelete).not.toHaveBeenCalled();
460
480
  });
461
481
 
@@ -482,7 +502,7 @@ describe("deleteWorkspaceFn", () => {
482
502
 
483
503
  await expect(
484
504
  deleteWorkspaceFn({ data: { workspaceId: "ws1" } }),
485
- ).rejects.toMatchObject({ message: expect.stringContaining("仅工作空间所有者可访问") });
505
+ ).rejects.toThrow("仅工作空间所有者可访问");
486
506
  });
487
507
  });
488
508
 
@@ -514,6 +534,6 @@ describe("updateWorkspaceFn", () => {
514
534
  updateWorkspaceFn({
515
535
  data: { workspaceId: "ws1", name: "新名称" },
516
536
  }),
517
- ).rejects.toMatchObject({ message: expect.stringContaining("仅工作空间所有者可访问") });
537
+ ).rejects.toThrow("仅工作空间所有者可访问");
518
538
  });
519
539
  });
@@ -95,7 +95,7 @@ describe("createIterationsFn", () => {
95
95
  ]);
96
96
  await expect(
97
97
  createIterationsFn({ data: { projectId: "p1", name: "S2", startDate: "2026-09-10", endDate: "2026-09-23" } }),
98
- ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
98
+ ).rejects.toThrow("重叠");
99
99
  });
100
100
 
101
101
  it("合法日期调用 create 且日期按 UTC 0 点入库", async () => {
@@ -130,9 +130,7 @@ describe("updateIterationsFn", () => {
130
130
  archived: false,
131
131
  projectId: "p1",
132
132
  });
133
- await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toMatchObject({
134
- message: expect.stringContaining("不可编辑"),
135
- });
133
+ await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toThrow("不可编辑");
136
134
  });
137
135
 
138
136
  it("改期时校验与其他迭代不重叠", async () => {
@@ -148,7 +146,7 @@ describe("updateIterationsFn", () => {
148
146
  ]);
149
147
  await expect(
150
148
  updateIterationsFn({ data: { id: "i1", startDate: "2026-09-18", endDate: "2026-09-25" } }),
151
- ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
149
+ ).rejects.toThrow("重叠");
152
150
  });
153
151
  });
154
152
 
@@ -83,7 +83,7 @@ describe("setStoryIterationFn", () => {
83
83
  });
84
84
  await expect(
85
85
  setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } }),
86
- ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
86
+ ).rejects.toThrow("已完成");
87
87
  });
88
88
 
89
89
  it("合法目标更新 story 并记录活动", async () => {
@@ -85,7 +85,7 @@ describe("transferUnfinishedFn", () => {
85
85
  });
86
86
  await expect(
87
87
  transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
88
- ).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
88
+ ).rejects.toThrow("已完成");
89
89
  });
90
90
 
91
91
  it("where 断言 state notIn 且 iterationId = 源迭代", async () => {
@@ -118,7 +118,7 @@ describe("transferUnfinishedFn", () => {
118
118
  });
119
119
  await expect(
120
120
  transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
121
- ).rejects.toMatchObject({ message: expect.stringContaining("迭代不存在") });
121
+ ).rejects.toThrow("迭代不存在");
122
122
  });
123
123
 
124
124
  it("二次调用(无未完成)返回 { count: 0 },不抛错", async () => {