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
+4
View File
@@ -0,0 +1,4 @@
node_modules/
test-results/
playwright-report/
blob-report/
+62
View File
@@ -0,0 +1,62 @@
# Jog E2EPlaywright
## 前置条件
1. 线上 MySQL 已建库并授权(推荐隔离库):
```sql
CREATE DATABASE IF NOT EXISTS jog_test CHARACTER SET utf8mb4;
GRANT ALL PRIVILEGES ON jog_test.* TO 'jog'@'%';
FLUSH PRIVILEGES;
```
2. 启动测试前设置(可选):
```powershell
$env:MYSQL_DATABASE = "jog_test"
$env:DASHSCOPE_API_KEY = "你的百炼Key" # 浏览器 chat 已 Mock,可不设
```
若暂未建 `jog_test`,默认使用 `jog` 库(`application-e2e.yml`)。
3. 管理员密码:应用启动后会被重置为 `admin@123`(见 `DatabaseInitializationVerifier`)。
4. **首次或改过后端代码**:在仓库根目录执行 `mvn -pl jog-admin -am install -Dmaven.frontend.skip=true -DskipTests`,否则 `spring-boot:run` 可能加载 `.m2` 中旧版 `jog-module-blog`(表现为 `/api/admin/ai/models` 404)。
## 测试模型
- 模型编码:`qwen3-vl-32b-thinking`
- 展示名称:`Qwen3 VL 32B Thinking`
## 运行
```bash
cd e2e
npm install
npm run install:browsers
npm test
```
Playwright 会自动执行(首次较慢):
1. `mvn -pl jog-admin -am install …` + `mvn -pl jog-admin spring-boot:run -Dspring-boot.run.profiles=e2e`
2. `npm run dev`jog-frontend
本地已手动启动 8080/5173 时,会复用现有进程(`reuseExistingServer`,勿设置 `CI=true`)。
## 用例
| 编号 | 文件 | 说明 |
| :--- | :--- | :--- |
| TC-AI-MODEL-E2E-18 | `tests/ai-model-management.spec.js` | 管理端 UI 上架模型 → 助手选模型、新会话、Mock 对话 |
| TC-AI-MODEL-E2E-19 | 同上 | 禁用模型 → 旧会话只读 |
`e2e` Profile 下 `jog.rate-limit.enabled=false`,避免注册/登录全局限流影响联调。
## API 集成测试(Maven
```bash
mvn test -pl jog-admin -am -Dmaven.frontend.skip=true -Dtest=AiModelManagementApiIT
```
Docker 可用时也可使用 Testcontainers`application-test` + `IntegrationTestContainers`)。
+76
View File
@@ -0,0 +1,76 @@
{
"name": "jog-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jog-e2e",
"devDependencies": {
"@playwright/test": "^1.49.1"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "jog-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"install:browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.49.1"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const isWin = process.platform === 'win32'
const mvn = isWin ? 'mvn.cmd' : 'mvn'
const backendStart = isWin
? `cmd /c "${mvn} -pl jog-admin -am install -Dmaven.frontend.skip=true -DskipTests && ${mvn} -pl jog-admin spring-boot:run -Dmaven.frontend.skip=true -Dspring-boot.run.profiles=e2e"`
: `${mvn} -pl jog-admin -am install -Dmaven.frontend.skip=true -DskipTests && ${mvn} -pl jog-admin spring-boot:run -Dmaven.frontend.skip=true -Dspring-boot.run.profiles=e2e`
export default defineConfig({
testDir: './tests',
timeout: 120_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: [
{
command: backendStart,
url: 'http://localhost:8080/api/public/captcha',
reuseExistingServer: !process.env.CI,
timeout: 300_000,
cwd: repoRoot,
},
{
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
cwd: path.join(repoRoot, 'jog-frontend'),
},
],
})
+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'