@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.
- package/package.json +86 -0
- package/src/client.tsx +31 -0
- package/src/components/admin/admin-user-menu.tsx +90 -0
- package/src/components/admin/developer-log.tsx +356 -0
- package/src/components/ai-provider/model-list-editor.tsx +147 -0
- package/src/components/ai-provider/provider-form.tsx +434 -0
- package/src/components/ai-provider/provider-icon.tsx +43 -0
- package/src/components/ai-provider/provider-logo.tsx +87 -0
- package/src/components/ai-provider/provider-table.tsx +258 -0
- package/src/components/auth/admin-login-form.tsx +214 -0
- package/src/components/auth/auth-brand-panel.tsx +60 -0
- package/src/components/auth/auth-header.tsx +38 -0
- package/src/components/auth/auth-layout.tsx +66 -0
- package/src/components/auth/auth-providers.tsx +34 -0
- package/src/components/auth/icon-input.tsx +46 -0
- package/src/components/auth/oauth-section.tsx +51 -0
- package/src/components/auth/password-input.tsx +67 -0
- package/src/components/common/page-header.tsx +42 -0
- package/src/components/common/placeholder-page.tsx +42 -0
- package/src/components/component/component-form-dialog.tsx +348 -0
- package/src/components/component/component-import-dialog.tsx +235 -0
- package/src/components/component/component-table.tsx +153 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table.tsx +218 -0
- package/src/components/data-table/table-detail-layout.tsx +99 -0
- package/src/components/dependency/changelog-dialog.tsx +108 -0
- package/src/components/dependency/columns.tsx +232 -0
- package/src/components/dependency/dependency-table.tsx +368 -0
- package/src/components/dependency/edit-dialog.tsx +200 -0
- package/src/components/knowledge-public/bookmark-form-dialog.tsx +215 -0
- package/src/components/knowledge-public/compliance-form-dialog.tsx +308 -0
- package/src/components/knowledge-public/legal-form-dialog.tsx +315 -0
- package/src/components/layout/sidebar-02/app-sidebar.tsx +160 -0
- package/src/components/layout/sidebar-02/index.tsx +33 -0
- package/src/components/layout/sidebar-02/logo.tsx +40 -0
- package/src/components/layout/sidebar-02/nav-main.tsx +153 -0
- package/src/components/layout/sidebar-02/nav-notifications.tsx +78 -0
- package/src/components/layout/sidebar-02/team-switcher.tsx +106 -0
- package/src/components/magicui/animated-theme-toggler.tsx +272 -0
- package/src/components/magicui/rainbow-button.tsx +78 -0
- package/src/components/market/columns.tsx +151 -0
- package/src/components/market/market-app-form.tsx +237 -0
- package/src/components/market/market-app-list.tsx +156 -0
- package/src/components/monitoring/error-list.tsx +143 -0
- package/src/components/monitoring/health-cards.tsx +152 -0
- package/src/components/monitoring/metrics-stats.tsx +184 -0
- package/src/components/monitoring/request-log-table.tsx +179 -0
- package/src/components/providers/theme-toggle.tsx +59 -0
- package/src/components/settings/avatar/avatar-config.ts +46 -0
- package/src/components/ui/alert-dialog.tsx +216 -0
- package/src/components/ui/alert.tsx +76 -0
- package/src/components/ui/avatar.tsx +129 -0
- package/src/components/ui/badge.tsx +66 -0
- package/src/components/ui/button.tsx +84 -0
- package/src/components/ui/card.tsx +120 -0
- package/src/components/ui/checkbox.tsx +50 -0
- package/src/components/ui/collapsible.tsx +50 -0
- package/src/components/ui/dialog.tsx +185 -0
- package/src/components/ui/dropdown-menu.tsx +286 -0
- package/src/components/ui/field.tsx +255 -0
- package/src/components/ui/input-group.tsx +171 -0
- package/src/components/ui/input.tsx +36 -0
- package/src/components/ui/label.tsx +41 -0
- package/src/components/ui/pagination.tsx +146 -0
- package/src/components/ui/select.tsx +209 -0
- package/src/components/ui/separator.tsx +45 -0
- package/src/components/ui/sheet.tsx +164 -0
- package/src/components/ui/sidebar.tsx +719 -0
- package/src/components/ui/skeleton.tsx +30 -0
- package/src/components/ui/sonner.tsx +37 -0
- package/src/components/ui/switch.tsx +48 -0
- package/src/components/ui/table.tsx +133 -0
- package/src/components/ui/textarea.tsx +35 -0
- package/src/components/ui/tooltip.tsx +74 -0
- package/src/components/user/columns.tsx +153 -0
- package/src/components/user/settings-activity-log.tsx +358 -0
- package/src/components/user/settings-profile/constants.ts +42 -0
- package/src/components/user/settings-profile/types.ts +47 -0
- package/src/components/user/settings-profile.tsx +664 -0
- package/src/components/user/user-create-dialog.tsx +137 -0
- package/src/components/user/user-filters.tsx +117 -0
- package/src/hooks/use-mobile.ts +36 -0
- package/src/instrument.client.ts +34 -0
- package/src/lib/anyhub/capabilities.ts +59 -0
- package/src/lib/anyhub/types.ts +43 -0
- package/src/lib/auth-client.ts +83 -0
- package/src/lib/auth-utils.ts +24 -0
- package/src/lib/auth.config.ts +264 -0
- package/src/lib/avatar.ts +41 -0
- package/src/lib/i18n/client.ts +32 -0
- package/src/lib/i18n/config.ts +40 -0
- package/src/lib/i18n/detect.ts +43 -0
- package/src/lib/i18n/locales/en/admin-ai.json +128 -0
- package/src/lib/i18n/locales/en/admin-billing.json +56 -0
- package/src/lib/i18n/locales/en/admin-users.json +88 -0
- package/src/lib/i18n/locales/en/common.json +877 -0
- package/src/lib/i18n/locales/en/enterprise.json +208 -0
- package/src/lib/i18n/locales/zh/admin-ai.json +128 -0
- package/src/lib/i18n/locales/zh/admin-billing.json +56 -0
- package/src/lib/i18n/locales/zh/admin-users.json +88 -0
- package/src/lib/i18n/locales/zh/common.json +877 -0
- package/src/lib/i18n/locales/zh/enterprise.json +208 -0
- package/src/lib/i18n/server.ts +18 -0
- package/src/lib/i18n/types.ts +5 -0
- package/src/lib/monitoring-benchmarks.ts +85 -0
- package/src/lib/monitoring-descriptions.ts +78 -0
- package/src/lib/prisma.ts +18 -0
- package/src/lib/router-context.ts +38 -0
- package/src/lib/session.ts +38 -0
- package/src/lib/utils.ts +23 -0
- package/src/providers/index.tsx +45 -0
- package/src/routeTree.gen.ts +1199 -0
- package/src/router.tsx +51 -0
- package/src/routes/__root.tsx +170 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.lazy.tsx +74 -0
- package/src/routes/_nav/ai-providers/$id/edit/route.tsx +35 -0
- package/src/routes/_nav/ai-providers/index/route.lazy.tsx +33 -0
- package/src/routes/_nav/ai-providers/new/route.lazy.tsx +31 -0
- package/src/routes/_nav/ai-providers/new/route.tsx +20 -0
- package/src/routes/_nav/ai-providers/route.lazy.tsx +26 -0
- package/src/routes/_nav/ai-providers/route.tsx +31 -0
- package/src/routes/_nav/apps/route.lazy.tsx +30 -0
- package/src/routes/_nav/apps/route.tsx +20 -0
- package/src/routes/_nav/audit/route.lazy.tsx +100 -0
- package/src/routes/_nav/audit/route.tsx +25 -0
- package/src/routes/_nav/billing/invoices/route.lazy.tsx +86 -0
- package/src/routes/_nav/billing/invoices/route.tsx +25 -0
- package/src/routes/_nav/billing/route.lazy.tsx +48 -0
- package/src/routes/_nav/billing/route.tsx +27 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.lazy.tsx +160 -0
- package/src/routes/_nav/billing/subscriptions/$subscriptionId/route.tsx +25 -0
- package/src/routes/_nav/billing/subscriptions/route.lazy.tsx +113 -0
- package/src/routes/_nav/billing/subscriptions/route.tsx +28 -0
- package/src/routes/_nav/compliance/route.lazy.tsx +184 -0
- package/src/routes/_nav/compliance/route.tsx +25 -0
- package/src/routes/_nav/components/route.lazy.tsx +184 -0
- package/src/routes/_nav/components/route.tsx +40 -0
- package/src/routes/_nav/dashboard/route.lazy.tsx +33 -0
- package/src/routes/_nav/dashboard/route.tsx +20 -0
- package/src/routes/_nav/dependencies/route.lazy.tsx +66 -0
- package/src/routes/_nav/dependencies/route.tsx +32 -0
- package/src/routes/_nav/developer-logs/route.lazy.tsx +6 -0
- package/src/routes/_nav/developer-logs/route.tsx +14 -0
- package/src/routes/_nav/docs/route.lazy.tsx +30 -0
- package/src/routes/_nav/docs/route.tsx +20 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.lazy.tsx +355 -0
- package/src/routes/_nav/knowledge-public/bookmarks/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/compliance/route.lazy.tsx +245 -0
- package/src/routes/_nav/knowledge-public/compliance/route.tsx +40 -0
- package/src/routes/_nav/knowledge-public/legal/route.lazy.tsx +242 -0
- package/src/routes/_nav/knowledge-public/legal/route.tsx +40 -0
- package/src/routes/_nav/licenses/$licenseId/route.lazy.tsx +235 -0
- package/src/routes/_nav/licenses/$licenseId/route.tsx +25 -0
- package/src/routes/_nav/licenses/new/route.lazy.tsx +237 -0
- package/src/routes/_nav/licenses/route.lazy.tsx +143 -0
- package/src/routes/_nav/licenses/route.tsx +25 -0
- package/src/routes/_nav/market/route.lazy.tsx +31 -0
- package/src/routes/_nav/market/route.tsx +20 -0
- package/src/routes/_nav/monitoring/ai-usage/route.lazy.tsx +305 -0
- package/src/routes/_nav/monitoring/ai-usage/route.tsx +3 -0
- package/src/routes/_nav/monitoring/errors/route.lazy.tsx +111 -0
- package/src/routes/_nav/monitoring/health/route.lazy.tsx +260 -0
- package/src/routes/_nav/monitoring/logs/route.lazy.tsx +165 -0
- package/src/routes/_nav/monitoring/metrics/route.lazy.tsx +240 -0
- package/src/routes/_nav/monitoring/overview/route.lazy.tsx +138 -0
- package/src/routes/_nav/monitoring/route.lazy.tsx +68 -0
- package/src/routes/_nav/monitoring/route.tsx +20 -0
- package/src/routes/_nav/monitoring/sentry/route.lazy.tsx +399 -0
- package/src/routes/_nav/monitoring/sentry/route.tsx +20 -0
- package/src/routes/_nav/permissions/route.lazy.tsx +504 -0
- package/src/routes/_nav/permissions/route.tsx +26 -0
- package/src/routes/_nav/route.lazy.tsx +31 -0
- package/src/routes/_nav/route.tsx +34 -0
- package/src/routes/_nav/settings/route.lazy.tsx +30 -0
- package/src/routes/_nav/settings/route.tsx +20 -0
- package/src/routes/_nav/sso/route.lazy.tsx +145 -0
- package/src/routes/_nav/sso/route.tsx +25 -0
- package/src/routes/_nav/users/$userId/route.lazy.tsx +432 -0
- package/src/routes/_nav/users/$userId/route.tsx +27 -0
- package/src/routes/_nav/users/-components/users-table.tsx +38 -0
- package/src/routes/_nav/users/route.lazy.tsx +74 -0
- package/src/routes/_nav/users/route.tsx +43 -0
- package/src/routes/api/compliance-download/$jobId.ts +35 -0
- package/src/routes/api/observability/errors.ts +41 -0
- package/src/routes/api/observability/health.ts +30 -0
- package/src/routes/api.auth.$.ts +31 -0
- package/src/routes/api.sentry-test.ts +33 -0
- package/src/routes/index/route.lazy.tsx +22 -0
- package/src/routes/index/route.tsx +24 -0
- package/src/routes/login/route.lazy.tsx +46 -0
- package/src/routes/login/route.tsx +28 -0
- package/src/routes/sentry-tunnel.ts +61 -0
- package/src/routes/setup/route.lazy.tsx +94 -0
- package/src/routes/setup/route.tsx +28 -0
- package/src/server/__tests__/admin-ai-alerts.test.ts +113 -0
- package/src/server/__tests__/licenses-issue.test.ts +98 -0
- package/src/server/admin-ai-alerts.ts +177 -0
- package/src/server/admin-ai-usage.ts +134 -0
- package/src/server/auth-helpers.ts +51 -0
- package/src/server/entitlements.ts +24 -0
- package/src/server/knowledge-public/canonical-id.ts +55 -0
- package/src/server/knowledge-public/github-sync.ts +86 -0
- package/src/server/license-issue.ts +78 -0
- package/src/server/middlewares/request.ts +26 -0
- package/src/server/observability/debug-middleware.ts +39 -0
- package/src/server/observability/error-reporter.ts +99 -0
- package/src/server/observability/health-check.ts +140 -0
- package/src/server/observability/index.ts +33 -0
- package/src/server/observability/logger.ts +49 -0
- package/src/server/observability/metrics.ts +105 -0
- package/src/server/observability/request-middleware.ts +125 -0
- package/src/server/observability/timing.ts +42 -0
- package/src/server/pro-services.ts +32 -0
- package/src/server/serverFns/admin-billing.ts +94 -0
- package/src/server/serverFns/ai-provider/create.ts +92 -0
- package/src/server/serverFns/ai-provider/delete.ts +42 -0
- package/src/server/serverFns/ai-provider/get.ts +35 -0
- package/src/server/serverFns/ai-provider/list.ts +92 -0
- package/src/server/serverFns/ai-provider/update.ts +73 -0
- package/src/server/serverFns/anyhub/market.ts +64 -0
- package/src/server/serverFns/audit.ts +57 -0
- package/src/server/serverFns/auth/session.ts +26 -0
- package/src/server/serverFns/auth/setup.ts +73 -0
- package/src/server/serverFns/compliance.ts +85 -0
- package/src/server/serverFns/components/import.ts +197 -0
- package/src/server/serverFns/components/list.ts +51 -0
- package/src/server/serverFns/components/mutations.ts +84 -0
- package/src/server/serverFns/dependency/helpers.ts +245 -0
- package/src/server/serverFns/dependency/list.ts +127 -0
- package/src/server/serverFns/dependency/sync.ts +123 -0
- package/src/server/serverFns/dependency/update.ts +51 -0
- package/src/server/serverFns/developer-log-auto-generate.ts +176 -0
- package/src/server/serverFns/developer-logs.ts +132 -0
- package/src/server/serverFns/knowledge-public/bookmarks/create.ts +53 -0
- package/src/server/serverFns/knowledge-public/bookmarks/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/bookmarks/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/bookmarks/list.ts +66 -0
- package/src/server/serverFns/knowledge-public/bookmarks/sync-all-github.ts +56 -0
- package/src/server/serverFns/knowledge-public/bookmarks/update.ts +50 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +58 -0
- package/src/server/serverFns/knowledge-public/compliance/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/compliance/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/compliance/list.ts +59 -0
- package/src/server/serverFns/knowledge-public/compliance/update.ts +53 -0
- package/src/server/serverFns/knowledge-public/legal/create.ts +62 -0
- package/src/server/serverFns/knowledge-public/legal/delete.ts +31 -0
- package/src/server/serverFns/knowledge-public/legal/detail.ts +32 -0
- package/src/server/serverFns/knowledge-public/legal/list.ts +60 -0
- package/src/server/serverFns/knowledge-public/legal/update.ts +55 -0
- package/src/server/serverFns/licenses.ts +92 -0
- package/src/server/serverFns/locale.ts +10 -0
- package/src/server/serverFns/monitoring/check-ai-alerts.ts +13 -0
- package/src/server/serverFns/monitoring/get-ai-usage-overview.ts +25 -0
- package/src/server/serverFns/monitoring/get-errors.ts +43 -0
- package/src/server/serverFns/monitoring/get-health-settings.ts +35 -0
- package/src/server/serverFns/monitoring/get-health.ts +55 -0
- package/src/server/serverFns/monitoring/get-metrics.ts +46 -0
- package/src/server/serverFns/monitoring/get-request-logs.ts +66 -0
- package/src/server/serverFns/monitoring/report-error.ts +55 -0
- package/src/server/serverFns/monitoring/reset.ts +70 -0
- package/src/server/serverFns/permissions.ts +214 -0
- package/src/server/serverFns/sentry/config.ts +88 -0
- package/src/server/serverFns/sso-config.ts +83 -0
- package/src/server/serverFns/user/create.ts +40 -0
- package/src/server/serverFns/user/delete.ts +29 -0
- package/src/server/serverFns/user/detail.ts +45 -0
- package/src/server/serverFns/user/list.ts +56 -0
- package/src/server/serverFns/user/reset-password.ts +28 -0
- package/src/server/serverFns/user/update-role.ts +26 -0
- package/src/server/serverFns/user/update-status.ts +26 -0
- package/src/server.ts +27 -0
- package/src/start.ts +85 -0
- package/src/styles/_keyframe-animations.scss +91 -0
- package/src/styles/_variables.scss +296 -0
- package/src/styles/globals.css +200 -0
- 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
|
+
}
|