AI配置化V0.1

This commit is contained in:
2026-09-28 12:00:23 +08:00
parent dbb0178918
commit f0056441f6
14 changed files with 959 additions and 15 deletions
@@ -1,8 +1,11 @@
package com.accounting.controller; package com.accounting.controller;
import com.accounting.config.AiConfig; 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.ChatMessageItem;
import com.accounting.dto.ai.ChatSendRequest; import com.accounting.dto.ai.ChatSendRequest;
import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.dto.ai.SessionItem; import com.accounting.dto.ai.SessionItem;
import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession; import com.accounting.entity.ChatSession;
@@ -11,6 +14,7 @@ import com.accounting.mapper.ChatMessageMapper;
import com.accounting.mapper.ChatSessionMapper; import com.accounting.mapper.ChatSessionMapper;
import com.accounting.mapper.UserMapper; import com.accounting.mapper.UserMapper;
import com.accounting.service.AiChatService; import com.accounting.service.AiChatService;
import com.accounting.service.AiConfigService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import io.swagger.v3.oas.annotations.Operation; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -54,6 +59,9 @@ public class AiController {
@Autowired @Autowired
private AiChatService aiChatService; private AiChatService aiChatService;
@Autowired
private AiConfigService aiConfigService;
@Autowired @Autowired
private ChatSessionMapper sessionMapper; private ChatSessionMapper sessionMapper;
@@ -125,7 +133,42 @@ public class AiController {
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@Valid @RequestBody ChatSendRequest request, Authentication authentication) { public SseEmitter chat(@Valid @RequestBody ChatSendRequest request, Authentication authentication) {
Long userId = getUserId(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<AiConfigResponse> 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<String, Object> deleteConfig(@PathVariable Long id, Authentication authentication) {
aiConfigService.delete(getUserId(authentication), id);
return Map.of("success", true);
} }
// ---------------------------------------------------------------- 内部方法 // ---------------------------------------------------------------- 内部方法
@@ -0,0 +1,29 @@
package com.accounting.dto.ai;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 新建 / 更新 AI 配置的请求。
*
* <p>{@code apiKey} 在**更新**时可以留空,表示保持原值 —— 前端拿到的是掩码,
* 用户只改模型名时不该被迫重新输入 key。</p>
*/
@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;
}
@@ -0,0 +1,36 @@
package com.accounting.dto.ai;
import lombok.Data;
/**
* AI 配置的对外视图。
*
* <p><b>永远不含完整 api key</b>,只有 {@link #apiKeyMasked}。</p>
*/
@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;
}
@@ -17,6 +17,14 @@ public class ChatSendRequest {
/** 会话 ID,null = 自动新建 */ /** 会话 ID,null = 自动新建 */
private Long sessionId; private Long sessionId;
/**
* 用哪套 AI 配置。null = 用服务端 .env 里那套(即列表里虚拟的「默认」项)。
*
* <p>App 端把用户选中的配置存在本地,每次对话带上来 ——
* 不做服务端「当前选中」状态,是因为那样会让网页端和手机端互相打架。</p>
*/
private Long configId;
@NotBlank(message = "消息内容不能为空") @NotBlank(message = "消息内容不能为空")
@Size(max = 4000, message = "单条消息最长 4000 字") @Size(max = 4000, message = "单条消息最长 4000 字")
private String content; private String content;
@@ -0,0 +1,72 @@
package com.accounting.dto.ai;
import com.accounting.config.AiConfig;
import com.accounting.entity.AiConfigEntity;
/**
* **一次对话实际生效的模型配置**。
*
* <p>这是个纯值对象,来源可能是两种:</p>
* <ul>
* <li>{@link #fromDefaults} —— 用户没选具体配置,用 {@code .env} 里那套</li>
* <li>{@link #fromEntity} —— 用了用户自己配的某一条</li>
* </ul>
*
* <p>把它抽出来的意义:{@code AiChatService} 只认这个类型,不再直接读
* {@code AiConfig}。于是「切换模型」就退化成一个「传哪个 configId 进来」的问题,
* 改动面很小,也不用在每个调用点写 if-else 判断用哪套值。</p>
*
* <p>注意 {@code searchBaseUrl} / {@code maxToolRounds} 这些**不在**这里 ——
* 它们是基础设施参数(自建 SearXNG 只有一台),不随模型切换而变,
* 继续由 {@code AiConfig} 统一提供。</p>
*/
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();
}
}
@@ -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 模型配置
*
* <p>一条记录 = 一套「base-url + api-key + model」。用户可以配多条,在手机端切换。</p>
*
* <p><b>注意「默认」不是这个表里的记录</b> —— 默认指的是 {@code .env} 里那套值,
* 由 {@code AiConfig} 提供。{@code configId} 传 null 就代表用默认。</p>
*/
@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;
}
@@ -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<AiConfigEntity> {
}
@@ -1,6 +1,7 @@
package com.accounting.service; package com.accounting.service;
import com.accounting.config.AiConfig; import com.accounting.config.AiConfig;
import com.accounting.dto.ai.EffectiveAiConfig;
import com.accounting.entity.ChatMessage; import com.accounting.entity.ChatMessage;
import com.accounting.entity.ChatSession; import com.accounting.entity.ChatSession;
import com.accounting.mapper.ChatMessageMapper; import com.accounting.mapper.ChatMessageMapper;
@@ -74,30 +75,34 @@ public class AiChatService {
* *
* <p>统一约定:无论出什么错,客户端收到的都是 SSE 的 error 事件 —— * <p>统一约定:无论出什么错,客户端收到的都是 SSE 的 error 事件 ——
* 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。</p> * 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。</p>
*
* @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); SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS);
emitter.onTimeout(emitter::complete); emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage())); emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage()));
if (!aiConfig.isReady()) { if (!effective.isReady()) {
fail(emitter, "AI 功能未配置:请在服务端设置 AI_BASE_URL / AI_API_KEY / AI_MODEL"); fail(emitter, "配置「" + effective.label() + "」不完整:"
+ "base url / api key / 模型名三项都要填");
return emitter; return emitter;
} }
aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter)); aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter, effective));
return emitter; 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 { try {
ChatSession session = resolveSession(userId, sessionId, content); ChatSession session = resolveSession(userId, sessionId, content);
saveMessage(session.getId(), ROLE_USER, content); saveMessage(session.getId(), ROLE_USER, content);
List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId()); List<Map<String, Object>> llmMessages = buildLlmMessages(session.getId());
String assistantText = runCompletionLoop(userId, llmMessages, emitter); String assistantText = runCompletionLoop(userId, llmMessages, emitter, effective);
if (assistantText.isBlank()) { if (assistantText.isBlank()) {
throw new IllegalStateException("模型没有返回内容"); throw new IllegalStateException("模型没有返回内容");
@@ -126,9 +131,10 @@ public class AiChatService {
* @return assistant 的最终文本(多轮的文本会拼在一起) * @return assistant 的最终文本(多轮的文本会拼在一起)
*/ */
private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages, private String runCompletionLoop(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter) throws Exception { SseEmitter emitter, EffectiveAiConfig effective) throws Exception {
StringBuilder full = new StringBuilder(); StringBuilder full = new StringBuilder();
int rounds = 0; int rounds = 0;
// 工具轮数不随模型切换而变 —— 它是编排策略,不是供应商的属性
int maxRounds = aiConfig.getMaxToolRounds(); int maxRounds = aiConfig.getMaxToolRounds();
while (true) { 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()); full.append(round.text());
// 给了工具但它没要 → 正常结束; // 给了工具但它没要 → 正常结束;
@@ -182,10 +188,11 @@ public class AiChatService {
* @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用) * @param allowTools false = 不带 tools 参数,模型只能输出文本(收尾轮用)
*/ */
private Round streamOnce(Long userId, List<Map<String, Object>> llmMessages, private Round streamOnce(Long userId, List<Map<String, Object>> llmMessages,
SseEmitter emitter, boolean allowTools) throws Exception { SseEmitter emitter, boolean allowTools,
EffectiveAiConfig effective) throws Exception {
// 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉 // 用 LinkedHashMap 而不是 Map.of:tools 是可选字段,要能整个省掉
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("model", aiConfig.getModel()); body.put("model", effective.model());
body.put("messages", llmMessages); body.put("messages", llmMessages);
if (allowTools) { if (allowTools) {
// 工具定义要按用户生成:记账工具的描述里带着他的真实分类清单 // 工具定义要按用户生成:记账工具的描述里带着他的真实分类清单
@@ -194,9 +201,9 @@ public class AiChatService {
body.put("stream", true); body.put("stream", true);
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(aiConfig.getBaseUrl() + "/chat/completions")) .uri(URI.create(effective.chatCompletionsUrl()))
.timeout(java.time.Duration.ofSeconds(30)) // 等响应头的超时 .timeout(java.time.Duration.ofSeconds(30)) // 等响应头的超时
.header("Authorization", "Bearer " + aiConfig.getApiKey()) .header("Authorization", "Bearer " + effective.apiKey())
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.header("Accept", "text/event-stream") .header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
@@ -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);
}
}
@@ -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';
+95
View File
@@ -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); padding: 10px 16px 16px; color: rgba(255,255,255,.75);
font-size: 12px; line-height: 1.7; text-align: center; 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;
}
+215 -2
View File
@@ -1467,12 +1467,17 @@
checked: false, checked: false,
sessionId: null, sessionId: null,
sessions: [], sessions: [],
streaming: false streaming: false,
// 当前选中的模型配置 id;null = 用服务端 .env 那套(列表里虚拟的「默认」项)
configId: null
}; };
function aiEnter() { function aiEnter() {
if (!ai.checked) { if (!ai.checked) {
ai.checked = true; ai.checked = true;
// 恢复上次在浏览器里选中的模型,并把列表拉回来填下拉框
ai.configId = adminLoadConfigId();
cfgLoad();
request('/ai/status').then(function (s) { request('/ai/status').then(function (s) {
ai.ready = !!(s && s.enabled); ai.ready = !!(s && s.enabled);
$('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI'; $('ai-hint').textContent = ai.ready ? '' : '后端未配置 AI';
@@ -1663,7 +1668,7 @@
'Authorization': 'Bearer ' + state.token, 'Authorization': 'Bearer ' + state.token,
'Content-Type': 'application/json' '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) { }).then(function (res) {
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
doLogout(); doLogout();
@@ -2151,6 +2156,30 @@
}); });
// AI 助手 // 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-new').onclick = aiNewChat;
$('ai-send').onclick = aiSend; $('ai-send').onclick = aiSend;
$('ai-input').oninput = aiAutoGrow; $('ai-input').oninput = aiAutoGrow;
@@ -2182,3 +2211,187 @@
doLogout(); 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 = '<div class="cfg-empty">加载中...</div>';
request('/ai/configs')
.then(function (list) {
cfg.items = list || [];
cfgRender();
aiRenderConfigSelect();
})
.catch(function (e) {
$('cfg-list').innerHTML = '<div class="cfg-empty">' + esc(e.message) + '</div>';
});
}
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 += '<span class="tag">默认 - 来自 .env</span>';
if (!c.ready) tags += '<span class="tag bad">不可用</span>';
var html = '<div class="body">' +
'<div class="nm">' + esc(c.name || '未命名') + tags + '</div>' +
'<div class="md">' + esc(c.model || '未填模型名') + '</div>' +
'<div class="bu">' + esc(c.baseUrl || '未填 base url') + '</div>';
if (c.apiKeyMasked) {
html += '<div class="ky">key: ' + esc(c.apiKeyMasked) + '</div>';
}
html += '</div>';
// 默认项不给编辑/删除入口
var acts = '';
if (c.editable) {
acts = '<div class="acts">' +
'<button data-act="edit">编辑</button>' +
'<button data-act="del" class="del">删除</button>' +
'</div>';
}
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; }
}
@@ -204,8 +204,11 @@
<div class="toolbar"> <div class="toolbar">
<span class="toolbar-title">AI 助手</span> <span class="toolbar-title">AI 助手</span>
<!-- 当前用哪个模型。放在工具栏上让「切错了模型」一眼可见 -->
<select id="ai-config-select" title="切换模型配置"></select>
<span class="toolbar-note" id="ai-hint"></span> <span class="toolbar-note" id="ai-hint"></span>
<div class="spacer"></div> <div class="spacer"></div>
<button id="ai-config-manage">配置模型…</button>
<button id="ai-new">+ 新对话</button> <button id="ai-new">+ 新对话</button>
</div> </div>
@@ -251,6 +254,59 @@
</div> </div>
</div> </div>
<!-- ==================== AI 模型配置 ==================== -->
<div id="ai-cfg">
<div class="cfg-box">
<div class="cfg-head">
<h3>AI 模型配置</h3>
<div class="spacer"></div>
<button class="primary" id="cfg-add">+ 新增</button>
<button id="cfg-close">关闭</button>
</div>
<p class="cfg-tip">
「默认」用的是服务器 <code>.env</code> 里那套,不能在这里改。
下面配的几条会存到你自己的账号下,手机端也能看到并切换。
</p>
<div id="cfg-list" class="cfg-list"></div>
</div>
</div>
<!-- ==================== 新增/编辑单条配置 ==================== -->
<div id="cfg-edit">
<div class="dialog-box cfg-edit-box">
<h3 id="cfg-edit-title">新增配置</h3>
<label class="cfg-field">
<span>名称</span>
<input id="cfg-f-name" placeholder="留空则用模型名,比如:DeepSeek 便宜">
</label>
<label class="cfg-field">
<span>Base URL</span>
<input id="cfg-f-url" placeholder="https://api.deepseek.com/v1">
<em>结尾不要带 /chat/completions</em>
</label>
<label class="cfg-field">
<span>API Key</span>
<input id="cfg-f-key" type="password" placeholder="sk-...">
<em id="cfg-f-key-hint">只存在你的服务器上,接口只回掩码</em>
</label>
<label class="cfg-field">
<span>模型名称</span>
<input id="cfg-f-model" placeholder="deepseek-chat">
</label>
<div class="cfg-presets">
<span>常用:</span>
<button type="button" class="cfg-preset" data-url="https://api.deepseek.com/v1" data-model="deepseek-chat">DeepSeek</button>
<button type="button" class="cfg-preset" data-url="https://dashscope.aliyuncs.com/compatible-mode/v1" data-model="qwen-plus">通义</button>
<button type="button" class="cfg-preset" data-url="https://open.bigmodel.cn/api/paas/v4" data-model="glm-4">智谱</button>
<button type="button" class="cfg-preset" data-url="https://api.openai.com/v1" data-model="gpt-4o-mini">OpenAI</button>
</div>
<div class="dialog-foot">
<button id="cfg-edit-cancel">取消</button>
<button class="primary" id="cfg-edit-save">保存</button>
</div>
</div>
</div>
<!-- ==================== 相片大图查看 ==================== --> <!-- ==================== 相片大图查看 ==================== -->
<div id="photo-view"> <div id="photo-view">
<div class="pv-head"> <div class="pv-head">
@@ -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 掩码测试。
*
* <p>{@code AiConfigService.mask} 是整个「多模型配置」功能里唯一有真实风险的
* 纯函数 —— 它算错就是把用户的 key 印在界面上。所以用例写得比较细,
* 特别是「短 key 全部打掉」这条:长度接近于掩码长度时最容易漏。</p>
*/
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);
}
}
}