@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.
- package/package.json +3 -3
- package/src/components/billing/__tests__/use-resource.test.tsx +2 -0
- package/src/components/billing/subscription-provider.tsx +2 -0
- package/src/components/bug/create-bug-dialog.tsx +1 -1
- package/src/components/bug/detail/props-section.tsx +1 -0
- package/src/components/bug/inline-editors/labels-editor.tsx +4 -1
- package/src/components/bug/views/bug-list/batch-toolbar.tsx +5 -2
- package/src/components/bug/views/bug-list/inline-bug-creator.tsx +1 -1
- package/src/components/bug/views/bug-list/row.tsx +1 -1
- package/src/components/design/editor/dashboard/design-dashboard.tsx +21 -7
- package/src/components/design/editor/layout/design-navigation.tsx +22 -12
- package/src/components/harness/dock/dock-panel-container.tsx +17 -4
- package/src/components/harness/harness-workbench-page.tsx +14 -1
- package/src/components/harness/todo-panel.tsx +2 -5
- package/src/components/harness/use-todo-projection.ts +3 -3
- package/src/components/story/ai/story-ai-panel.tsx +2 -2
- package/src/components/story/create-story-dialog.tsx +1 -1
- package/src/components/story/detail/story-collab-gate.tsx +191 -0
- package/src/components/story/detail/story-detail-body.tsx +5 -169
- package/src/components/story/detail/story-properties.tsx +1 -0
- package/src/components/story/filters/field-configs.tsx +6 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +14 -10
- package/src/components/story/peek-panel.tsx +1 -1
- package/src/components/story/relations/story-ref-row.tsx +1 -1
- package/src/components/story/views/story-list/batch-toolbar.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +1 -0
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/testcase/runs/convert-bug-button.tsx +1 -1
- package/src/design-engine/document/serialize-read.ts +30 -1
- package/src/design-engine/engine/app.ts +18 -1
- package/src/design-engine/engine/engine-base.ts +2 -0
- package/src/lib/changelog/sdk-versions.ts +4 -4
- package/src/lib/collections/reference/labels-collection.ts +1 -1
- package/src/paraglide/messages/_index.d.ts +4 -0
- package/src/paraglide/messages/bug_list_loadmorefailed2.d.ts +17 -0
- package/src/paraglide/messages/bug_list_loadmorefailed2.js +30 -0
- package/src/paraglide/messages/bug_storypanel_partialfailed2.d.ts +19 -0
- package/src/paraglide/messages/bug_storypanel_partialfailed2.js +30 -0
- package/src/paraglide/messages/chat_workspace_githubnotconnected2.d.ts +17 -0
- package/src/paraglide/messages/chat_workspace_githubnotconnected2.js +30 -0
- package/src/paraglide/messages/dashboard_activityfeed_ai1.d.ts +17 -0
- package/src/paraglide/messages/dashboard_activityfeed_ai1.js +30 -0
- package/src/routes/_nav/bugs/$bugId/route.tsx +1 -1
- package/src/routes/_nav/bugs/index.tsx +1 -1
- package/src/routes/_nav/dashboard/-components/activity-feed.tsx +23 -4
- package/src/routes/_nav/dashboard/route.lazy.tsx +6 -6
- package/src/routes/_nav/dashboard/route.tsx +11 -4
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +35 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +1 -1
- package/src/routes/_nav/stories/index.tsx +1 -1
- package/src/routes/api/billing-webhook.ts +1 -1
- package/src/server/__tests__/resources.agents.test.ts +2 -0
- package/src/server/__tests__/resources.license.test.ts +2 -0
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/resources.test.ts +2 -0
- package/src/server/ai-gateway/__tests__/pgvector-memory-adapter.test.ts +16 -0
- package/src/server/ai-gateway/memory/pgvector-adapter.ts +14 -0
- package/src/server/ai-gateway/storage/__tests__/blob-store.test.ts +4 -0
- package/src/server/ai-gateway/storage/db-blob-store.ts +4 -0
- package/src/server/billing-email.ts +18 -5
- package/src/server/bug/bug-crud-helpers.ts +107 -0
- package/src/server/knowledge/knowledge-helpers.ts +40 -0
- package/src/server/resources.ts +43 -2
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +27 -14
- package/src/server/serverFns/activity.ts +61 -0
- package/src/server/serverFns/anyhub/install.ts +3 -0
- package/src/server/serverFns/bug/bug-crud.ts +10 -83
- package/src/server/serverFns/knowledge.ts +2 -17
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +2 -2
- package/src/server/serverFns/story/kb-scope.ts +6 -6
- package/src/server/serverFns/story/story-crud/labels.ts +20 -7
- package/src/server/story/story-crud/label-upsert.ts +47 -9
- package/src/start.ts +15 -0
package/src/server/resources.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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:
|
|
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("
|
|
199
|
+
it("S24:无 projectId → 仅存量全局标签", async () => {
|
|
199
200
|
M.label.findMany.mockResolvedValue([]);
|
|
200
|
-
await expect(listLabelsFn({ data:
|
|
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:
|
|
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("
|
|
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
|
-
|
|
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
|
|
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:
|
|
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):自己的全部 ∪ 他人
|
|
39
|
+
* - 独立需求(STORY):自己的全部 ∪ 他人 general
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
* `team` 可见性 KB
|
|
43
|
-
*
|
|
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:
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
name,
|
|
28
|
-
|
|
29
|
-
|
|
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')
|