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 模型配置的增删改查 + 「当前该用哪套配置」的解析。 * *

两条纪律:

* */ @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); } }