Files
Aq-Accounting-Spring/src/main/java/com/accounting/service/AiConfigService.java
T
2026-09-28 12:00:23 +08:00

224 lines
8.7 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}