AI配置化V0.1
This commit is contained in:
@@ -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