diff --git a/src/main/java/com/accounting/controller/AiController.java b/src/main/java/com/accounting/controller/AiController.java index d874610..3a768b9 100644 --- a/src/main/java/com/accounting/controller/AiController.java +++ b/src/main/java/com/accounting/controller/AiController.java @@ -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 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 deleteConfig(@PathVariable Long id, Authentication authentication) { + aiConfigService.delete(getUserId(authentication), id); + return Map.of("success", true); } // ---------------------------------------------------------------- 内部方法 diff --git a/src/main/java/com/accounting/dto/ai/AiConfigRequest.java b/src/main/java/com/accounting/dto/ai/AiConfigRequest.java new file mode 100644 index 0000000..69dfcd1 --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/AiConfigRequest.java @@ -0,0 +1,29 @@ +package com.accounting.dto.ai; + +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 新建 / 更新 AI 配置的请求。 + * + *

{@code apiKey} 在**更新**时可以留空,表示保持原值 —— 前端拿到的是掩码, + * 用户只改模型名时不该被迫重新输入 key。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/dto/ai/AiConfigResponse.java b/src/main/java/com/accounting/dto/ai/AiConfigResponse.java new file mode 100644 index 0000000..d8edef0 --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/AiConfigResponse.java @@ -0,0 +1,36 @@ +package com.accounting.dto.ai; + +import lombok.Data; + +/** + * AI 配置的对外视图。 + * + *

永远不含完整 api key,只有 {@link #apiKeyMasked}。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java index 3c4d77d..22984fd 100644 --- a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java +++ b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java @@ -17,6 +17,14 @@ public class ChatSendRequest { /** 会话 ID,null = 自动新建 */ private Long sessionId; + /** + * 用哪套 AI 配置。null = 用服务端 .env 里那套(即列表里虚拟的「默认」项)。 + * + *

App 端把用户选中的配置存在本地,每次对话带上来 —— + * 不做服务端「当前选中」状态,是因为那样会让网页端和手机端互相打架。

+ */ + private Long configId; + @NotBlank(message = "消息内容不能为空") @Size(max = 4000, message = "单条消息最长 4000 字") private String content; diff --git a/src/main/java/com/accounting/dto/ai/EffectiveAiConfig.java b/src/main/java/com/accounting/dto/ai/EffectiveAiConfig.java new file mode 100644 index 0000000..7da0251 --- /dev/null +++ b/src/main/java/com/accounting/dto/ai/EffectiveAiConfig.java @@ -0,0 +1,72 @@ +package com.accounting.dto.ai; + +import com.accounting.config.AiConfig; +import com.accounting.entity.AiConfigEntity; + +/** + * **一次对话实际生效的模型配置**。 + * + *

这是个纯值对象,来源可能是两种:

+ * + * + *

把它抽出来的意义:{@code AiChatService} 只认这个类型,不再直接读 + * {@code AiConfig}。于是「切换模型」就退化成一个「传哪个 configId 进来」的问题, + * 改动面很小,也不用在每个调用点写 if-else 判断用哪套值。

+ * + *

注意 {@code searchBaseUrl} / {@code maxToolRounds} 这些**不在**这里 —— + * 它们是基础设施参数(自建 SearXNG 只有一台),不随模型切换而变, + * 继续由 {@code AiConfig} 统一提供。

+ */ +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(); + } +} diff --git a/src/main/java/com/accounting/entity/AiConfigEntity.java b/src/main/java/com/accounting/entity/AiConfigEntity.java new file mode 100644 index 0000000..fb66cc8 --- /dev/null +++ b/src/main/java/com/accounting/entity/AiConfigEntity.java @@ -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 模型配置 + * + *

一条记录 = 一套「base-url + api-key + model」。用户可以配多条,在手机端切换。

+ * + *

注意「默认」不是这个表里的记录 —— 默认指的是 {@code .env} 里那套值, + * 由 {@code AiConfig} 提供。{@code configId} 传 null 就代表用默认。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/mapper/AiConfigMapper.java b/src/main/java/com/accounting/mapper/AiConfigMapper.java new file mode 100644 index 0000000..b19eb8c --- /dev/null +++ b/src/main/java/com/accounting/mapper/AiConfigMapper.java @@ -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 { +} diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java index dcbebec..c3eef03 100644 --- a/src/main/java/com/accounting/service/AiChatService.java +++ b/src/main/java/com/accounting/service/AiChatService.java @@ -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 { * *

统一约定:无论出什么错,客户端收到的都是 SSE 的 error 事件 —— * 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。

+ * + * @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> 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> 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> llmMessages, - SseEmitter emitter, boolean allowTools) throws Exception { + SseEmitter emitter, boolean allowTools, + EffectiveAiConfig effective) throws Exception { // 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉 Map 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))) diff --git a/src/main/java/com/accounting/service/AiConfigService.java b/src/main/java/com/accounting/service/AiConfigService.java new file mode 100644 index 0000000..23097d8 --- /dev/null +++ b/src/main/java/com/accounting/service/AiConfigService.java @@ -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 模型配置的增删改查 + 「当前该用哪套配置」的解析。 + * + *

两条纪律:

+ *
    + *
  • 对外只给掩码:{@code apiKeyMasked} 是 {@code sk-****abcd} 这种, + * 完整 key 永不回传。前端要改 key 就整个覆盖,不做「回显再编辑」。
  • + *
  • 默认项不入库:列表第一项是虚拟的「默认」,值来自 {@code .env}。 + * {@code configId} 传 null 即选中它。
  • + *
+ */ +@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),后面是用户配的。 + * + *

顺序按更新时间倒序 —— 刚改过的排前面,符合「刚才在调这个」的直觉。

+ */ + public List list(Long userId) { + List 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 rows = aiConfigMapper.selectList( + new LambdaQueryWrapper() + .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().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); + } + + /** + * 更新一条。 + * + *

{@code apiKey} 传空或 null 表示**保持原值不变** —— 前端拿到的是掩码, + * 用户只改模型名时不该被迫重新输入 key。要清空 key 就建一条新的。

+ */ + 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); + } + + // ---------------------------------------------------------------- 解析 + + /** + * 解析出这次对话该用哪套配置。 + * + *

{@code configId} 为 null → 用 .env 的默认值。 + * 指向的配置不存在或不属于该用户 → 抛异常(而不是静默退回默认)—— + * 静默退回会让用户以为「切换成功了」但实际用的还是旧模型,很难排查。

+ */ + public EffectiveAiConfig resolve(Long userId, Long configId) { + if (configId == null) { + return EffectiveAiConfig.fromDefaults(aiConfig); + } + AiConfigEntity entity = aiConfigMapper.selectOne( + new LambdaQueryWrapper() + .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() + .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}。 + * + *

太短的 key 全部打掉,避免「短 key 反而暴露了大半」。

+ */ + 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); + } +} diff --git a/src/main/resources/db/migration_add_ai_config.sql b/src/main/resources/db/migration_add_ai_config.sql new file mode 100644 index 0000000..a9936dd --- /dev/null +++ b/src/main/resources/db/migration_add_ai_config.sql @@ -0,0 +1,34 @@ +-- AI 模型配置表(2026-09-28) +-- +-- 用途:把「用哪个大模型」变成可在 App / Web 后台里配置和切换的东西, +-- 而不是只能改服务器上的 .env 再重启。 +-- +-- 「默认」那一项**不入库** —— 它不是一条记录,而是「`.env` 里那套值」的投影。 +-- `configId` 传 null 就代表用默认。这样好处是: +-- · 没有配置记录时行为完全不变(还是读 .env) +-- · 不会出现「库里存了一份和 .env 不一致的默认值」这种二义性 +-- +-- ⚠️ api_key 是**明文存**的。和 .env 一样,这台库是自己的,没有引入加密的 +-- 必要性;但对外接口只返回掩码(sk-****abcd),完整值永不回传。 +-- +-- 执行方式(在 231 上): +-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \ +-- -uroot -p密码 accounting_db < migration_add_ai_config.sql + +CREATE TABLE IF NOT EXISTS `ai_config` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '配置ID', + `user_id` BIGINT NOT NULL COMMENT '用户ID', + `name` VARCHAR(50) NOT NULL COMMENT '显示名,如「DeepSeek 便宜」「通义」', + `base_url` VARCHAR(255) NOT NULL COMMENT '不带 /chat/completions 后缀', + `api_key` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '明文存储,接口只回掩码', + `model` VARCHAR(100) NOT NULL COMMENT '模型名,如 deepseek-chat', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`), + FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 模型配置表'; + +-- 核对:跑完应该能看到 ai_config 表 +-- SHOW TABLES LIKE 'ai_config'; diff --git a/src/main/resources/static/admin/admin.css b/src/main/resources/static/admin/admin.css index 81ece78..6254e09 100644 --- a/src/main/resources/static/admin/admin.css +++ b/src/main/resources/static/admin/admin.css @@ -603,3 +603,98 @@ mark { background: #FFE9A8; color: inherit; padding: 0 1px; border-radius: 2px; padding: 10px 16px 16px; color: rgba(255,255,255,.75); font-size: 12px; line-height: 1.7; text-align: center; } + +/* ---------- AI 模型配置 ---------- */ +#ai-cfg, #cfg-edit { + position: fixed; inset: 0; z-index: 210; display: none; + background: rgba(20, 16, 12, .55); + align-items: center; justify-content: center; +} +#ai-cfg.open, #cfg-edit.open { display: flex; } + +.cfg-box { + background: #fff; border-radius: 14px; width: min(660px, 92vw); + max-height: 86vh; display: flex; flex-direction: column; +} +.cfg-head { + display: flex; align-items: center; gap: 8px; + padding: 16px 20px 12px; border-bottom: 1px solid var(--border); +} +.cfg-head h3 { margin: 0; font-size: 15px; } +.cfg-head .spacer { flex: 1; } +.cfg-head button { + border: 1px solid var(--border); background: #fff; color: var(--text); + padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px; +} +.cfg-head button.primary { + background: var(--brand); border-color: var(--brand); color: #fff; +} +.cfg-head button:hover { opacity: .88; } + +.cfg-tip { + margin: 0; padding: 10px 20px; font-size: 12px; line-height: 1.7; + color: var(--text-dim); background: var(--brand-light); +} +.cfg-tip code { + background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 4px; + font-size: 11.5px; +} + +.cfg-list { padding: 12px 20px 20px; overflow-y: auto; } + +.cfg-row { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 14px; margin-bottom: 9px; + border: 1px solid var(--border); border-radius: 10px; +} +.cfg-row.active { border-color: var(--brand); border-width: 1.5px; } +.cfg-row .body { flex: 1; min-width: 0; } +.cfg-row .nm { + font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; +} +.cfg-row .nm .tag { + font-size: 10.5px; font-weight: 400; padding: 1px 6px; border-radius: 6px; + background: rgba(141,110,99,.12); color: var(--text-dim); +} +.cfg-row .nm .tag.bad { background: rgba(229,57,53,.11); color: #E53935; } +.cfg-row .md { font-size: 12px; color: var(--text-dim); margin-top: 3px; } +.cfg-row .bu, .cfg-row .ky { + font-size: 11px; color: #BCAAA4; margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.cfg-row .acts { display: flex; gap: 4px; flex-shrink: 0; } +.cfg-row .acts button { + border: none; background: transparent; cursor: pointer; + font-size: 12.5px; color: var(--text-dim); padding: 4px 7px; border-radius: 6px; +} +.cfg-row .acts button:hover { background: var(--brand-light); color: var(--brand); } +.cfg-row .acts button.del:hover { background: rgba(229,57,53,.09); color: #E53935; } + +.cfg-edit-box { width: min(520px, 92vw); padding: 22px 24px 18px; } +.cfg-edit-box h3 { margin: 0 0 16px; font-size: 15px; } + +.cfg-field { display: block; margin-bottom: 13px; } +.cfg-field span { font-size: 12.5px; color: var(--text-dim); display: block; margin-bottom: 5px; } +.cfg-field input { + width: 100%; box-sizing: border-box; + padding: 9px 12px; font-size: 13.5px; + border: 1px solid var(--border); border-radius: 8px; + background: #FBF8F5; color: var(--text); outline: none; +} +.cfg-field input:focus { border-color: var(--brand); background: #fff; } +.cfg-field em { font-size: 11px; color: #BCAAA4; font-style: normal; display: block; margin-top: 4px; } + +.cfg-presets { + display: flex; align-items: center; flex-wrap: wrap; gap: 6px; + margin: 2px 0 16px; font-size: 11.5px; color: var(--text-dim); +} +.cfg-preset { + border: 1px solid var(--brand); background: transparent; color: var(--brand); + padding: 3px 9px; border-radius: 999px; cursor: pointer; font-size: 11.5px; +} +.cfg-preset:hover { background: var(--brand); color: #fff; } + +.cfg-empty { + text-align: center; padding: 26px 16px; font-size: 12.5px; + color: #BCAAA4; line-height: 1.8; +} diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 3139850..8079dc6 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -1467,12 +1467,17 @@ checked: false, sessionId: null, sessions: [], - streaming: false + streaming: false, + // 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项) + configId: null }; function aiEnter() { if (!ai.checked) { ai.checked = true; + // 恢复上次在浏览器里选中的模型,并把列表拉回来填下拉框 + ai.configId = adminLoadConfigId(); + cfgLoad(); request('/ai/status').then(function (s) { ai.ready = !!(s && s.enabled); $('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI'; @@ -1663,7 +1668,7 @@ 'Authorization': 'Bearer ' + state.token, 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: ai.sessionId, content: text }) + body: JSON.stringify({ sessionId: ai.sessionId, configId: ai.configId, content: text }) }).then(function (res) { if (res.status === 401 || res.status === 403) { doLogout(); @@ -2151,6 +2156,30 @@ }); // AI 助手 + $('ai-config-manage').onclick = cfgOpen; + $('cfg-close').onclick = cfgClose; + $('cfg-add').onclick = function () { cfgOpenEditor(null); }; + $('cfg-edit-cancel').onclick = cfgCloseEditor; + $('cfg-edit-save').onclick = cfgSave; + // 点遮罩关掉弹层 + $('ai-cfg').onclick = function (e) { if (e.target === this) cfgClose(); }; + $('cfg-edit').onclick = function (e) { if (e.target === this) cfgCloseEditor(); }; + // 顶栏下拉切换 + $('ai-config-select').onchange = function () { + var v = this.value; + ai.configId = v === '' ? null : parseInt(v, 10); + adminSaveConfigId(ai.configId); + cfgRender(); + }; + // 常用厂商一键填地址和模型名(key 要自己填,不该替他编) + document.querySelectorAll('.cfg-preset').forEach(function (btn) { + btn.onclick = function () { + $('cfg-f-url').value = btn.dataset.url; + $('cfg-f-model').value = btn.dataset.model; + $('cfg-f-key').focus(); + }; + }); + $('ai-new').onclick = aiNewChat; $('ai-send').onclick = aiSend; $('ai-input').oninput = aiAutoGrow; @@ -2182,3 +2211,187 @@ doLogout(); } })(); + + // ================================================================ AI 模型配置 + // + // 和 App 端共用 /api/ai/configs。三条纪律: + // · 「默认」是**虚拟项**(id 为 null),值来自服务器 .env,不能改不能删 + // · 接口只回掩码,所以编辑时 key 输入框留空 = 保持原值 + // · 当前选中存在浏览器本地,不动服务端 —— 免得和手机端互相打架 + var cfg = { items: [], editing: null }; + + function cfgOpen() { + $('ai-cfg').classList.add('open'); + cfgLoad(); + } + + function cfgClose() { $('ai-cfg').classList.remove('open'); } + + function cfgLoad() { + $('cfg-list').innerHTML = '
加载中...
'; + request('/ai/configs') + .then(function (list) { + cfg.items = list || []; + cfgRender(); + aiRenderConfigSelect(); + }) + .catch(function (e) { + $('cfg-list').innerHTML = '
' + esc(e.message) + '
'; + }); + } + + function cfgRender() { + var box = $('cfg-list'); + box.innerHTML = ''; + + cfg.items.forEach(function (c) { + var el = document.createElement('div'); + el.className = 'cfg-row' + (cfgIsActive(c) ? ' active' : ''); + + var tags = ''; + if (c.isDefault) tags += '默认 - 来自 .env'; + if (!c.ready) tags += '不可用'; + + var html = '
' + + '
' + esc(c.name || '未命名') + tags + '
' + + '
' + esc(c.model || '未填模型名') + '
' + + '
' + esc(c.baseUrl || '未填 base url') + '
'; + if (c.apiKeyMasked) { + html += '
key: ' + esc(c.apiKeyMasked) + '
'; + } + html += '
'; + + // 默认项不给编辑/删除入口 + var acts = ''; + if (c.editable) { + acts = '
' + + '' + + '' + + '
'; + } + el.innerHTML = html + acts; + + // 点行 = 切换(点按钮不算) + el.onclick = function (e) { + if (e.target.closest('.acts')) return; + cfgSelect(c); + }; + var editBtn = el.querySelector('[data-act="edit"]'); + if (editBtn) editBtn.onclick = function () { cfgOpenEditor(c); }; + var delBtn = el.querySelector('[data-act="del"]'); + if (delBtn) delBtn.onclick = function () { cfgDelete(c); }; + + box.appendChild(el); + }); + } + + function cfgIsActive(c) { + if (ai.configId == null) return !!c.isDefault; + return c.id === ai.configId; + } + + function cfgSelect(c) { + ai.configId = c.id; + adminSaveConfigId(c.id); + cfgRender(); + aiRenderConfigSelect(); + toast('已切换到「' + c.name + '」'); + } + + /** 工具栏上那个下拉,只是 cfg 列表的快捷入口 */ + function aiRenderConfigSelect() { + var sel = $('ai-config-select'); + sel.innerHTML = ''; + cfg.items.forEach(function (c) { + var opt = document.createElement('option'); + opt.value = c.id == null ? '' : String(c.id); + opt.textContent = c.name || '未命名'; + if (cfgIsActive(c)) opt.selected = true; + sel.appendChild(opt); + }); + // 只有「默认」一项时没必要显示下拉 + sel.style.display = cfg.items.length > 1 ? '' : 'none'; + } + + // ---------------- 增删改 ---------------- + + function cfgOpenEditor(existing) { + cfg.editing = existing || null; + $('cfg-edit-title').textContent = existing ? '编辑配置' : '新增配置'; + $('cfg-f-name').value = existing ? (existing.name || '') : ''; + $('cfg-f-url').value = existing ? (existing.baseUrl || '') : ''; + $('cfg-f-model').value = existing ? (existing.model || '') : ''; + $('cfg-f-key').value = ''; + $('cfg-f-key-hint').textContent = (existing && existing.apiKeyMasked) + ? '留空 = 保持原来的 key(当前 ' + existing.apiKeyMasked + ')' + : '只存在你的服务器上,接口只回掩码'; + $('cfg-edit').classList.add('open'); + $('cfg-f-name').focus(); + } + + function cfgCloseEditor() { + $('cfg-edit').classList.remove('open'); + cfg.editing = null; + } + + function cfgSave() { + var name = $('cfg-f-name').value.trim(); + var baseUrl = $('cfg-f-url').value.trim(); + var model = $('cfg-f-model').value.trim(); + var apiKey = $('cfg-f-key').value.trim(); + + if (!baseUrl) { toast('base url 不能为空', true); return; } + if (!model) { toast('模型名称不能为空', true); return; } + // 新增时必填;编辑时留空 = 不改动 + if (!cfg.editing && !apiKey) { toast('api key 不能为空', true); return; } + + var payload = { name: name, baseUrl: baseUrl, model: model }; + if (apiKey) payload.apiKey = apiKey; + + var isEdit = !!cfg.editing; + var req = isEdit + ? request('/ai/configs/' + cfg.editing.id, { method: 'PUT', body: JSON.stringify(payload) }) + : request('/ai/configs', { method: 'POST', body: JSON.stringify(payload) }); + + req.then(function (saved) { + cfgCloseEditor(); + // 新增的第一条顺手选中,省得再点一下 + if (!isEdit && ai.configId == null) { + ai.configId = saved.id; + adminSaveConfigId(saved.id); + } + cfgLoad(); + toast(isEdit ? '已保存' : '已添加'); + }).catch(function (e) { toast(e.message, true); }); + } + + function cfgDelete(c) { + if (!confirm('删除配置「' + c.name + '」?')) return; + + request('/ai/configs/' + c.id, { method: 'DELETE' }) + .then(function () { + // 删掉的正好是当前选中的 -> 退回默认 + if (ai.configId === c.id) { + ai.configId = null; + adminSaveConfigId(null); + } + cfgLoad(); + toast('已删除'); + }) + .catch(function (e) { toast(e.message, true); }); + } + + // 当前选中存在浏览器本地(和手机端各存各的,互不干扰) + function adminSaveConfigId(id) { + try { + if (id == null) localStorage.removeItem('ai_config_id'); + else localStorage.setItem('ai_config_id', String(id)); + } catch (e) { /* 隐私模式下写不了,忽略 */ } + } + + function adminLoadConfigId() { + try { + var raw = localStorage.getItem('ai_config_id'); + return raw ? parseInt(raw, 10) : null; + } catch (e) { return null; } + } diff --git a/src/main/resources/static/admin/index.html b/src/main/resources/static/admin/index.html index 2b9854d..b17f33d 100644 --- a/src/main/resources/static/admin/index.html +++ b/src/main/resources/static/admin/index.html @@ -204,8 +204,11 @@
AI 助手 + +
+
@@ -251,6 +254,59 @@ + +
+
+
+

AI 模型配置

+
+ + +
+

+ 「默认」用的是服务器 .env 里那套,不能在这里改。 + 下面配的几条会存到你自己的账号下,手机端也能看到并切换。 +

+
+
+
+ + +
+
+

新增配置

+ + + + +
+ 常用: + + + + +
+
+ + +
+
+
+
diff --git a/src/test/java/com/accounting/service/AiConfigServiceTest.java b/src/test/java/com/accounting/service/AiConfigServiceTest.java new file mode 100644 index 0000000..b255bc0 --- /dev/null +++ b/src/test/java/com/accounting/service/AiConfigServiceTest.java @@ -0,0 +1,74 @@ +package com.accounting.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * AI 配置的 key 掩码测试。 + * + *

{@code AiConfigService.mask} 是整个「多模型配置」功能里唯一有真实风险的 + * 纯函数 —— 它算错就是把用户的 key 印在界面上。所以用例写得比较细, + * 特别是「短 key 全部打掉」这条:长度接近于掩码长度时最容易漏。

+ */ +class AiConfigServiceTest { + + @Test + @DisplayName("常规 key 只露出前 3 后 4") + void masksNormalKey() { + String masked = AiConfigService.mask("sk-1234567890abcdef"); + + assertEquals("sk-****cdef", masked); + // 中间那段必须消失 —— 这是这个函数的全部意义 + assertFalse(masked.contains("1234567890")); + } + + @Test + @DisplayName("超长 key 也只露前 3 后 4,不会随长度变松") + void masksLongKey() { + String key = "sk-" + "a".repeat(200); + String masked = AiConfigService.mask(key); + + assertTrue(masked.startsWith("sk-")); + assertTrue(masked.endsWith("aaaa")); + // 大量重复字符时别把整串带出来 + assertTrue(masked.length() < 20, "掩码后不该还有几十个字符"); + } + + @Test + @DisplayName("★ 短 key 全部打掉(不能因为短就露出大半)") + void masksShortKeyCompletely() { + // 长度 8,只比「前3+后4」多 1 —— 最容易写错成 sk-****cdef 这种 + assertEquals("********", AiConfigService.mask("sk-abcde")); + assertEquals("*****", AiConfigService.mask("12345")); + assertEquals("*", AiConfigService.mask("a")); + } + + @Test + @DisplayName("空 / null / 空白都返回空串,不是 null") + void handlesEmpty() { + assertEquals("", AiConfigService.mask(null)); + assertEquals("", AiConfigService.mask("")); + assertEquals("", AiConfigService.mask(" ")); + } + + @Test + @DisplayName("掩码结果里永远不含原文的关键片段") + void neverLeaksWholeKey() { + // 挑几个真实供应商的 key 形态 + for (String key : new String[]{ + "sk-proj-abcdefghijklmnopqrstuvwxyz", + "sk-1234567890abcdef1234567890abcdef", + "dashscope-key-abcdefghijklmnop", + }) { + String masked = AiConfigService.mask(key); + assertFalse(masked.contains(key), "掩码后不该含原文: " + key); + // 至少有一半字符被吃掉 + assertTrue(masked.length() < key.length(), + "掩码应该更短: " + key + " -> " + masked); + } + } +}