@allbluecn/admin-app 0.1.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 (276) hide show
  1. package/package.json +86 -0
  2. package/src/client.tsx +31 -0
  3. package/src/components/admin/admin-user-menu.tsx +90 -0
  4. package/src/components/admin/developer-log.tsx +356 -0
  5. package/src/components/ai-provider/model-list-editor.tsx +147 -0
  6. package/src/components/ai-provider/provider-form.tsx +434 -0
  7. package/src/components/ai-provider/provider-icon.tsx +43 -0
  8. package/src/components/ai-provider/provider-logo.tsx +87 -0
  9. package/src/components/ai-provider/provider-table.tsx +258 -0
  10. package/src/components/auth/admin-login-form.tsx +214 -0
  11. package/src/components/auth/auth-brand-panel.tsx +60 -0
  12. package/src/components/auth/auth-header.tsx +38 -0
  13. package/src/components/auth/auth-layout.tsx +66 -0
  14. package/src/components/auth/auth-providers.tsx +34 -0
  15. package/src/components/auth/icon-input.tsx +46 -0
  16. package/src/components/auth/oauth-section.tsx +51 -0
  17. package/src/components/auth/password-input.tsx +67 -0
  18. package/src/components/common/page-header.tsx +42 -0
  19. package/src/components/common/placeholder-page.tsx +42 -0
  20. package/src/components/component/component-form-dialog.tsx +348 -0
  21. package/src/components/component/component-import-dialog.tsx +235 -0
  22. package/src/components/component/component-table.tsx +153 -0
  23. package/src/components/data-table/data-table-pagination.tsx +114 -0
  24. package/src/components/data-table/data-table.tsx +218 -0
  25. package/src/components/data-table/table-detail-layout.tsx +99 -0
  26. package/src/components/dependency/changelog-dialog.tsx +108 -0
  27. package/src/components/dependency/columns.tsx +232 -0
  28. package/src/components/dependency/dependency-table.tsx +368 -0
  29. package/src/components/dependency/edit-dialog.tsx +200 -0
  30. package/src/components/knowledge-public/bookmark-form-dialog.tsx +215 -0
  31. package/src/components/knowledge-public/compliance-form-dialog.tsx +308 -0
  32. package/src/components/knowledge-public/legal-form-dialog.tsx +315 -0
  33. package/src/components/layout/sidebar-02/app-sidebar.tsx +160 -0
  34. package/src/components/layout/sidebar-02/index.tsx +33 -0
  35. package/src/components/layout/sidebar-02/logo.tsx +40 -0
  36. package/src/components/layout/sidebar-02/nav-main.tsx +153 -0
  37. package/src/components/layout/sidebar-02/nav-notifications.tsx +78 -0
  38. package/src/components/layout/sidebar-02/team-switcher.tsx +106 -0
  39. package/src/components/magicui/animated-theme-toggler.tsx +272 -0
  40. package/src/components/magicui/rainbow-button.tsx +78 -0
  41. package/src/components/market/columns.tsx +151 -0
  42. package/src/components/market/market-app-form.tsx +237 -0
  43. package/src/components/market/market-app-list.tsx +156 -0
  44. package/src/components/monitoring/error-list.tsx +143 -0
  45. package/src/components/monitoring/health-cards.tsx +152 -0
  46. package/src/components/monitoring/metrics-stats.tsx +184 -0
  47. package/src/components/monitoring/request-log-table.tsx +179 -0
  48. package/src/components/providers/theme-toggle.tsx +59 -0
  49. package/src/components/settings/avatar/avatar-config.ts +46 -0
  50. package/src/components/ui/alert-dialog.tsx +216 -0
  51. package/src/components/ui/alert.tsx +76 -0
  52. package/src/components/ui/avatar.tsx +129 -0
  53. package/src/components/ui/badge.tsx +66 -0
  54. package/src/components/ui/button.tsx +84 -0
  55. package/src/components/ui/card.tsx +120 -0
  56. package/src/components/ui/checkbox.tsx +50 -0
  57. package/src/components/ui/collapsible.tsx +50 -0
  58. package/src/components/ui/dialog.tsx +185 -0
  59. package/src/components/ui/dropdown-menu.tsx +286 -0
  60. package/src/components/ui/field.tsx +255 -0
  61. package/src/components/ui/input-group.tsx +171 -0
  62. package/src/components/ui/input.tsx +36 -0
  63. package/src/components/ui/label.tsx +41 -0
  64. package/src/components/ui/pagination.tsx +146 -0
  65. package/src/components/ui/select.tsx +209 -0
  66. package/src/components/ui/separator.tsx +45 -0
  67. package/src/components/ui/sheet.tsx +164 -0
  68. package/src/components/ui/sidebar.tsx +719 -0
  69. package/src/components/ui/skeleton.tsx +30 -0
  70. package/src/components/ui/sonner.tsx +37 -0
  71. package/src/components/ui/switch.tsx +48 -0
  72. package/src/components/ui/table.tsx +133 -0
  73. package/src/components/ui/textarea.tsx +35 -0
  74. package/src/components/ui/tooltip.tsx +74 -0
  75. package/src/components/user/columns.tsx +153 -0
  76. package/src/components/user/settings-activity-log.tsx +358 -0
  77. package/src/components/user/settings-profile/constants.ts +42 -0
  78. package/src/components/user/settings-profile/types.ts +47 -0
  79. package/src/components/user/settings-profile.tsx +664 -0
  80. package/src/components/user/user-create-dialog.tsx +137 -0
  81. package/src/components/user/user-filters.tsx +117 -0
  82. package/src/hooks/use-mobile.ts +36 -0
  83. package/src/instrument.client.ts +34 -0
  84. package/src/lib/anyhub/capabilities.ts +59 -0
  85. package/src/lib/anyhub/types.ts +43 -0
  86. package/src/lib/auth-client.ts +83 -0
  87. package/src/lib/auth-utils.ts +24 -0
  88. package/src/lib/auth.config.ts +264 -0
  89. package/src/lib/avatar.ts +41 -0
  90. package/src/lib/i18n/client.ts +32 -0
  91. package/src/lib/i18n/config.ts +40 -0
  92. package/src/lib/i18n/detect.ts +43 -0
  93. package/src/lib/i18n/locales/en/admin-ai.json +128 -0
  94. package/src/lib/i18n/locales/en/admin-billing.json +56 -0
  95. package/src/lib/i18n/locales/en/admin-users.json +88 -0
  96. package/src/lib/i18n/locales/en/common.json +877 -0
  97. package/src/lib/i18n/locales/en/enterprise.json +208 -0
  98. package/src/lib/i18n/locales/zh/admin-ai.json +128 -0
  99. package/src/lib/i18n/locales/zh/admin-billing.json +56 -0
  100. package/src/lib/i18n/locales/zh/admin-users.json +88 -0
  101. package/src/lib/i18n/locales/zh/common.json +877 -0
  102. package/src/lib/i18n/locales/zh/enterprise.json +208 -0
  103. package/src/lib/i18n/server.ts +18 -0
  104. package/src/lib/i18n/types.ts +5 -0
  105. package/src/lib/monitoring-benchmarks.ts +85 -0
  106. package/src/lib/monitoring-descriptions.ts +78 -0
  107. package/src/lib/prisma.ts +18 -0
  108. package/src/lib/router-context.ts +38 -0
  109. package/src/lib/session.ts +38 -0
  110. package/src/lib/utils.ts +23 -0
  111. package/src/providers/index.tsx +45 -0
  112. package/src/routeTree.gen.ts +1199 -0
  113. package/src/router.tsx +51 -0
  114. package/src/routes/__root.tsx +170 -0
  115. package/src/routes/_nav/ai-providers/$id/edit/route.lazy.tsx +74 -0
  116. package/src/routes/_nav/ai-providers/$id/edit/route.tsx +35 -0
  117. package/src/routes/_nav/ai-providers/index/route.lazy.tsx +33 -0
  118. package/src/routes/_nav/ai-providers/new/route.lazy.tsx +31 -0
  119. package/src/routes/_nav/ai-providers/new/route.tsx +20 -0
  120. package/src/routes/_nav/ai-providers/route.lazy.tsx +26 -0
  121. package/src/routes/_nav/ai-providers/route.tsx +31 -0
  122. package/src/routes/_nav/apps/route.lazy.tsx +30 -0
  123. package/src/routes/_nav/apps/route.tsx +20 -0
  124. package/src/routes/_nav/audit/route.lazy.tsx +100 -0
  125. package/src/routes/_nav/audit/route.tsx +25 -0
  126. package/src/routes/_nav/billing/invoices/route.lazy.tsx +86 -0
  127. package/src/routes/_nav/billing/invoices/route.tsx +25 -0
  128. package/src/routes/_nav/billing/route.lazy.tsx +48 -0
  129. package/src/routes/_nav/billing/route.tsx +27 -0
  130. package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.lazy.tsx +160 -0
  131. package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.tsx +25 -0
  132. package/src/routes/_nav/billing/subscriptions/route.lazy.tsx +113 -0
  133. package/src/routes/_nav/billing/subscriptions/route.tsx +28 -0
  134. package/src/routes/_nav/compliance/route.lazy.tsx +184 -0
  135. package/src/routes/_nav/compliance/route.tsx +25 -0
  136. package/src/routes/_nav/components/route.lazy.tsx +184 -0
  137. package/src/routes/_nav/components/route.tsx +40 -0
  138. package/src/routes/_nav/dashboard/route.lazy.tsx +33 -0
  139. package/src/routes/_nav/dashboard/route.tsx +20 -0
  140. package/src/routes/_nav/dependencies/route.lazy.tsx +66 -0
  141. package/src/routes/_nav/dependencies/route.tsx +32 -0
  142. package/src/routes/_nav/developer-logs/route.lazy.tsx +6 -0
  143. package/src/routes/_nav/developer-logs/route.tsx +14 -0
  144. package/src/routes/_nav/docs/route.lazy.tsx +30 -0
  145. package/src/routes/_nav/docs/route.tsx +20 -0
  146. package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +355 -0
  147. package/src/routes/_nav/knowledge-public/bookmarks/route.tsx +40 -0
  148. package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +245 -0
  149. package/src/routes/_nav/knowledge-public/compliance/route.tsx +40 -0
  150. package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +242 -0
  151. package/src/routes/_nav/knowledge-public/legal/route.tsx +40 -0
  152. package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +235 -0
  153. package/src/routes/_nav/licenses/$licenseId/route.tsx +25 -0
  154. package/src/routes/_nav/licenses/new/route.lazy.tsx +237 -0
  155. package/src/routes/_nav/licenses/route.lazy.tsx +143 -0
  156. package/src/routes/_nav/licenses/route.tsx +25 -0
  157. package/src/routes/_nav/market/route.lazy.tsx +31 -0
  158. package/src/routes/_nav/market/route.tsx +20 -0
  159. package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +305 -0
  160. package/src/routes/_nav/monitoring/ai-usage/route.tsx +3 -0
  161. package/src/routes/_nav/monitoring/errors/route.lazy.tsx +111 -0
  162. package/src/routes/_nav/monitoring/health/route.lazy.tsx +260 -0
  163. package/src/routes/_nav/monitoring/logs/route.lazy.tsx +165 -0
  164. package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +240 -0
  165. package/src/routes/_nav/monitoring/overview/route.lazy.tsx +138 -0
  166. package/src/routes/_nav/monitoring/route.lazy.tsx +68 -0
  167. package/src/routes/_nav/monitoring/route.tsx +20 -0
  168. package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +399 -0
  169. package/src/routes/_nav/monitoring/sentry/route.tsx +20 -0
  170. package/src/routes/_nav/permissions/route.lazy.tsx +504 -0
  171. package/src/routes/_nav/permissions/route.tsx +26 -0
  172. package/src/routes/_nav/route.lazy.tsx +31 -0
  173. package/src/routes/_nav/route.tsx +34 -0
  174. package/src/routes/_nav/settings/route.lazy.tsx +30 -0
  175. package/src/routes/_nav/settings/route.tsx +20 -0
  176. package/src/routes/_nav/sso/route.lazy.tsx +145 -0
  177. package/src/routes/_nav/sso/route.tsx +25 -0
  178. package/src/routes/_nav/users/$userId/route.lazy.tsx +432 -0
  179. package/src/routes/_nav/users/$userId/route.tsx +27 -0
  180. package/src/routes/_nav/users/-components/users-table.tsx +38 -0
  181. package/src/routes/_nav/users/route.lazy.tsx +74 -0
  182. package/src/routes/_nav/users/route.tsx +43 -0
  183. package/src/routes/api/compliance-download/$jobId.ts +35 -0
  184. package/src/routes/api/observability/errors.ts +41 -0
  185. package/src/routes/api/observability/health.ts +30 -0
  186. package/src/routes/api.auth.$.ts +31 -0
  187. package/src/routes/api.sentry-test.ts +33 -0
  188. package/src/routes/index/route.lazy.tsx +22 -0
  189. package/src/routes/index/route.tsx +24 -0
  190. package/src/routes/login/route.lazy.tsx +46 -0
  191. package/src/routes/login/route.tsx +28 -0
  192. package/src/routes/sentry-tunnel.ts +61 -0
  193. package/src/routes/setup/route.lazy.tsx +94 -0
  194. package/src/routes/setup/route.tsx +28 -0
  195. package/src/server/__tests__/admin-ai-alerts.test.ts +113 -0
  196. package/src/server/__tests__/licenses-issue.test.ts +98 -0
  197. package/src/server/admin-ai-alerts.ts +177 -0
  198. package/src/server/admin-ai-usage.ts +134 -0
  199. package/src/server/auth-helpers.ts +51 -0
  200. package/src/server/entitlements.ts +24 -0
  201. package/src/server/knowledge-public/canonical-id.ts +55 -0
  202. package/src/server/knowledge-public/github-sync.ts +86 -0
  203. package/src/server/license-issue.ts +78 -0
  204. package/src/server/middlewares/request.ts +26 -0
  205. package/src/server/observability/debug-middleware.ts +39 -0
  206. package/src/server/observability/error-reporter.ts +99 -0
  207. package/src/server/observability/health-check.ts +140 -0
  208. package/src/server/observability/index.ts +33 -0
  209. package/src/server/observability/logger.ts +49 -0
  210. package/src/server/observability/metrics.ts +105 -0
  211. package/src/server/observability/request-middleware.ts +125 -0
  212. package/src/server/observability/timing.ts +42 -0
  213. package/src/server/pro-services.ts +32 -0
  214. package/src/server/serverFns/admin-billing.ts +94 -0
  215. package/src/server/serverFns/ai-provider/create.ts +92 -0
  216. package/src/server/serverFns/ai-provider/delete.ts +42 -0
  217. package/src/server/serverFns/ai-provider/get.ts +35 -0
  218. package/src/server/serverFns/ai-provider/list.ts +92 -0
  219. package/src/server/serverFns/ai-provider/update.ts +73 -0
  220. package/src/server/serverFns/anyhub/market.ts +64 -0
  221. package/src/server/serverFns/audit.ts +57 -0
  222. package/src/server/serverFns/auth/session.ts +26 -0
  223. package/src/server/serverFns/auth/setup.ts +73 -0
  224. package/src/server/serverFns/compliance.ts +85 -0
  225. package/src/server/serverFns/components/import.ts +197 -0
  226. package/src/server/serverFns/components/list.ts +51 -0
  227. package/src/server/serverFns/components/mutations.ts +84 -0
  228. package/src/server/serverFns/dependency/helpers.ts +245 -0
  229. package/src/server/serverFns/dependency/list.ts +127 -0
  230. package/src/server/serverFns/dependency/sync.ts +123 -0
  231. package/src/server/serverFns/dependency/update.ts +51 -0
  232. package/src/server/serverFns/developer-log-auto-generate.ts +176 -0
  233. package/src/server/serverFns/developer-logs.ts +132 -0
  234. package/src/server/serverFns/knowledge-public/bookmarks/create.ts +53 -0
  235. package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +31 -0
  236. package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +32 -0
  237. package/src/server/serverFns/knowledge-public/bookmarks/list.ts +66 -0
  238. package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +56 -0
  239. package/src/server/serverFns/knowledge-public/bookmarks/update.ts +50 -0
  240. package/src/server/serverFns/knowledge-public/compliance/create.ts +58 -0
  241. package/src/server/serverFns/knowledge-public/compliance/delete.ts +31 -0
  242. package/src/server/serverFns/knowledge-public/compliance/detail.ts +32 -0
  243. package/src/server/serverFns/knowledge-public/compliance/list.ts +59 -0
  244. package/src/server/serverFns/knowledge-public/compliance/update.ts +53 -0
  245. package/src/server/serverFns/knowledge-public/legal/create.ts +62 -0
  246. package/src/server/serverFns/knowledge-public/legal/delete.ts +31 -0
  247. package/src/server/serverFns/knowledge-public/legal/detail.ts +32 -0
  248. package/src/server/serverFns/knowledge-public/legal/list.ts +60 -0
  249. package/src/server/serverFns/knowledge-public/legal/update.ts +55 -0
  250. package/src/server/serverFns/licenses.ts +92 -0
  251. package/src/server/serverFns/locale.ts +10 -0
  252. package/src/server/serverFns/monitoring/check-ai-alerts.ts +13 -0
  253. package/src/server/serverFns/monitoring/get-ai-usage-overview.ts +25 -0
  254. package/src/server/serverFns/monitoring/get-errors.ts +43 -0
  255. package/src/server/serverFns/monitoring/get-health-settings.ts +35 -0
  256. package/src/server/serverFns/monitoring/get-health.ts +55 -0
  257. package/src/server/serverFns/monitoring/get-metrics.ts +46 -0
  258. package/src/server/serverFns/monitoring/get-request-logs.ts +66 -0
  259. package/src/server/serverFns/monitoring/report-error.ts +55 -0
  260. package/src/server/serverFns/monitoring/reset.ts +70 -0
  261. package/src/server/serverFns/permissions.ts +214 -0
  262. package/src/server/serverFns/sentry/config.ts +88 -0
  263. package/src/server/serverFns/sso-config.ts +83 -0
  264. package/src/server/serverFns/user/create.ts +40 -0
  265. package/src/server/serverFns/user/delete.ts +29 -0
  266. package/src/server/serverFns/user/detail.ts +45 -0
  267. package/src/server/serverFns/user/list.ts +56 -0
  268. package/src/server/serverFns/user/reset-password.ts +28 -0
  269. package/src/server/serverFns/user/update-role.ts +26 -0
  270. package/src/server/serverFns/user/update-status.ts +26 -0
  271. package/src/server.ts +27 -0
  272. package/src/start.ts +85 -0
  273. package/src/styles/_keyframe-animations.scss +91 -0
  274. package/src/styles/_variables.scss +296 -0
  275. package/src/styles/globals.css +200 -0
  276. package/src/types/start-authjs.d.ts +27 -0
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('@allbluecn/database', () => ({
4
+ prisma: {
5
+ aiUsageLog: {
6
+ count: vi.fn(),
7
+ findMany: vi.fn(),
8
+ groupBy: vi.fn(),
9
+ },
10
+ },
11
+ }))
12
+
13
+ import { checkAiAlerts, getAiAlertSummary } from '../admin-ai-alerts'
14
+ import { prisma } from '@allbluecn/database'
15
+
16
+ const getMock = () =>
17
+ (prisma.aiUsageLog as unknown) as {
18
+ count: ReturnType<typeof vi.fn>
19
+ findMany: ReturnType<typeof vi.fn>
20
+ groupBy: ReturnType<typeof vi.fn>
21
+ }
22
+
23
+ describe('AI 异常告警检测', () => {
24
+ beforeEach(() => { vi.clearAllMocks() })
25
+
26
+ it('无调用日志时返回空告警', async () => {
27
+ getMock().count.mockResolvedValue(0)
28
+ getMock().findMany.mockResolvedValue([])
29
+
30
+ const alerts = await checkAiAlerts()
31
+ expect(alerts).toEqual([])
32
+ })
33
+
34
+ it('错误率超过阈值触发 error_spike 告警', async () => {
35
+ getMock().count
36
+ .mockResolvedValueOnce(100)
37
+ .mockResolvedValueOnce(15)
38
+ getMock().findMany.mockResolvedValue(
39
+ Array.from({ length: 15 }, () => ({
40
+ provider: 'openrouter',
41
+ model: 'gpt-4o',
42
+ errorMessage: 'timeout',
43
+ workspaceId: 'ws1',
44
+ userId: 'u1',
45
+ createdAt: new Date(),
46
+ })),
47
+ )
48
+
49
+ const alerts = await checkAiAlerts({ errorRateThreshold: 0.05 })
50
+ const errorAlert = alerts.find((a) => a.type === 'error_spike')
51
+ expect(errorAlert).toBeDefined()
52
+ expect(errorAlert?.severity).toBe('warning')
53
+ })
54
+
55
+ it('单日成本超阈值触发 high_cost 告警', async () => {
56
+ getMock().count.mockResolvedValue(100)
57
+ getMock().findMany.mockResolvedValue([
58
+ { createdAt: new Date('2026-08-11T00:00:00Z'), costUsd: 40 },
59
+ { createdAt: new Date('2026-08-11T01:00:00Z'), costUsd: 40 },
60
+ { createdAt: new Date('2026-08-12T00:00:00Z'), costUsd: 30 },
61
+ ])
62
+
63
+ const alerts = await checkAiAlerts({ dailyCostThresholdUsd: 50 })
64
+ const costAlert = alerts.find((a) => a.type === 'high_cost')
65
+ expect(costAlert).toBeDefined()
66
+ expect(costAlert?.value).toBe(80)
67
+ })
68
+
69
+ it('getAiAlertSummary 聚合告警统计', async () => {
70
+ getMock().count.mockResolvedValue(0)
71
+ getMock().findMany.mockResolvedValue([])
72
+
73
+ const summary = await getAiAlertSummary()
74
+ expect(summary.totalAlerts).toBe(0)
75
+ expect(summary.bySeverity.warning).toBe(0)
76
+ expect(summary.bySeverity.critical).toBe(0)
77
+ expect(summary.byType).toEqual([])
78
+ })
79
+
80
+ it('单 workspace 单日调用超阈值触发 quota_exhausted 告警', async () => {
81
+ getMock().count.mockResolvedValue(250)
82
+ getMock().findMany.mockResolvedValue(
83
+ Array.from({ length: 250 }, () => ({
84
+ workspaceId: 'ws-heavy',
85
+ createdAt: new Date('2026-08-11T00:00:00Z'),
86
+ costUsd: 0.01,
87
+ })),
88
+ )
89
+
90
+ const alerts = await checkAiAlerts({ workspaceDailyCallsThreshold: 200 })
91
+ const quotaAlert = alerts.find((a) => a.type === 'quota_exhausted')
92
+ expect(quotaAlert).toBeDefined()
93
+ expect(quotaAlert?.count).toBe(250)
94
+ expect(quotaAlert?.workspaceId).toBe('ws-heavy')
95
+ expect(quotaAlert?.severity).toBe('warning')
96
+ })
97
+
98
+ it('默认单日成本阈值对齐设计规格 $10(env 未设时)', async () => {
99
+ const prev = process.env.HOSTED_AI_DAILY_ALERT_THRESHOLD_USD
100
+ delete process.env.HOSTED_AI_DAILY_ALERT_THRESHOLD_USD
101
+ getMock().count.mockResolvedValue(100)
102
+ getMock().findMany.mockResolvedValue([
103
+ { workspaceId: 'ws1', createdAt: new Date('2026-08-11T00:00:00Z'), costUsd: 15 },
104
+ ])
105
+
106
+ const alerts = await checkAiAlerts()
107
+ const costAlert = alerts.find((a) => a.type === 'high_cost')
108
+ expect(costAlert).toBeDefined()
109
+ expect(costAlert?.threshold).toBe(10)
110
+
111
+ if (prev !== undefined) process.env.HOSTED_AI_DAILY_ALERT_THRESHOLD_USD = prev
112
+ })
113
+ })
@@ -0,0 +1,98 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ let currentIssueFn: unknown = vi.fn()
4
+ let currentVerifyFn: ReturnType<typeof vi.fn> = vi.fn()
5
+
6
+ vi.mock('@/server/pro-services', () => ({
7
+ getProService: (key: string) =>
8
+ key === 'license'
9
+ ? {
10
+ verify: currentVerifyFn,
11
+ issue: currentIssueFn as ReturnType<typeof vi.fn>,
12
+ }
13
+ : null,
14
+ isProEdition: () => false,
15
+ }))
16
+ vi.mock('@/server/auth-helpers', () => ({
17
+ requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin1' } }),
18
+ }))
19
+ vi.mock('@allbluecn/database', () => ({
20
+ prisma: {
21
+ licenseKey: {
22
+ findMany: vi.fn(),
23
+ count: vi.fn(),
24
+ findUnique: vi.fn(),
25
+ update: vi.fn(),
26
+ create: vi.fn().mockResolvedValue({}),
27
+ },
28
+ },
29
+ }))
30
+
31
+ import { issueLicense } from '@/server/license-issue'
32
+ import { prisma } from '@allbluecn/database'
33
+
34
+ const validInput = {
35
+ customerName: '某公司',
36
+ customerEmail: 'ops@example.com',
37
+ customerId: 'cust-001',
38
+ plan: 'enterprise',
39
+ seats: 50,
40
+ features: { hostedAi: true },
41
+ expiresAt: null,
42
+ }
43
+
44
+ const getPrismaCreate = () =>
45
+ (prisma.licenseKey as unknown as { create: ReturnType<typeof vi.fn> }).create
46
+
47
+ describe('issueLicense', () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+ currentIssueFn = vi.fn()
51
+ currentVerifyFn = vi.fn()
52
+ })
53
+
54
+ it('合法输入 → 调 license.issue + 落库 LicenseKey + 返回 key', async () => {
55
+ currentIssueFn = vi.fn().mockResolvedValue({
56
+ key: 'payload.sig',
57
+ signature: 'sig',
58
+ payload: {
59
+ seats: 50,
60
+ plan: 'enterprise',
61
+ issuedAt: '2026-01-01T00:00:00.000Z',
62
+ expiresAt: null,
63
+ },
64
+ })
65
+ const result = await issueLicense(validInput)
66
+ expect(result).toMatchObject({ key: 'payload.sig' })
67
+ expect(currentIssueFn).toHaveBeenCalledWith(
68
+ expect.objectContaining({ customerName: '某公司', seats: 50 }),
69
+ )
70
+ expect(getPrismaCreate()).toHaveBeenCalledWith(
71
+ expect.objectContaining({
72
+ data: expect.objectContaining({
73
+ customerId: 'cust-001',
74
+ customerName: '某公司',
75
+ seats: 50,
76
+ plan: 'enterprise',
77
+ }),
78
+ }),
79
+ )
80
+ })
81
+
82
+ it('features 含未注册 key → 拒绝(C1 防线)', async () => {
83
+ await expect(
84
+ issueLicense({ ...validInput, features: { notInRegistry: true } }),
85
+ ).rejects.toMatchObject({
86
+ message: expect.stringContaining('未注册'),
87
+ })
88
+ expect(currentIssueFn).not.toHaveBeenCalled()
89
+ expect(getPrismaCreate()).not.toHaveBeenCalled()
90
+ })
91
+
92
+ it('Pro 未实现 issue(OSS)→ 报错', async () => {
93
+ currentIssueFn = undefined
94
+ await expect(issueLicense(validInput)).rejects.toMatchObject({
95
+ message: expect.stringContaining('签发'),
96
+ })
97
+ })
98
+ })
@@ -0,0 +1,177 @@
1
+ import { prisma } from '@allbluecn/database'
2
+
3
+ export interface AiAlert {
4
+ type: 'quota_exhausted' | 'high_cost' | 'error_spike' | 'provider_failure'
5
+ severity: 'warning' | 'critical'
6
+ message: string
7
+ workspaceId?: string
8
+ workspaceName?: string
9
+ userId?: string
10
+ userName?: string
11
+ metric: string
12
+ value: number
13
+ threshold: number
14
+ period: string
15
+ count: number
16
+ createdAt: Date
17
+ }
18
+
19
+ export interface AlertSummary {
20
+ totalAlerts: number
21
+ bySeverity: { warning: number; critical: number }
22
+ byType: Array<{ type: string; count: number }>
23
+ alerts: AiAlert[]
24
+ }
25
+
26
+ export interface AlertCheckOptions {
27
+ hours?: number
28
+ errorRateThreshold?: number
29
+ dailyCostThresholdUsd?: number
30
+ workspaceDailyCallsThreshold?: number
31
+ }
32
+
33
+ export async function checkAiAlerts(options: AlertCheckOptions = {}): Promise<AiAlert[]> {
34
+ const { hours = 24, errorRateThreshold = 0.05 } = options
35
+ const dailyCostThresholdUsd =
36
+ options.dailyCostThresholdUsd ??
37
+ (Number(process.env.HOSTED_AI_DAILY_ALERT_THRESHOLD_USD) || 10)
38
+ const workspaceDailyCallsThreshold =
39
+ options.workspaceDailyCallsThreshold ??
40
+ (Number(process.env.HOSTED_AI_DAILY_ALERT_WORKSPACE_CALLS) || 200)
41
+
42
+ const since = new Date(Date.now() - hours * 3600_000)
43
+ const alerts: AiAlert[] = []
44
+
45
+ const [totalLogs, errorLogs, recentLogs, dailyCosts] = await Promise.all([
46
+ prisma.aiUsageLog.count({ where: { createdAt: { gte: since } } }),
47
+ prisma.aiUsageLog.count({ where: { createdAt: { gte: since }, success: false } }),
48
+ prisma.aiUsageLog.findMany({
49
+ where: { createdAt: { gte: since }, success: false },
50
+ select: {
51
+ workspaceId: true,
52
+ userId: true,
53
+ provider: true,
54
+ model: true,
55
+ errorMessage: true,
56
+ createdAt: true,
57
+ },
58
+ take: 50,
59
+ }),
60
+ prisma.aiUsageLog.findMany({
61
+ where: { createdAt: { gte: since }, success: true },
62
+ select: { createdAt: true, costUsd: true, workspaceId: true },
63
+ }),
64
+ ])
65
+
66
+ if (totalLogs === 0) return alerts
67
+
68
+ const errorRate = errorLogs / totalLogs
69
+ if (errorRate > errorRateThreshold && errorLogs > 5) {
70
+ alerts.push({
71
+ type: 'error_spike',
72
+ severity: errorRate > 0.2 ? 'critical' : 'warning',
73
+ message: `AI 调用错误率异常:${(errorRate * 100).toFixed(1)}%(阈值 ${(errorRateThreshold * 100).toFixed(1)}%)`,
74
+ metric: 'error_rate',
75
+ value: errorRate,
76
+ threshold: errorRateThreshold,
77
+ period: `${hours}h`,
78
+ count: errorLogs,
79
+ createdAt: new Date(),
80
+ })
81
+ }
82
+
83
+ const dailyCostMap = new Map<string, number>()
84
+ for (const log of dailyCosts) {
85
+ const date = log.createdAt.toISOString().slice(0, 10)
86
+ const cost = Number(log.costUsd ?? 0)
87
+ dailyCostMap.set(date, (dailyCostMap.get(date) ?? 0) + cost)
88
+ }
89
+ for (const [date, cost] of dailyCostMap.entries()) {
90
+ if (cost > dailyCostThresholdUsd) {
91
+ alerts.push({
92
+ type: 'high_cost',
93
+ severity: cost > dailyCostThresholdUsd * 2 ? 'critical' : 'warning',
94
+ message: `单日 AI 成本异常:$${cost.toFixed(2)}(阈值 $${dailyCostThresholdUsd})`,
95
+ metric: 'daily_cost_usd',
96
+ value: cost,
97
+ threshold: dailyCostThresholdUsd,
98
+ period: date,
99
+ count: 1,
100
+ createdAt: new Date(date),
101
+ })
102
+ }
103
+ }
104
+
105
+ const wsDailyCalls = new Map<string, { wsId: string; date: string; count: number }>()
106
+ for (const log of dailyCosts) {
107
+ if (!log.workspaceId) continue
108
+ const date = log.createdAt.toISOString().slice(0, 10)
109
+ const key = `${log.workspaceId}:${date}`
110
+ const entry = wsDailyCalls.get(key) ?? { wsId: log.workspaceId, date, count: 0 }
111
+ entry.count += 1
112
+ wsDailyCalls.set(key, entry)
113
+ }
114
+ for (const { wsId, date, count } of wsDailyCalls.values()) {
115
+ if (count > workspaceDailyCallsThreshold) {
116
+ alerts.push({
117
+ type: 'quota_exhausted',
118
+ severity: 'warning',
119
+ message: `工作空间 ${wsId.slice(0, 8)} 单日调用 ${count} 次(阈值 ${workspaceDailyCallsThreshold})`,
120
+ metric: 'workspace_daily_calls',
121
+ value: count,
122
+ threshold: workspaceDailyCallsThreshold,
123
+ period: date,
124
+ count,
125
+ workspaceId: wsId,
126
+ createdAt: new Date(date),
127
+ })
128
+ }
129
+ }
130
+
131
+ const providerErrors = new Map<string, number>()
132
+ for (const log of recentLogs) {
133
+ const key = log.provider || 'unknown'
134
+ providerErrors.set(key, (providerErrors.get(key) ?? 0) + 1)
135
+ }
136
+ for (const [provider, count] of providerErrors.entries()) {
137
+ if (count >= 10) {
138
+ alerts.push({
139
+ type: 'provider_failure',
140
+ severity: count >= 50 ? 'critical' : 'warning',
141
+ message: `Provider ${provider} 失败次数异常:${count} 次`,
142
+ metric: 'provider_errors',
143
+ value: count,
144
+ threshold: 10,
145
+ period: `${hours}h`,
146
+ count,
147
+ createdAt: new Date(),
148
+ })
149
+ }
150
+ }
151
+
152
+ return alerts.sort((a, b) => {
153
+ const sevOrder = { critical: 0, warning: 1 }
154
+ return sevOrder[a.severity] - sevOrder[b.severity] || b.createdAt.getTime() - a.createdAt.getTime()
155
+ })
156
+ }
157
+
158
+ export async function getAiAlertSummary(options?: AlertCheckOptions): Promise<AlertSummary> {
159
+ const alerts = await checkAiAlerts(options)
160
+ return {
161
+ totalAlerts: alerts.length,
162
+ bySeverity: {
163
+ warning: alerts.filter((a) => a.severity === 'warning').length,
164
+ critical: alerts.filter((a) => a.severity === 'critical').length,
165
+ },
166
+ byType: alerts.reduce(
167
+ (acc, a) => {
168
+ const existing = acc.find((x) => x.type === a.type)
169
+ if (existing) existing.count++
170
+ else acc.push({ type: a.type, count: 1 })
171
+ return acc
172
+ },
173
+ [] as Array<{ type: string; count: number }>,
174
+ ),
175
+ alerts,
176
+ }
177
+ }
@@ -0,0 +1,134 @@
1
+ import { prisma } from '@allbluecn/database'
2
+
3
+ export interface AiUsageOverview {
4
+ totalCalls: number
5
+ totalCostUsd: number | null
6
+ bySource: Array<{ source: string; count: number }>
7
+ topWorkspaces: Array<{
8
+ workspaceId: string
9
+ workspaceName?: string
10
+ count: number
11
+ costUsd: number | null
12
+ }>
13
+ topUsers: Array<{ userId: string; userName?: string; count: number }>
14
+ topWorkspacesTotal: number
15
+ topUsersTotal: number
16
+ trend: Array<{ date: string; calls: number; cost: number }>
17
+ }
18
+
19
+ export interface AiUsageOverviewOptions {
20
+ days?: number
21
+ workspace?: { page?: number; perPage?: number }
22
+ user?: { page?: number; perPage?: number }
23
+ }
24
+
25
+ export async function getAiUsageOverview(options: AiUsageOverviewOptions = {}): Promise<AiUsageOverview> {
26
+ const {
27
+ days = 30,
28
+ workspace: workspaceOpt = {},
29
+ user: userOpt = {},
30
+ } = options
31
+ const workspacePage = workspaceOpt.page ?? 1
32
+ const workspacePerPage = workspaceOpt.perPage ?? 10
33
+ const userPage = userOpt.page ?? 1
34
+ const userPerPage = userOpt.perPage ?? 10
35
+ const since = new Date(Date.now() - days * 86400_000)
36
+
37
+ const [
38
+ totalCalls,
39
+ costAgg,
40
+ bySource,
41
+ topWorkspacesRaw,
42
+ topWorkspacesCount,
43
+ topUsersRaw,
44
+ topUsersCount,
45
+ recentLogs,
46
+ ] = await Promise.all([
47
+ prisma.aiUsageLog.count({ where: { createdAt: { gte: since } } }),
48
+ prisma.aiUsageLog.aggregate({
49
+ _sum: { costUsd: true },
50
+ where: { createdAt: { gte: since }, success: true },
51
+ }),
52
+ prisma.aiUsageLog.groupBy({
53
+ by: ['source'],
54
+ _count: true,
55
+ where: { createdAt: { gte: since } },
56
+ }),
57
+ prisma.aiUsageLog.groupBy({
58
+ by: ['workspaceId'],
59
+ _count: true,
60
+ _sum: { costUsd: true },
61
+ orderBy: { _count: { workspaceId: 'desc' } },
62
+ take: workspacePerPage,
63
+ skip: (workspacePage - 1) * workspacePerPage,
64
+ where: { createdAt: { gte: since } },
65
+ }),
66
+ prisma.aiUsageLog.groupBy({
67
+ by: ['workspaceId'],
68
+ _count: true,
69
+ where: { createdAt: { gte: since } },
70
+ }).then((r) => r.length),
71
+ prisma.aiUsageLog.groupBy({
72
+ by: ['userId'],
73
+ _count: true,
74
+ orderBy: { _count: { userId: 'desc' } },
75
+ take: userPerPage,
76
+ skip: (userPage - 1) * userPerPage,
77
+ where: { createdAt: { gte: since } },
78
+ }),
79
+ prisma.aiUsageLog.groupBy({
80
+ by: ['userId'],
81
+ _count: true,
82
+ where: { createdAt: { gte: since } },
83
+ }).then((r) => r.length),
84
+ prisma.aiUsageLog.findMany({
85
+ where: { createdAt: { gte: since } },
86
+ select: { createdAt: true, costUsd: true },
87
+ }),
88
+ ])
89
+
90
+ const trendMap = new Map<string, { calls: number; cost: number }>()
91
+ for (const log of recentLogs) {
92
+ const date = log.createdAt.toISOString().slice(0, 10)
93
+ const entry = trendMap.get(date) ?? { calls: 0, cost: 0 }
94
+ entry.calls += 1
95
+ if (log.costUsd) entry.cost += Number(log.costUsd)
96
+ trendMap.set(date, entry)
97
+ }
98
+ const trend = Array.from(trendMap.entries())
99
+ .map(([date, v]) => ({ date, ...v }))
100
+ .sort((a, b) => a.date.localeCompare(b.date))
101
+
102
+ const workspaceIds = topWorkspacesRaw.map((w) => w.workspaceId)
103
+ const userIds = topUsersRaw.map((u) => u.userId)
104
+ const [workspaces, users] = await Promise.all([
105
+ workspaceIds.length > 0
106
+ ? prisma.workspace.findMany({ where: { id: { in: workspaceIds } }, select: { id: true, name: true } })
107
+ : [],
108
+ userIds.length > 0
109
+ ? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, username: true } })
110
+ : [],
111
+ ])
112
+ const wsNameMap = new Map(workspaces.map((w) => [w.id, w.name]))
113
+ const userNameMap = new Map(users.map((u) => [u.id, u.username]))
114
+
115
+ return {
116
+ totalCalls,
117
+ totalCostUsd: costAgg._sum.costUsd ? Number(costAgg._sum.costUsd) : null,
118
+ bySource: bySource.map((b) => ({ source: b.source, count: b._count })),
119
+ topWorkspaces: topWorkspacesRaw.map((w) => ({
120
+ workspaceId: w.workspaceId,
121
+ workspaceName: wsNameMap.get(w.workspaceId),
122
+ count: w._count,
123
+ costUsd: w._sum.costUsd ? Number(w._sum.costUsd) : null,
124
+ })),
125
+ topUsers: topUsersRaw.map((u) => ({
126
+ userId: u.userId,
127
+ userName: userNameMap.get(u.userId),
128
+ count: u._count,
129
+ })),
130
+ topWorkspacesTotal: topWorkspacesCount,
131
+ topUsersTotal: topUsersCount,
132
+ trend,
133
+ }
134
+ }
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { getRequest } from '@tanstack/react-start/server'
19
+ import { getCurrentSession, type Session } from '@/lib/session'
20
+
21
+ export class UnauthorizedError extends Error {
22
+ constructor(message: string = 'UNAUTHORIZED') {
23
+ super(message)
24
+ this.name = 'UnauthorizedError'
25
+ Object.setPrototypeOf(this, UnauthorizedError.prototype)
26
+ }
27
+ }
28
+
29
+ export class ForbiddenError extends Error {
30
+ constructor(message: string = 'FORBIDDEN') {
31
+ super(message)
32
+ this.name = 'ForbiddenError'
33
+ Object.setPrototypeOf(this, ForbiddenError.prototype)
34
+ }
35
+ }
36
+
37
+ export async function requireAdmin(): Promise<Session> {
38
+ const request = getRequest()
39
+ const session = await getCurrentSession(request)
40
+ if (!session?.user?.id) {
41
+ const url = request?.url ?? 'unknown'
42
+ console.warn(`[requireAdmin] 未授权访问: ${url}`)
43
+ throw new UnauthorizedError()
44
+ }
45
+ if (session.user.role !== 'ADMIN' && session.user.role !== 'SUPER_ADMIN') {
46
+ const url = request?.url ?? 'unknown'
47
+ console.warn(`[requireAdmin] 权限不足 (role=${session.user.role ?? 'null'}): ${url}`)
48
+ throw new ForbiddenError()
49
+ }
50
+ return session
51
+ }
@@ -0,0 +1,24 @@
1
+ import { FeatureRegistry } from '@allbluecn/kernel'
2
+
3
+ export class FeatureUnavailableError extends Error {
4
+ constructor(feature: string) {
5
+ super(`功能 ${feature} 在当前部署不可用`)
6
+ this.name = 'FeatureUnavailableError'
7
+ Object.setPrototypeOf(this, FeatureUnavailableError.prototype)
8
+ }
9
+ }
10
+
11
+ export class FeatureForbiddenError extends Error {
12
+ constructor(feature: string) {
13
+ super(`无权限使用功能 ${feature}`)
14
+ this.name = 'FeatureForbiddenError'
15
+ Object.setPrototypeOf(this, FeatureForbiddenError.prototype)
16
+ }
17
+ }
18
+
19
+ export async function requireFeature(_request: Request | undefined, feature: string): Promise<void> {
20
+ const meta = FeatureRegistry[feature as keyof typeof FeatureRegistry]
21
+ if (meta?.tier === 'oss') return
22
+ void _request
23
+ return
24
+ }
@@ -0,0 +1,55 @@
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
+ export function legalCanonicalId(input: { title: string; code?: string }): string {
19
+ const code = input.code?.trim()
20
+ return code || input.title.trim()
21
+ }
22
+
23
+ export function complianceCanonicalId(input: { title: string; code?: string }): string {
24
+ const code = input.code?.trim()
25
+ return code || input.title.trim()
26
+ }
27
+
28
+ export function bookmarkCanonicalId(rawUrl: string): string {
29
+ let url: URL
30
+ try {
31
+ url = new URL(rawUrl.trim())
32
+ } catch {
33
+ return rawUrl.trim().toLowerCase()
34
+ }
35
+ const host = url.hostname.toLowerCase().replace(/^www\./, '')
36
+ let path = url.pathname.replace(/\/+$/, '')
37
+ if (path.endsWith('.git')) path = path.replace(/\.git$/, '')
38
+ return path ? `${host}${path}` : host
39
+ }
40
+
41
+ export function parseGithubUrl(rawUrl: string): { owner: string; repo: string } | null {
42
+ let url: URL
43
+ try {
44
+ url = new URL(rawUrl.trim())
45
+ } catch {
46
+ return null
47
+ }
48
+ if (url.hostname.toLowerCase() !== 'github.com') return null
49
+ const parts = url.pathname.split('/').filter(Boolean)
50
+ if (parts.length < 2) return null
51
+ const owner = parts[0]
52
+ const repo = parts[1].replace(/\.git$/, '')
53
+ if (!owner || !repo) return null
54
+ return { owner, repo }
55
+ }