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:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user