AI配置化V0.1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user