AI配置化V0.1

This commit is contained in:
2026-09-28 12:00:23 +08:00
parent dbb0178918
commit f0056441f6
14 changed files with 959 additions and 15 deletions
@@ -1,8 +1,11 @@
package com.accounting.controller;
import com.accounting.config.AiConfig;
import com.accounting.dto.ai.AiConfigRequest;
import com.accounting.dto.ai.AiConfigResponse;
import com.accounting.dto.ai.ChatMessageItem;
import com.accounting.dto.ai.ChatSendRequest;
import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.dto.ai.SessionItem;
import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession;
@@ -11,6 +14,7 @@ import com.accounting.mapper.ChatMessageMapper;
import com.accounting.mapper.ChatSessionMapper;
import com.accounting.mapper.UserMapper;
import com.accounting.service.AiChatService;
import com.accounting.service.AiConfigService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.swagger.v3.oas.annotations.Operation;
@@ -25,6 +29,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -54,6 +59,9 @@ public class AiController {
@Autowired
private AiChatService aiChatService;
@Autowired
private AiConfigService aiConfigService;
@Autowired
private ChatSessionMapper sessionMapper;
@@ -125,7 +133,42 @@ public class AiController {
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@Valid @RequestBody ChatSendRequest request, Authentication authentication) {
Long userId = getUserId(authentication);
return aiChatService.chat(userId, request.getSessionId(), request.getContent());
// 先把选中的配置解析出来再开 SSE —— 配置不存在时要能直接抛错,
// 而不是建了 emitter 之后再往里推一个 error 事件(那会让人以为是模型的问题)
EffectiveAiConfig effective = aiConfigService.resolve(userId, request.getConfigId());
return aiChatService.chat(userId, request.getSessionId(), request.getContent(), effective);
}
// ---------------------------------------------------------------- AI 配置
@Operation(summary = "AI 配置列表(第一项是虚拟的「默认」,值来自服务端 .env)")
@GetMapping("/configs")
public List<AiConfigResponse> configs(Authentication authentication) {
return aiConfigService.list(getUserId(authentication));
}
@Operation(summary = "新增一条 AI 配置")
@PostMapping("/configs")
public AiConfigResponse createConfig(@Valid @RequestBody AiConfigRequest request,
Authentication authentication) {
return aiConfigService.create(getUserId(authentication), request);
}
@Operation(summary = "修改 AI 配置(apiKey 留空表示不改动)")
@PutMapping("/configs/{id}")
public AiConfigResponse updateConfig(@PathVariable Long id,
@Valid @RequestBody AiConfigRequest request,
Authentication authentication) {
return aiConfigService.update(getUserId(authentication), id, request);
}
@Operation(summary = "删除 AI 配置")
@DeleteMapping("/configs/{id}")
public Map<String, Object> deleteConfig(@PathVariable Long id, Authentication authentication) {
aiConfigService.delete(getUserId(authentication), id);
return Map.of("success", true);
}
// ---------------------------------------------------------------- 内部方法
@@ -0,0 +1,29 @@
package com.accounting.dto.ai;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 新建 / 更新 AI 配置的请求。
*
* <p>{@code apiKey} 在**更新**时可以留空,表示保持原值 —— 前端拿到的是掩码,
* 用户只改模型名时不该被迫重新输入 key。</p>
*/
@Data
public class AiConfigRequest {
/** 显示名。留空则用模型名兜底 */
@Size(max = 50, message = "名称最长 50 字")
private String name;
/** 不带 /chat/completions 后缀 */
@Size(max = 255, message = "base url 过长")
private String baseUrl;
/** 更新时留空 = 不改动 */
@Size(max = 255, message = "api key 过长")
private String apiKey;
@Size(max = 100, message = "模型名过长")
private String model;
}
@@ -0,0 +1,36 @@
package com.accounting.dto.ai;
import lombok.Data;
/**
* AI 配置的对外视图。
*
* <p><b>永远不含完整 api key</b>,只有 {@link #apiKeyMasked}。</p>
*/
@Data
public class AiConfigResponse {
/** null = 虚拟的「默认」项(值来自 .env,不是数据库记录) */
private Long id;
private String name;
private String baseUrl;
/** 如 {@code sk-****abcd} */
private String apiKeyMasked;
private String model;
/** 是否是「默认」那一项 */
private Boolean isDefault;
/** 是否可编辑/删除。默认项为 false */
private Boolean editable;
/** 来源是不是 .env。前端据此显示「来自服务器 .env」的说明 */
private Boolean fromEnv;
/** 这套配置是否齐全可用(三项都有值) */
private Boolean ready;
}
@@ -17,6 +17,14 @@ public class ChatSendRequest {
/** 会话 ID,null = 自动新建 */
private Long sessionId;
/**
* 用哪套 AI 配置。null = 用服务端 .env 里那套(即列表里虚拟的「默认」项)。
*
* <p>App 端把用户选中的配置存在本地,每次对话带上来 ——
* 不做服务端「当前选中」状态,是因为那样会让网页端和手机端互相打架。</p>
*/
private Long configId;
@NotBlank(message = "消息内容不能为空")
@Size(max = 4000, message = "单条消息最长 4000 字")
private String content;
@@ -0,0 +1,72 @@
package com.accounting.dto.ai;
import com.accounting.config.AiConfig;
import com.accounting.entity.AiConfigEntity;
/**
* **一次对话实际生效的模型配置**。
*
* <p>这是个纯值对象,来源可能是两种:</p>
* <ul>
* <li>{@link #fromDefaults} —— 用户没选具体配置,用 {@code .env} 里那套</li>
* <li>{@link #fromEntity} —— 用了用户自己配的某一条</li>
* </ul>
*
* <p>把它抽出来的意义:{@code AiChatService} 只认这个类型,不再直接读
* {@code AiConfig}。于是「切换模型」就退化成一个「传哪个 configId 进来」的问题,
* 改动面很小,也不用在每个调用点写 if-else 判断用哪套值。</p>
*
* <p>注意 {@code searchBaseUrl} / {@code maxToolRounds} 这些**不在**这里 ——
* 它们是基础设施参数(自建 SearXNG 只有一台),不随模型切换而变,
* 继续由 {@code AiConfig} 统一提供。</p>
*/
public record EffectiveAiConfig(
String baseUrl,
String apiKey,
String model,
/** 显示名,出错时告诉用户「是哪个配置挂了」 */
String label
) {
public static final String DEFAULT_LABEL = "默认";
/** 用 .env 里那套 */
public static EffectiveAiConfig fromDefaults(AiConfig config) {
return new EffectiveAiConfig(
config.getBaseUrl(),
config.getApiKey(),
config.getModel(),
DEFAULT_LABEL);
}
/** 用用户自己配的一条 */
public static EffectiveAiConfig fromEntity(AiConfigEntity entity) {
return new EffectiveAiConfig(
stripTrailingSlash(entity.getBaseUrl()),
entity.getApiKey() == null ? "" : entity.getApiKey(),
entity.getModel(),
entity.getName() == null ? "未命名" : entity.getName());
}
/** 三项都齐全才算可用 —— 缺一项就会拼出一个必然 401/404 的请求 */
public boolean isReady() {
return notBlank(baseUrl) && notBlank(apiKey) && notBlank(model);
}
/** 拼 chat/completions 的完整地址 */
public String chatCompletionsUrl() {
return baseUrl + "/chat/completions";
}
/**
* 容忍结尾多余的斜杠:否则会拼出 {@code //chat/completions},
* 网关直接 404,而且报错完全看不出是这个原因。
*/
private static String stripTrailingSlash(String url) {
return url == null ? "" : url.replaceAll("/+$", "");
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
}
@@ -0,0 +1,45 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* AI 模型配置
*
* <p>一条记录 = 一套「base-url + api-key + model」。用户可以配多条,在手机端切换。</p>
*
* <p><b>注意「默认」不是这个表里的记录</b> —— 默认指的是 {@code .env} 里那套值,
* 由 {@code AiConfig} 提供。{@code configId} 传 null 就代表用默认。</p>
*/
@Data
@TableName("ai_config")
public class AiConfigEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/** 显示名,如「DeepSeek 便宜」「通义」 */
private String name;
/** 不带 /chat/completions 后缀 */
private String baseUrl;
/** 明文存储。对外接口只返回掩码,完整值永不回传 */
private String apiKey;
private String model;
@TableLogic
private Integer deleted;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,9 @@
package com.accounting.mapper;
import com.accounting.entity.AiConfigEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiConfigMapper extends BaseMapper<AiConfigEntity> {
}
@@ -1,6 +1,7 @@
package com.accounting.service;
import com.accounting.config.AiConfig;
import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession;
import com.accounting.mapper.ChatMessageMapper;
@@ -74,30 +75,34 @@ public class AiChatService {
*
* <p>统一约定:无论出什么错,客户端收到的都是 SSE 的 error 事件 ——
* 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。</p>
*
* @param effective 本次对话用哪套模型配置(来自 {@code AiConfigService.resolve})
*/
public SseEmitter chat(Long userId, Long sessionId, String content) {
public SseEmitter chat(Long userId, Long sessionId, String content, EffectiveAiConfig effective) {
SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS);
emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage()));
if (!aiConfig.isReady()) {
fail(emitter, "AI 功能未配置:请在服务端设置 AI_BASE_URL / AI_API_KEY / AI_MODEL");
if (!effective.isReady()) {
fail(emitter, "配置「" + effective.label() + "」不完整:"
+ "base url / api key / 模型名三项都要填");
return emitter;
}
aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter));
aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective));
return emitter;
}
// ---------------------------------------------------------------- 主流程
private void runChat(Long userId, Long sessionId, String content, SseEmitter emitter) {
private void runChat(Long userId, Long sessionId, String content,
SseEmitter emitter, EffectiveAiConfig effective) {
try {
ChatSession session = resolveSession(userId, sessionId, content);
saveMessage(session.getId(), ROLE_USER, content);
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId());
String assistantText = runCompletionLoop(userId, llmMessages, emitter);
String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
if (assistantText.isBlank()) {
throw new IllegalStateException("模型没有返回内容");
@@ -126,9 +131,10 @@ public class AiChatService {
* @return assistant 的最终文本(多轮的文本会拼在一起)
*/
private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter) throws Exception {
SseEmitter emitter, EffectiveAiConfig effective) throws Exception {
StringBuilder full = new StringBuilder();
int rounds = 0;
// 工具轮数不随模型切换而变 —— 它是编排策略,不是供应商的属性
int maxRounds = aiConfig.getMaxToolRounds();
while (true) {
@@ -141,7 +147,7 @@ public class AiChatService {
+ "如果信息确实不够,就如实说明还差什么。)"));
}
Round round = streamOnce(userId, llmMessages, emitter, allowTools);
Round round = streamOnce(userId, llmMessages, emitter, allowTools, effective);
full.append(round.text());
// 给了工具但它没要 → 正常结束;
@@ -182,10 +188,11 @@ public class AiChatService {
* @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用)
*/
private Round streamOnce(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter, boolean allowTools) throws Exception {
SseEmitter emitter, boolean allowTools,
EffectiveAiConfig effective) throws Exception {
// 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉
Map<String, Object> body = new LinkedHashMap<>();
body.put("model", aiConfig.getModel());
body.put("model", effective.model());
body.put("messages", llmMessages);
if (allowTools) {
// 工具定义要按用户生成:记账工具的描述里带着他的真实分类清单
@@ -194,9 +201,9 @@ public class AiChatService {
body.put("stream", true);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions"))
.uri(URI.create(effective.chatCompletionsUrl()))
.timeout(java.time.Duration.ofSeconds(30)) // 等响应头的超时
.header("Authorization", "Bearer " + aiConfig.getApiKey())
.header("Authorization", "Bearer " + effective.apiKey())
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
@@ -0,0 +1,223 @@
package com.accounting.service;
import com.accounting.config.AiConfig;
import com.accounting.dto.ai.AiConfigRequest;
import com.accounting.dto.ai.AiConfigResponse;
import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.entity.AiConfigEntity;
import com.accounting.mapper.AiConfigMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* AI 模型配置的增删改查 + 「当前该用哪套配置」的解析。
*
* <p>两条纪律:</p>
* <ul>
* <li><b>对外只给掩码</b>:{@code apiKeyMasked} 是 {@code sk-****abcd} 这种,
* 完整 key 永不回传。前端要改 key 就整个覆盖,不做「回显再编辑」。</li>
* <li><b>默认项不入库</b>:列表第一项是虚拟的「默认」,值来自 {@code .env}。
* {@code configId} 传 null 即选中它。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiConfigService {
/** 单用户最多配几条 —— 太多了手机上的选择器会很难用 */
private static final int MAX_CONFIGS = 10;
private static final int MAX_NAME = 50;
private static final int MAX_URL = 255;
private static final int MAX_KEY = 255;
private static final int MAX_MODEL = 100;
/** 掩码保留的前后可见位数 */
private static final int MASK_PREFIX = 3;
private static final int MASK_SUFFIX = 4;
private final AiConfigMapper aiConfigMapper;
private final AiConfig aiConfig;
// ---------------------------------------------------------------- 查询
/**
* 列表:第一项永远是虚拟的「默认」(id 为 null),后面是用户配的。
*
* <p>顺序按更新时间倒序 —— 刚改过的排前面,符合「刚才在调这个」的直觉。</p>
*/
public List<AiConfigResponse> list(Long userId) {
List<AiConfigResponse> result = new java.util.ArrayList<>();
// 虚拟的默认项:值来自 .env,不可编辑/删除
AiConfigResponse def = new AiConfigResponse();
def.setId(null);
def.setName(EffectiveAiConfig.DEFAULT_LABEL);
def.setBaseUrl(aiConfig.getBaseUrl());
def.setModel(aiConfig.getModel());
def.setApiKeyMasked(mask(aiConfig.getApiKey()));
def.setIsDefault(true);
def.setEditable(false);
def.setReady(aiConfig.isReady());
def.setFromEnv(true);
result.add(def);
List<AiConfigEntity> rows = aiConfigMapper.selectList(
new LambdaQueryWrapper<AiConfigEntity>()
.eq(AiConfigEntity::getUserId, userId)
.orderByDesc(AiConfigEntity::getUpdateTime));
for (AiConfigEntity row : rows) {
result.add(toResponse(row));
}
return result;
}
// ---------------------------------------------------------------- 写入
public AiConfigResponse create(Long userId, AiConfigRequest request) {
long count = aiConfigMapper.selectCount(
new LambdaQueryWrapper<AiConfigEntity>().eq(AiConfigEntity::getUserId, userId));
if (count >= MAX_CONFIGS) {
throw new IllegalArgumentException("最多只能配 " + MAX_CONFIGS + " 条,先删掉不用的");
}
AiConfigEntity entity = new AiConfigEntity();
entity.setUserId(userId);
applyRequest(entity, request);
aiConfigMapper.insert(entity);
log.info("AI 配置已创建: userId={}, id={}, name={}, model={}",
userId, entity.getId(), entity.getName(), entity.getModel());
return toResponse(entity);
}
/**
* 更新一条。
*
* <p>{@code apiKey} 传空或 null 表示**保持原值不变** —— 前端拿到的是掩码,
* 用户只改模型名时不该被迫重新输入 key。要清空 key 就建一条新的。</p>
*/
public AiConfigResponse update(Long userId, Long id, AiConfigRequest request) {
AiConfigEntity entity = requireOwned(userId, id);
applyRequest(entity, request);
aiConfigMapper.updateById(entity);
log.info("AI 配置已更新: userId={}, id={}, name={}, model={}",
userId, entity.getId(), entity.getName(), entity.getModel());
return toResponse(entity);
}
public void delete(Long userId, Long id) {
AiConfigEntity entity = requireOwned(userId, id);
aiConfigMapper.deleteById(entity.getId());
log.info("AI 配置已删除: userId={}, id={}", userId, id);
}
// ---------------------------------------------------------------- 解析
/**
* 解析出这次对话该用哪套配置。
*
* <p>{@code configId} 为 null → 用 .env 的默认值。
* 指向的配置不存在或不属于该用户 → 抛异常(而不是静默退回默认)——
* 静默退回会让用户以为「切换成功了」但实际用的还是旧模型,很难排查。</p>
*/
public EffectiveAiConfig resolve(Long userId, Long configId) {
if (configId == null) {
return EffectiveAiConfig.fromDefaults(aiConfig);
}
AiConfigEntity entity = aiConfigMapper.selectOne(
new LambdaQueryWrapper<AiConfigEntity>()
.eq(AiConfigEntity::getId, configId)
.eq(AiConfigEntity::getUserId, userId));
if (entity == null) {
throw new IllegalArgumentException("这个 AI 配置不存在,可能已经被删掉了");
}
return EffectiveAiConfig.fromEntity(entity);
}
// ---------------------------------------------------------------- 内部
private AiConfigEntity requireOwned(Long userId, Long id) {
AiConfigEntity entity = aiConfigMapper.selectOne(
new LambdaQueryWrapper<AiConfigEntity>()
.eq(AiConfigEntity::getId, id)
.eq(AiConfigEntity::getUserId, userId));
if (entity == null) {
throw new IllegalArgumentException("配置不存在");
}
return entity;
}
/** 把请求写进实体,顺手做长度截断,避免超长直接把插入打回 */
private void applyRequest(AiConfigEntity entity, AiConfigRequest request) {
String baseUrl = trim(request.getBaseUrl());
String model = trim(request.getModel());
String name = trim(request.getName());
if (baseUrl.isEmpty()) {
throw new IllegalArgumentException("base url 不能为空");
}
if (model.isEmpty()) {
throw new IllegalArgumentException("模型名称不能为空");
}
entity.setName(name.isEmpty() ? model : truncate(name, MAX_NAME));
// 存之前统一去掉结尾斜杠,免得每次拼 URL 都要处理
entity.setBaseUrl(truncate(baseUrl.replaceAll("/+$", ""), MAX_URL));
entity.setModel(truncate(model, MAX_MODEL));
// key 传空 = 保持原值(前端只有掩码,不回显真 key)
String apiKey = trim(request.getApiKey());
if (!apiKey.isEmpty()) {
entity.setApiKey(truncate(apiKey, MAX_KEY));
} else if (entity.getId() == null) {
// 新建时 key 必须给
throw new IllegalArgumentException("api key 不能为空");
}
}
private AiConfigResponse toResponse(AiConfigEntity entity) {
AiConfigResponse response = new AiConfigResponse();
response.setId(entity.getId());
response.setName(entity.getName());
response.setBaseUrl(entity.getBaseUrl());
response.setModel(entity.getModel());
response.setApiKeyMasked(mask(entity.getApiKey()));
response.setIsDefault(false);
response.setEditable(true);
response.setFromEnv(false);
response.setReady(entity.getApiKey() != null && !entity.getApiKey().isBlank());
return response;
}
/**
* 打掩码:{@code sk-1234567890abcd} → {@code sk-****abcd}。
*
* <p>太短的 key 全部打掉,避免「短 key 反而暴露了大半」。</p>
*/
static String mask(String apiKey) {
if (apiKey == null || apiKey.isBlank()) return "";
String key = apiKey.trim();
if (key.length() <= MASK_PREFIX + MASK_SUFFIX + 2) {
return "*".repeat(key.length());
}
return key.substring(0, MASK_PREFIX)
+ "****"
+ key.substring(key.length() - MASK_SUFFIX);
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
private String truncate(String value, int max) {
return value.length() <= max ? value : value.substring(0, max);
}
}