From 1e1c476bc3e97c87c9877c28c59abbe801b0a861 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 11 Jul 2026 14:25:34 +0800 Subject: [PATCH] feat: add CASL authorization and AI configuration --- .env.example | 16 + apps/admin/src/App.tsx | 10 + apps/admin/src/layouts/MainLayout.tsx | 2 + .../AiConfig/helpers.integration.test.ts | 115 +++ apps/admin/src/pages/AiConfig/helpers.ts | 89 ++ .../admin/src/pages/AiConfig/index.module.css | 73 ++ apps/admin/src/pages/AiConfig/index.tsx | 506 ++++++++++++ apps/server/package.json | 4 +- .../agent-tools/agent-tool.executor.spec.ts | 678 +++++++++++++++ .../src/agent-tools/agent-tool.executor.ts | 251 ++++++ .../src/agent-tools/agent-tool.registry.ts | 52 ++ .../src/agent-tools/agent-tool.types.ts | 180 ++++ .../src/agent-tools/agent-tools.module.ts | 43 + apps/server/src/agent-tools/index.ts | 4 + .../tools/get-student-basic.tool.spec.ts | 180 ++++ .../tools/get-student-basic.tool.ts | 82 ++ .../tools/search-students.tool.spec.ts | 181 ++++ .../agent-tools/tools/search-students.tool.ts | 122 +++ .../ai-config/ai-config.controller.spec.ts | 302 +++++++ .../src/ai-config/ai-config.controller.ts | 93 +++ apps/server/src/ai-config/ai-config.entity.ts | 68 ++ apps/server/src/ai-config/ai-config.module.ts | 14 + .../src/ai-config/ai-config.service.spec.ts | 781 ++++++++++++++++++ .../server/src/ai-config/ai-config.service.ts | 753 +++++++++++++++++ .../src/ai-config/dto/ai-config.dto.spec.ts | 129 +++ .../server/src/ai-config/dto/ai-config.dto.ts | 116 +++ apps/server/src/app.module.ts | 20 +- .../attendance/attendance.controller.spec.ts | 5 + .../src/attendance/attendance.controller.ts | 57 +- .../src/auth/guards/permission.guard.spec.ts | 237 +++++- .../src/auth/guards/permission.guard.ts | 72 +- .../src/authorization/authorization.module.ts | 11 + .../authorization.service.spec.ts | 180 ++++ .../authorization/authorization.service.ts | 95 +++ .../casl-ability.factory.spec.ts | 327 ++++++++ .../src/authorization/casl-ability.factory.ts | 61 ++ .../src/authorization/casl.constants.ts | 180 ++++ .../decorators/check-policies.decorator.ts | 31 + .../guards/policies.guard.spec.ts | 201 +++++ .../authorization/guards/policies.guard.ts | 79 ++ apps/server/src/authorization/index.ts | 14 + apps/server/src/authorization/interfaces.ts | 67 ++ apps/server/src/classes/classes.controller.ts | 17 +- .../src/dashboard/dashboard.controller.ts | 31 +- .../database/database-migrations.service.ts | 87 ++ .../src/database/database-migrations.spec.ts | 178 ++++ apps/server/src/entities/index.ts | 1 + .../dto/notification.dto.spec.ts | 17 + .../src/notifications/dto/notification.dto.ts | 3 + apps/server/src/rbac/rbac.service.ts | 3 + .../src/schedules/schedules.controller.ts | 25 +- .../students/student-access-scope.factory.ts | 50 ++ .../src/students/student-access-scope.ts | 14 + .../src/students/students.agent-api.spec.ts | 363 ++++++++ .../src/students/students.controller.ts | 21 +- apps/server/src/students/students.module.ts | 5 +- apps/server/src/students/students.service.ts | 227 ++++- docs/superpowers/plans/casl-migration.md | 282 +++++++ package-lock.json | 48 ++ 59 files changed, 7733 insertions(+), 120 deletions(-) create mode 100644 apps/admin/src/pages/AiConfig/helpers.integration.test.ts create mode 100644 apps/admin/src/pages/AiConfig/helpers.ts create mode 100644 apps/admin/src/pages/AiConfig/index.module.css create mode 100644 apps/admin/src/pages/AiConfig/index.tsx create mode 100644 apps/server/src/agent-tools/agent-tool.executor.spec.ts create mode 100644 apps/server/src/agent-tools/agent-tool.executor.ts create mode 100644 apps/server/src/agent-tools/agent-tool.registry.ts create mode 100644 apps/server/src/agent-tools/agent-tool.types.ts create mode 100644 apps/server/src/agent-tools/agent-tools.module.ts create mode 100644 apps/server/src/agent-tools/index.ts create mode 100644 apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts create mode 100644 apps/server/src/agent-tools/tools/get-student-basic.tool.ts create mode 100644 apps/server/src/agent-tools/tools/search-students.tool.spec.ts create mode 100644 apps/server/src/agent-tools/tools/search-students.tool.ts create mode 100644 apps/server/src/ai-config/ai-config.controller.spec.ts create mode 100644 apps/server/src/ai-config/ai-config.controller.ts create mode 100644 apps/server/src/ai-config/ai-config.entity.ts create mode 100644 apps/server/src/ai-config/ai-config.module.ts create mode 100644 apps/server/src/ai-config/ai-config.service.spec.ts create mode 100644 apps/server/src/ai-config/ai-config.service.ts create mode 100644 apps/server/src/ai-config/dto/ai-config.dto.spec.ts create mode 100644 apps/server/src/ai-config/dto/ai-config.dto.ts create mode 100644 apps/server/src/authorization/authorization.module.ts create mode 100644 apps/server/src/authorization/authorization.service.spec.ts create mode 100644 apps/server/src/authorization/authorization.service.ts create mode 100644 apps/server/src/authorization/casl-ability.factory.spec.ts create mode 100644 apps/server/src/authorization/casl-ability.factory.ts create mode 100644 apps/server/src/authorization/casl.constants.ts create mode 100644 apps/server/src/authorization/decorators/check-policies.decorator.ts create mode 100644 apps/server/src/authorization/guards/policies.guard.spec.ts create mode 100644 apps/server/src/authorization/guards/policies.guard.ts create mode 100644 apps/server/src/authorization/index.ts create mode 100644 apps/server/src/authorization/interfaces.ts create mode 100644 apps/server/src/database/database-migrations.spec.ts create mode 100644 apps/server/src/notifications/dto/notification.dto.spec.ts create mode 100644 apps/server/src/students/student-access-scope.factory.ts create mode 100644 apps/server/src/students/student-access-scope.ts create mode 100644 apps/server/src/students/students.agent-api.spec.ts create mode 100644 docs/superpowers/plans/casl-migration.md diff --git a/.env.example b/.env.example index 345418b..e97c93d 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,19 @@ DB_SYNCHRONIZE=false JWT_SECRET=change-me-to-a-random-string-at-least-32-chars JWT_EXPIRES_IN=24h PORT=3000 + +# ---- AI 模型配置 ---- +# AES-256-GCM 加密主密钥,用于加密存储 API Key +# 生产环境必须设置,生成方式:openssl rand -hex 32 +# 格式:64 位 hex(推荐)或 base64 编码后恰好 32 字节 +# 示例 hex:openssl rand -hex 32 +# 示例 base64:openssl rand -base64 32 +AI_CONFIG_ENCRYPTION_KEY= + +# AI API Key 环境变量回退(可选) +# 若数据库未保存 Key,将从该环境变量读取 +# 环境变量 Key 不可从页面覆盖或清除 +# AI_API_KEY= + +# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用) +# AI_ALLOW_PRIVATE_BASE_URL=true diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 3bd0e2b..15007b0 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -32,6 +32,7 @@ const AttendancePage = lazy(() => import('./pages/Attendance')); const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace')); const NotificationsPage = lazy(() => import('./pages/Notifications')); const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig')); +const AiConfigPage = lazy(() => import('./pages/AiConfig')); const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { const token = localStorage.getItem('token'); @@ -274,6 +275,15 @@ const App: React.FC = () => { } /> + + + + + } + /> diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index d5e77cc..ce94223 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -26,6 +26,7 @@ import { LaptopOutlined, BellOutlined, ApiOutlined, + RobotOutlined, } from '@ant-design/icons'; import { usePermission } from '../hooks/usePermission'; import api from '../api'; @@ -115,6 +116,7 @@ const allMenuItems: MenuItemType[] = [ { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, { key: '/permissions', icon: , label: '权限一览', permission: 'role:view' }, { key: '/integration-config', icon: , label: '钉钉集成配置', permission: 'integration:read' }, + { key: '/ai-config', icon: , label: 'AI 模型配置', permission: 'ai:config:read' }, { key: '/users', icon: , label: '账号管理', permission: 'user:view' }, ], }, diff --git a/apps/admin/src/pages/AiConfig/helpers.integration.test.ts b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts new file mode 100644 index 0000000..1509288 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { + shouldAutoSwapBaseUrl, + formatDateTime, + sourceLabel, + sourceColor, + PROVIDER_DEFAULTS, + extractErrorMessage, +} from './helpers'; + +describe('AiConfig helpers', () => { + describe('shouldAutoSwapBaseUrl', () => { + it('swaps to default on first provider selection', () => { + const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', null); + expect(result.shouldSwap).toBe(true); + expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK); + }); + + it('swaps when current baseUrl matches previous provider default', () => { + const result = shouldAutoSwapBaseUrl( + 'DEEPSEEK', + 'https://api.openai.com/v1', + 'OPENAI', + ); + expect(result.shouldSwap).toBe(true); + expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK); + }); + + it('swaps when current baseUrl is empty', () => { + const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', 'OPENAI'); + expect(result.shouldSwap).toBe(true); + }); + + it('keeps custom baseUrl unchanged', () => { + const result = shouldAutoSwapBaseUrl( + 'OPENAI', + 'https://custom.api.com/v1', + 'DEEPSEEK', + ); + expect(result.shouldSwap).toBe(false); + expect(result.baseUrl).toBe('https://custom.api.com/v1'); + }); + }); + + describe('formatDateTime', () => { + it('returns hyphen for null', () => { + expect(formatDateTime(null)).toBe('-'); + }); + + it('returns Chinese locale string for ISO date', () => { + expect(formatDateTime('2024-01-15T10:30:00Z')).toContain('2024'); + }); + }); + + describe('sourceLabel', () => { + it('returns Chinese labels', () => { + expect(sourceLabel('database')).toBe('数据库'); + expect(sourceLabel('environment')).toBe('环境变量'); + expect(sourceLabel('none')).toBe('未配置'); + }); + }); + + describe('sourceColor', () => { + it('returns correct colors', () => { + expect(sourceColor('database')).toBe('green'); + expect(sourceColor('environment')).toBe('blue'); + expect(sourceColor('none')).toBe('default'); + }); + }); + + describe('extractErrorMessage', () => { + it('extracts axios-style response error message', () => { + const err = { + response: { data: { message: 'API出错' } }, + }; + expect(extractErrorMessage(err)).toBe('API出错'); + }); + + it('falls back to message property', () => { + const err = { message: 'Network error' }; + expect(extractErrorMessage(err)).toBe('Network error'); + }); + + it('falls back to default on unknown type', () => { + expect(extractErrorMessage('unknown string')).toBe('操作失败'); + expect(extractErrorMessage(null)).toBe('操作失败'); + expect(extractErrorMessage(undefined)).toBe('操作失败'); + }); + + it('sanitizes: newlines replaced with spaces', () => { + const err = { message: 'line1\nline2\r\nline3' }; + expect(extractErrorMessage(err)).toBe('line1 line2 line3'); + }); + + it('sanitizes: message > 120 chars truncated with ellipsis', () => { + const long = 'x'.repeat(200); + const err = { message: long }; + const result = extractErrorMessage(err); + expect(result).toHaveLength(121); // 120 + '…' (1 char) + expect(result.endsWith('\u2026')).toBe(true); + }); + + it('sanitizes: empty trimmed message falls back', () => { + const err = { message: ' ' }; + expect(extractErrorMessage(err)).toBe('操作失败'); + }); + + it('sanitizes: plain object message property sanitized', () => { + const err = { + message: ' some \n\nerror \r\nmessage ', + }; + expect(extractErrorMessage(err)).toBe('some error message'); + }); + }); +}); diff --git a/apps/admin/src/pages/AiConfig/helpers.ts b/apps/admin/src/pages/AiConfig/helpers.ts new file mode 100644 index 0000000..2cda601 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/helpers.ts @@ -0,0 +1,89 @@ +// --------------------------------------------------------------------------- +// AiConfig helpers — pure functions, no React / DOM dependencies +// --------------------------------------------------------------------------- + +export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE'; + +export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [ + { value: 'OPENAI', label: 'OpenAI' }, + { value: 'DEEPSEEK', label: 'DeepSeek' }, + { value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' }, +]; + +export const PROVIDER_DEFAULTS: Record = { + OPENAI: 'https://api.openai.com/v1', + DEEPSEEK: 'https://api.deepseek.com', + OPENAI_COMPATIBLE: '', +} as const; + +export const FIXED_PROVIDERS: AiProvider[] = ['OPENAI', 'DEEPSEEK']; + +export function formatDateTime(iso: string | null): string { + if (!iso) return '-'; + return new Date(iso).toLocaleString('zh-CN'); +} + +export function sourceLabel(source: string): string { + switch (source) { + case 'database': + return '数据库'; + case 'environment': + return '环境变量'; + default: + return '未配置'; + } +} + +export function sourceColor(source: string): 'green' | 'blue' | 'default' { + switch (source) { + case 'database': + return 'green'; + case 'environment': + return 'blue'; + default: + return 'default'; + } +} + +export function shouldAutoSwapBaseUrl( + provider: AiProvider, + currentBaseUrl: string, + lastProvider: AiProvider | null, +): { baseUrl: string; shouldSwap: boolean } { + if (!lastProvider) { + return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true }; + } + const prevDefault = PROVIDER_DEFAULTS[lastProvider]; + if (!currentBaseUrl || currentBaseUrl === prevDefault) { + return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true }; + } + return { baseUrl: currentBaseUrl, shouldSwap: false }; +} + +/** Extract a safe user-facing error message from any caught value */ +export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string { + let msg = ''; + + // axios-style error: { response: { data: { message: string } } } + if (err && typeof err === 'object' && 'response' in err) { + const resp: unknown = err.response; + if (resp && typeof resp === 'object' && 'data' in resp) { + const data: unknown = resp.data; + if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') { + msg = data.message; + } + } + } + + // standard Error or any object with a string message property + if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { + msg = err.message; + } + + // sanitize: trim, collapse whitespace, strip newlines, truncate + const trimmed = msg.trim(); + if (!trimmed) return fallback; + + const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' '); + return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine; +} diff --git a/apps/admin/src/pages/AiConfig/index.module.css b/apps/admin/src/pages/AiConfig/index.module.css new file mode 100644 index 0000000..8fb4693 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/index.module.css @@ -0,0 +1,73 @@ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 16px; +} + +.header { + margin-bottom: 16px; +} + +.header h2 { + margin: 0 0 4px 0; + font-size: 20px; + font-weight: 600; +} + +.headerDesc { + color: #666; + font-size: 13px; + margin: 0; +} + +.statusRow { + margin-top: 8px; +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +@media (max-width: 768px) { + .grid { + grid-template-columns: 1fr; + } +} + +.cardTitle { + font-size: 15px; + font-weight: 600; +} + +.actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.testResult { + margin-top: 16px; +} + +.safetyNote { + margin-top: 12px; + padding: 8px 12px; + background: #f6ffed; + border: 1px solid #b7eb8f; + border-radius: 6px; + font-size: 12px; + color: #389e0d; +} + +.safetyNoteKey { + margin-top: 8px; + padding: 8px 12px; + background: #fff7e6; + border: 1px solid #ffd591; + border-radius: 6px; + font-size: 12px; + color: #d46b08; +} diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx new file mode 100644 index 0000000..a1e03b4 --- /dev/null +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -0,0 +1,506 @@ +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { + App, + Card, + Form, + Input, + Button, + Select, + Switch, + InputNumber, + Tag, + Descriptions, + Spin, + Alert, + Typography, + Tooltip, + Space, +} from 'antd'; +import { + SaveOutlined, + ApiOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + KeyOutlined, + DeleteOutlined, + WarningOutlined, +} from '@ant-design/icons'; +import api from '../../api'; +import { message } from '../../ui/app-message'; +import { usePermission } from '../../hooks/usePermission'; +import type { AiProvider } from './helpers'; +import { + PROVIDER_OPTIONS, + PROVIDER_DEFAULTS, + FIXED_PROVIDERS, + formatDateTime, + sourceLabel, + sourceColor, + shouldAutoSwapBaseUrl, + extractErrorMessage, +} from './helpers'; +import styles from './index.module.css'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface AiConfigData { + id: number; + provider: AiProvider; + baseUrl: string; + hasApiKey: boolean; + hasDatabaseKey: boolean; + maskedApiKey: string | null; + keySource: 'database' | 'environment' | 'none'; + defaultModel: string | null; + enabled: boolean; + timeoutMs: number; + verified: boolean; + lastTestedAt: string | null; + lastTestLatencyMs: number | null; + createdAt: string; + updatedAt: string; +} + +interface TestResult { + success: boolean; + latencyMs: number | null; + modelCount: number | null; + modelAvailable: boolean; + testedAt: string; + message: string; +} + +interface ApiResponse { + success: boolean; + data: T; + message?: string; +} + +// --------------------------------------------------------------------------- +// Page Component +// --------------------------------------------------------------------------- + +const AiConfigPage: React.FC = () => { + const { hasPermission } = usePermission(); + const { modal } = App.useApp(); + const [form] = Form.useForm(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [config, setConfig] = useState(null); + const [testResult, setTestResult] = useState(null); + const [error, setError] = useState(null); + + const lastProviderRef = useRef(null); + + const canWrite = hasPermission('ai:config:write'); + const canTest = hasPermission('ai:config:test'); + const canRead = hasPermission('ai:config:read'); + + // ── Load config ── + + const loadConfig = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await api.get>('/ai/config'); + setConfig(res.data); + form.setFieldsValue({ + provider: res.data.provider, + baseUrl: res.data.baseUrl, + defaultModel: res.data.defaultModel ?? undefined, + enabled: res.data.enabled, + timeoutMs: res.data.timeoutMs, + }); + lastProviderRef.current = res.data.provider; + } catch (err: unknown) { + setError(extractErrorMessage(err, '加载配置失败')); + } finally { + setLoading(false); + } + }, [form]); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + // ── Provider change → swap baseUrl ── + + const handleProviderChange = useCallback( + (provider: AiProvider) => { + const currentBaseUrl = form.getFieldValue('baseUrl') || ''; + const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current); + if (result.shouldSwap) { + form.setFieldValue('baseUrl', result.baseUrl); + } + lastProviderRef.current = provider; + }, + [form], + ); + + const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined; + const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false; + + // ── Save ── + + const handleSave = useCallback(async () => { + try { + const values = await form.validateFields(); + setSaving(true); + + // Validate baseUrl for OPENAI_COMPATIBLE + if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) { + message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL'); + setSaving(false); + return; + } + + const body: Record = { + provider: values.provider, + baseUrl: values.baseUrl, + defaultModel: values.defaultModel || undefined, + enabled: values.enabled, + timeoutMs: values.timeoutMs, + }; + + if (values.apiKey && values.apiKey !== '••••') { + body.apiKey = values.apiKey; + } + + await api.put('/ai/config', body); + message.success('配置已保存'); + form.setFieldValue('apiKey', ''); + await loadConfig(); + } catch (err: unknown) { + message.error(extractErrorMessage(err, '保存失败')); + } finally { + setSaving(false); + } + }, [form, loadConfig]); + + // ── Test connection ── + + const handleTest = useCallback(async () => { + try { + // Validated fields: compatible requires baseUrl + const fieldsToValidate = ['timeoutMs'] as string[]; + if (currentProvider === 'OPENAI_COMPATIBLE') { + fieldsToValidate.push('baseUrl'); + } + const values = await form.validateFields(fieldsToValidate); + + setTesting(true); + setTestResult(null); + + const body: Record = { + timeoutMs: values.timeoutMs, + }; + + // Always send provider if form has it + if (currentProvider) body.provider = currentProvider; + if (values.baseUrl) body.baseUrl = values.baseUrl; + + // Include defaultModel so backend checks target model + const defaultModel = form.getFieldValue('defaultModel'); + if (defaultModel) body.defaultModel = defaultModel; + + const typedKey = form.getFieldValue('apiKey'); + if (typedKey && typedKey !== '••••') { + body.apiKey = typedKey; + } + + const res = await api.post('/ai/config/test', body); + setTestResult(res); + await loadConfig(); + } catch (err: unknown) { + setTestResult({ + success: false, + latencyMs: null, + modelCount: null, + modelAvailable: false, + testedAt: new Date().toISOString(), + message: extractErrorMessage(err, '测试请求失败'), + }); + } finally { + setTesting(false); + } + }, [form, loadConfig, currentProvider]); + + // ── Clear key ── + + const handleClearKey = useCallback(() => { + const isEnv = config?.keySource === 'environment'; + modal.confirm({ + title: '确认清除密钥', + content: isEnv + ? '数据库中的密钥将被清除,但环境变量 AI_API_KEY 仍可使用。确定继续?' + : '密钥将被永久清除,之后将无法使用 AI 功能。确定继续?', + okText: '确认清除', + okType: 'danger', + cancelText: '取消', + onOk: async () => { + try { + await api.post('/ai/config/clear-key'); + message.success('密钥已清除'); + await loadConfig(); + } catch (err: unknown) { + message.error(extractErrorMessage(err, '清除失败')); + } + }, + }); + }, [config, loadConfig, modal]); + + // ── No read permission ── + + if (!canRead) { + return ( +
+ +
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (error && !config) { + return ( +
+ +
+ ); + } + + // ── Render ── + + return ( +
+
+

AI 模型配置

+

密钥仅保存在服务器端,浏览器无法读取明文

+
+ + + {config?.enabled ? '已启用' : '未启用'} + + {config?.verified && 已验证} + {config?.hasApiKey && ( + + 密钥: {sourceLabel(config?.keySource || 'none')} + + )} + +
+
+ +
+
+ {/* Left: 模型路由 */} + 模型路由} extra={}> + + + + + prev.enabled !== curr.enabled}> + {({ getFieldValue }) => { + const enabled = getFieldValue('enabled'); + return ( + + + + ); + }} + + + + + + + + + + + + {/* Right: 密钥保险库 */} + 密钥保险库} + extra={} + > + + + + + {config && ( + + + {config.hasApiKey ? ( + {config.maskedApiKey || '••••'} + ) : ( + 未配置 + )} + + + {sourceLabel(config.keySource)} + {config.keySource === 'environment' && ( + + 由环境变量托管,需在服务器修改 + + )} + + + {formatDateTime(config.updatedAt)} + + + )} + + {config?.hasDatabaseKey && canWrite && ( +
+ +
+ )} + + {config?.keySource === 'environment' && !config.hasDatabaseKey && ( +
+ 密钥由环境变量提供,无法通过页面清除 +
+ )} + +
+ API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。 传输层通过 HTTPS + 保护,服务端日志不记录密钥。 +
+
+ 也可通过环境变量 AI_API_KEY 注入密钥, + 环境变量优先级高于数据库存储。 +
+
+
+ + {/* Actions */} +
+ + + + + + +
+
+ + {/* Test result */} + {testResult && ( + + + + {testResult.success ? ( + testResult.modelAvailable ? ( + } color="success"> + 成功 + + ) : ( + } color="warning"> + 模型未找到 + + ) + ) : ( + } color="error"> + 失败 + + )} + + + {testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'} + + + {testResult.modelCount != null ? testResult.modelCount : '-'} + + + {formatDateTime(testResult.testedAt)} + + + + + )} +
+ ); +}; + +export default AiConfigPage; diff --git a/apps/server/package.json b/apps/server/package.json index 95526d5..39fbd54 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -8,10 +8,9 @@ "scripts": { "dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json", "build": "nest build -p tsconfig.build.json", - "start:dev": "SEED_DEV=true nest start --watch", + "start:dev": "nest start --watch", "format": "oxfmt", "start": "nest start", - "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -23,6 +22,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@casl/ability": "^7.0.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", diff --git a/apps/server/src/agent-tools/agent-tool.executor.spec.ts b/apps/server/src/agent-tools/agent-tool.executor.spec.ts new file mode 100644 index 0000000..4db5e9a --- /dev/null +++ b/apps/server/src/agent-tools/agent-tool.executor.spec.ts @@ -0,0 +1,678 @@ +import { NotFoundException } from '@nestjs/common'; +import { CaslAbilityFactory } from '../authorization/casl-ability.factory'; +import { AuthorizationService } from '../authorization/authorization.service'; +import { AgentToolRegistry } from './agent-tool.registry'; +import { AgentToolExecutor } from './agent-tool.executor'; +import { AgentToolContextFactory, AgentToolContext } from './agent-tool.types'; +import type { ToolDef, ToolInputResult, ToolDescriptor } from './agent-tool.types'; +import type { AuthenticatedUser } from '../authorization'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const abilityFactory = new CaslAbilityFactory(); + +/** Create context via the factory (the ONLY valid path). */ +function makeCtx(user: Partial & { id: number; username: string }): AgentToolContext { + const u: AuthenticatedUser = { + id: user.id, + username: user.username, + permissions: user.permissions ?? [], + isSuperAdmin: user.isSuperAdmin ?? false, + roles: user.roles ?? [], + }; + return AgentToolContextFactory.fromAuthenticatedUser(u); +} + +const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true }); +const studentViewerCtx = makeCtx({ + id: 2, + username: 'teacher_zhang', + permissions: ['student:view'], +}); +const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] }); + +/** Create a simple mock tool. */ +function makeTool(overrides: Partial = {}): ToolDef { + return { + name: 'echo', + description: 'echoes input', + requiredPermission: 'student:view', + inputSchema: { type: 'object', properties: { text: { type: 'string' } }, additionalProperties: false }, + validate(input: Record): ToolInputResult> { + const forbidden = new Set(['userId', 'isSuperAdmin', 'permissions', 'roles', 'ability']); + for (const key of Object.keys(input)) { + if (forbidden.has(key)) return { ok: false, error: `禁止字段: ${key}` }; + } + return { ok: true, value: input }; + }, + async execute(input: Record): Promise { + return { echoed: input }; + }, + ...overrides, + }; +} + +function makeExecutor(opLogMock?: { log: jest.Mock }): { + executor: AgentToolExecutor; + registry: AgentToolRegistry; + opLog: { log: jest.Mock }; +} { + const authz = new AuthorizationService(abilityFactory); + const registry = new AgentToolRegistry(); + const opLog = opLogMock ?? { log: jest.fn().mockResolvedValue(undefined) }; + const executor = new AgentToolExecutor(registry, abilityFactory, authz, opLog as never); + return { executor, registry, opLog }; +} + +// --------------------------------------------------------------------------- +// Fix 2: AgentToolContext — immutability & forgery resistance +// --------------------------------------------------------------------------- + +describe('AgentToolContext — immutability & forgery resistance', () => { + it('context is fully frozen (cannot add/remove/modify properties)', () => { + const ctx = AgentToolContextFactory.fromAuthenticatedUser({ + id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [], + }); + + // Frozen object throws in strict mode on mutation + expect(() => { + (ctx as Record).isSuperAdmin = true; + }).toThrow(); + expect(() => { + (ctx as Record).userId = 999; + }).toThrow(); + expect(() => { + (ctx as Record).newField = 'injected'; + }).toThrow(); + }); + + it('permissions array is frozen (cannot push/splice)', () => { + const ctx = AgentToolContextFactory.fromAuthenticatedUser({ + id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [], + }); + expect(() => { + (ctx.permissions as string[]).push('student:delete'); + }).toThrow(); + }); + + it('mutating original AuthenticatedUser does NOT affect context', () => { + const user: AuthenticatedUser = { + id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [], + }; + const ctx = AgentToolContextFactory.fromAuthenticatedUser(user); + + user.permissions.push('superadmin:hack'); + user.isSuperAdmin = true; + + expect(ctx.permissions).toEqual(['student:view']); + expect(ctx.isSuperAdmin).toBe(false); + }); + + it('hand-crafted plain-object context is rejected by executor.execute', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const fakeCtx = { + userId: 1, + username: 'hacker', + permissions: Object.freeze(['student:view', 'student:delete']), + isSuperAdmin: true, + } as AgentToolContext; + + const result = await executor.execute('echo', { text: 'hi' }, fakeCtx); + expect(result.status).toBe('denied'); + expect(result.error).toBe('权限不足'); + expect(result.result).toBeUndefined(); + }); + + it('hand-crafted plain-object context is rejected by executor.listAvailable', () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const fakeCtx = { + userId: 1, + username: 'hacker', + permissions: Object.freeze(['student:delete']), + isSuperAdmin: true, + } as AgentToolContext; + + expect(() => executor.listAvailable(fakeCtx)).toThrow('DENIED'); + }); + + it('Object.create(prototype) without factory is rejected', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + // Even if you get the prototype right, it's not in the WeakSet + const fakeCtx2 = Object.create(AgentToolContext.prototype) as AgentToolContext; + Object.defineProperties(fakeCtx2, { + userId: { value: 1 }, + username: { value: 'hacker' }, + permissions: { value: Object.freeze(['student:view', 'student:delete']) }, + isSuperAdmin: { value: true }, + }); + Object.freeze(fakeCtx2); + + const result = await executor.execute('echo', { text: 'hi' }, fakeCtx2); + expect(result.status).toBe('denied'); + }); + + it('context does NOT expose ability field', () => { + const ctx = superAdminCtx; + expect((ctx as Record).ability).toBeUndefined(); + }); + + it('passing superAdmin-like permissions on non-superAdmin user does NOT grant superAdmin', () => { + const ctx = makeCtx({ id: 5, username: 'fake', permissions: ['superadmin:all'], isSuperAdmin: false }); + expect(ctx.isSuperAdmin).toBe(false); + + const ability = abilityFactory.createForUser({ + permissions: ctx.permissions, + isSuperAdmin: ctx.isSuperAdmin, + }); + expect(ability.can('manage', 'all')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Fix 1: listAvailable via Executor (not Registry) +// --------------------------------------------------------------------------- + +describe('listAvailable via Executor', () => { + it('returns ToolDescriptors with name, description, inputSchema — but NOT execute/validate', () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' })); + + const tools: ToolDescriptor[] = executor.listAvailable(studentViewerCtx); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('student_search'); + expect(tools[0].description).toBeTruthy(); + expect(tools[0].inputSchema).toBeDefined(); + expect((tools[0] as Record).execute).toBeUndefined(); + expect((tools[0] as Record).validate).toBeUndefined(); + expect((tools[0] as Record).requiredPermission).toBeUndefined(); + }); + + it('super admin sees all tools', () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 't1', requiredPermission: 'student:view' })); + registry.register(makeTool({ name: 't2', requiredPermission: 'bill:export' })); + + const tools = executor.listAvailable(superAdminCtx); + expect(tools).toHaveLength(2); + }); + + it('hides tool when principal lacks required permission', () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' })); + + const tools = executor.listAvailable(noPermCtx); + expect(tools).toHaveLength(0); + }); + + it('filters by exact permission code', () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' })); + registry.register(makeTool({ name: 'bill_export', requiredPermission: 'bill:export-excel' })); + + const tools = executor.listAvailable(studentViewerCtx); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('student_search'); + }); + + it('descriptors include inputSchema when present', () => { + const { executor, registry } = makeExecutor(); + registry.register( + makeTool({ + name: 'get_student', + requiredPermission: 'student:view', + inputSchema: { type: 'object', properties: { studentId: { type: 'integer' } }, required: ['studentId'], additionalProperties: false }, + }), + ); + + const tools = executor.listAvailable(studentViewerCtx); + expect(tools[0].inputSchema).toBeDefined(); + expect(tools[0].inputSchema!.required).toContain('studentId'); + }); +}); + +// --------------------------------------------------------------------------- +// rawInput type guards +// --------------------------------------------------------------------------- + +describe('rawInput type guards', () => { + it('rejects null input', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const result = await executor.execute('echo', null, studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('输入参数无效'); + }); + + it('rejects array input', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const result = await executor.execute('echo', [1, 2, 3], studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('输入参数无效'); + }); + + it('rejects string input', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const result = await executor.execute('echo', 'just a string', studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('输入参数无效'); + }); + + it('rejects number input', async () => { + const { executor, registry } = makeExecutor(); + registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' })); + + const result = await executor.execute('echo', 42, studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('输入参数无效'); + }); + + it('validator that throws is caught and returns safe error', async () => { + const { executor, registry } = makeExecutor(); + registry.register( + makeTool({ + name: 'crash_validate', + requiredPermission: 'student:view', + validate(): never { + throw new Error('INTERNAL: validator crashed with raw SQL'); + }, + }), + ); + + const result = await executor.execute('crash_validate', { x: 1 }, studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('输入参数无效'); + expect(result.error).not.toContain('SQL'); + expect(result.error).not.toContain('INTERNAL'); + }); +}); + +// --------------------------------------------------------------------------- +// Error & audit sanitization +// --------------------------------------------------------------------------- + +describe('Error & audit sanitization', () => { + it('tool throw with phone/SQL in message does NOT leak to result', async () => { + const { executor, registry } = makeExecutor(); + registry.register( + makeTool({ + name: 'leaky', + requiredPermission: 'student:view', + async execute(): Promise { + throw new Error('phone=13800138000, idNumber=320106199001011234, SQL: SELECT * FROM students WHERE id=1'); + }, + }), + ); + + const result = await executor.execute('leaky', {}, studentViewerCtx); + expect(result.status).toBe('failed'); + expect(result.error).toBe('工具执行失败'); + expect(result.error).not.toContain('13800138000'); + expect(result.error).not.toContain('320106'); + expect(result.error).not.toContain('SQL'); + expect(result.error).not.toContain('SELECT'); + }); + + it('malicious tool name is sanitized in audit action', async () => { + const opLog = { log: jest.fn().mockResolvedValue(undefined) }; + const { executor } = makeExecutor(opLog); + + const result = await executor.execute( + 'evil\n!@#$%^&*()very_long_name_exceeding_64_chars_padding_padding_padding_padding_END', + {}, + studentViewerCtx, + ); + + expect(result.toolName).not.toContain('\n'); + expect(result.toolName).not.toContain('