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);
}
}