@allbluecn/web-app 0.22.0 → 0.22.3

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 (93) hide show
  1. package/package.json +6 -8
  2. package/public/logo/allblue-logo-app-icon.svg +1 -1
  3. package/src/components/changelog/changelog-page.tsx +24 -5
  4. package/src/components/desktop/cloud-login.tsx +91 -0
  5. package/src/components/desktop/conflict-center.tsx +148 -0
  6. package/src/components/desktop/renamed-toast.tsx +23 -0
  7. package/src/components/desktop/shell-widgets.tsx +19 -0
  8. package/src/components/desktop/sync-indicator.tsx +60 -0
  9. package/src/components/desktop/sync-status-card.tsx +60 -0
  10. package/src/components/layout/sidebar-03/app-sidebar.tsx +38 -3
  11. package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +17 -1
  12. package/src/components/shared-kit/avatars/avatar-shape.ts +10 -0
  13. package/src/components/shared-kit/avatars/user-avatar.tsx +2 -2
  14. package/src/lib/auth/auth.config.ts +4 -1
  15. package/src/lib/changelog/sdk-versions.ts +33 -2
  16. package/src/lib/desktop-bridge-types.ts +30 -0
  17. package/src/lib/desktop-detect.ts +2 -0
  18. package/src/lib/vector-store/__tests__/factory.test.ts +0 -7
  19. package/src/lib/vector-store/index.ts +2 -4
  20. package/src/paraglide/messages/_index.d.ts +1 -1
  21. package/src/paraglide/messages/_index.js +32 -16
  22. package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.d.ts +1 -1
  23. package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.js +3 -3
  24. package/src/paraglide/messages/en.js +11 -6
  25. package/src/paraglide/messages/{tasks_ai_desktop_notice.d.ts → settings_tabs_sync.d.ts} +5 -5
  26. package/src/paraglide/messages/settings_tabs_sync.js +29 -0
  27. package/src/paraglide/messages/zh.js +11 -6
  28. package/src/providers/index.tsx +6 -0
  29. package/src/routeTree.gen.ts +107 -49
  30. package/src/routes/_nav/route.lazy.tsx +12 -0
  31. package/src/routes/_nav/settings/-settings-tabs.tsx +1 -1
  32. package/src/routes/_nav/settings/sync/route.lazy.tsx +40 -0
  33. package/src/routes/_nav/settings/sync/route.tsx +15 -0
  34. package/src/routes/_nav/tasks/index.lazy.tsx +1 -11
  35. package/src/routes/api/billing-webhook.ts +6 -2
  36. package/src/routes/api/sync/bootstrap.ts +38 -0
  37. package/src/routes/api/sync/pull.ts +96 -0
  38. package/src/routes/api/sync/push.ts +169 -0
  39. package/src/routes/api/sync/snapshot.ts +104 -0
  40. package/src/server/__tests__/fetch-guard.test.ts +2 -2
  41. package/src/server/__tests__/resource-registry.test.ts +4 -2
  42. package/src/server/__tests__/sandbox-entitlement.test.ts +5 -0
  43. package/src/server/__tests__/security-headers.test.ts +25 -0
  44. package/src/server/__tests__/task-assigned-query.test.ts +0 -11
  45. package/src/server/__tests__/task-serverfn-crud.test.ts +1 -22
  46. package/src/server/__tests__/task-serverfn-queries.test.ts +1 -9
  47. package/src/server/ai-gateway/__tests__/chat-stream-builder-memory.test.ts +0 -11
  48. package/src/server/ai-gateway/__tests__/embedding-request.test.ts +1 -1
  49. package/src/server/ai-gateway/__tests__/provider-adapter.test.ts +2 -2
  50. package/src/server/ai-gateway/__tests__/run-store-durable.test.ts +1 -1
  51. package/src/server/ai-gateway/durability/__tests__/pg-stream.test.ts +1 -1
  52. package/src/server/ai-gateway/durability/__tests__/reaper-real-db.test.ts +1 -1
  53. package/src/server/ai-gateway/memory/index.ts +2 -2
  54. package/src/server/ai-gateway/sandbox/__tests__/instance-store.test.ts +1 -1
  55. package/src/server/ai-task/assigned-query.ts +4 -15
  56. package/src/server/desktop-guest.ts +12 -0
  57. package/src/server/fetch-guard.ts +3 -3
  58. package/src/server/middlewares/desktop-token.ts +23 -0
  59. package/src/server/remote-auth.ts +6 -0
  60. package/src/server/serverFns/__tests__/billing.test.ts +58 -1
  61. package/src/server/serverFns/__tests__/update-seats.test.ts +8 -8
  62. package/src/server/serverFns/agents/agent-crud.ts +3 -1
  63. package/src/server/serverFns/auth/session.ts +9 -0
  64. package/src/server/serverFns/billing.ts +10 -4
  65. package/src/server/serverFns/bug/bug-crud.ts +7 -2
  66. package/src/server/serverFns/story/__tests__/labels.test.ts +37 -15
  67. package/src/server/serverFns/story/story-comments.ts +2 -1
  68. package/src/server/serverFns/story/story-crud/activities.ts +1 -1
  69. package/src/server/serverFns/story/story-crud/user-brief.ts +1 -1
  70. package/src/server/serverFns/story/story-crud.ts +2 -1
  71. package/src/server/serverFns/story/story-transfer.ts +2 -1
  72. package/src/server/serverFns/tasks/task-crud.ts +0 -13
  73. package/src/server/serverFns/tasks/task-queries.ts +1 -12
  74. package/src/server/serverFns/testcase/plans.ts +1 -1
  75. package/src/server/serverFns/testcase/run-cases.ts +1 -1
  76. package/src/server/serverFns/testcase/runs.ts +1 -1
  77. package/src/server/serverFns/testcase/testcase-activities.ts +1 -1
  78. package/src/server/serverFns/testcase/testcase-board.ts +1 -1
  79. package/src/server/serverFns/testcase/testcase-crud.ts +1 -1
  80. package/src/server/serverFns/workspace.ts +1 -1
  81. package/src/server/story/types.ts +7 -22
  82. package/src/server/story/user-brief.ts +48 -0
  83. package/src/server/sync/scopes.ts +29 -0
  84. package/src/start.ts +10 -2
  85. package/src/styles/globals.css +10 -0
  86. package/src/__tests__/desktop-connection-validate.test.ts +0 -66
  87. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +0 -143
  88. package/src/lib/vector-store/sqlite-vec-store.ts +0 -224
  89. package/src/paraglide/messages/tasks_ai_desktop_notice.js +0 -29
  90. package/src/routes/__desktop/onboarding/route.lazy.tsx +0 -175
  91. package/src/routes/__desktop/onboarding/route.tsx +0 -11
  92. package/src/routes/_nav/settings/connection/route.lazy.tsx +0 -64
  93. package/src/routes/_nav/settings/connection/route.tsx +0 -10
@@ -2,11 +2,6 @@ import { Prisma, prisma } from "@allbluecn/database"
2
2
  import { mapToUnifiedPriority } from "./priority-map"
3
3
  import type { AssignedItem, AssignedResult, TaskSource } from "./types"
4
4
 
5
- // provider 探测与 packages/database/index.ts 同口径(process.env.DATABASE_PROVIDER ?? "postgresql")
6
- export function getDatabaseProvider(): string {
7
- return process.env.DATABASE_PROVIDER ?? "postgresql"
8
- }
9
-
10
5
  const storySelect = {
11
6
  id: true,
12
7
  identifier: true,
@@ -137,17 +132,11 @@ function toTestCaseItem(row: TestCaseRow): AssignedItem {
137
132
 
138
133
  /**
139
134
  * 「分配给我的」三表聚合(story → bug → testcase 组序,组内 updatedAt 倒序,不跨表重排)。
140
- * 桌面端 SQLite(DATABASE_PROVIDER=sqlite)无 bug/testcase 数据源,降级为仅 story 并在 degraded 中声明。
135
+ * 历史 sqlite 降级已随 Tauri 清退移除(迁移 P1):postgresql/pglite 均有全量表。
141
136
  */
142
137
  export async function fetchAssignedToMe(userId: string): Promise<AssignedResult> {
143
- const isSqlite = getDatabaseProvider() === "sqlite"
144
-
145
- const pendingBugs: Promise<BugRow[]> = isSqlite
146
- ? Promise.resolve([])
147
- : queryAssignedBugs(userId)
148
- const pendingTestCases: Promise<TestCaseRow[]> = isSqlite
149
- ? Promise.resolve([])
150
- : queryAssignedTestCases(userId)
138
+ const pendingBugs: Promise<BugRow[]> = queryAssignedBugs(userId)
139
+ const pendingTestCases: Promise<TestCaseRow[]> = queryAssignedTestCases(userId)
151
140
 
152
141
  const [storyRows, bugRows, testCaseRows] = await Promise.all([
153
142
  queryAssignedStories(userId),
@@ -161,7 +150,7 @@ export async function fetchAssignedToMe(userId: string): Promise<AssignedResult>
161
150
  ...testCaseRows.map(toTestCaseItem),
162
151
  ]
163
152
 
164
- const degraded: TaskSource[] = isSqlite ? ["bug", "testcase"] : []
153
+ const degraded: TaskSource[] = []
165
154
 
166
155
  return { items, degraded }
167
156
  }
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@/lib/ai/default-model'
2
+ import { defaultAvatarConfig, presetBgColors } from '@/lib/avatar/avatar-config'
2
3
  import type { Session } from '@/lib/session'
3
4
 
4
5
  export const GUEST_USER_ID = 'local-guest'
@@ -9,6 +10,12 @@ export function isGuestSession(session: Session | null): boolean {
9
10
  return !!(session?.user?.id === GUEST_USER_ID)
10
11
  }
11
12
 
13
+ /** guest 无 DB 行:用户名散列取预设底色,保证跨请求稳定(不闪变) */
14
+ function guestBgColor(seed: string): string {
15
+ const sum = [...seed].reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
16
+ return presetBgColors[sum % presetBgColors.length] as string
17
+ }
18
+
12
19
  export function buildGuestSession(): Session {
13
20
  return {
14
21
  user: {
@@ -17,6 +24,11 @@ export function buildGuestSession(): Session {
17
24
  email: GUEST_EMAIL,
18
25
  role: 'USER',
19
26
  isGuest: true,
27
+ // 本地 guest 无 avatarConfig(DB 无行),缺省会让 UserAvatar 退化成
28
+ // 首字母灰块——桌面离线首屏「头像不显示」来源之一,给稳定默认配置
29
+ avatarConfig: defaultAvatarConfig,
30
+ bgShape: 'square',
31
+ bgColor: guestBgColor(GUEST_USERNAME),
20
32
  aiProvider: DEFAULT_AI_PROVIDER,
21
33
  aiModel: DEFAULT_AI_MODEL,
22
34
  hasAiKey: false,
@@ -34,14 +34,14 @@ export interface FetchGuardOptions {
34
34
  }
35
35
 
36
36
  /**
37
- * 桌面端运行时判定:desktop 端以 Tauri sidecar 形态单机部署,数据库走
38
- * DATABASE_PROVIDER=sqlite;此时 localhost/私网是合法服务地址(如本地
37
+ * 桌面端运行时判定:desktop 端(Electron + PGlite,迁移设计 §3.8)单机部署,
38
+ * 数据库走 DATABASE_PROVIDER=pglite;此时 localhost/私网是合法服务地址(如本地
39
39
  * Ollama http://localhost:11434),BYOK 请求的私网 baseUrl 应放行。
40
40
  * 云端 SaaS 为 postgres,恒返回 false——SSRF 防护默认语义不变(未知取值
41
41
  * 按云端处理,fail-closed)。
42
42
  */
43
43
  export function isDesktopRuntime(): boolean {
44
- return process.env.DATABASE_PROVIDER === "sqlite"
44
+ return process.env.DATABASE_PROVIDER === "pglite"
45
45
  }
46
46
 
47
47
  /**
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面端本地端口防直连(迁移设计 §2.1 D1 安全增强):main 进程为每会话生成
6
+ // X-Desktop-Token 并注入 renderer cookie 与 backend env;backend(DEPLOY_TARGET=desktop
7
+ // 且注入了 X_DESKTOP_TOKEN)校验请求携带的 cookie/头一致——堵「本机其他进程
8
+ // 直连随机端口拿到 guest 会话」。云端部署(无该 env)不受影响,恒放行。
9
+ import { createMiddleware } from "@tanstack/react-start"
10
+
11
+ export const desktopTokenMiddleware = createMiddleware().server(async ({ request, next }) => {
12
+ const expected = process.env.X_DESKTOP_TOKEN
13
+ if (process.env.DEPLOY_TARGET !== "desktop" || !expected) return next()
14
+
15
+ const cookieHeader = request.headers.get("cookie") ?? ""
16
+ const cookieToken = /(?:^|;\s*)X-Desktop-Token=([^;]+)/.exec(cookieHeader)?.[1]
17
+ const headerToken = request.headers.get("x-desktop-token")
18
+ const provided = cookieToken ?? headerToken ?? ""
19
+ if (provided !== expected) {
20
+ return new Response("Desktop token mismatch", { status: 403 })
21
+ }
22
+ return next()
23
+ })
@@ -1,3 +1,5 @@
1
+ // DESKTOP-LEGACY(迁移 P1 标记,勿删勿改):Tauri 时代的桌面远端会话读取桥,
2
+ // Electron 壳(P2)将以 preload desktopBridge 等价实现替代后整链移除。
1
3
  export interface RemoteAuthData {
2
4
  userId: string
3
5
  planName: string
@@ -8,6 +10,10 @@ export interface RemoteAuthData {
8
10
  sessionToken?: string
9
11
  tokenExpired?: boolean
10
12
  inGracePeriod?: boolean
13
+ /** 云端用户头像(SaaS desktop-callback profile 下发;缺省时 UI 回落首字母) */
14
+ avatarConfig?: Record<string, number>
15
+ bgShape?: string
16
+ bgColor?: string
11
17
  }
12
18
 
13
19
  export async function readRemoteAuth(): Promise<RemoteAuthData | null> {
@@ -27,6 +27,8 @@ const M = vi.hoisted(() => ({
27
27
  getEntitlements: vi.fn(),
28
28
  getResourceSnapshots: vi.fn(),
29
29
  userUpdate: vi.fn(),
30
+ subscriptionFindUnique: vi.fn(),
31
+ teamGroupBy: vi.fn(),
30
32
  }));
31
33
 
32
34
  const billingHolder = vi.hoisted(() => ({
@@ -36,6 +38,7 @@ const billingHolder = vi.hoisted(() => ({
36
38
  createCheckoutSession: ReturnType<typeof vi.fn>;
37
39
  createCustomerPortalSession: ReturnType<typeof vi.fn>;
38
40
  listInvoices: ReturnType<typeof vi.fn>;
41
+ updateSeats: ReturnType<typeof vi.fn>;
39
42
  },
40
43
  }));
41
44
 
@@ -66,7 +69,11 @@ vi.mock("@tanstack/react-start", () => ({
66
69
  vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
67
70
 
68
71
  vi.mock("@allbluecn/database", () => ({
69
- prisma: { user: { update: M.userUpdate } },
72
+ prisma: {
73
+ user: { update: M.userUpdate },
74
+ subscription: { findUnique: M.subscriptionFindUnique },
75
+ teamMembership: { groupBy: M.teamGroupBy },
76
+ },
70
77
  Prisma: {},
71
78
  }));
72
79
 
@@ -88,6 +95,7 @@ import {
88
95
  createCustomerPortalSessionFn,
89
96
  listInvoicesFn,
90
97
  updatePreferredCurrencyFn,
98
+ updateSeatsFn,
91
99
  } from "../billing";
92
100
  import { BillingUnavailableError } from "@allbluecn/kernel";
93
101
 
@@ -103,6 +111,7 @@ beforeEach(() => {
103
111
  createCheckoutSession: vi.fn().mockResolvedValue({ url: "https://checkout/x" }),
104
112
  createCustomerPortalSession: vi.fn().mockResolvedValue({ url: "https://portal/x" }),
105
113
  listInvoices: vi.fn().mockResolvedValue([{ id: "in-1" }]),
114
+ updateSeats: vi.fn().mockResolvedValue({ plan: "pro", addonSeats: 2 }),
106
115
  };
107
116
  });
108
117
 
@@ -261,3 +270,51 @@ describe("updatePreferredCurrencyFn", () => {
261
270
  });
262
271
  });
263
272
  });
273
+
274
+ describe("updateSeatsFn(审计 M8:席位口径=团队成员数,与展示侧一致)", () => {
275
+ const subWith = (overrides: Record<string, unknown> = {}) => ({
276
+ status: "ACTIVE",
277
+ plan: {
278
+ maxSeats: 5,
279
+ providerSeatPriceIdUsd: "price_seat",
280
+ providerSeatPriceIdCny: null,
281
+ ...overrides,
282
+ },
283
+ });
284
+
285
+ it("未登录拒绝", async () => {
286
+ M.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"));
287
+ await expect(updateSeatsFn({ data: { addonSeats: 2 } })).rejects.toThrow("UNAUTHORIZED");
288
+ });
289
+
290
+ it("无有效订阅拒绝", async () => {
291
+ M.subscriptionFindUnique.mockResolvedValue(null);
292
+ await expect(updateSeatsFn({ data: { addonSeats: 2 } })).rejects.toThrow(/无有效订阅/);
293
+ });
294
+
295
+ it("席位不限量(maxSeats=null)拒绝加购", async () => {
296
+ M.subscriptionFindUnique.mockResolvedValue(subWith({ maxSeats: null }));
297
+ await expect(updateSeatsFn({ data: { addonSeats: 2 } })).rejects.toThrow(/不限量/);
298
+ });
299
+
300
+ it("团队成员数在上限内:放行并调用计费模块加购", async () => {
301
+ M.subscriptionFindUnique.mockResolvedValue(subWith());
302
+ M.teamGroupBy.mockResolvedValue([{ ownerId: U, _count: { _all: 4 } }]);
303
+ await expect(updateSeatsFn({ data: { addonSeats: 2 } })).resolves.toEqual({ plan: "pro", addonSeats: 2 });
304
+ expect(M.teamGroupBy).toHaveBeenCalledWith({
305
+ by: ["ownerId"],
306
+ where: { owner: { id: U } },
307
+ _count: { _all: true },
308
+ });
309
+ expect(billingHolder.current!.updateSeats).toHaveBeenCalledWith(
310
+ expect.objectContaining({ userId: U, addonSeats: 2 }),
311
+ );
312
+ });
313
+
314
+ it("团队成员数超上限(5+2 < 10):拒绝,文案按团队成员口径", async () => {
315
+ M.subscriptionFindUnique.mockResolvedValue(subWith());
316
+ M.teamGroupBy.mockResolvedValue([{ ownerId: U, _count: { _all: 10 } }]);
317
+ await expect(updateSeatsFn({ data: { addonSeats: 2 } })).rejects.toThrow(/团队成员数(10)/);
318
+ expect(billingHolder.current!.updateSeats).not.toHaveBeenCalled();
319
+ });
320
+ });
@@ -46,8 +46,8 @@ vi.mock('@tanstack/react-start/server', () => ({
46
46
 
47
47
  vi.mock('@allbluecn/database', () => ({
48
48
  prisma: {
49
- workspace: {
50
- count: vi.fn(),
49
+ teamMembership: {
50
+ groupBy: vi.fn(),
51
51
  },
52
52
  subscription: {
53
53
  findUnique: vi.fn(),
@@ -73,7 +73,7 @@ import { prisma } from '@allbluecn/database'
73
73
  import { updateSeatsFn } from '../billing'
74
74
 
75
75
  const mockedSubFindUnique = prisma.subscription.findUnique as ReturnType<typeof vi.fn>
76
- const mockedWorkspaceCount = prisma.workspace.count as ReturnType<typeof vi.fn>
76
+ const mockedTeamGroupBy = prisma.teamMembership.groupBy as ReturnType<typeof vi.fn>
77
77
 
78
78
  const fakeBilling = billingHolder.current!
79
79
 
@@ -81,7 +81,7 @@ describe('updateSeatsFn', () => {
81
81
  beforeEach(() => {
82
82
  vi.clearAllMocks()
83
83
  billingHolder.current = fakeBilling
84
- mockedWorkspaceCount.mockResolvedValue(0)
84
+ mockedTeamGroupBy.mockResolvedValue([])
85
85
  })
86
86
 
87
87
  const proSubWithPlan = (over: Record<string, unknown> = {}) => ({
@@ -123,17 +123,17 @@ describe('updateSeatsFn', () => {
123
123
  ).rejects.toThrow('不限量')
124
124
  })
125
125
 
126
- it('降购低于工作空间数被 floor 拦截', async () => {
126
+ it('加购后仍低于团队成员数被 floor 拦截(审计 M8:口径=团队成员数)', async () => {
127
127
  mockedSubFindUnique.mockResolvedValue(proSubWithPlan())
128
- mockedWorkspaceCount.mockResolvedValue(7)
128
+ mockedTeamGroupBy.mockResolvedValue([{ ownerId: 'u1', _count: { _all: 7 } }])
129
129
  await expect(
130
130
  updateSeatsFn({ data: { addonSeats: 1 } }),
131
- ).rejects.toThrow('工作空间数')
131
+ ).rejects.toThrow('团队成员数')
132
132
  })
133
133
 
134
134
  it('合法加购:透传 billing.updateSeats 并返回其结果', async () => {
135
135
  mockedSubFindUnique.mockResolvedValue(proSubWithPlan())
136
- mockedWorkspaceCount.mockResolvedValue(4)
136
+ mockedTeamGroupBy.mockResolvedValue([{ ownerId: 'u1', _count: { _all: 4 } }])
137
137
  fakeBilling.updateSeats.mockResolvedValue({ planId: 'p_pro', addonSeats: 3 } as never)
138
138
  const result = await updateSeatsFn({ data: { addonSeats: 3 } })
139
139
  expect(fakeBilling.updateSeats).toHaveBeenCalledWith({
@@ -19,7 +19,9 @@ import { getPrisma } from "@allbluecn/database/get-prisma"
19
19
  import { createServerFn } from "@tanstack/react-start"
20
20
  import { getRequest } from "@tanstack/react-start/server"
21
21
  import { z } from "zod"
22
- import { AGENT_TOOL_KEYS } from "@/server/ai-gateway/tools"
22
+ // AGENT_TOOL_KEYS 在模块顶层(zod schema)被消费,会保留进客户端 split stub——
23
+ // 必须走纯净子路径 tools/select;tools index re-export 含 prisma 的 tools/server.ts
24
+ import { AGENT_TOOL_KEYS } from "@/server/ai-gateway/tools/select"
23
25
  import { requireFeature } from "@/server/entitlements"
24
26
  import { requireResource } from "@/server/resources"
25
27
  import { requireUserId } from "@/server/auth-helpers"
@@ -37,6 +37,10 @@ const saasProfileSchema = z.object({
37
37
  name: z.string().nullable().optional(),
38
38
  email: z.string().nullable().optional(),
39
39
  role: z.string().optional(),
40
+ // 头像字段可选下发(旧 SaaS 无此字段时 UI 回落首字母)
41
+ avatarConfig: z.record(z.string(), z.number()).optional(),
42
+ bgShape: z.string().optional(),
43
+ bgColor: z.string().optional(),
40
44
  }),
41
45
  planName: z.string().optional(),
42
46
  features: z.record(z.string(), z.boolean()).optional(),
@@ -51,6 +55,11 @@ function buildRemoteSession(data: RemoteAuthData): Session {
51
55
  email: data.email,
52
56
  role: (data.role ?? 'USER') as 'USER' | 'ADMIN' | 'SUPER_ADMIN',
53
57
  isGuest: false,
58
+ // 云端头像透传(SaaS desktop-callback 下发;缺省回落首字母,修复桌面端
59
+ // 当前用户头像不显示)
60
+ avatarConfig: data.avatarConfig,
61
+ bgShape: data.bgShape,
62
+ bgColor: data.bgColor,
54
63
  aiProvider: DEFAULT_AI_PROVIDER,
55
64
  aiModel: DEFAULT_AI_MODEL,
56
65
  hasAiKey: false,
@@ -151,13 +151,19 @@ export const updateSeatsFn = createServerFn({ method: 'POST' })
151
151
  throw new Error('当前计划未开通席位加购')
152
152
  }
153
153
 
154
- const wsCount = await prisma.workspace.count({
155
- where: { ownerId: userId },
154
+ // 席位用量与展示侧(getSubscription.seats → maxActiveTeamMembers)同一口径:
155
+ // 账号名下团队席位总数。审计 M8 裁决(2026-10-29):此前误用 workspace.count
156
+ // 作校验基数,与客户看到的「席位」数字互相矛盾(席位≠工作空间,
157
+ // maxSeats 与 maxWorkspaces 是两项独立限制)。
158
+ const seatGroups = await prisma.teamMembership.groupBy({
159
+ by: ['ownerId'],
160
+ where: { owner: { id: userId } },
161
+ _count: { _all: true },
156
162
  })
157
- const maxMembers = wsCount
163
+ const maxMembers = seatGroups.reduce((m, g) => Math.max(m, g._count._all), 0)
158
164
  if (subscription.plan.maxSeats + data.addonSeats < maxMembers) {
159
165
  throw new Error(
160
- `席位上限(${subscription.plan.maxSeats + data.addonSeats})不能低于当前工作空间数(${maxMembers}),请先删除多余工作空间`,
166
+ `席位上限(${subscription.plan.maxSeats + data.addonSeats})不能低于当前团队成员数(${maxMembers}),请先移除多余成员`,
161
167
  )
162
168
  }
163
169
 
@@ -41,14 +41,19 @@ import { touchCollabSnapshot } from "@/server/collab/touch-snapshot";
41
41
  import { assertProjectMembership as assertProjectMembershipShared } from "@/server/lib/project-membership";
42
42
 
43
43
 
44
- // S2:纯 helper 迁兄弟文件(bug-crud-helpers),此处 re-export 保持测试/消费方 import 路径不变
44
+ // S2:helper 迁兄弟文件(bug-crud-helpers),handler 内按需引用。禁止在此
45
+ // re-export:serverFn 的客户端 split stub 会保留模块顶层 import,而
46
+ // bug-crud-helpers 依赖 get-prisma/team-access(→ @allbluecn/database →
47
+ // dotenv/config),会把 server-only 链拉进浏览器令全站 hydration 崩溃
48
+ // (2026-10-30 实锤:routeTree → serverFns/bug/index → bug-crud stub →
49
+ // helpers → dotenv,浏览器端 process.argv.reduce 抛 TypeError,按钮全灭)。
50
+ // 消费方一律直接 import @/server/bug/bug-crud-helpers。
45
51
  import {
46
52
  validateBugAssignee,
47
53
  assertBugsInMemberProjects,
48
54
  toBugActivityItem,
49
55
  bugDescriptionToContent,
50
56
  } from "@/server/bug/bug-crud-helpers";
51
- export { validateBugAssignee, assertBugsInMemberProjects, toBugActivityItem, bugDescriptionToContent };
52
57
  // 白名单校验模式(与 story 同构):DB 中 state/severity/source 为 String,写入侧用 z.enum 收敛
53
58
  const BUG_STATE_VALUES = BUG_STATES.map((s) => s.value) as [BugState, ...BugState[]];
54
59
  const BUG_SEVERITY_VALUES = BUG_SEVERITIES.map((s) => s.value) as [BugSeverity, ...BugSeverity[]];
@@ -18,11 +18,14 @@
18
18
  import { describe, it, expect, vi, beforeEach } from "vitest";
19
19
 
20
20
  const mocks = vi.hoisted(() => ({
21
- labelUpsert: vi.fn(),
21
+ labelFindFirst: vi.fn(),
22
+ labelCreate: vi.fn(),
22
23
  }));
23
24
 
24
25
  vi.mock("@allbluecn/database", () => ({
25
- prisma: { label: { upsert: mocks.labelUpsert } },
26
+ prisma: {
27
+ label: { findFirst: mocks.labelFindFirst, create: mocks.labelCreate },
28
+ },
26
29
  }));
27
30
 
28
31
  import { upsertLabel } from "../../../story/story-crud/label-upsert";
@@ -45,23 +48,42 @@ describe("pickLabelColor", () => {
45
48
  });
46
49
  });
47
50
 
51
+ // S24 语义:项目归属标签 find→create(部分唯一索引兜底并发);
52
+ // 历史「全局 upsert」用例已随实现下线重写(迁移 P1 顺手修复的既有失败)
48
53
  describe("upsertLabel", () => {
49
- it("走 upsert 且 create 分支使用色板颜色", async () => {
50
- mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ef4444" });
51
- const r = await upsertLabel("性能", "u1");
52
- expect(mocks.labelUpsert).toHaveBeenCalledTimes(1);
53
- const arg = mocks.labelUpsert.mock.calls[0][0] as any;
54
- expect(arg.where).toEqual({ name: "性能" });
55
- expect(arg.update).toEqual({});
56
- expect(LABEL_PALETTE).toContain(arg.create.color);
57
- expect(arg.create.createdById).toBe("u1");
54
+ it("项目标签命中即直接返回,不创建", async () => {
55
+ mocks.labelFindFirst.mockResolvedValue({ id: "l1", name: "性能", color: "#ef4444" });
56
+ const r = await upsertLabel("性能", "u1", undefined, "p1");
58
57
  expect(r).toEqual({ id: "l1", name: "性能", color: "#ef4444" });
58
+ expect(mocks.labelFindFirst).toHaveBeenCalledWith({
59
+ where: { name: "性能", projectId: "p1" },
60
+ select: { id: true, name: true, color: true },
61
+ });
62
+ expect(mocks.labelCreate).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("未命中创建:create 分支使用色板颜色", async () => {
66
+ mocks.labelFindFirst.mockResolvedValue(null);
67
+ mocks.labelCreate.mockResolvedValue({ id: "l2", name: "性能", color: "#123456" });
68
+ const r = await upsertLabel("性能", "u1", undefined, "p1");
69
+ const arg = mocks.labelCreate.mock.calls[0][0] as any;
70
+ expect(LABEL_PALETTE).toContain(arg.data.color);
71
+ expect(arg.data.createdById).toBe("u1");
72
+ expect(arg.data.projectId).toBe("p1");
73
+ expect(r.id).toBe("l2");
59
74
  });
60
75
 
61
76
  it("传自定义颜色时优先使用自定义颜色", async () => {
62
- mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ff0000" });
63
- await upsertLabel("性能", "u1", "#ff0000");
64
- const arg = mocks.labelUpsert.mock.calls[0][0] as any;
65
- expect(arg.create.color).toBe("#ff0000");
77
+ mocks.labelFindFirst.mockResolvedValue(null);
78
+ mocks.labelCreate.mockResolvedValue({ id: "l3", name: "性能", color: "#ff0000" });
79
+ await upsertLabel("性能", "u1", "#ff0000", "p1");
80
+ const arg = mocks.labelCreate.mock.calls[0][0] as any;
81
+ expect(arg.data.color).toBe("#ff0000");
82
+ });
83
+
84
+ it("兼容签名(无 projectId):仅匹配存量全局标签,找不到即抛不新建", async () => {
85
+ mocks.labelFindFirst.mockResolvedValue(null);
86
+ await expect(upsertLabel("旧标签", "u1")).rejects.toThrow("全局标签命名空间已关闭");
87
+ expect(mocks.labelCreate).not.toHaveBeenCalled();
66
88
  });
67
89
  });
@@ -21,7 +21,8 @@ import { getRequest } from "@tanstack/react-start/server";
21
21
  import { z } from "zod";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import type { CommentWithReplies, ReactionGroup } from "@/shared/story/types";
24
- import { USER_BRIEF_SELECT, castUserBrief, aggregateReactions } from "@/server/story/types";
24
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
25
+ import { aggregateReactions } from "@/server/story/types";
25
26
  import { assertStoryProjectMember, assertStoryProjectMembership as assertProjectMembership } from "@/server/story/permissions";
26
27
 
27
28
 
@@ -21,7 +21,7 @@ import { getRequest } from "@tanstack/react-start/server";
21
21
  import { z } from "zod";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import { assertStoryProjectMember } from "@/server/story/permissions";
24
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
24
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
25
25
 
26
26
  const listActivitiesSchema = z.object({
27
27
  storyId: z.string(),
@@ -9,7 +9,7 @@ import { createServerFn } from "@tanstack/react-start";
9
9
  import { getRequest } from "@tanstack/react-start/server";
10
10
  import { requireAuth } from "@/server/auth-helpers";
11
11
  import { assertStoryProjectMembership } from "@/server/story/permissions";
12
- import { USER_BRIEF_SELECT } from "@/server/story/types";
12
+ import { USER_BRIEF_SELECT } from "@/server/story/user-brief";
13
13
 
14
14
  export const listUsersBriefFn = createServerFn({ method: "GET" })
15
15
  .validator((data?: { projectId?: string }) => data)
@@ -27,7 +27,8 @@ import { withCollabGuard } from "@/server/collab/write-guard";
27
27
  import { touchCollabSnapshot } from "@/server/collab/touch-snapshot";
28
28
  import type { ActivityItem } from "@/shared/story/types";
29
29
  import { diffDescriptionBlocks } from "@/lib/story/description-diff";
30
- import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief } from "@/server/story/types";
30
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
31
+ import { STORY_LIST_SELECT, castStoryUsers } from "@/server/story/types";
31
32
  import { toDateKeyUtc } from "@/server/story/date-key";
32
33
  import { buildListWhere } from "./story-list-query";
33
34
  import { createStoryInternal } from "./story-crud/create-story-internal";
@@ -25,7 +25,8 @@ import { requireAuth } from "@/server/auth-helpers";
25
25
  import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
26
26
  import { resolveStoryScopeProjectId } from "@/server/builtin-project";
27
27
  import { withCollabGuard } from "@/server/collab/write-guard";
28
- import { STORY_LIST_SELECT, castStoryUsers, castUserBrief, USER_BRIEF_SELECT, checkDescendant } from "@/server/story/types";
28
+ import { castUserBrief, USER_BRIEF_SELECT } from "@/server/story/user-brief";
29
+ import { STORY_LIST_SELECT, castStoryUsers, checkDescendant } from "@/server/story/types";
29
30
  import { withSequenceNumberRetry } from "@/server/lib/sequence-number-retry";
30
31
  import { assertProjectMembership as assertProjectMembershipShared } from "@/server/lib/project-membership";
31
32
 
@@ -9,8 +9,6 @@
9
9
  // checkAiRateLimit(userId, "aiTask"),再做属主/成员校验。
10
10
  // 并发裁决(双击防重入):updateMany 以 status:{not:"running"} 做条件更新占位,
11
11
  // count===0 即已有在跑实例,抛「任务正在运行」,不建 run 不调执行体。
12
- // sqlite 降级:aiTask 表 pg-only(DATABASE_PROVIDER=sqlite 无此表),三个变更入口
13
- // 直接拒绝,不触 prisma(provider 探测复用任务 3 的 getDatabaseProvider())。
14
12
  import { getPrisma } from "@allbluecn/database/get-prisma";
15
13
  import { createServerFn } from "@tanstack/react-start";
16
14
  import { getRequest } from "@tanstack/react-start/server";
@@ -19,7 +17,6 @@ import { requireAuth } from "@/server/auth-helpers";
19
17
  import { assertProjectMembership } from "@/server/bug/bug-permissions";
20
18
  import { checkAiRateLimit } from "@/server/lib/rate-limit";
21
19
  import { executeAiTaskRun } from "@/server/ai-task/executor";
22
- import { getDatabaseProvider } from "@/server/ai-task/assigned-query";
23
20
 
24
21
  /** 源条目在创建流程中的投影(title 已归一化:story.name / bug.title / testCase.title) */
25
22
  interface CreateSourceRef {
@@ -120,13 +117,6 @@ async function runTaskExecution(taskId: string, userId: string): Promise<string>
120
117
  return run.id;
121
118
  }
122
119
 
123
- /** sqlite(aiTask pg-only)变更入口统一降级:明确报错而非裸 Prisma 崩溃 */
124
- function assertPgOnlyMutation(): void {
125
- if (getDatabaseProvider() === "sqlite") {
126
- throw new Error("桌面端暂不支持 AI 任务");
127
- }
128
- }
129
-
130
120
  const createAiTaskSchema = z
131
121
  .object({
132
122
  title: z.string().min(1).max(200).optional(),
@@ -158,7 +148,6 @@ export const createAiTaskFn = createServerFn({ method: "POST" })
158
148
  const session = await requireAuth(request);
159
149
  const userId = session.user.id;
160
150
 
161
- assertPgOnlyMutation();
162
151
 
163
152
  const prisma = await getPrisma();
164
153
 
@@ -230,7 +219,6 @@ export const runAiTaskFn = createServerFn({ method: "POST" })
230
219
  const session = await requireAuth(request);
231
220
  const userId = session.user.id;
232
221
 
233
- assertPgOnlyMutation();
234
222
  await checkAiRateLimit(userId, "aiTask");
235
223
 
236
224
  const runId = await runTaskExecution(data.id, userId);
@@ -245,7 +233,6 @@ export const deleteAiTaskFn = createServerFn({ method: "POST" })
245
233
  const session = await requireAuth(request);
246
234
  const userId = session.user.id;
247
235
 
248
- assertPgOnlyMutation();
249
236
 
250
237
  const prisma = await getPrisma();
251
238
  // 归属过滤放 where:非属主与不存在同样抛「任务不存在」,不泄露存在性
@@ -4,19 +4,13 @@
4
4
  // 非导出成员。
5
5
  // 鉴权惯例(同 testcase-crud):serverFn 是独立 RPC 入口,路由层守卫拦不到,handler
6
6
  // 首行业务必须 requireAuth(getRequest()) 自查。
7
- // sqlite 降级:aiTask 表 pg-only(DATABASE_PROVIDER=sqlite 无此表),listAiTasksFn
8
- // 直接返回 { items: [], degraded: true } 不触 prisma.aiTask;provider 探测复用任务 3
9
- // 的 getDatabaseProvider()(与 packages/database/index.ts 同口径),不自建探测。
10
7
  import { getPrisma } from "@allbluecn/database/get-prisma";
11
8
  import type { Prisma } from "@allbluecn/database";
12
9
  import { createServerFn } from "@tanstack/react-start";
13
10
  import { getRequest } from "@tanstack/react-start/server";
14
11
  import { z } from "zod";
15
12
  import { requireAuth } from "@/server/auth-helpers";
16
- import {
17
- fetchAssignedToMe,
18
- getDatabaseProvider,
19
- } from "@/server/ai-task/assigned-query";
13
+ import { fetchAssignedToMe } from "@/server/ai-task/assigned-query";
20
14
  import type { AssignedResult } from "@/server/ai-task/types";
21
15
 
22
16
  type AiTaskRow = Prisma.AiTaskModel;
@@ -82,11 +76,6 @@ export const listAiTasksFn = createServerFn({ method: "GET" }).handler(
82
76
  const session = await requireAuth(request);
83
77
  const userId = session.user.id;
84
78
 
85
- // 桌面端 SQLite 无 aiTask 表(pg-only):降级空列表并声明 degraded,不触 DB
86
- if (getDatabaseProvider() === "sqlite") {
87
- return { items: [], degraded: true };
88
- }
89
-
90
79
  const prisma = await getPrisma();
91
80
  const rows = await prisma.aiTask.findMany({
92
81
  where: { createdById: userId },
@@ -32,7 +32,7 @@ import { z } from "zod";
32
32
  import type { Prisma } from "@allbluecn/database";
33
33
  import { requireAuth } from "@/server/auth-helpers";
34
34
  import { resolveStoryScopeProjectId } from "@/server/builtin-project";
35
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
35
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
36
36
  import {
37
37
  TEST_PLAN_STATE_VALUES,
38
38
  type TestCaseState,
@@ -40,7 +40,7 @@ import {
40
40
  finalizeRunIfComplete,
41
41
  } from "@/server/testcase/run-finalize";
42
42
  import { fireTestRunWebhook } from "@/server/testcase/run-webhook";
43
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
43
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
44
44
  import { RUN_STEP_RESULT_STATUS_VALUES } from "./constants";
45
45
  import type { RunCaseStepResult, RunCaseExecutionDetail } from "./types";
46
46
  import { assertRunCaseProjectMember, assertTestProjectMembership as assertProjectMembership } from "@/server/testcase/permissions";
@@ -37,7 +37,7 @@ import type { Prisma } from "@allbluecn/database";
37
37
  import { requireAuth } from "@/server/auth-helpers";
38
38
  import { resolveStoryScopeProjectId } from "@/server/builtin-project";
39
39
  import { buildCaseSnapshot } from "@/server/testcase/snapshot";
40
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
40
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
41
41
  import {
42
42
  RUN_EXECUTOR_TYPE_VALUES,
43
43
  TEST_RUN_STATUS_VALUES,
@@ -26,7 +26,7 @@ import { getRequest } from "@tanstack/react-start/server";
26
26
  import { z } from "zod";
27
27
  import { requireAuth } from "@/server/auth-helpers";
28
28
  import { assertTestCaseProjectMember } from "@/server/testcase/permissions";
29
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
29
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
30
30
  import type { TestCaseActivityItem } from "./types";
31
31
 
32
32
  const listActivitiesSchema = z.object({
@@ -30,7 +30,7 @@ import type { Prisma } from "@allbluecn/database";
30
30
  import { requireAuth } from "@/server/auth-helpers";
31
31
  import { resolveStoryScopeProjectIds } from "@/server/builtin-project";
32
32
  import { buildListWhere } from "@/server/testcase/list-query";
33
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
33
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
34
34
  import {
35
35
  TESTCASE_ORDER_DIRECTIONS,
36
36
  TESTCASE_ORDER_FIELD_MAP,
@@ -32,7 +32,7 @@ import { requireAuth } from "@/server/auth-helpers";
32
32
  import { resolveStoryScopeProjectIds } from "@/server/builtin-project";
33
33
  import { buildListWhere } from "@/server/testcase/list-query";
34
34
  import { validateAssignee } from "@/server/lib/validate-assignee";
35
- import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/types";
35
+ import { USER_BRIEF_SELECT, castUserBrief } from "@/server/story/user-brief";
36
36
  import {
37
37
  TESTCASE_ORDER_DIRECTIONS,
38
38
  TESTCASE_ORDER_FIELD_MAP,