feat: AI 助手模型管理(CRUD、会话绑定、自动化验收)

- Flyway V6:模型表、默认种子、ai_chat_session.model_id 回填
- 管理端 /api/admin/ai/models;用户端选模与新会话绑定
- 禁用模型后会话只读(5009);chat 按会话 modelCode 调 DashScope
- 前端 AiModels 页、助手模型选择器与 localStorage
- 单测、AiModelManagementApiIT、Playwright E2E;e2e Profile 关闭注解限流
- 技术实施文档与验收报告;移除过期冲突分析文档

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nojava
2026-05-24 03:36:31 +08:00
parent 9cf811d8c1
commit 6c8b31e38a
58 changed files with 2693 additions and 556 deletions
+230
View File
@@ -0,0 +1,230 @@
import { test, expect } from '@playwright/test'
import {
IT_MODEL_CODE,
IT_DISPLAY_NAME,
ADMIN_ACCOUNT,
ADMIN_PASSWORD,
USER_PASSWORD,
} from './constants.js'
async function seedAuth(page, loginData, role) {
await page.goto('/')
await page.evaluate(
([data, userRole]) => {
localStorage.setItem('access_token', data.accessToken)
localStorage.setItem('refresh_token', data.refreshToken)
localStorage.setItem(
'user_info',
JSON.stringify({
id: data.userId,
email: data.email,
nickname: data.nickname,
avatar: data.avatar,
type: data.type,
role: userRole,
}),
)
},
[loginData, role],
)
}
async function loginAsAdmin(page, request) {
const res = await request.post('/api/public/admin/login', {
data: { email: ADMIN_ACCOUNT, password: ADMIN_PASSWORD },
})
expect(res.ok()).toBeTruthy()
const json = await res.json()
expect(json.code).toBe(200)
await seedAuth(page, json.data, 'ADMIN')
return json.data.accessToken
}
async function deleteModelByCodeIfExists(request, adminToken, modelCode) {
const listRes = await request.get('/api/admin/ai/models', {
headers: { Authorization: `Bearer ${adminToken}` },
})
const list = await listRes.json()
if (list.code !== 200) return
for (const model of list.data ?? []) {
if (model.modelCode === modelCode && !model.isDefault) {
await request.delete(`/api/admin/ai/models/${model.id}`, {
headers: { Authorization: `Bearer ${adminToken}` },
})
}
}
}
async function registerUser(request, email) {
const res = await request.post('/api/public/user/register', {
data: { email, password: USER_PASSWORD, nickname: 'E2E测试用户' },
})
expect(res.ok()).toBeTruthy()
const json = await res.json()
expect(json.code).toBe(200)
}
async function loginAsUser(page, request, email) {
const res = await request.post('/api/public/user/login', {
data: { email, password: USER_PASSWORD, deviceFingerprint: 'e2e' },
})
expect(res.ok()).toBeTruthy()
const json = await res.json()
expect(json.code).toBe(200)
await seedAuth(page, json.data, 'USER')
}
const MOCK_REPLY = '浏览器联调:这是 Mock 的 AI 回复。'
/** Mock chat 与发送后的 messages 重载(前端 send 后会再次 GET messages)。 */
async function mockAiChatRoutes(page) {
const mockedSessions = new Map()
await page.route('**/api/app/ai/chat', async (route) => {
const body = route.request().postDataJSON()
const sessionId = body?.sessionId
const userMessage = body?.message ?? ''
mockedSessions.set(sessionId, [
{ id: 1, role: 'user', content: userMessage },
{ id: 2, role: 'assistant', content: MOCK_REPLY },
])
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
sessionId,
reply: MOCK_REPLY,
roundCount: 1,
maxRounds: 5,
model: IT_MODEL_CODE,
modelDisplayName: IT_DISPLAY_NAME,
costMillis: 10,
},
timestamp: Date.now(),
}),
})
})
await page.route(/\/api\/app\/ai\/sessions\/\d+\/messages(\?.*)?$/, async (route) => {
if (route.request().method() !== 'GET') {
await route.continue()
return
}
const sessionId = Number(route.request().url().match(/sessions\/(\d+)/)[1])
const messages = mockedSessions.get(sessionId)
if (!messages) {
await route.continue()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 200,
message: 'success',
data: {
messages,
roundCount: 1,
modelEnabled: true,
readOnlyReason: null,
},
timestamp: Date.now(),
}),
})
})
}
test.describe.configure({ mode: 'serial' })
test.describe('AI 模型管理浏览器联调', () => {
const userEmail = `e2e-${Date.now()}@test.local`
test('TC-AI-MODEL-E2E-18: 管理端上架模型 → 助手页选择并新会话对话', async ({ page, request }) => {
await mockAiChatRoutes(page)
const adminToken = await loginAsAdmin(page, request)
await deleteModelByCodeIfExists(request, adminToken, IT_MODEL_CODE)
await page.goto('/admin/ai-models')
await expect(page.getByRole('heading', { name: 'AI 模型管理' })).toBeVisible()
await expect(page.locator('.el-table')).toBeVisible({ timeout: 15_000 })
const existingRow = page.locator('tr', { hasText: IT_MODEL_CODE })
if (await existingRow.count() === 0) {
await page.getByRole('button', { name: '新增模型' }).click()
const dialog = page.getByRole('dialog', { name: '新增模型' })
await dialog.getByPlaceholder('如:通义 Turbo · 快速').fill(IT_DISPLAY_NAME)
await dialog.getByPlaceholder('如:qwen-turbo').fill(IT_MODEL_CODE)
const [createResp] = await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/admin/ai/models') && r.request().method() === 'POST',
),
dialog.getByRole('button', { name: '确定' }).click(),
])
expect(createResp.ok()).toBeTruthy()
const createBody = await createResp.json()
expect(createBody?.code).toBe(200)
await expect(dialog).toBeHidden({ timeout: 10_000 })
}
await expect(page.locator('tr', { hasText: IT_MODEL_CODE })).toBeVisible({ timeout: 10_000 })
await registerUser(request, userEmail)
await loginAsUser(page, request, userEmail)
await page.goto('/ai-assistant')
await expect(page.getByText('我是 Jog AI 助手')).toBeVisible({ timeout: 15_000 })
const modelSelect = page.locator('.model-select')
await expect(modelSelect).toBeVisible()
await modelSelect.click()
await page.getByRole('option', { name: IT_DISPLAY_NAME }).click()
await Promise.all([
page.waitForResponse(
(r) => r.url().includes('/api/app/ai/sessions') && r.request().method() === 'POST',
),
page.getByRole('button', { name: '新会话' }).click(),
])
const question = '浏览器联调:一句话介绍 REST。'
const input = page.locator('.input-area textarea')
await input.click()
await input.fill(question)
await expect(page.getByRole('button', { name: '发送' })).toBeEnabled()
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/app/ai/chat')),
page.getByRole('button', { name: '发送' }).click(),
])
await expect(page.getByText(MOCK_REPLY)).toBeVisible({ timeout: 30_000 })
})
test('TC-AI-MODEL-E2E-19: 禁用模型后旧会话只读', async ({ page, context, request }) => {
await loginAsAdmin(page, request)
await page.goto('/admin/ai-models')
const row = page.locator('tr', { hasText: IT_MODEL_CODE })
await expect(row).toBeVisible()
await row.getByRole('button', { name: '编辑' }).click()
const dialog = page.getByRole('dialog', { name: '编辑模型' })
const enabledSwitch = dialog.locator('.el-form-item', { hasText: '启用' }).locator('.el-switch')
const isChecked = await enabledSwitch.getAttribute('class')
if (isChecked?.includes('is-checked')) {
await enabledSwitch.click()
}
await dialog.getByRole('button', { name: '确定' }).click()
await expect(row.getByText('禁用')).toBeVisible({ timeout: 10_000 })
const userPage = await context.newPage()
await loginAsUser(userPage, request, userEmail)
await userPage.goto('/ai-assistant')
const sessionItem = userPage.locator('.session-item').first()
await expect(sessionItem).toBeVisible({ timeout: 15_000 })
await sessionItem.click()
await expect(userPage.getByText('该会话所用模型已停用')).toBeVisible({ timeout: 15_000 })
await expect(userPage.getByPlaceholder('请输入您的问题…')).toBeDisabled()
})
})
+5
View File
@@ -0,0 +1,5 @@
export const IT_MODEL_CODE = 'qwen3-vl-32b-thinking'
export const IT_DISPLAY_NAME = 'Qwen3 VL 32B Thinking'
export const ADMIN_ACCOUNT = 'admin'
export const ADMIN_PASSWORD = 'admin@123'
export const USER_PASSWORD = 'User@Test1'