fix: harden controlled AI session consistency
This commit is contained in:
@@ -106,12 +106,17 @@ const aiApiSource = readFileSync('src/api/ai.ts', 'utf8')
|
|||||||
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
for (const required of ['/api/v1/projects/', '/ai-sessions', 'listAISessions', 'createAISession']) {
|
||||||
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
|
if (!aiApiSource.includes(required)) failures.push(`AI API must include ${required}`)
|
||||||
}
|
}
|
||||||
|
if (!aiApiSource.includes('signal?: AbortSignal')) failures.push('AI API requests must accept an AbortSignal')
|
||||||
|
if (!aiApiSource.includes('signal,')) failures.push('AI API requests must pass the AbortSignal to apiRequest')
|
||||||
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
|
if (/\b(?:task|note|source)Id\b/.test(aiApiSource)) failures.push('AI session responses must not expose automatic formal object IDs')
|
||||||
|
|
||||||
const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
|
const aiPageSource = readFileSync('src/pages/projects/project-ai.tsx', 'utf8')
|
||||||
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
|
for (const required of ['AI 助手', '创建会话', 'loading', 'error']) {
|
||||||
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
|
if (!aiPageSource.includes(required)) failures.push(`project AI page must include ${required}`)
|
||||||
}
|
}
|
||||||
|
for (const required of ['AbortController', 'generationRef', 'projectRef', 'setSessions([])']) {
|
||||||
|
if (!aiPageSource.includes(required)) failures.push(`project AI page must gate stale requests with ${required}`)
|
||||||
|
}
|
||||||
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
|
for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'IconAttachment', 'agent-send-button']) {
|
||||||
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
|
if (aiPageSource.includes(forbidden)) failures.push(`project AI page contains unsupported chat control ${forbidden}`)
|
||||||
}
|
}
|
||||||
@@ -119,7 +124,7 @@ for (const forbidden of ['DeepSeek V4.0 Flash', '给 DeepSeek 发送消息', 'Ic
|
|||||||
const unsupportedControls = [
|
const unsupportedControls = [
|
||||||
{ file: 'src/pages/workspace-explore.tsx', required: '暂未开放', forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'] },
|
{ file: 'src/pages/workspace-explore.tsx', required: '暂未开放', forbidden: ['同步数据源', '添加数据源', 'IconRefresh', 'IconEdit', 'IconDelete'] },
|
||||||
{ file: 'src/pages/projects/project-new-channel.tsx', required: '暂未开放', forbidden: ['保存频道', '<Input', '<Select', '<TextArea'] },
|
{ file: 'src/pages/projects/project-new-channel.tsx', required: '暂未开放', forbidden: ['保存频道', '<Input', '<Select', '<TextArea'] },
|
||||||
{ file: 'src/pages/projects/project-statusbar.tsx', forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲'] },
|
{ file: 'src/pages/projects/project-statusbar.tsx', forbidden: ['升级', '支付', 'billing-plan', '152GB', 'AI 空闲', '服务已连接', 'IconCheckCircle'] },
|
||||||
{ file: 'src/pages/projects/project-topbar.tsx', forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'] },
|
{ file: 'src/pages/projects/project-topbar.tsx', forbidden: ['停靠左边', '停靠右边', 'DockIcon', 'isDesktopRuntime'] },
|
||||||
]
|
]
|
||||||
for (const check of unsupportedControls) {
|
for (const check of unsupportedControls) {
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ export type CreateAISessionInput = {
|
|||||||
context: string
|
context: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAISessions(session: ApiSession, projectId: string) {
|
export async function listAISessions(session: ApiSession, projectId: string, signal?: AbortSignal) {
|
||||||
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
return apiRequest<AISessionDTO[]>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||||
token: session.token,
|
token: session.token,
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput) {
|
export async function createAISession(session: ApiSession, projectId: string, input: CreateAISessionInput, signal?: AbortSignal) {
|
||||||
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
return apiRequest<AISessionDTO>(`/api/v1/projects/${projectId}/ai-sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: input,
|
body: input,
|
||||||
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ function App() {
|
|||||||
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
|
||||||
const workspaceSearch = useWorkbenchSearch(session)
|
const workspaceSearch = useWorkbenchSearch(session)
|
||||||
|
|
||||||
const handleListAISessions = useCallback((projectId: string) => {
|
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
|
||||||
if (!session) return Promise.reject(new Error('未登录'))
|
if (!session) return Promise.reject(new Error('未登录'))
|
||||||
return listAISessions(session, projectId)
|
return listAISessions(session, projectId, signal)
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput) => {
|
const handleCreateAISession = useCallback((projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => {
|
||||||
if (!session) return Promise.reject(new Error('未登录'))
|
if (!session) return Promise.reject(new Error('未登录'))
|
||||||
return createAISession(session, projectId, input)
|
return createAISession(session, projectId, input, signal)
|
||||||
}, [session])
|
}, [session])
|
||||||
|
|
||||||
const dark = theme === 'dark'
|
const dark = theme === 'dark'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
import { Alert, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from '@arco-design/web-react'
|
||||||
import { IconPlusCircle, IconRobot } from '@arco-design/web-react/icon'
|
import { IconPlusCircle, IconRobot } from '@arco-design/web-react/icon'
|
||||||
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
import type { AISessionDTO, CreateAISessionInput } from '../../api/ai'
|
||||||
@@ -14,8 +14,8 @@ export function ProjectAi({
|
|||||||
}: {
|
}: {
|
||||||
activeWorkspace: ProjectWorkspace
|
activeWorkspace: ProjectWorkspace
|
||||||
onSelectItem: (title: string) => void
|
onSelectItem: (title: string) => void
|
||||||
onListSessions: (projectId: string) => Promise<AISessionDTO[]>
|
onListSessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||||
onCreateSession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
onCreateSession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||||
}) {
|
}) {
|
||||||
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
const [sessions, setSessions] = useState<AISessionDTO[]>([])
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
@@ -24,23 +24,47 @@ export function ProjectAi({
|
|||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const projectId = activeWorkspace.project.id
|
const projectId = activeWorkspace.project.id
|
||||||
|
const projectRef = useRef(projectId)
|
||||||
|
const generationRef = useRef(0)
|
||||||
|
const listControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const createControllerRef = useRef<AbortController | null>(null)
|
||||||
|
projectRef.current = projectId
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let current = true
|
const generation = ++generationRef.current
|
||||||
|
const controller = new AbortController()
|
||||||
|
listControllerRef.current?.abort()
|
||||||
|
createControllerRef.current?.abort()
|
||||||
|
listControllerRef.current = controller
|
||||||
|
createControllerRef.current = null
|
||||||
|
setSessions([])
|
||||||
|
setTitle('')
|
||||||
|
setContext('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setCreating(false)
|
||||||
setError('')
|
setError('')
|
||||||
void onListSessions(projectId)
|
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||||
|
|
||||||
|
void onListSessions(projectId, controller.signal)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (current) setSessions(items)
|
if (isCurrent()) setSessions(items)
|
||||||
})
|
})
|
||||||
.catch((requestError: unknown) => {
|
.catch((requestError: unknown) => {
|
||||||
if (current) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
if (isCurrent()) setError(requestError instanceof Error ? requestError.message : 'AI 会话加载失败,请稍后重试')
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (current) setLoading(false)
|
if (isCurrent()) {
|
||||||
|
setLoading(false)
|
||||||
|
listControllerRef.current = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
current = false
|
generationRef.current += 1
|
||||||
|
controller.abort()
|
||||||
|
if (listControllerRef.current === controller) listControllerRef.current = null
|
||||||
|
createControllerRef.current?.abort()
|
||||||
|
createControllerRef.current = null
|
||||||
}
|
}
|
||||||
}, [onListSessions, projectId])
|
}, [onListSessions, projectId])
|
||||||
|
|
||||||
@@ -50,21 +74,35 @@ export function ProjectAi({
|
|||||||
setError('请输入 AI 会话标题')
|
setError('请输入 AI 会话标题')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const generation = ++generationRef.current
|
||||||
|
listControllerRef.current?.abort()
|
||||||
|
listControllerRef.current = null
|
||||||
|
createControllerRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
createControllerRef.current = controller
|
||||||
|
const isCurrent = () => projectRef.current === projectId && generationRef.current === generation && !controller.signal.aborted
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const created = await onCreateSession(projectId, {
|
const created = await onCreateSession(projectId, {
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
context: context.trim(),
|
context: context.trim(),
|
||||||
})
|
}, controller.signal)
|
||||||
|
if (!isCurrent()) return
|
||||||
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)])
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setContext('')
|
setContext('')
|
||||||
onSelectItem(created.title)
|
onSelectItem(created.title)
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
|
if (!isCurrent()) return
|
||||||
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
setError(requestError instanceof Error ? requestError.message : 'AI 会话创建失败,请稍后重试')
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false)
|
if (isCurrent()) {
|
||||||
|
setCreating(false)
|
||||||
|
createControllerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export function ProjectChannelPage({
|
|||||||
onUpdateTask: (update: ProjectTaskUpdate) => void
|
onUpdateTask: (update: ProjectTaskUpdate) => void
|
||||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||||
}) {
|
}) {
|
||||||
switch (activeChannel) {
|
switch (activeChannel) {
|
||||||
case 'inbox':
|
case 'inbox':
|
||||||
@@ -49,7 +49,7 @@ export function ProjectChannelPage({
|
|||||||
case 'tasks':
|
case 'tasks':
|
||||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
|
||||||
case 'ai':
|
case 'ai':
|
||||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
|
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onCreateSession={onCreateAISession} />
|
||||||
case 'notes':
|
case 'notes':
|
||||||
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
|
||||||
case 'cron':
|
case 'cron':
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Layout, Space } from '@arco-design/web-react'
|
import { Layout } from '@arco-design/web-react'
|
||||||
import { IconCheckCircle } from '@arco-design/web-react/icon'
|
|
||||||
|
|
||||||
const { Footer } = Layout
|
const { Footer } = Layout
|
||||||
|
|
||||||
@@ -10,10 +9,6 @@ export function ProjectStatusbar() {
|
|||||||
<span className="status-avatar" aria-hidden="true">森</span>
|
<span className="status-avatar" aria-hidden="true">森</span>
|
||||||
<span className="status-name">已登录</span>
|
<span className="status-name">已登录</span>
|
||||||
</div>
|
</div>
|
||||||
<Space className="status-system" size={8}>
|
|
||||||
<IconCheckCircle />
|
|
||||||
<span>服务已连接</span>
|
|
||||||
</Space>
|
|
||||||
</Footer>
|
</Footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ export function ProjectPage({
|
|||||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||||
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
onAnalyzeInbox: (inboxId: string) => Promise<InboxSuggestionDTO[]>
|
||||||
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise<InboxConfirmationOutcome>
|
||||||
onListAISessions: (projectId: string) => Promise<AISessionDTO[]>
|
onListAISessions: (projectId: string, signal?: AbortSignal) => Promise<AISessionDTO[]>
|
||||||
onCreateAISession: (projectId: string, input: CreateAISessionInput) => Promise<AISessionDTO>
|
onCreateAISession: (projectId: string, input: CreateAISessionInput, signal?: AbortSignal) => Promise<AISessionDTO>
|
||||||
}) {
|
}) {
|
||||||
const isProject = activeView === 'project'
|
const isProject = activeView === 'project'
|
||||||
const projects = workspaces.map((workspace) => workspace.project)
|
const projects = workspaces.map((workspace) => workspace.project)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
type Gateway struct {
|
type Gateway struct {
|
||||||
systemKey string
|
systemKey string
|
||||||
encryptionSecret string
|
encryptionSecret string
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectedKey struct {
|
type SelectedKey struct {
|
||||||
@@ -36,7 +37,7 @@ func NewGateway(systemKey string) *Gateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway {
|
func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway {
|
||||||
return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret}
|
return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret, now: time.Now}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
||||||
@@ -55,11 +56,13 @@ func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
|||||||
var userKey models.SenlinAgentAIKey
|
var userKey models.SenlinAgentAIKey
|
||||||
err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error
|
err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
selected := SelectedKey{Provider: userKey.Provider, KeyType: "user"}
|
||||||
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return SelectedKey{}, err
|
return selected, err
|
||||||
}
|
}
|
||||||
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
|
selected.APIKey = apiKey
|
||||||
|
return selected, nil
|
||||||
}
|
}
|
||||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return SelectedKey{}, err
|
return SelectedKey{}, err
|
||||||
@@ -70,8 +73,11 @@ func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
|||||||
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
|
return SelectedKey{Provider: "openai", APIKey: g.systemKey, KeyType: "system"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error {
|
func (g *Gateway) RecordCall(database *gorm.DB, userID uint, provider string, usedKeyType string, action string, status string, errText string) error {
|
||||||
return models.DBService.Create(&models.SenlinAgentAICallLog{
|
if database == nil {
|
||||||
|
database = models.DBService
|
||||||
|
}
|
||||||
|
return database.Create(&models.SenlinAgentAICallLog{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
UsedKeyType: usedKeyType,
|
UsedKeyType: usedKeyType,
|
||||||
@@ -81,17 +87,34 @@ func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, a
|
|||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
// ReserveRateLimit 以数据库单条 UPSERT 原子占用固定窗口配额。
|
||||||
|
// 配额在 provider/key 选择前占用,后续缺 key 或 provider 失败同样计入该窗口的尝试次数。
|
||||||
|
func (g *Gateway) ReserveRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var count int64
|
currentTime := time.Now().UTC()
|
||||||
if err := models.DBService.Model(&models.SenlinAgentAICallLog{}).
|
if g.now != nil {
|
||||||
Where("user_id = ? AND action = ? AND created_at >= ?", userID, action, time.Now().Add(-window)).
|
currentTime = g.now().UTC()
|
||||||
Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if count >= int64(limit) {
|
windowStart := currentTime.Truncate(window)
|
||||||
|
bucket := models.SenlinAgentAIRateBucket{
|
||||||
|
UserID: userID, Action: action, WindowStart: windowStart, Count: 1,
|
||||||
|
}
|
||||||
|
result := models.DBService.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "action"}, {Name: "window_start"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]any{
|
||||||
|
"count": gorm.Expr("senlin_agent_ai_rate_buckets.count + 1"),
|
||||||
|
"updated_at": currentTime,
|
||||||
|
}),
|
||||||
|
Where: clause.Where{Exprs: []clause.Expression{
|
||||||
|
clause.Lt{Column: clause.Column{Table: "senlin_agent_ai_rate_buckets", Name: "count"}, Value: limit},
|
||||||
|
}},
|
||||||
|
}).Create(&bucket)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
return ErrAIRateLimited
|
return ErrAIRateLimited
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
|
|||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
gateway := NewGateway("system-key")
|
gateway := NewGateway("system-key")
|
||||||
|
|
||||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "failed", "rate limited"))
|
require.NoError(t, gateway.RecordCall(database, 3, "openai", "system", "inbox_analyze", "failed", "rate limited"))
|
||||||
|
|
||||||
var log models.SenlinAgentAICallLog
|
var log models.SenlinAgentAICallLog
|
||||||
require.NoError(t, database.First(&log).Error)
|
require.NoError(t, database.First(&log).Error)
|
||||||
@@ -75,12 +75,13 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
|
|||||||
require.Equal(t, "rate limited", log.Error)
|
require.Equal(t, "rate limited", log.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
func TestReserveRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||||
newTestDB(t)
|
database := newTestDB(t)
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentUser{Email: "rate@example.com", DisplayName: "Rate", PasswordHash: "hash"}).Error)
|
||||||
gateway := NewGateway("system-key")
|
gateway := NewGateway("system-key")
|
||||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "succeeded", ""))
|
require.NoError(t, gateway.ReserveRateLimit(1, "inbox_analyze", 1, time.Hour))
|
||||||
|
|
||||||
err := gateway.CheckRateLimit(3, "inbox_analyze", 1, time.Hour)
|
err := gateway.ReserveRateLimit(1, "inbox_analyze", 1, time.Hour)
|
||||||
|
|
||||||
require.ErrorContains(t, err, "ai rate limit exceeded")
|
require.ErrorContains(t, err, "ai rate limit exceeded")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ai
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -73,7 +74,7 @@ func TestCreateAISessionReturnsRateLimitBeforeMissingKeyAndAuditsFailure(t *test
|
|||||||
project := createAIHandlerProject(t, database, owner.ID, "LIMITED")
|
project := createAIHandlerProject(t, database, owner.ID, "LIMITED")
|
||||||
gateway := NewGatewayWithSecret("", "test-encryption-secret")
|
gateway := NewGatewayWithSecret("", "test-encryption-secret")
|
||||||
for range aiSessionCreateLimit {
|
for range aiSessionCreateLimit {
|
||||||
require.NoError(t, gateway.RecordCall(owner.ID, "openai", "system", "ai_session_create", "ready", ""))
|
require.NoError(t, gateway.ReserveRateLimit(owner.ID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour))
|
||||||
}
|
}
|
||||||
router := aiHandlerTestRouter(owner.ID, gateway)
|
router := aiHandlerTestRouter(owner.ID, gateway)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
@@ -130,6 +131,69 @@ func TestCreateAISessionWithoutKeyReturnsAuditedErrorAndCreatesNoFormalObjects(t
|
|||||||
require.Equal(t, "ai_session_create", call.Action)
|
require.Equal(t, "ai_session_create", call.Action)
|
||||||
require.Equal(t, "failed", call.Status)
|
require.Equal(t, "failed", call.Status)
|
||||||
require.Equal(t, "ai_key_missing", call.Error)
|
require.Equal(t, "ai_key_missing", call.Error)
|
||||||
|
var bucket models.SenlinAgentAIRateBucket
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", owner.ID, aiSessionCreateAction).First(&bucket).Error)
|
||||||
|
require.Equal(t, 1, bucket.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAISessionRollsBackSessionWhenReadyAuditWriteFails(t *testing.T) {
|
||||||
|
database := newAIHandlerTestDB(t)
|
||||||
|
owner := createAIHandlerUser(t, database, "audit-failure@example.com")
|
||||||
|
project := createAIHandlerProject(t, database, owner.ID, "AUDIT_FAILURE")
|
||||||
|
injectedError := errors.New("injected ready audit failure")
|
||||||
|
callbackName := "test:fail_ready_ai_audit"
|
||||||
|
require.NoError(t, database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) {
|
||||||
|
call, ok := tx.Statement.Dest.(*models.SenlinAgentAICallLog)
|
||||||
|
if ok && call.Status == defaultSessionStatus {
|
||||||
|
tx.AddError(injectedError)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() { database.Callback().Create().Remove(callbackName) })
|
||||||
|
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||||
|
"title": "必须回滚", "context": "成功审计失败时不能残留会话",
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||||
|
var sessionCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", project.ID).Count(&sessionCount).Error)
|
||||||
|
require.Zero(t, sessionCount)
|
||||||
|
var calls []models.SenlinAgentAICallLog
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", owner.ID, aiSessionCreateAction).Find(&calls).Error)
|
||||||
|
require.Len(t, calls, 1)
|
||||||
|
require.Equal(t, "openai", calls[0].Provider)
|
||||||
|
require.Equal(t, "system", calls[0].UsedKeyType)
|
||||||
|
require.Equal(t, "failed", calls[0].Status)
|
||||||
|
require.Equal(t, "audit_write_failed", calls[0].Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAISessionAuditsKnownProviderMetadataWhenUserKeyDecryptFails(t *testing.T) {
|
||||||
|
database := newAIHandlerTestDB(t)
|
||||||
|
owner := createAIHandlerUser(t, database, "decrypt-failure@example.com")
|
||||||
|
project := createAIHandlerProject(t, database, owner.ID, "DECRYPT_FAILURE")
|
||||||
|
require.NoError(t, database.Create(&models.SenlinAgentAIKey{
|
||||||
|
UserID: owner.ID, Provider: "deepseek", EncryptedAPIKey: "v1:not-valid-base64",
|
||||||
|
}).Error)
|
||||||
|
router := aiHandlerTestRouter(owner.ID, NewGatewayWithSecret("system-key", "test-encryption-secret"))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, authenticatedAIRequest(t, http.MethodPost, "/api/v1/projects/"+project.Identity+"/ai-sessions", map[string]any{
|
||||||
|
"title": "解密失败", "context": "审计不得丢失已知元数据",
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||||
|
var call models.SenlinAgentAICallLog
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", owner.ID, aiSessionCreateAction).First(&call).Error)
|
||||||
|
require.Equal(t, "deepseek", call.Provider)
|
||||||
|
require.Equal(t, "user", call.UsedKeyType)
|
||||||
|
require.Equal(t, "failed", call.Status)
|
||||||
|
require.Equal(t, "provider_selection_failed", call.Error)
|
||||||
|
require.NotContains(t, call.Error, "not-valid-base64")
|
||||||
|
var sessionCount int64
|
||||||
|
require.NoError(t, database.Model(&models.SenlinAgentAISession{}).Count(&sessionCount).Error)
|
||||||
|
require.Zero(t, sessionCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObjectIDs(t *testing.T) {
|
func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObjectIDs(t *testing.T) {
|
||||||
@@ -205,7 +269,7 @@ type recordingSessionGateway struct {
|
|||||||
selectErr error
|
selectErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *recordingSessionGateway) CheckRateLimit(uint, string, int, time.Duration) error {
|
func (g *recordingSessionGateway) ReserveRateLimit(uint, string, int, time.Duration) error {
|
||||||
g.steps = append(g.steps, "rate")
|
g.steps = append(g.steps, "rate")
|
||||||
return g.rateErr
|
return g.rateErr
|
||||||
}
|
}
|
||||||
@@ -215,7 +279,7 @@ func (g *recordingSessionGateway) SelectKey(uint) (SelectedKey, error) {
|
|||||||
return g.selected, g.selectErr
|
return g.selected, g.selectErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *recordingSessionGateway) RecordCall(uint, string, string, string, string, string) error {
|
func (g *recordingSessionGateway) RecordCall(*gorm.DB, uint, string, string, string, string, string) error {
|
||||||
g.steps = append(g.steps, "record")
|
g.steps = append(g.steps, "record")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
72
backend/internal/logic/ai/rate_limit_postgres_test.go
Normal file
72
backend/internal/logic/ai/rate_limit_postgres_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresReserveRateLimitIsAtomicAcrossConcurrentConnections(t *testing.T) {
|
||||||
|
dsn := os.Getenv("DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("DATABASE_URL is not configured; skipping PostgreSQL AI rate reservation test")
|
||||||
|
}
|
||||||
|
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true, Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
suffix := fmt.Sprint(time.Now().UnixNano())
|
||||||
|
user := createAIRateTestUser(t, database, "postgres-rate-"+suffix+"@example.com")
|
||||||
|
action := "postgres_concurrent_" + suffix
|
||||||
|
t.Cleanup(func() {
|
||||||
|
database.Where("user_id = ?", user.ID).Delete(&models.SenlinAgentAIRateBucket{})
|
||||||
|
database.Delete(&user)
|
||||||
|
})
|
||||||
|
gateway := NewGatewayWithSecret("system-key", "test-encryption-secret")
|
||||||
|
const (
|
||||||
|
limit = 9
|
||||||
|
attempts = 48
|
||||||
|
)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, attempts)
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
for range attempts {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
<-start
|
||||||
|
results <- gateway.ReserveRateLimit(user.ID, action, limit, time.Hour)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wait.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
allowed := 0
|
||||||
|
limited := 0
|
||||||
|
for err := range results {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
allowed++
|
||||||
|
case errors.Is(err, ErrAIRateLimited):
|
||||||
|
limited++
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, limit, allowed)
|
||||||
|
require.Equal(t, attempts-limit, limited)
|
||||||
|
var bucket models.SenlinAgentAIRateBucket
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", user.ID, action).First(&bucket).Error)
|
||||||
|
require.Equal(t, limit, bucket.Count)
|
||||||
|
}
|
||||||
101
backend/internal/logic/ai/rate_limit_test.go
Normal file
101
backend/internal/logic/ai/rate_limit_test.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
"senlinai-agent/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReserveRateLimitIsAtomicUnderConcurrentSQLiteRequests(t *testing.T) {
|
||||||
|
database := newConcurrentAIRateTestDB(t)
|
||||||
|
user := createAIRateTestUser(t, database, "sqlite-rate@example.com")
|
||||||
|
gateway := NewGatewayWithSecret("system-key", "test-encryption-secret")
|
||||||
|
const (
|
||||||
|
limit = 7
|
||||||
|
attempts = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, attempts)
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
for range attempts {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
<-start
|
||||||
|
results <- gateway.ReserveRateLimit(user.ID, "concurrent_session_create", limit, time.Hour)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wait.Wait()
|
||||||
|
close(results)
|
||||||
|
|
||||||
|
allowed := 0
|
||||||
|
limited := 0
|
||||||
|
for err := range results {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
allowed++
|
||||||
|
case errors.Is(err, ErrAIRateLimited):
|
||||||
|
limited++
|
||||||
|
default:
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, limit, allowed)
|
||||||
|
require.Equal(t, attempts-limit, limited)
|
||||||
|
var buckets []models.SenlinAgentAIRateBucket
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", user.ID, "concurrent_session_create").Find(&buckets).Error)
|
||||||
|
require.Len(t, buckets, 1)
|
||||||
|
require.Equal(t, limit, buckets[0].Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveRateLimitUsesFixedWindowsAndCountsFailedAttempts(t *testing.T) {
|
||||||
|
database := newConcurrentAIRateTestDB(t)
|
||||||
|
user := createAIRateTestUser(t, database, "window-rate@example.com")
|
||||||
|
gateway := NewGatewayWithSecret("system-key", "test-encryption-secret")
|
||||||
|
current := time.Date(2026, 7, 21, 10, 15, 0, 0, time.UTC)
|
||||||
|
gateway.now = func() time.Time { return current }
|
||||||
|
|
||||||
|
require.NoError(t, gateway.ReserveRateLimit(user.ID, "windowed_session_create", 2, time.Hour))
|
||||||
|
require.NoError(t, gateway.ReserveRateLimit(user.ID, "windowed_session_create", 2, time.Hour))
|
||||||
|
require.ErrorIs(t, gateway.ReserveRateLimit(user.ID, "windowed_session_create", 2, time.Hour), ErrAIRateLimited)
|
||||||
|
|
||||||
|
current = current.Add(time.Hour)
|
||||||
|
require.NoError(t, gateway.ReserveRateLimit(user.ID, "windowed_session_create", 2, time.Hour))
|
||||||
|
var buckets []models.SenlinAgentAIRateBucket
|
||||||
|
require.NoError(t, database.Where("user_id = ? AND action = ?", user.ID, "windowed_session_create").Order("window_start asc").Find(&buckets).Error)
|
||||||
|
require.Len(t, buckets, 2)
|
||||||
|
require.Equal(t, []int{2, 1}, []int{buckets[0].Count, buckets[1].Count})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConcurrentAIRateTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
databasePath := filepath.ToSlash(filepath.Join(t.TempDir(), "ai-rate.db"))
|
||||||
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)", databasePath)
|
||||||
|
database, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
require.NoError(t, err)
|
||||||
|
sqlDatabase, err := database.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, sqlDatabase.Close()) })
|
||||||
|
sqlDatabase.SetMaxOpenConns(20)
|
||||||
|
require.NoError(t, models.AutoMigrate(database))
|
||||||
|
models.DBService = database
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAIRateTestUser(t *testing.T, database *gorm.DB, email string) models.SenlinAgentUser {
|
||||||
|
t.Helper()
|
||||||
|
user := models.SenlinAgentUser{Email: email, DisplayName: email, PasswordHash: "hash"}
|
||||||
|
require.NoError(t, database.Create(&user).Error)
|
||||||
|
return user
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
"senlinai-agent/backend/internal/logic/projects"
|
"senlinai-agent/backend/internal/logic/projects"
|
||||||
"senlinai-agent/backend/internal/models"
|
"senlinai-agent/backend/internal/models"
|
||||||
)
|
)
|
||||||
@@ -21,9 +22,9 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type sessionGateway interface {
|
type sessionGateway interface {
|
||||||
CheckRateLimit(userID uint, action string, limit int, window time.Duration) error
|
ReserveRateLimit(userID uint, action string, limit int, window time.Duration) error
|
||||||
SelectKey(userID uint) (SelectedKey, error)
|
SelectKey(userID uint) (SelectedKey, error)
|
||||||
RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error
|
RecordCall(database *gorm.DB, userID uint, provider string, usedKeyType string, action string, status string, errText string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionService 只管理项目内普通 AI 会话;它不会把上下文自动转换为任务、笔记或资料。
|
// SessionService 只管理项目内普通 AI 会话;它不会把上下文自动转换为任务、笔记或资料。
|
||||||
@@ -66,14 +67,14 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
|||||||
return nil, errors.New("ai gateway is required")
|
return nil, errors.New("ai gateway is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.gateway.CheckRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
|
if err := s.gateway.ReserveRateLimit(userID, aiSessionCreateAction, aiSessionCreateLimit, time.Hour); err != nil {
|
||||||
if errors.Is(err, ErrAIRateLimited) {
|
if errors.Is(err, ErrAIRateLimited) {
|
||||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "ai_rate_limited"); auditErr != nil {
|
if auditErr := s.gateway.RecordCall(models.DBService, userID, "none", "none", aiSessionCreateAction, "failed", "ai_rate_limited"); auditErr != nil {
|
||||||
return nil, fmt.Errorf("record ai rate limit failure: %w", auditErr)
|
return nil, fmt.Errorf("record ai rate limit failure: %w", auditErr)
|
||||||
}
|
}
|
||||||
return nil, ErrAIRateLimited
|
return nil, ErrAIRateLimited
|
||||||
}
|
}
|
||||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", "rate_limit_check_failed"); auditErr != nil {
|
if auditErr := s.gateway.RecordCall(models.DBService, userID, "none", "none", aiSessionCreateAction, "failed", "rate_limit_reservation_failed"); auditErr != nil {
|
||||||
return nil, fmt.Errorf("check rate limit: %v; record failure: %w", err, auditErr)
|
return nil, fmt.Errorf("check rate limit: %v; record failure: %w", err, auditErr)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -85,7 +86,8 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
|||||||
if errors.Is(err, ErrAIKeyMissing) {
|
if errors.Is(err, ErrAIKeyMissing) {
|
||||||
code = "ai_key_missing"
|
code = "ai_key_missing"
|
||||||
}
|
}
|
||||||
if auditErr := s.gateway.RecordCall(userID, "none", "none", aiSessionCreateAction, "failed", code); auditErr != nil {
|
provider, keyType := selectedAuditMetadata(selected)
|
||||||
|
if auditErr := s.gateway.RecordCall(models.DBService, userID, provider, keyType, aiSessionCreateAction, "failed", code); auditErr != nil {
|
||||||
return nil, fmt.Errorf("select ai key: %v; record failure: %w", err, auditErr)
|
return nil, fmt.Errorf("select ai key: %v; record failure: %w", err, auditErr)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -98,19 +100,40 @@ func (s *SessionService) Create(userID uint, projectIdentity, title, context str
|
|||||||
Context: context,
|
Context: context,
|
||||||
Status: defaultSessionStatus,
|
Status: defaultSessionStatus,
|
||||||
}
|
}
|
||||||
if err := models.DBService.Create(&session).Error; err != nil {
|
failureCode := "session_create_failed"
|
||||||
if auditErr := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, "failed", "session_create_failed"); auditErr != nil {
|
err = models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||||
return nil, fmt.Errorf("create ai session: %v; record failure: %w", err, auditErr)
|
if err := tx.Create(&session).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// ready 只表示会话入口已建立,不表示 provider 已回复或任何业务对象已创建。
|
||||||
|
failureCode = "audit_write_failed"
|
||||||
|
if err := s.gateway.RecordCall(tx, userID, selected.Provider, selected.KeyType, aiSessionCreateAction, defaultSessionStatus, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
failureCode = "session_transaction_failed"
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if auditErr := s.gateway.RecordCall(models.DBService, userID, selected.Provider, selected.KeyType, aiSessionCreateAction, "failed", failureCode); auditErr != nil {
|
||||||
|
return nil, fmt.Errorf("create ai session transaction: %v; record failure: %w", err, auditErr)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// ready 只表示会话入口已建立,不表示 provider 已回复或任何业务对象已创建。
|
|
||||||
if err := s.gateway.RecordCall(userID, selected.Provider, selected.KeyType, aiSessionCreateAction, defaultSessionStatus, ""); err != nil {
|
|
||||||
return nil, fmt.Errorf("record ai session creation: %w", err)
|
|
||||||
}
|
|
||||||
return &session, nil
|
return &session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectedAuditMetadata(selected SelectedKey) (string, string) {
|
||||||
|
provider := strings.TrimSpace(selected.Provider)
|
||||||
|
keyType := strings.TrimSpace(selected.KeyType)
|
||||||
|
if provider == "" {
|
||||||
|
provider = "none"
|
||||||
|
}
|
||||||
|
if keyType == "" {
|
||||||
|
keyType = "none"
|
||||||
|
}
|
||||||
|
return provider, keyType
|
||||||
|
}
|
||||||
|
|
||||||
func aiSessionStatus(session models.SenlinAgentAISession) string {
|
func aiSessionStatus(session models.SenlinAgentAISession) string {
|
||||||
if status := strings.TrimSpace(session.Status); status != "" {
|
if status := strings.TrimSpace(session.Status); status != "" {
|
||||||
return status
|
return status
|
||||||
|
|||||||
19
backend/internal/models/ai_rate_bucket.go
Normal file
19
backend/internal/models/ai_rate_bucket.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// SenlinAgentAIRateBucket 保存用户在固定窗口内已占用的 AI 请求配额。
|
||||||
|
// 复合唯一键让单条 UPSERT 在数据库层完成跨进程并发仲裁。
|
||||||
|
type SenlinAgentAIRateBucket struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
UserID uint `gorm:"not null;uniqueIndex:uidx_senlin_agent_ai_rate_bucket,priority:1"`
|
||||||
|
Action string `gorm:"size:100;not null;uniqueIndex:uidx_senlin_agent_ai_rate_bucket,priority:2"`
|
||||||
|
WindowStart time.Time `gorm:"not null;uniqueIndex:uidx_senlin_agent_ai_rate_bucket,priority:3"`
|
||||||
|
Count int `gorm:"not null"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SenlinAgentAIRateBucket) TableName() string {
|
||||||
|
return "senlin_agent_ai_rate_buckets"
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ func AutoMigrate(database *gorm.DB) error {
|
|||||||
&SenlinAgentProjectEvent{},
|
&SenlinAgentProjectEvent{},
|
||||||
&SenlinAgentAIKey{},
|
&SenlinAgentAIKey{},
|
||||||
&SenlinAgentAICallLog{},
|
&SenlinAgentAICallLog{},
|
||||||
|
&SenlinAgentAIRateBucket{},
|
||||||
&SenlinAgentTaskShare{},
|
&SenlinAgentTaskShare{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user