@allbluecn/web-app 0.21.0 → 0.22.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 (74) hide show
  1. package/package.json +3 -3
  2. package/src/components/billing/__tests__/use-resource.test.tsx +2 -0
  3. package/src/components/billing/subscription-provider.tsx +2 -0
  4. package/src/components/bug/create-bug-dialog.tsx +1 -1
  5. package/src/components/bug/detail/props-section.tsx +1 -0
  6. package/src/components/bug/inline-editors/labels-editor.tsx +4 -1
  7. package/src/components/bug/views/bug-list/batch-toolbar.tsx +5 -2
  8. package/src/components/bug/views/bug-list/inline-bug-creator.tsx +1 -1
  9. package/src/components/bug/views/bug-list/row.tsx +1 -1
  10. package/src/components/design/editor/dashboard/design-dashboard.tsx +21 -7
  11. package/src/components/design/editor/layout/design-navigation.tsx +22 -12
  12. package/src/components/harness/dock/dock-panel-container.tsx +17 -4
  13. package/src/components/harness/harness-workbench-page.tsx +14 -1
  14. package/src/components/harness/todo-panel.tsx +2 -5
  15. package/src/components/harness/use-todo-projection.ts +3 -3
  16. package/src/components/story/ai/story-ai-panel.tsx +2 -2
  17. package/src/components/story/create-story-dialog.tsx +1 -1
  18. package/src/components/story/detail/story-collab-gate.tsx +191 -0
  19. package/src/components/story/detail/story-detail-body.tsx +5 -169
  20. package/src/components/story/detail/story-properties.tsx +1 -0
  21. package/src/components/story/filters/field-configs.tsx +6 -0
  22. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -1
  23. package/src/components/story/inline-editors/labels-editor.tsx +14 -10
  24. package/src/components/story/peek-panel.tsx +1 -1
  25. package/src/components/story/relations/story-ref-row.tsx +1 -1
  26. package/src/components/story/views/story-list/batch-toolbar.tsx +1 -1
  27. package/src/components/story/views/story-list/inline-story-creator.tsx +1 -0
  28. package/src/components/story/views/story-list/row.tsx +1 -1
  29. package/src/components/testcase/runs/convert-bug-button.tsx +1 -1
  30. package/src/design-engine/document/serialize-read.ts +30 -1
  31. package/src/design-engine/engine/app.ts +18 -1
  32. package/src/design-engine/engine/engine-base.ts +2 -0
  33. package/src/lib/changelog/sdk-versions.ts +4 -4
  34. package/src/lib/collections/reference/labels-collection.ts +1 -1
  35. package/src/paraglide/messages/_index.d.ts +4 -0
  36. package/src/paraglide/messages/bug_list_loadmorefailed2.d.ts +17 -0
  37. package/src/paraglide/messages/bug_list_loadmorefailed2.js +30 -0
  38. package/src/paraglide/messages/bug_storypanel_partialfailed2.d.ts +19 -0
  39. package/src/paraglide/messages/bug_storypanel_partialfailed2.js +30 -0
  40. package/src/paraglide/messages/chat_workspace_githubnotconnected2.d.ts +17 -0
  41. package/src/paraglide/messages/chat_workspace_githubnotconnected2.js +30 -0
  42. package/src/paraglide/messages/dashboard_activityfeed_ai1.d.ts +17 -0
  43. package/src/paraglide/messages/dashboard_activityfeed_ai1.js +30 -0
  44. package/src/routes/_nav/bugs/$bugId/route.tsx +1 -1
  45. package/src/routes/_nav/bugs/index.tsx +1 -1
  46. package/src/routes/_nav/dashboard/-components/activity-feed.tsx +23 -4
  47. package/src/routes/_nav/dashboard/route.lazy.tsx +6 -6
  48. package/src/routes/_nav/dashboard/route.tsx +11 -4
  49. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +35 -1
  50. package/src/routes/_nav/stories/$storyId/route.tsx +1 -1
  51. package/src/routes/_nav/stories/index.tsx +1 -1
  52. package/src/routes/api/billing-webhook.ts +1 -1
  53. package/src/server/__tests__/resources.agents.test.ts +2 -0
  54. package/src/server/__tests__/resources.license.test.ts +2 -0
  55. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  56. package/src/server/__tests__/resources.test.ts +2 -0
  57. package/src/server/ai-gateway/__tests__/pgvector-memory-adapter.test.ts +16 -0
  58. package/src/server/ai-gateway/memory/pgvector-adapter.ts +14 -0
  59. package/src/server/ai-gateway/storage/__tests__/blob-store.test.ts +4 -0
  60. package/src/server/ai-gateway/storage/db-blob-store.ts +4 -0
  61. package/src/server/billing-email.ts +18 -5
  62. package/src/server/bug/bug-crud-helpers.ts +107 -0
  63. package/src/server/knowledge/knowledge-helpers.ts +40 -0
  64. package/src/server/resources.ts +43 -2
  65. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +27 -14
  66. package/src/server/serverFns/activity.ts +61 -0
  67. package/src/server/serverFns/anyhub/install.ts +3 -0
  68. package/src/server/serverFns/bug/bug-crud.ts +10 -83
  69. package/src/server/serverFns/knowledge.ts +2 -17
  70. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +2 -2
  71. package/src/server/serverFns/story/kb-scope.ts +6 -6
  72. package/src/server/serverFns/story/story-crud/labels.ts +20 -7
  73. package/src/server/story/story-crud/label-upsert.ts +47 -9
  74. package/src/start.ts +15 -0
@@ -39,6 +39,8 @@ const PLAN_LIMIT_SELECT = {
39
39
  sandboxMinutesMonthly: true,
40
40
  mediaGenerationsMonthly: true,
41
41
  galleryStorageMb: true,
42
+ installQuota: true,
43
+ blobStorageMb: true,
42
44
  } as const
43
45
 
44
46
  interface PlanLimits {
@@ -51,6 +53,8 @@ interface PlanLimits {
51
53
  sandboxMinutesMonthly: number | null
52
54
  mediaGenerationsMonthly: number | null
53
55
  galleryStorageMb: number | null
56
+ installQuota: number | null
57
+ blobStorageMb: number | null
54
58
  }
55
59
 
56
60
  async function resolveAccountPlanLimits(userId: string): Promise<{
@@ -119,9 +123,16 @@ export async function requireTeamSeats(
119
123
  export async function getResourceSnapshots(
120
124
  request: Request | undefined,
121
125
  ): Promise<ResourceSnapshots> {
122
- const licensed = await verifyActiveLicense()
123
126
  const session = await requireAuth(request)
124
- const userId = session.user.id
127
+ return getResourceSnapshotsForUser(session.user.id)
128
+ }
129
+
130
+ /**
131
+ * S6:无 Request 的快照/预检变体(服务端存储层调用,如 db-blob-store.put)。
132
+ * 口径与 getResourceSnapshots 完全一致(license/plan 解析相同)。
133
+ */
134
+ export async function getResourceSnapshotsForUser(userId: string): Promise<ResourceSnapshots> {
135
+ const licensed = await verifyActiveLicense()
125
136
  let limits: PlanLimits | null
126
137
  let addonSeats = 0
127
138
  let seatLimit: number | 'unlimited' = 'unlimited'
@@ -156,6 +167,8 @@ export async function getResourceSnapshots(
156
167
  limits?.mediaGenerationsMonthly == null ? 'unlimited' : limits.mediaGenerationsMonthly
157
168
  const galleryStorageLimit =
158
169
  limits?.galleryStorageMb == null ? 'unlimited' : limits.galleryStorageMb
170
+ const installQuotaLimit = limits?.installQuota == null ? 'unlimited' : limits.installQuota
171
+ const blobStorageLimit = limits?.blobStorageMb == null ? 'unlimited' : limits.blobStorageMb
159
172
 
160
173
  // 席位口径与 requireTeamSeats 对齐(2026-10 审计 🟠:原实现 member 快照把 guest
161
174
  // 成员计入、guest 快照漏计已接受的 guest 成员且把 member 邀请计入——进度条虚高/
@@ -204,6 +217,8 @@ export async function getResourceSnapshots(
204
217
  agentUsage,
205
218
  skillUsage,
206
219
  sandboxDurationMsSum,
220
+ installUsage,
221
+ blobSizeSum,
207
222
  ] = await Promise.all([
208
223
  prisma.workspace.count({ where: { ownerId: userId } }),
209
224
  prisma.aiUsageLog.count({
@@ -215,7 +230,11 @@ export async function getResourceSnapshots(
215
230
  _sum: { durationMs: true },
216
231
  where: { userId, activity: 'sandbox', createdAt: { gte: startOfCurrentMonth() } },
217
232
  }),
233
+ // S6:安装按尝试计(含 installing/failed——防滥用量纲,失败安装同样消耗记录面)
234
+ prisma.anyHubApp.count({ where: { userId } }),
235
+ prisma.blob.aggregate({ _sum: { size: true }, where: { userId } }),
218
236
  ])
237
+ const MB = 1024 * 1024
219
238
 
220
239
  return {
221
240
  seats: { limit: seatLimit, usage: memberSeatUsage + memberPendingInvites },
@@ -238,6 +257,28 @@ export async function getResourceSnapshots(
238
257
  limit: galleryStorageLimit,
239
258
  usage: galleryStorageUsage,
240
259
  },
260
+ installQuota: { limit: installQuotaLimit, usage: installUsage },
261
+ blobStorageMb: {
262
+ limit: blobStorageLimit,
263
+ usage: Math.ceil((blobSizeSum._sum.size ?? 0) / MB),
264
+ },
265
+ }
266
+ }
267
+
268
+ /**
269
+ * S6:无 Request 的配额预检(存储汇点用,如 db-blob-store.put)。
270
+ * ⚠ TOCTOU 边界同 requireResource:统计性预检,非硬闸。
271
+ */
272
+ export async function requireResourceForUser(
273
+ userId: string,
274
+ resource: keyof ResourceSnapshots,
275
+ amount: number = 1,
276
+ ): Promise<void> {
277
+ const snapshots = await getResourceSnapshotsForUser(userId)
278
+ const { limit, usage } = snapshots[resource]
279
+ if (limit === 'unlimited') return
280
+ if (usage + amount > limit) {
281
+ throw new ResourceLimitError(resource, limit, usage)
241
282
  }
242
283
  }
243
284
 
@@ -177,17 +177,18 @@ describe("listUsersBriefFn", () => {
177
177
  });
178
178
 
179
179
  describe("listLabelsFn", () => {
180
- it("按名称升序返回全部标签", async () => {
180
+ it("S24:按项目过滤(全局存量 ∪ 本项目),名称升序", async () => {
181
181
  M.label.findMany.mockResolvedValue([
182
182
  { id: "lb-2", name: "Beta", color: "#ff0000" },
183
183
  { id: "lb-1", name: "Alpha", color: null },
184
184
  ]);
185
185
 
186
- const res = await listLabelsFn({ data: undefined });
186
+ const res = await listLabelsFn({ data: { projectId: "p1" } });
187
187
 
188
188
  expect(M.label.findMany).toHaveBeenCalledWith({
189
+ where: { OR: [{ projectId: null }, { projectId: "p1" }] },
189
190
  orderBy: { name: "asc" },
190
- select: { id: true, name: true, color: true },
191
+ select: { id: true, name: true, color: true, projectId: true },
191
192
  });
192
193
  expect(res).toEqual([
193
194
  { id: "lb-2", name: "Beta", color: "#ff0000" },
@@ -195,47 +196,59 @@ describe("listLabelsFn", () => {
195
196
  ]);
196
197
  });
197
198
 
198
- it("无标签时返回空数组", async () => {
199
+ it("S24:无 projectId → 仅存量全局标签", async () => {
199
200
  M.label.findMany.mockResolvedValue([]);
200
- await expect(listLabelsFn({ data: undefined })).resolves.toEqual([]);
201
+ await expect(listLabelsFn({ data: {} })).resolves.toEqual([]);
202
+ expect(M.label.findMany).toHaveBeenCalledWith({
203
+ where: { projectId: null },
204
+ orderBy: { name: "asc" },
205
+ select: { id: true, name: true, color: true, projectId: true },
206
+ });
201
207
  });
202
208
 
203
209
  it("未登录拒绝且不查询", async () => {
204
210
  M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
205
- await expect(listLabelsFn({ data: undefined })).rejects.toThrow("UNAUTHORIZED");
211
+ await expect(listLabelsFn({ data: {} })).rejects.toThrow("UNAUTHORIZED");
206
212
  expect(M.label.findMany).not.toHaveBeenCalled();
207
213
  });
208
214
  });
209
215
 
210
216
  describe("createLabelFn", () => {
211
- it("透传名称与颜色给 upsertLabel 并使用当前用户", async () => {
217
+ it("S24:透传名称/颜色/projectId 给 upsertLabel 并使用当前用户", async () => {
212
218
  M.upsertLabel.mockResolvedValue({ id: "lb-9", name: "新标签", color: "#00ff00" });
213
219
 
214
- const res = await createLabelFn({ data: { name: "新标签", color: "#00ff00" } });
220
+ const res = await createLabelFn({ data: { name: "新标签", color: "#00ff00", projectId: "p1" } });
215
221
 
216
- expect(M.upsertLabel).toHaveBeenCalledWith("新标签", USER, "#00ff00");
222
+ expect(M.upsertLabel).toHaveBeenCalledWith("新标签", USER, "#00ff00", "p1");
217
223
  expect(res).toEqual({ id: "lb-9", name: "新标签", color: "#00ff00" });
218
224
  });
219
225
 
220
226
  it("未传颜色时以 undefined 透传", async () => {
221
227
  M.upsertLabel.mockResolvedValue({ id: "lb-9", name: "B", color: null });
222
- await createLabelFn({ data: { name: "B" } });
223
- expect(M.upsertLabel).toHaveBeenCalledWith("B", USER, undefined);
228
+ await createLabelFn({ data: { name: "B", projectId: "p1" } });
229
+ expect(M.upsertLabel).toHaveBeenCalledWith("B", USER, undefined, "p1");
230
+ });
231
+
232
+ it("S24:缺 projectId 被 validator 拒绝(新标签必挂项目)", async () => {
233
+ await expect(
234
+ createLabelFn({ data: { name: "X", projectId: "" } as unknown as { name: string; projectId: string } }),
235
+ ).rejects.toThrow();
236
+ expect(M.upsertLabel).not.toHaveBeenCalled();
224
237
  });
225
238
 
226
239
  it("非法颜色格式被 validator 拒绝(不写库)", async () => {
227
- await expect(createLabelFn({ data: { name: "X", color: "red" } })).rejects.toThrow();
240
+ await expect(createLabelFn({ data: { name: "X", color: "red", projectId: "p1" } })).rejects.toThrow();
228
241
  expect(M.upsertLabel).not.toHaveBeenCalled();
229
242
  });
230
243
 
231
244
  it("空名称被 validator 拒绝", async () => {
232
- await expect(createLabelFn({ data: { name: "" } })).rejects.toThrow();
245
+ await expect(createLabelFn({ data: { name: "", projectId: "p1" } })).rejects.toThrow();
233
246
  expect(M.upsertLabel).not.toHaveBeenCalled();
234
247
  });
235
248
 
236
249
  it("未登录拒绝且不写库", async () => {
237
250
  M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
238
- await expect(createLabelFn({ data: { name: "X" } })).rejects.toThrow("UNAUTHORIZED");
251
+ await expect(createLabelFn({ data: { name: "X", projectId: "p1" } })).rejects.toThrow("UNAUTHORIZED");
239
252
  expect(M.upsertLabel).not.toHaveBeenCalled();
240
253
  });
241
254
  });
@@ -131,3 +131,64 @@ export const getActivityDataFn = createServerFn({ method: 'GET' })
131
131
  level: computeLevel(count),
132
132
  }))
133
133
  })
134
+
135
+ // ── S16(裁决:dashboard 真数据最小范围)──
136
+
137
+ /** 统计卡四项:PRD 总数 / 今日完成动态 / 未读评论(占位 0,读模型未建)/ 已装应用 */
138
+ export const getDashboardStatsFn = createServerFn({ method: "GET" }).handler(async () => {
139
+ const prisma = await getPrisma()
140
+ const request = getRequest()
141
+ const session = await requireAuth(request)
142
+ const userId = session.user.id
143
+ const now = new Date()
144
+
145
+ const [prdCount, todayDone, installedApps] = await Promise.all([
146
+ prisma.design.count({ where: { userId, isDeleted: false } }),
147
+ prisma.storyActivity.count({
148
+ where: {
149
+ userId,
150
+ action: "state_changed",
151
+ newValue: "done",
152
+ createdAt: { gte: startOfDay(now) },
153
+ },
154
+ }),
155
+ prisma.anyHubApp.count({ where: { userId } }),
156
+ ])
157
+
158
+ // 未读评论:读模型未建(跨实体已读位),占位 0——卡片保留,不假数据
159
+ return { prdCount, completedToday: todayDone, unreadComments: 0, installedApps }
160
+ })
161
+
162
+ const recentActivitiesSchema = z.object({ take: z.number().int().min(1).max(50).default(10) })
163
+
164
+ /** 最近动态流:本人 storyActivity 最近 N 条(跨项目),action 经 ACTIVITY_LABEL
165
+ * 本地化在客户端做(shared 常量同源)。 */
166
+ export const getRecentActivitiesFn = createServerFn({ method: "GET" })
167
+ .validator(recentActivitiesSchema)
168
+ .handler(async ({ data }) => {
169
+ const prisma = await getPrisma()
170
+ const request = getRequest()
171
+ const session = await requireAuth(request)
172
+ const userId = session.user.id
173
+
174
+ const rows = await prisma.storyActivity.findMany({
175
+ where: { userId },
176
+ orderBy: { createdAt: "desc" },
177
+ take: data.take,
178
+ select: {
179
+ id: true,
180
+ action: true,
181
+ createdAt: true,
182
+ isAI: true,
183
+ story: { select: { identifier: true, name: true, projectId: true } },
184
+ },
185
+ })
186
+ return rows.map((r) => ({
187
+ id: r.id,
188
+ action: r.action,
189
+ isAI: r.isAI,
190
+ createdAt: r.createdAt.toISOString(),
191
+ storyIdentifier: r.story.identifier,
192
+ storyName: r.story.name,
193
+ }))
194
+ })
@@ -21,6 +21,7 @@ import { getRequest } from '@tanstack/react-start/server'
21
21
  import { z } from 'zod'
22
22
  import path from 'node:path'
23
23
  import { requireAuth } from '@/server/auth-helpers'
24
+ import { requireResource } from '@/server/resources'
24
25
  import { isLocalPathAllowed } from '@/lib/anyhub/local-path'
25
26
 
26
27
  const installSchema = z.object({
@@ -52,6 +53,8 @@ export const installAnyHubAppFn = createServerFn({ method: 'POST' })
52
53
  const prisma = await getPrisma()
53
54
  const request = getRequest()
54
55
  const session = await requireAuth(request)
56
+ // S6:安装按尝试计(含 installing/failed),超档拒绝(裁决:三档 200/1000/5000)
57
+ await requireResource(request, 'installQuota', 1)
55
58
 
56
59
  if (data.source === 'marketplace' && !data.marketplaceAppId) throw new Error('缺少 marketplaceAppId')
57
60
  if (data.source === 'github' && !data.githubUrl) throw new Error('缺少 githubUrl')
@@ -21,7 +21,6 @@ import { getRequest } from "@tanstack/react-start/server";
21
21
  import { z } from "zod";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import { ensureBuiltinIndependentProject, resolveStoryScopeProjectIds } from "@/server/builtin-project";
24
- import { isUserInTeam } from "@/server/team-access";
25
24
  import { diffDescriptionBlocks } from "@/lib/story/description-diff";
26
25
  import {
27
26
  BUG_STATES,
@@ -33,7 +32,6 @@ import {
33
32
  } from "@/shared/bug/constants";
34
33
  import { BUG_LIST_SELECT, BUG_USER_BRIEF_SELECT, castBugUsers } from "@/shared/bug/types";
35
34
  import { buildListWhere } from "./bug-list-query";
36
- import type { AvatarConfig } from "@allbluecn/kernel";
37
35
  import type { Prisma } from "@allbluecn/database";
38
36
  import { buildRoomName } from "@allbluecn/collab-core/rooms";
39
37
  import { normalizeCollabSteps } from "@allbluecn/collab-core/steps";
@@ -42,43 +40,21 @@ import { withSequenceNumberRetry } from "@/server/lib/sequence-number-retry";
42
40
  import { touchCollabSnapshot } from "@/server/collab/touch-snapshot";
43
41
  import { assertProjectMembership as assertProjectMembershipShared } from "@/server/lib/project-membership";
44
42
 
43
+
44
+ // S2:纯 helper 迁兄弟文件(bug-crud-helpers),此处 re-export 保持测试/消费方 import 路径不变
45
+ import {
46
+ validateBugAssignee,
47
+ assertBugsInMemberProjects,
48
+ toBugActivityItem,
49
+ bugDescriptionToContent,
50
+ } from "@/server/bug/bug-crud-helpers";
51
+ export { validateBugAssignee, assertBugsInMemberProjects, toBugActivityItem, bugDescriptionToContent };
45
52
  // 白名单校验模式(与 story 同构):DB 中 state/severity/source 为 String,写入侧用 z.enum 收敛
46
53
  const BUG_STATE_VALUES = BUG_STATES.map((s) => s.value) as [BugState, ...BugState[]];
47
54
  const BUG_SEVERITY_VALUES = BUG_SEVERITIES.map((s) => s.value) as [BugSeverity, ...BugSeverity[]];
48
55
  const BUG_SOURCE_VALUES = BUG_SOURCES.map((s) => s.value) as [BugSource, ...BugSource[]];
49
56
 
50
- async function validateBugAssignee(projectId: string, assigneeId: string | null | undefined) {
51
- if (!assigneeId) return;
52
- const prisma = await getPrisma();
53
- const project = await prisma.project.findUnique({
54
- where: { id: projectId },
55
- select: { ownerId: true },
56
- });
57
- if (!project) throw new Error("项目不存在");
58
- const ok = await isUserInTeam(project.ownerId, assigneeId);
59
- if (!ok) throw new Error("负责人必须是团队成员");
60
- }
61
-
62
- // 批量写路径的成员圈定(R6):与 deleteBugFn 同口径——目标 bugs 所属每个项目
63
- // 都须有当前用户的有效成员关系(status 非 suspended),任一无权即整体拒绝。
64
- // 判定在事务前完成,防止批量入口绕过单删的权限语义。
65
- async function assertBugsInMemberProjects(bugIds: string[], userId: string) {
66
- const prisma = await getPrisma();
67
- const rows = await prisma.bug.findMany({
68
- where: { id: { in: bugIds } },
69
- select: { projectId: true },
70
- });
71
- const projectIds = [...new Set(rows.map((r) => r.projectId))];
72
- if (projectIds.length === 0) return;
73
- const memberships = await prisma.projectMember.findMany({
74
- where: { userId, projectId: { in: projectIds }, status: { not: "suspended" } },
75
- select: { projectId: true },
76
- });
77
- const allowed = new Set(memberships.map((m) => m.projectId));
78
- if (projectIds.some((p) => !allowed.has(p))) {
79
- throw new Error("无权限操作该项目的 BUG");
80
- }
81
- }
57
+
82
58
 
83
59
  // 按 projectId 直判变体(R8):createBugFn 显式传 projectId 与 updateBugFn
84
60
  // (事务内已有 existing.projectId,免二次反查)使用;成员谓词与
@@ -87,36 +63,6 @@ async function assertBugsInMemberProjects(bugIds: string[], userId: string) {
87
63
  const assertProjectMembership = (projectId: string, userId: string) =>
88
64
  assertProjectMembershipShared(projectId, userId, "无权限操作该项目的 BUG");
89
65
 
90
- function toBugActivityItem(a: {
91
- id: string;
92
- action: string;
93
- field: string | null;
94
- oldValue: string | null;
95
- newValue: string | null;
96
- isAI: boolean;
97
- createdAt: Date;
98
- user: {
99
- id: string;
100
- username: string;
101
- email: string;
102
- avatarConfig: unknown;
103
- bgShape: string | null;
104
- bgColor: string | null;
105
- } | null;
106
- }) {
107
- return {
108
- id: a.id,
109
- action: a.action,
110
- field: a.field,
111
- oldValue: a.oldValue,
112
- newValue: a.newValue,
113
- isAI: a.isAI,
114
- createdAt: a.createdAt.toISOString(),
115
- user: a.user
116
- ? { ...a.user, avatarConfig: (a.user.avatarConfig as AvatarConfig | null) ?? null }
117
- : null,
118
- };
119
- }
120
66
 
121
67
  const listSchema = z.object({
122
68
  projectId: z.string().optional(),
@@ -403,25 +349,6 @@ const BUG_Y_UPDATE_KEYS = new Set([
403
349
  "reproduceSteps",
404
350
  ]);
405
351
 
406
- /**
407
- * Bug.description(Text 列 JSON 串)→ {html, markdown} 门卫 content。
408
- * 与 components/story/types.ts parseStoryDescription 读取口径同构的镜像
409
- * (服务端不 import 组件模块);历史裸串兜底为双轨原串。Bug.description 与
410
- * Story.description 存储同构(schema 注释),S12 裁决 UI 本期不接协同编辑,
411
- * 但旁路写(创建对话框等)仍须经理卫转发防互吞。
412
- */
413
- function bugDescriptionToContent(description: string): { html: string; markdown: string } {
414
- try {
415
- const parsed = JSON.parse(description) as { html?: unknown; markdown?: unknown };
416
- if (typeof parsed.html === "string") {
417
- const markdown = typeof parsed.markdown === "string" ? parsed.markdown : parsed.html;
418
- return { html: parsed.html, markdown };
419
- }
420
- } catch {
421
- // 历史裸串兜底
422
- }
423
- return { html: description, markdown: description };
424
- }
425
352
 
426
353
  export const updateBugFn = createServerFn({ method: "POST" })
427
354
  .validator(updateSchema)
@@ -20,6 +20,8 @@ import { buildRoomName } from "@allbluecn/collab-core/rooms"
20
20
  import { createServerFn } from '@tanstack/react-start'
21
21
  import { getRequest } from '@tanstack/react-start/server'
22
22
  import { z } from 'zod'
23
+ import { jsonSizeOk, documentIdFromEmbeddingRowId, MAX_DOC_CONTENT_JSON_CHARS, MAX_DOC_PROPERTIES_JSON_CHARS } from '@/server/knowledge/knowledge-helpers'
24
+
23
25
  import { requireAuth } from '@/server/auth-helpers'
24
26
  import { indexDocumentById, resolveAiConfig, KB_EMBEDDINGS_TABLE } from '@/server/knowledge/knowledge-index-utils'
25
27
  import { generateEmbedding } from '@/server/ai-gateway/embedding'
@@ -300,17 +302,6 @@ export const getDocumentFn = createServerFn({ method: 'GET' })
300
302
  // 表格配置),不做结构收敛;仅加序列化体积上限防超大 JSON 写库(审计 2026-10-25)。
301
303
  // 注意:字段类型保持 z.any()(推断 any)而非 z.unknown()——content 会回显进
302
304
  // updateDocumentFn 返回值,unknown 过不了 serverFn 的 TSerializable 序列化校验。
303
- const MAX_DOC_CONTENT_JSON_CHARS = 2_000_000
304
- const MAX_DOC_PROPERTIES_JSON_CHARS = 256_000
305
-
306
- function jsonSizeOk(value: unknown, max: number): boolean {
307
- if (value === undefined || value === null) return true
308
- try {
309
- return JSON.stringify(value).length <= max
310
- } catch {
311
- return false
312
- }
313
- }
314
305
 
315
306
  const updateDocSchema = z
316
307
  .object({
@@ -606,12 +597,6 @@ const PUBLIC_SEARCH_DOMAINS = [
606
597
  { table: 'websiteBookmark', entityType: 'bookmark', label: '网站收藏' },
607
598
  ] as const
608
599
 
609
- /** 嵌入行 id 约定为 `${documentId}-${chunkIndex}`(knowledge-index-utils
610
- * indexDocumentEmbeddings 写入),chunkIndex 可为多位数字,取最后一个 "-" 前缀。 */
611
- function documentIdFromEmbeddingRowId(rowId: string): string {
612
- const cut = rowId.lastIndexOf('-')
613
- return cut > 0 ? rowId.slice(0, cut) : rowId
614
- }
615
600
 
616
601
  const searchPublicKnowledge = async (
617
602
  domain: (typeof PUBLIC_SEARCH_DOMAINS)[number],
@@ -40,7 +40,7 @@ describe("kb-scope 可用范围判定", () => {
40
40
  });
41
41
  });
42
42
 
43
- it("独立需求项目:自己的全部 ∪ 他人 team/general", () => {
43
+ it("独立需求项目:自己的全部 ∪ 他人 general(S42 fail-closed,不命中他人 team)", () => {
44
44
  const where = buildKbScopeWhere({
45
45
  projectId: "p2",
46
46
  projectDisplayId: "STORY",
@@ -49,7 +49,7 @@ describe("kb-scope 可用范围判定", () => {
49
49
  expect(where).toEqual({
50
50
  OR: [
51
51
  { userId: "u1" },
52
- { visibility: { in: ["team", "general"] } },
52
+ { visibility: "general" },
53
53
  ],
54
54
  });
55
55
  });
@@ -36,19 +36,19 @@ export function isIndependentProject(displayId: string | null): boolean {
36
36
  /**
37
37
  * 需求可选知识库范围(Prisma where,用于 KnowledgeBase 查询):
38
38
  * - 普通项目:visibility=general(跨用户)∪ 项目关联 KB(关联仅允许 team)
39
- * - 独立需求(STORY):自己的全部 ∪ 他人 team/general
39
+ * - 独立需求(STORY):自己的全部 ∪ 他人 general
40
40
  *
41
- * ⚠ 可见性语义注记(审计 ⚪ 2026-10-25,留待产品确认):独立需求分支放行他人
42
- * `team` 可见性 KB——前提是本产品 `team` 取「平台内全员可发现」语义(与项目
43
- * KB 关联仅允许 team 的口径一致);若 `team` 改为「所有者团队内可见」,此处
44
- * 须同步收紧为按团队过滤。
41
+ * S42(审计 ⚪ 2026-10-25,控制者裁决「按高收益方向」):独立需求分支收窄为
42
+ * fail-closed——不再命中他人 `team` 可见性 KB。`team` 的真实语义(平台全员
43
+ * 可发现 vs 所有者团队内可见)尚未落地,按后者理解时旧口径即跨团队泄露面;
44
+ * 待团队语义落地后可放宽为按团队过滤。
45
45
  */
46
46
  export function buildKbScopeWhere(input: KbScopeInput): Prisma.KnowledgeBaseWhereInput {
47
47
  if (isIndependentProject(input.projectDisplayId)) {
48
48
  return {
49
49
  OR: [
50
50
  { userId: input.userId },
51
- { visibility: { in: ["team", "general"] } },
51
+ { visibility: "general" },
52
52
  ],
53
53
  };
54
54
  }
@@ -15,26 +15,39 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { getPrisma } from "@allbluecn/database/get-prisma"
19
- import { createServerFn } from "@tanstack/react-start";
20
- import { getRequest } from "@tanstack/react-start/server";
21
18
  import { z } from "zod";
19
+ import { getPrisma } from "@allbluecn/database/get-prisma";
20
+ import { getRequest } from "@tanstack/react-start/server";
21
+ import { createServerFn } from "@tanstack/react-start";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
+ import type { Prisma } from '@allbluecn/database'
23
24
  import { upsertLabel } from "@/server/story/story-crud/label-upsert";
24
25
 
25
26
  const labelCreateSchema = z.object({
26
27
  name: z.string().min(1).max(30),
27
28
  color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
29
+ // S24:新标签必挂项目(裁决)。项目标签与存量全局标签在列表中并出。
30
+ projectId: z.string().min(1),
28
31
  });
29
32
 
30
- export const listLabelsFn = createServerFn({ method: "GET" }).handler(
31
- async () => {
33
+ /** S24:标签范围 = 存量全局(projectId IS NULL)∪ 本项目归属。 */
34
+ export const listLabelsFn = createServerFn({ method: "GET" })
35
+ .validator(z.object({ projectId: z.string().optional() }))
36
+ .handler(async ({ data }) => {
32
37
  const prisma = await getPrisma()
33
38
  const request = getRequest();
34
39
  await requireAuth(request);
40
+ // null 字面量与 Prisma Nullable 字段类型不可比(TS 限制),Json 边界同款
41
+ // 单点断言收窄(S24)
42
+ const where = {
43
+ ...(data.projectId
44
+ ? { OR: [{ projectId: null }, { projectId: data.projectId }] }
45
+ : { projectId: null }),
46
+ } as unknown as Prisma.LabelWhereInput;
35
47
  return prisma.label.findMany({
48
+ where,
36
49
  orderBy: { name: "asc" },
37
- select: { id: true, name: true, color: true },
50
+ select: { id: true, name: true, color: true, projectId: true },
38
51
  });
39
52
  },
40
53
  );
@@ -45,5 +58,5 @@ export const createLabelFn = createServerFn({ method: "POST" })
45
58
  const prisma = await getPrisma()
46
59
  const request = getRequest();
47
60
  const session = await requireAuth(request);
48
- return upsertLabel(data.name, session.user.id, data.color);
61
+ return upsertLabel(data.name, session.user.id, data.color, data.projectId);
49
62
  });
@@ -18,16 +18,54 @@
18
18
  import { getPrisma } from "@allbluecn/database/get-prisma"
19
19
  import { pickLabelColor } from "./label-palette";
20
20
 
21
- export async function upsertLabel(name: string, createdById: string, color?: string) {
21
+ /**
22
+ * S24:项目归属标签获取/创建。
23
+ * - projectId 给定(现行唯一路径):按 (projectId, name) 查找,未命中创建
24
+ * (部分唯一索引兜底并发——P2002 回退再查,竞态安全)。
25
+ * - 兼容签名(无 projectId):仅匹配存量全局标签(projectId IS NULL),
26
+ * 不再新建全局标签(裁决:新标签必挂项目)。
27
+ */
28
+ export async function upsertLabel(
29
+ name: string,
30
+ createdById: string,
31
+ color?: string,
32
+ projectId?: string,
33
+ ) {
22
34
  const prisma = await getPrisma();
23
- return prisma.label.upsert({
24
- where: { name },
25
- update: {},
26
- create: {
27
- name,
28
- color: color ?? pickLabelColor(name),
29
- createdById,
30
- },
35
+ if (!projectId) {
36
+ // 兼容路径:全局命名空间已关闭(裁决:新标签必挂项目),找不到即抛——
37
+ // 调用方语义都是「确保标签存在」,静默返回 null 会让调用方拿 null.id
38
+ const legacy = await prisma.label.findFirst({
39
+ where: { name, projectId: null },
40
+ select: { id: true, name: true, color: true },
41
+ });
42
+ if (!legacy) throw new Error("全局标签命名空间已关闭:新标签必须归属项目");
43
+ return legacy;
44
+ }
45
+ const found = await prisma.label.findFirst({
46
+ where: { name, projectId },
31
47
  select: { id: true, name: true, color: true },
32
48
  });
49
+ if (found) return found;
50
+ try {
51
+ return await prisma.label.create({
52
+ data: {
53
+ name,
54
+ color: color ?? pickLabelColor(name),
55
+ createdById,
56
+ projectId,
57
+ },
58
+ select: { id: true, name: true, color: true },
59
+ });
60
+ } catch (e) {
61
+ // 部分唯一索引 (projectId, name) 并发兜底:撞索引即回读
62
+ if ((e as { code?: string }).code === "P2002") {
63
+ const raced = await prisma.label.findFirst({
64
+ where: { name, projectId },
65
+ select: { id: true, name: true, color: true },
66
+ });
67
+ if (raced) return raced;
68
+ }
69
+ throw e;
70
+ }
33
71
  }
package/src/start.ts CHANGED
@@ -143,6 +143,21 @@ export const startInstance = createStart(async () => {
143
143
  const requestMiddleware = [securityHeadersMiddleware, requestContextMiddleware, neonKeepaliveMiddleware, aiPortsMiddleware, apiCorsMiddleware, csrfMiddleware, auditRequestMiddleware]
144
144
  if (import.meta.env.SSR) {
145
145
  ensureGithubSyncTimer()
146
+ // S18(审计 A08 建议,裁决:仅 stdin 兜底):桌面端 sidecar 由 Rust 壳拉起,
147
+ // 壳崩溃时子进程沦落孤儿继续监听端口,用户重开应用拉起新 sidecar 即端口
148
+ // 冲突/旧代码伺服。兜底:desktop 部署目标下 stdin EOF(父进程死亡时管道
149
+ // 关闭)即自杀退出——Vercel/容器部署无 stdin 或有外部管护,不受影响。
150
+ if (process.env.DEPLOY_TARGET === 'desktop' && !globalAny.__sidecarStdinGuard) {
151
+ globalAny.__sidecarStdinGuard = true
152
+ process.stdin.resume()
153
+ process.stdin.once('end', () => {
154
+ console.warn('[sidecar] stdin EOF:父进程已退出,Node sidecar 随之退出')
155
+ process.exit(0)
156
+ })
157
+ process.stdin.on('error', () => {
158
+ // stdin 不可读(无 TTY/管道被劫持等)时静默——兜底失效不炸服务
159
+ })
160
+ }
146
161
  const { registerServerSeam } = await import('@allbluecn/kernel')
147
162
  const { requireAuth, requireUserInDb } = await import('@/server/auth-helpers')
148
163
  const { getCurrentSession } = await import('@/lib/session')