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
+15
View File
@@ -61,6 +61,21 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -0,0 +1,32 @@
# E2E / 浏览器联调:线上 MySQL + Redis(与 application.yml 同主机)
# 默认库名 jog_test;若无权限可先设环境变量 MYSQL_DATABASE=jog
spring:
datasource:
url: jdbc:mysql://${MYSQL_HOST:81.70.204.4}:3306/${MYSQL_DATABASE:jog}?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
username: ${MYSQL_USERNAME:jog}
password: ${MYSQL_PASSWORD:Jg.123459}
data:
redis:
host: ${REDIS_HOST:81.70.204.4}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Rd.123459}
database: ${REDIS_DATABASE:0}
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
dashscope:
api-key: ${DASHSCOPE_API_KEY:integration-test-key}
model: ${DASHSCOPE_MODEL:qwen-turbo}
timeout: 60000
rate-limit-per-minute: 100
logging:
level:
fun.nojava: INFO
org.springframework.security: WARN
jog:
rate-limit:
enabled: false
@@ -0,0 +1,31 @@
-- V6__ai_assistant_model.sql
-- AI 助手可配置模型 + 会话绑定
CREATE TABLE ai_assistant_model (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
model_code VARCHAR(64) NOT NULL COMMENT 'DashScope model 参数',
display_name VARCHAR(32) NOT NULL,
description VARCHAR(200) NULL,
enabled TINYINT NOT NULL DEFAULT 1,
allow_image TINYINT NOT NULL DEFAULT 0,
allow_voice TINYINT NOT NULL DEFAULT 0,
is_default TINYINT NOT NULL DEFAULT 0,
sort INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_enabled_sort (enabled, sort, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO ai_assistant_model (
model_code, display_name, description, enabled, allow_image, allow_voice, is_default, sort
) VALUES (
'qwen-turbo', '默认模型', '系统迁移种子模型', 1, 0, 0, 1, 0
);
ALTER TABLE ai_chat_session
ADD COLUMN model_id BIGINT NULL COMMENT '绑定 ai_assistant_model.id' AFTER user_id;
UPDATE ai_chat_session
SET model_id = (SELECT id FROM ai_assistant_model WHERE is_default = 1 AND deleted = 0 LIMIT 1)
WHERE model_id IS NULL;
@@ -0,0 +1,287 @@
package fun.nojava.admin.integration;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import fun.nojava.admin.JogApplication;
import fun.nojava.module.blog.service.impl.StubAiSummaryServiceImpl;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* AI 助手模型管理 API 集成测试(待办 18、19)。
* <p>
* 使用 {@code e2e} Profile:线上 MySQL {@code jog_test} + 线上 Redis(见 application-e2e.yml)。
*/
@SpringBootTest(classes = JogApplication.class)
@AutoConfigureMockMvc
@ActiveProfiles("e2e")
@Import(IntegrationTestConfig.class)
@Tag("integration")
@Tag("p0")
class AiModelManagementApiIT {
private static final String ADMIN_ACCOUNT = "admin";
/** 启动时 {@link fun.nojava.admin.config.DatabaseInitializationVerifier} 会重置为此密码 */
private static final String ADMIN_PASSWORD = "admin@123";
private static final String TEST_USER_PASSWORD = "User@Test1";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private StubAiSummaryServiceImpl stubAiSummaryService;
private String adminToken;
private String userToken;
private Long createdModelId;
@BeforeEach
void setUp() throws Exception {
stubAiSummaryService.setCallModelHandler(null);
adminToken = loginAdmin();
deleteModelByCodeIfExists(AiModelTestConstants.IT_MODEL_CODE);
}
@AfterEach
void tearDown() throws Exception {
if (createdModelId != null && adminToken != null) {
mockMvc.perform(delete("/api/admin/ai/models/" + createdModelId)
.header("Authorization", bearer(adminToken)))
.andReturn();
}
deleteModelByCodeIfExists(AiModelTestConstants.IT_MODEL_CODE);
}
@Test
@DisplayName("TC-AI-MODEL-IT-18: 管理端上架 qwen3-vl-32b-thinking → 用户新会话绑定 → chat 使用对应 modelCode")
void shouldCreateModelAndChatWithBoundModelCode() throws Exception {
createdModelId = createModel(
AiModelTestConstants.IT_MODEL_CODE,
AiModelTestConstants.IT_DISPLAY_NAME,
true);
userToken = registerAndLoginUser();
Long modelId = findAppModelId();
assertEquals(createdModelId, modelId);
Long sessionId = createSession(userToken, modelId);
MvcResult chatResult = mockMvc.perform(post("/api/app/ai/chat")
.header("Authorization", bearer(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sessionId":%d,"message":"集成测试:用一句话说明 REST 是什么?"}
""".formatted(sessionId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.model").value(AiModelTestConstants.IT_MODEL_CODE))
.andExpect(jsonPath("$.data.modelDisplayName").value(AiModelTestConstants.IT_DISPLAY_NAME))
.andReturn();
JsonNode chatJson = objectMapper.readTree(chatResult.getResponse().getContentAsString());
assertNotNull(chatJson.path("data").path("reply").asText(null));
}
@Test
@DisplayName("TC-AI-MODEL-IT-19: 禁用 qwen3-vl-32b-thinking 后旧会话只读且 chat 返回 AI_MODEL_DISABLED")
void shouldRejectChatWhenModelDisabled() throws Exception {
createdModelId = createModel(
AiModelTestConstants.IT_MODEL_CODE,
AiModelTestConstants.IT_DISPLAY_NAME_DISABLED,
true);
userToken = registerAndLoginUser();
Long sessionId = createSession(userToken, createdModelId);
chatOnce(userToken, sessionId, "集成测试:先发送一条消息建立会话。");
disableModel(createdModelId);
mockMvc.perform(get("/api/app/ai/sessions/" + sessionId + "/messages")
.header("Authorization", bearer(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.modelEnabled").value(false))
.andExpect(jsonPath("$.data.readOnlyReason").value("MODEL_DISABLED"));
MvcResult rejected = mockMvc.perform(post("/api/app/ai/chat")
.header("Authorization", bearer(userToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sessionId":%d,"message":"禁用后不应成功发送"}
""".formatted(sessionId)))
.andExpect(status().isOk())
.andReturn();
JsonNode body = objectMapper.readTree(rejected.getResponse().getContentAsString());
assertEquals(5009, body.path("code").asInt());
}
private void deleteModelByCodeIfExists(String modelCode) throws Exception {
if (adminToken == null) {
return;
}
MvcResult list = mockMvc.perform(get("/api/admin/ai/models")
.header("Authorization", bearer(adminToken)))
.andReturn();
JsonNode body = objectMapper.readTree(list.getResponse().getContentAsString());
if (body.path("code").asInt() != 200) {
return;
}
for (JsonNode row : body.path("data")) {
if (modelCode.equals(row.path("modelCode").asText())) {
long id = row.path("id").asLong();
if (!row.path("isDefault").asBoolean(false)) {
mockMvc.perform(delete("/api/admin/ai/models/" + id)
.header("Authorization", bearer(adminToken)));
}
}
}
}
private String loginAdmin() throws Exception {
MvcResult result = mockMvc.perform(post("/api/public/admin/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"%s","password":"%s","deviceFingerprint":"ai-model-it"}
""".formatted(ADMIN_ACCOUNT, ADMIN_PASSWORD)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.accessToken").exists())
.andReturn();
return extractToken(result);
}
private String registerAndLoginUser() throws Exception {
String email = "ai-it-" + UUID.randomUUID().toString().substring(0, 8) + "@test.local";
mockMvc.perform(post("/api/public/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"%s","password":"%s","nickname":"AI集成测试用户"}
""".formatted(email, TEST_USER_PASSWORD)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
MvcResult result = mockMvc.perform(post("/api/public/user/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"%s","password":"%s","deviceFingerprint":"ai-model-it"}
""".formatted(email, TEST_USER_PASSWORD)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
return extractToken(result);
}
private Long createModel(String modelCode, String displayName, boolean enabled) throws Exception {
MvcResult result = mockMvc.perform(post("/api/admin/ai/models")
.header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"modelCode":"%s",
"displayName":"%s",
"description":"API 集成测试模型",
"enabled":%s,
"allowImageUpload":false,
"allowVoiceUpload":false,
"isDefault":false,
"sort":99
}
""".formatted(modelCode, displayName, enabled)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.id").exists())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString())
.path("data").path("id").asLong();
}
private void disableModel(Long modelId) throws Exception {
mockMvc.perform(put("/api/admin/ai/models/" + modelId)
.header("Authorization", bearer(adminToken))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"modelCode":"%s",
"displayName":"%s",
"enabled":false,
"allowImageUpload":false,
"allowVoiceUpload":false,
"isDefault":false,
"sort":99
}
""".formatted(
AiModelTestConstants.IT_MODEL_CODE,
AiModelTestConstants.IT_DISPLAY_NAME_DISABLED)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
private Long findAppModelId() throws Exception {
MvcResult result = mockMvc.perform(get("/api/app/ai/models")
.header("Authorization", bearer(userToken)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andReturn();
JsonNode models = objectMapper.readTree(result.getResponse().getContentAsString()).path("data");
for (JsonNode model : models) {
if (createdModelId.equals(model.path("id").asLong())) {
return model.path("id").asLong();
}
}
fail("未在用户端模型列表中找到 id=" + createdModelId);
return null;
}
private Long createSession(String token, Long modelId) throws Exception {
MvcResult result = mockMvc.perform(post("/api/app/ai/sessions")
.header("Authorization", bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"modelId\":" + modelId + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.sessionId").exists())
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString())
.path("data").path("sessionId").asLong();
}
private void chatOnce(String token, Long sessionId, String message) throws Exception {
mockMvc.perform(post("/api/app/ai/chat")
.header("Authorization", bearer(token))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sessionId\":" + sessionId + ",\"message\":\"" + message + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
private String extractToken(MvcResult result) throws Exception {
return objectMapper.readTree(result.getResponse().getContentAsString())
.path("data").path("accessToken").asText();
}
private static String bearer(String token) {
return "Bearer " + token;
}
}
@@ -0,0 +1,11 @@
package fun.nojava.admin.integration;
final class AiModelTestConstants {
static final String IT_MODEL_CODE = "qwen3-vl-32b-thinking";
static final String IT_DISPLAY_NAME = "Qwen3 VL 32B Thinking";
static final String IT_DISPLAY_NAME_DISABLED = "Qwen3 VL 32B(禁用联调)";
private AiModelTestConstants() {
}
}
@@ -0,0 +1,20 @@
package fun.nojava.admin.integration;
import fun.nojava.module.blog.config.DashScopeProperties;
import fun.nojava.module.blog.service.impl.StubAiSummaryServiceImpl;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.StringRedisTemplate;
@TestConfiguration
public class IntegrationTestConfig {
@Bean
@Primary
StubAiSummaryServiceImpl stubAiSummaryService(
DashScopeProperties properties,
StringRedisTemplate redisTemplate) {
return new StubAiSummaryServiceImpl(properties, redisTemplate);
}
}
@@ -0,0 +1,44 @@
package fun.nojava.admin.integration;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
/**
* Testcontainers 基类:MySQLjog_test+ Redis,供 API 集成测试复用。
*/
@Testcontainers(disabledWithoutDocker = true)
abstract class IntegrationTestContainers {
private static final DockerImageName MYSQL_IMAGE = DockerImageName.parse("mysql:8.4");
private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7-alpine");
@Container
@SuppressWarnings("resource")
static final MySQLContainer<?> MYSQL = new MySQLContainer<>(MYSQL_IMAGE)
.withDatabaseName("jog_test")
.withUsername("jog")
.withPassword("JogTest.123")
.withReuse(true);
@Container
@SuppressWarnings("resource")
static final GenericContainer<?> REDIS = new GenericContainer<>(REDIS_IMAGE)
.withExposedPorts(6379)
.withReuse(true);
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.data.redis.host", REDIS::getHost);
registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
registry.add("spring.data.redis.password", () -> "");
registry.add("spring.data.redis.database", () -> "0");
}
}
@@ -0,0 +1,38 @@
package fun.nojava.module.blog.service.impl;
import fun.nojava.module.blog.config.DashScopeProperties;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* 集成测试用:拦截 DashScope 调用,避免外网依赖。
*/
public class StubAiSummaryServiceImpl extends AiSummaryServiceImpl {
private Function<Map<String, Object>, Map<String, Object>> callModelHandler = StubAiSummaryServiceImpl::defaultResponse;
public StubAiSummaryServiceImpl(DashScopeProperties properties, StringRedisTemplate redisTemplate) {
super(properties, redisTemplate);
}
public void setCallModelHandler(Function<Map<String, Object>, Map<String, Object>> handler) {
this.callModelHandler = handler == null ? StubAiSummaryServiceImpl::defaultResponse : handler;
}
@Override
protected Map<String, Object> callModel(Map<String, Object> requestBody) {
return callModelHandler.apply(requestBody);
}
private static Map<String, Object> defaultResponse(Map<String, Object> request) {
String model = (String) request.get("model");
return Map.of(
"choices", List.of(Map.of("message", Map.of("content", "集成测试 AI 回复"))),
"model", model == null ? "qwen-turbo" : model,
"usage", Map.of("prompt_tokens", 5, "completion_tokens", 10)
);
}
}
@@ -0,0 +1,28 @@
spring:
flyway:
enabled: true
locations: classpath:db/migration
baseline-on-migrate: true
baseline-version: 0
mail:
host: localhost
port: 1025
username: test
password: test
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
dashscope:
api-key: integration-test-key
model: qwen-turbo
timeout: 10000
max-input: 8000
rate-limit-per-minute: 100
logging:
level:
fun.nojava: INFO
org.springframework.security: WARN
org.testcontainers: INFO
@@ -0,0 +1 @@
junit.platform.discovery.includeClassNamePattern=^.*(Test|Tests|IT)$