@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
@@ -0,0 +1,17 @@
1
+ export { chat_workspace_githubnotconnected2 as "chat_workspace_githubNotConnected" };
2
+ export type LocalizedString = import("../runtime.js").LocalizedString;
3
+ export type Chat_Workspace_Githubnotconnected2Inputs = {};
4
+ /**
5
+ * | output |
6
+ * | --- |
7
+ * | "GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry" |
8
+ *
9
+ * @param {Chat_Workspace_Githubnotconnected2Inputs} inputs
10
+ * @param {{ locale?: "zh" | "en" }} options
11
+ * @returns {LocalizedString}
12
+ */
13
+ declare const chat_workspace_githubnotconnected2: ((inputs?: Chat_Workspace_Githubnotconnected2Inputs, options?: {
14
+ locale?: "zh" | "en";
15
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Chat_Workspace_Githubnotconnected2Inputs, {
16
+ locale?: "zh" | "en";
17
+ }, {}>;
@@ -0,0 +1,30 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Chat_Workspace_Githubnotconnected2Inputs */
7
+
8
+ const zh_chat_workspace_githubnotconnected2 = /** @type {(inputs: Chat_Workspace_Githubnotconnected2Inputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`未连接 GitHub:请在「设置 → AI 提供方」保存 GitHub Token,或使用 GitHub 登录后重试`)
10
+ };
11
+
12
+ const en_chat_workspace_githubnotconnected2 = /** @type {(inputs: Chat_Workspace_Githubnotconnected2Inputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "GitHub not connected: save a GitHub Token in Settings → AI Providers, or sign in with GitHub and retry" |
20
+ *
21
+ * @param {Chat_Workspace_Githubnotconnected2Inputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ const chat_workspace_githubnotconnected2 = /** @type {((inputs?: Chat_Workspace_Githubnotconnected2Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Chat_Workspace_Githubnotconnected2Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_chat_workspace_githubnotconnected2(inputs)
28
+ return zh_chat_workspace_githubnotconnected2(inputs)
29
+ });
30
+ export { chat_workspace_githubnotconnected2 as "chat_workspace_githubNotConnected" }
@@ -0,0 +1,17 @@
1
+ export { dashboard_activityfeed_ai1 as "dashboard_activityFeed_ai" };
2
+ export type LocalizedString = import("../runtime.js").LocalizedString;
3
+ export type Dashboard_Activityfeed_Ai1Inputs = {};
4
+ /**
5
+ * | output |
6
+ * | --- |
7
+ * | "AI" |
8
+ *
9
+ * @param {Dashboard_Activityfeed_Ai1Inputs} inputs
10
+ * @param {{ locale?: "zh" | "en" }} options
11
+ * @returns {LocalizedString}
12
+ */
13
+ declare const dashboard_activityfeed_ai1: ((inputs?: Dashboard_Activityfeed_Ai1Inputs, options?: {
14
+ locale?: "zh" | "en";
15
+ }) => LocalizedString) & import("../runtime.js").MessageMetadata<Dashboard_Activityfeed_Ai1Inputs, {
16
+ locale?: "zh" | "en";
17
+ }, {}>;
@@ -0,0 +1,30 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Dashboard_Activityfeed_Ai1Inputs */
7
+
8
+ const zh_dashboard_activityfeed_ai1 = /** @type {(inputs: Dashboard_Activityfeed_Ai1Inputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`AI`)
10
+ };
11
+
12
+ const en_dashboard_activityfeed_ai1 = /** @type {(inputs: Dashboard_Activityfeed_Ai1Inputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`AI`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "AI" |
20
+ *
21
+ * @param {Dashboard_Activityfeed_Ai1Inputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ const dashboard_activityfeed_ai1 = /** @type {((inputs?: Dashboard_Activityfeed_Ai1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Dashboard_Activityfeed_Ai1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_dashboard_activityfeed_ai1(inputs)
28
+ return zh_dashboard_activityfeed_ai1(inputs)
29
+ });
30
+ export { dashboard_activityfeed_ai1 as "dashboard_activityFeed_ai" }
@@ -63,7 +63,7 @@ export const Route = createFileRoute("/_nav/bugs/$bugId")({
63
63
  const [collab, users, labels, neighbors] = await Promise.all([
64
64
  collabPromise,
65
65
  listUsersBriefFn({ data: bug.projectId ? { projectId: bug.projectId } : {} }),
66
- listLabelsFn(),
66
+ listLabelsFn({ data: { projectId: bug.projectId } }),
67
67
  getBugNeighborsFn({ data: { bugId: params.bugId } }),
68
68
  ]);
69
69
  return { bug, collab, users, labels, neighbors };
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/_nav/bugs/")({
24
24
  // 分组/table 视图均走 usePagedBugGroups 自拉数据,listBugsFn 零消费方不再预取
25
25
  const [users, labels, projects] = await Promise.all([
26
26
  listUsersBriefFn(),
27
- listLabelsFn(),
27
+ listLabelsFn({ data: {} }),
28
28
  listProjectsBriefFn(),
29
29
  ]);
30
30
  return { users, labels, projects };
@@ -18,12 +18,27 @@
18
18
  import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
19
19
  import { MessageSquare } from 'lucide-react'
20
20
  import { m } from "@/paraglide/messages.js";
21
+ import { ACTIVITY_LABEL } from "@/shared/story/constants";
22
+
23
+ function formatRelative(iso: string): string {
24
+ const diff = Date.now() - new Date(iso).getTime();
25
+ const min = Math.floor(diff / 60000);
26
+ if (min < 1) return m.story_time_justNow();
27
+ if (min < 60) return m.story_time_minutesAgo({ count: min });
28
+ const hr = Math.floor(min / 60);
29
+ if (hr < 24) return m.story_time_hoursAgo({ count: hr });
30
+ const day = Math.floor(hr / 24);
31
+ if (day < 30) return m.story_time_daysAgo({ count: day });
32
+ return new Date(iso).toLocaleDateString();
33
+ }
21
34
 
22
35
  interface Activity {
23
36
  id: string
24
- content: string
25
- userName: string
37
+ action: string
38
+ isAI: boolean
26
39
  createdAt: string
40
+ storyIdentifier: string
41
+ storyName: string
27
42
  }
28
43
 
29
44
  interface ActivityFeedProps {
@@ -47,9 +62,13 @@ export function ActivityFeed({ activities }: ActivityFeedProps) {
47
62
  {activities.map((activity) => (
48
63
  <div key={activity.id} className="flex items-start gap-3">
49
64
  <div className="flex-1">
50
- <p className="text-sm">{activity.content}</p>
65
+ <p className="text-sm">
66
+ <span className="font-medium">{activity.storyIdentifier}</span>{" "}
67
+ {ACTIVITY_LABEL[activity.action]?.() ?? activity.action}
68
+ {activity.isAI ? ` · ${m.dashboard_activityFeed_ai()}` : ""}
69
+ </p>
51
70
  <p className="text-xs text-muted-foreground">
52
- {activity.userName} · {activity.createdAt}
71
+ {activity.storyName} · {formatRelative(activity.createdAt)}
53
72
  </p>
54
73
  </div>
55
74
  </div>
@@ -38,7 +38,7 @@ export const Route = createLazyFileRoute('/_nav/dashboard')({
38
38
  })
39
39
 
40
40
  function DashboardPage() {
41
- const { prds, activityData } = Route.useLoaderData()
41
+ const { prds, activityData, stats, recentActivities } = Route.useLoaderData()
42
42
  const context = Route.useRouteContext()
43
43
  // 明暗跟随全局 ThemeModeProvider(auto 模式含系统外观 + 时段规则),
44
44
  // 不再自建 MutationObserver 监听 documentElement class
@@ -54,10 +54,10 @@ function DashboardPage() {
54
54
  <AiChatPrompt hasAiKey={context?.session?.user?.hasAiKey} />
55
55
 
56
56
  <StatsGrid
57
- prdCount={prds.length}
58
- completedToday={0}
59
- unreadComments={0}
60
- installedApps={0}
57
+ prdCount={stats.prdCount}
58
+ completedToday={stats.completedToday}
59
+ unreadComments={stats.unreadComments}
60
+ installedApps={stats.installedApps}
61
61
  className="mt-60"
62
62
  />
63
63
 
@@ -71,7 +71,7 @@ function DashboardPage() {
71
71
  </Card>
72
72
 
73
73
  <div className="grid gap-6 lg:grid-cols-2">
74
- <ActivityFeed activities={[]} />
74
+ <ActivityFeed activities={recentActivities} />
75
75
  <Card className="border">
76
76
  <CardHeader>
77
77
  <CardTitle>{m.dashboard_dashboard_quickEntry()}</CardTitle>
@@ -17,22 +17,29 @@
17
17
 
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
19
  import { listDesignsFn } from '@/server/serverFns/design'
20
- import { getActivityDataFn } from '@/server/serverFns/activity'
20
+ import { getActivityDataFn, getDashboardStatsFn, getRecentActivitiesFn } from '@/server/serverFns/activity'
21
21
  import { resolveTimezone } from '@/lib/datetime'
22
22
 
23
23
  export const Route = createFileRoute('/_nav/dashboard')({
24
24
  loader: async ({ context }) => {
25
25
  try {
26
26
  const timezone = resolveTimezone(null, context.session?.user?.timezone)
27
- const [prds, activityData] = await Promise.all([
27
+ const [prds, activityData, stats, recentActivities] = await Promise.all([
28
28
  listDesignsFn({ data: {} }),
29
29
  getActivityDataFn({ data: { timezone } }),
30
+ getDashboardStatsFn(),
31
+ getRecentActivitiesFn({ data: { take: 10 } }),
30
32
  ])
31
- return { prds, activityData }
33
+ return { prds, activityData, stats, recentActivities }
32
34
  } catch (e) {
33
35
  // 降级为空数据前先留痕(否则服务端故障被伪装成「用户没有数据」)
34
36
  console.error('[dashboard] loader failed:', e instanceof Error ? e.message : String(e))
35
- return { prds: [], activityData: [] }
37
+ return {
38
+ prds: [],
39
+ activityData: [],
40
+ stats: { prdCount: 0, completedToday: 0, unreadComments: 0, installedApps: 0 },
41
+ recentActivities: [],
42
+ }
36
43
  }
37
44
  },
38
45
  })
@@ -59,12 +59,17 @@ function ProjectSettingsPage() {
59
59
  const [displayIdError, setDisplayIdError] = useState("");
60
60
  const [useCustomTz, setUseCustomTz] = useState(!!project.timezone);
61
61
  const [projectTimezone, setProjectTimezone] = useState(project.timezone ?? "");
62
+ // S17(裁决:独立保存):时区卡片拆出独立保存——与基本信息卡「保存更改」
63
+ // 解耦,脏检查(与 server 权威值比对)控制按钮可用性,保存即时落库
64
+ const [savingTz, setSavingTz] = useState(false);
62
65
  const [saving, setSaving] = useState(false);
63
66
  const [deleteOpen, setDeleteOpen] = useState(false);
64
67
  const [kbChecked, setKbChecked] = useState<Set<string>>(() => new Set(linkedKbIds));
65
68
  const [savingKbs, setSavingKbs] = useState(false);
66
69
  const [deleteMode, setDeleteMode] = useState<"transfer" | "clear">("transfer");
67
70
  const userTimezone = useTimezone();
71
+ // S17:时区脏检查(与 server 权威值比对;按钮仅变化时可用)
72
+ const tzUnchanged = (useCustomTz ? projectTimezone : null) === project.timezone;
68
73
 
69
74
  useEffect(() => {
70
75
  setIcon(project.icon);
@@ -96,7 +101,6 @@ function ProjectSettingsPage() {
96
101
  icon,
97
102
  description: description.trim() || null,
98
103
  displayId: displayId.toUpperCase(),
99
- timezone: useCustomTz ? projectTimezone : null,
100
104
  },
101
105
  });
102
106
  toast.success(m.projects_settings_saved());
@@ -109,6 +113,25 @@ function ProjectSettingsPage() {
109
113
  }
110
114
  };
111
115
 
116
+ // S17:时区独立保存(不再搭车基本信息卡)
117
+ const handleSaveTimezone = async () => {
118
+ setSavingTz(true);
119
+ try {
120
+ await updateProjectFn({
121
+ data: {
122
+ projectId: project.id,
123
+ timezone: useCustomTz ? projectTimezone : null,
124
+ },
125
+ });
126
+ toast.success(m.projects_settings_saved());
127
+ await router.invalidate();
128
+ } catch (err) {
129
+ toastError(err, m.projects_settings_saveFailed());
130
+ } finally {
131
+ setSavingTz(false);
132
+ }
133
+ };
134
+
112
135
  const saveKbs = async () => {
113
136
  setSavingKbs(true);
114
137
  try {
@@ -246,6 +269,17 @@ function ProjectSettingsPage() {
246
269
  )}
247
270
  </div>
248
271
  </RadioGroup>
272
+ <div>
273
+ <Button
274
+ size="sm"
275
+ variant="outline"
276
+ onClick={handleSaveTimezone}
277
+ disabled={savingTz || tzUnchanged}
278
+ >
279
+ {savingTz ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
280
+ {m.common_button_save()}
281
+ </Button>
282
+ </div>
249
283
  </CardContent>
250
284
  </Card>
251
285
  )}
@@ -74,7 +74,7 @@ export const Route = createFileRoute("/_nav/stories/$storyId")({
74
74
  listActivitiesFn({ data: { storyId: params.storyId, take: 50 } }),
75
75
  getStoryNeighborsFn({ data: { storyId: params.storyId } }),
76
76
  listProjectsBriefFn(),
77
- listLabelsFn(),
77
+ listLabelsFn({ data: { projectId: story.projectId ?? undefined } }),
78
78
  listStoriesFn({ data: { archived: false, take: RELATED_STORIES_TAKE } }),
79
79
  listChildStoriesFn({ data: { parentId: params.storyId } }),
80
80
  getDependenciesFn({ data: { storyId: params.storyId } }),
@@ -24,7 +24,7 @@ export const Route = createFileRoute("/_nav/stories/")({
24
24
  const [stories, users, labels, projects] = await Promise.all([
25
25
  listStoriesFn({ data: {} }),
26
26
  listUsersBriefFn(),
27
- listLabelsFn(),
27
+ listLabelsFn({ data: {} }),
28
28
  listProjectsBriefFn(),
29
29
  ]);
30
30
  return { stories, users, labels, projects };
@@ -39,7 +39,7 @@ export const Route = createFileRoute('/api/billing-webhook')({
39
39
  }
40
40
  try {
41
41
  await billing.handleWebhookEvent(rawBody, signature)
42
- await sendBillingEmailForEvent(rawBody)
42
+ await sendBillingEmailForEvent(rawBody, signature, billing)
43
43
  return new Response(JSON.stringify({ received: true }), {
44
44
  status: 200,
45
45
  headers: { 'Content-Type': 'application/json' },
@@ -18,6 +18,8 @@ vi.mock('@allbluecn/database', () => ({
18
18
  aiUsageLog: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
19
19
  userAgent: { count: vi.fn().mockResolvedValue(0) },
20
20
  userSkill: { count: vi.fn().mockResolvedValue(0) },
21
+ anyHubApp: { count: vi.fn().mockResolvedValue(0) },
22
+ blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
21
23
  generationRun: { count: vi.fn().mockResolvedValue(0) },
22
24
  subscription: { findUnique: vi.fn() },
23
25
  plan: { findUnique: vi.fn(), findFirst: vi.fn() },
@@ -28,6 +28,8 @@ vi.mock('@allbluecn/database', () => ({
28
28
  aiUsageLog: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
29
29
  userAgent: { count: vi.fn().mockResolvedValue(0) },
30
30
  userSkill: { count: vi.fn().mockResolvedValue(0) },
31
+ anyHubApp: { count: vi.fn().mockResolvedValue(0) },
32
+ blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
31
33
  generationRun: { count: vi.fn().mockResolvedValue(0) },
32
34
  subscription: { findUnique: vi.fn() },
33
35
  plan: { findUnique: vi.fn(), findFirst: vi.fn() },
@@ -10,6 +10,8 @@ vi.mock('@allbluecn/database', () => ({
10
10
  aiUsageLog: { count: vi.fn() },
11
11
  userAgent: { count: vi.fn() },
12
12
  userSkill: { count: vi.fn() },
13
+ anyHubApp: { count: vi.fn().mockResolvedValue(0) },
14
+ blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
13
15
  },
14
16
  }))
15
17
  vi.mock('@/server/auth-helpers', () => ({ requireAuth: vi.fn() }))
@@ -39,6 +39,8 @@ vi.mock('@allbluecn/database', () => ({
39
39
  aiUsageLog: { count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { durationMs: null } }) },
40
40
  userAgent: { count: vi.fn().mockResolvedValue(0) },
41
41
  userSkill: { count: vi.fn().mockResolvedValue(0) },
42
+ anyHubApp: { count: vi.fn().mockResolvedValue(0) },
43
+ blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
42
44
  generationRun: { count: vi.fn().mockResolvedValue(0) },
43
45
  },
44
46
  }))
@@ -101,6 +101,22 @@ afterEach(() => {
101
101
  vi.restoreAllMocks()
102
102
  })
103
103
 
104
+ describe("S25:per-user 记忆事实上限窗口", () => {
105
+ it("save 成功后执行裁剪 DELETE(保留最近 200 条,淘汰最旧)", async () => {
106
+ mocks.queryRaw.mockResolvedValue({ rows: [{ embedding: "[1,2,3]" }] })
107
+ mocks.executeRaw.mockResolvedValue(undefined)
108
+ const adapter = makeAdapter({ now: () => new Date("2026-01-01T00:00:00Z").toISOString() })
109
+ await adapter.save(
110
+ { userId: "u1", threadId: "t1" },
111
+ { user: "u".repeat(30), assistant: "a" },
112
+ )
113
+ const calls = mocks.executeRaw.mock.calls.map((c) => String(c[0]))
114
+ expect(calls.length).toBe(2)
115
+ expect(calls[1]).toContain("DELETE FROM \"memory_facts\"")
116
+ expect(calls[1]).toContain("LIMIT")
117
+ })
118
+ })
119
+
104
120
  describe("pgvector MemoryAdapter.save", () => {
105
121
  it("持久化 turn(embedding + 原文)并返回 ok 与 latencyMs", async () => {
106
122
  advanceNowBy(25)
@@ -85,6 +85,8 @@ export function createPgVectorMemoryAdapter(
85
85
  options: PgVectorMemoryAdapterOptions = {},
86
86
  ): MemoryAdapter {
87
87
  const minUserChars = options.minUserChars ?? DEFAULT_MIN_USER_CHARS
88
+ // S25:per-user 记忆事实上限(超限淘汰最旧)
89
+ const MEMORY_FACTS_USER_CAP = 200
88
90
  const candidateLimit = options.recallCandidateLimit ?? DEFAULT_RECALL_CANDIDATE_LIMIT
89
91
  const topK = options.recallTopK ?? DEFAULT_RECALL_TOP_K
90
92
  const similarityThreshold =
@@ -180,6 +182,18 @@ export function createPgVectorMemoryAdapter(
180
182
  await prisma.$executeRaw`
181
183
  INSERT INTO "memory_facts" ("id", "userId", "threadId", "content", "embedding")
182
184
  VALUES (${id}, ${scope.userId}, ${scope.threadId}, ${content}, ${formatVector(embedding)}::vector)`
185
+ // S25(裁决:按高收益方向):per-user 上限窗口——超限淘汰最旧,防无限
186
+ // 增长;相似度去重复杂度不成比例,不做。上限写死 200(长期记忆召回
187
+ // 取 TOP-K 远小于此,裁剪只影响被淘汰的最旧事实)。
188
+ await prisma.$executeRaw`
189
+ DELETE FROM "memory_facts"
190
+ WHERE "userId" = ${scope.userId}
191
+ AND "id" NOT IN (
192
+ SELECT "id" FROM "memory_facts"
193
+ WHERE "userId" = ${scope.userId}
194
+ ORDER BY "createdAt" DESC
195
+ LIMIT ${MEMORY_FACTS_USER_CAP}
196
+ )`
183
197
  return [
184
198
  {
185
199
  ok: true,
@@ -17,6 +17,10 @@ vi.mock("@allbluecn/database", () => ({
17
17
  },
18
18
  }))
19
19
 
20
+ vi.mock("@/server/resources", () => ({
21
+ requireResourceForUser: vi.fn().mockResolvedValue(undefined),
22
+ }))
23
+
20
24
  describe("DbBlobStore", () => {
21
25
  beforeEach(() => {
22
26
  vi.clearAllMocks()
@@ -1,4 +1,5 @@
1
1
  import { prisma } from "@allbluecn/database"
2
+ import { requireResourceForUser } from "@/server/resources"
2
3
  import type { BlobRef, BlobStore } from "./blob-store"
3
4
 
4
5
  /**
@@ -19,6 +20,9 @@ export class DbBlobStore implements BlobStore {
19
20
  if (size === 0 || size > MAX_BLOB_BYTES) {
20
21
  throw new Error(`blob size ${size} exceeds limit ${MAX_BLOB_BYTES}`)
21
22
  }
23
+ // S6:blob 存储配额预检(存储汇点覆盖全部上传路径;TOCTOU 统计性预检同
24
+ // requireResource 口径)。1MB=1MiB,向上取整。
25
+ await requireResourceForUser(userId, 'blobStorageMb', Math.ceil(size / (1024 * 1024)))
22
26
  const record = await prisma.blob.create({
23
27
  data: { mimeType, bytes: Uint8Array.from(blob), size, userId },
24
28
  })
@@ -1,7 +1,7 @@
1
- // 计费 webhook 邮件链路:上游 routes/api/billing-webhook.ts 必须先经
2
- // getProService('billing').handleWebhookEvent(rawBody, signature) 完成 Creem
3
- // 签名校验(WebhookSignatureError → 400)后才调用本函数——本模块信任该前置,
4
- // 不重复验签。OSS 无 billing 服务时上游同 gate 拦截,本函数不会被触达。
1
+ // 计费 webhook 邮件链路。S43(裁决落地):不再单靠「上游 handleWebhookEvent
2
+ // 已验签」的顺序契约——本函数在 JSON.parse 前调用 billing 服务暴露的
3
+ // verifyWebhookSignatureRaw 独立验签(Pro 实现;OSS 无 billing 时上游 gate
4
+ // 已 501 拦截,本函数不会被触达)。上游双重验签成本为一次 HMAC,可忽略。
5
5
  import { prisma } from '@allbluecn/database'
6
6
  import { sendTemplateEmail } from '@/server/email'
7
7
 
@@ -11,8 +11,21 @@ interface CreemEvent {
11
11
  object?: { id?: string; subscriptionId?: string; metadata?: Record<string, string> }
12
12
  }
13
13
 
14
- export async function sendBillingEmailForEvent(rawBody: string): Promise<void> {
14
+ export async function sendBillingEmailForEvent(
15
+ rawBody: string,
16
+ signature?: string,
17
+ billing?: {
18
+ verifyWebhookSignatureRaw?(rawBody: string, signature: string): Promise<unknown>
19
+ },
20
+ ): Promise<void> {
15
21
  try {
22
+ // S43:独立验签优先——Pro 端未实现(旧 Pro 包)时回退顺序契约并留证
23
+ if (billing?.verifyWebhookSignatureRaw) {
24
+ if (!signature) throw new Error('缺少 creem-signature(邮件链路独立验签)')
25
+ await billing.verifyWebhookSignatureRaw(rawBody, signature)
26
+ } else {
27
+ console.warn('[billing-email] billing 服务未暴露独立验签,回退上游顺序契约')
28
+ }
16
29
  const evt = JSON.parse(rawBody) as CreemEvent
17
30
  // 官方路径 `object.id`;保留 `object.subscriptionId` fallback 兜底旧版/非标准 payload。
18
31
  const subId = evt.object?.id ?? evt.object?.subscriptionId
@@ -0,0 +1,107 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // S2(裁决:纯机械拆分):bug-crud.ts 的纯 helper 子流程迁此——主文件只留
19
+ // serverFn 定义。import 自兄弟文件,测试经 bug-crud re-export 零改动。
20
+
21
+ import { getPrisma } from "@allbluecn/database/get-prisma";
22
+ import type { AvatarConfig } from "@allbluecn/kernel";
23
+ import { isUserInTeam } from "@/server/team-access";
24
+
25
+ export async function validateBugAssignee(projectId: string, assigneeId: string | null | undefined) {
26
+ if (!assigneeId) return;
27
+ const prisma = await getPrisma();
28
+ const project = await prisma.project.findUnique({
29
+ where: { id: projectId },
30
+ select: { ownerId: true },
31
+ });
32
+ if (!project) throw new Error("项目不存在");
33
+ const ok = await isUserInTeam(project.ownerId, assigneeId);
34
+ if (!ok) throw new Error("负责人必须是团队成员");
35
+ }
36
+
37
+ // 批量写路径的成员圈定(R6):与 deleteBugFn 同口径——目标 bugs 所属每个项目
38
+ // 都须有当前用户的有效成员关系(status 非 suspended),任一无权即整体拒绝。
39
+ // 判定在事务前完成,防止批量入口绕过单删的权限语义。
40
+ export async function assertBugsInMemberProjects(bugIds: string[], userId: string) {
41
+ const prisma = await getPrisma();
42
+ const rows = await prisma.bug.findMany({
43
+ where: { id: { in: bugIds } },
44
+ select: { projectId: true },
45
+ });
46
+ const projectIds = [...new Set(rows.map((r) => r.projectId))];
47
+ if (projectIds.length === 0) return;
48
+ const memberships = await prisma.projectMember.findMany({
49
+ where: { userId, projectId: { in: projectIds }, status: { not: "suspended" } },
50
+ select: { projectId: true },
51
+ });
52
+ const allowed = new Set(memberships.map((m) => m.projectId));
53
+ if (projectIds.some((p) => !allowed.has(p))) {
54
+ throw new Error("无权限操作该项目的 BUG");
55
+ }
56
+ }
57
+
58
+ export function toBugActivityItem(a: {
59
+ id: string;
60
+ action: string;
61
+ field: string | null;
62
+ oldValue: string | null;
63
+ newValue: string | null;
64
+ isAI: boolean;
65
+ createdAt: Date;
66
+ user: {
67
+ id: string;
68
+ username: string;
69
+ email: string;
70
+ avatarConfig: unknown;
71
+ bgShape: string | null;
72
+ bgColor: string | null;
73
+ } | null;
74
+ }) {
75
+ return {
76
+ id: a.id,
77
+ action: a.action,
78
+ field: a.field,
79
+ oldValue: a.oldValue,
80
+ newValue: a.newValue,
81
+ isAI: a.isAI,
82
+ createdAt: a.createdAt.toISOString(),
83
+ user: a.user
84
+ ? { ...a.user, avatarConfig: (a.user.avatarConfig as AvatarConfig | null) ?? null }
85
+ : null,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Bug.description(Text 列 JSON 串)→ {html, markdown} 门卫 content。
91
+ * 与 components/story/types.ts parseStoryDescription 读取口径同构的镜像
92
+ * (服务端不 import 组件模块);历史裸串兜底为双轨原串。Bug.description 与
93
+ * Story.description 存储同构(schema 注释),S12 裁决 UI 本期不接协同编辑,
94
+ * 但旁路写(创建对话框等)仍须经理卫转发防互吞。
95
+ */
96
+ export function bugDescriptionToContent(description: string): { html: string; markdown: string } {
97
+ try {
98
+ const parsed = JSON.parse(description) as { html?: unknown; markdown?: unknown };
99
+ if (typeof parsed.html === "string") {
100
+ const markdown = typeof parsed.markdown === "string" ? parsed.markdown : parsed.html;
101
+ return { html: parsed.html, markdown };
102
+ }
103
+ } catch {
104
+ // 历史裸串兜底
105
+ }
106
+ return { html: description, markdown: description };
107
+ }
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // S2(纯机械拆分):knowledge.ts 纯 helper 迁 server 树兄弟文件
19
+ //(serverFns 树外,纯净性口径②)。
20
+
21
+ // 文档 content/properties 的 JSON 体积上限(updateDocumentFn schema refine 消费)
22
+ export const MAX_DOC_CONTENT_JSON_CHARS = 2_000_000
23
+ export const MAX_DOC_PROPERTIES_JSON_CHARS = 256_000
24
+
25
+
26
+ export function jsonSizeOk(value: unknown, max: number): boolean {
27
+ if (value === undefined || value === null) return true
28
+ try {
29
+ return JSON.stringify(value).length <= max
30
+ } catch {
31
+ return false
32
+ }
33
+ }
34
+
35
+ /** 嵌入行 id 约定为 `${documentId}-${chunkIndex}`(knowledge-index-utils
36
+ * indexDocumentEmbeddings 写入),chunkIndex 可为多位数字,取最后一个 "-" 前缀。 */
37
+ export function documentIdFromEmbeddingRowId(rowId: string): string {
38
+ const cut = rowId.lastIndexOf('-')
39
+ return cut > 0 ? rowId.slice(0, cut) : rowId
40
+ }