feat(ai-chat): 新增豆包API + AI模型配置项支持动态切换

- 后端新增豆包(火山引擎Ark)API集成:DoubaoController、ToolDoubaoServiceImpl,
  使用OkHttp3 SSE流式对话,兼容OpenAI Chat Completions格式
- 新增DoubaoConfig配置类,读取doubao.api.*配置
- 在eb_system_config表新增ai_chat_model配置项,支持doubao/coze/gemini三种模型切换
- 新增GET /api/front/doubao/ai-model-config接口供前端读取当前模型配置
- 前端ai-nutritionist.vue的sendToAI按系统配置分发到_sendViaDoubao/_sendViaCoze/_sendViaGemini
- 前端models-api.js新增doubaoChatStream/doubaoChat/getAiModelConfig函数
- 附带豆包API测试脚本和数据库初始化SQL

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
msh-agent
2026-04-11 18:03:21 +08:00
parent 58ea76498f
commit b164d8ba11
14 changed files with 1369 additions and 119 deletions

View File

@@ -0,0 +1,171 @@
package com.zbkj.service.service.impl.tool;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zbkj.common.config.DoubaoConfig;
import com.zbkj.common.request.doubao.DoubaoChatRequest;
import com.zbkj.common.utils.SseEmitterUtil;
import com.zbkj.service.service.tool.ToolDoubaoService;
import okhttp3.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 豆包(火山引擎 Ark服务实现类
* 使用 OpenAI 兼容的 Chat Completions 接口 + OkHttp SSE 流式
* +----------------------------------------------------------------------
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
@Service
public class ToolDoubaoServiceImpl implements ToolDoubaoService {
private static final Logger logger = LoggerFactory.getLogger(ToolDoubaoServiceImpl.class);
private static final String CHAT_COMPLETIONS_PATH = "/chat/completions";
@Autowired
private DoubaoConfig config;
/**
* 构建发送给豆包 API 的请求体
*/
private Map<String, Object> buildRequestBody(DoubaoChatRequest request, boolean stream) {
Map<String, Object> body = new LinkedHashMap<>();
// 模型:优先使用请求指定的,否则使用配置默认的
String model = (request.getModel() != null && !request.getModel().isEmpty())
? request.getModel() : config.getModel();
body.put("model", model);
// 消息列表 — 用 FastJSON 解析保留原始结构(与 KieAI 实现一致)
List<Object> messages = JSON.parseArray(JSON.toJSONString(request.getMessages()));
body.put("messages", messages);
body.put("stream", stream);
if (request.getTemperature() != null) {
body.put("temperature", request.getTemperature());
}
if (request.getMaxTokens() != null) {
body.put("max_tokens", request.getMaxTokens());
}
return body;
}
@Override
public Map<String, Object> chat(DoubaoChatRequest request) {
if (config.getApiKey() == null || config.getApiKey().isEmpty()) {
throw new RuntimeException("豆包 API Key 未配置");
}
Map<String, Object> body = buildRequestBody(request, false);
String url = config.getBaseUrl() + CHAT_COMPLETIONS_PATH;
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setReadTimeout(config.getReadTimeout());
factory.setConnectTimeout(config.getConnectTimeout());
RestTemplate rt = new RestTemplate(factory);
org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
headers.set("Authorization", "Bearer " + config.getApiKey());
headers.set("Content-Type", "application/json");
org.springframework.http.HttpEntity<String> entity =
new org.springframework.http.HttpEntity<>(JSON.toJSONString(body), headers);
try {
org.springframework.http.ResponseEntity<String> resp =
rt.postForEntity(url, entity, String.class);
if (resp.getBody() != null) {
return JSON.parseObject(resp.getBody(), Map.class);
}
return Collections.emptyMap();
} catch (Exception e) {
logger.error("Doubao chat error", e);
throw new RuntimeException("豆包对话失败: " + e.getMessage());
}
}
@Override
public SseEmitter chatStream(DoubaoChatRequest request) {
if (config.getApiKey() == null || config.getApiKey().isEmpty()) {
throw new RuntimeException("豆包 API Key 未配置");
}
SseEmitter emitter = new SseEmitter(120000L);
Map<String, Object> body = buildRequestBody(request, true);
String url = config.getBaseUrl() + CHAT_COMPLETIONS_PATH;
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(config.getConnectTimeout(), TimeUnit.MILLISECONDS)
.readTimeout(config.getReadTimeout(), TimeUnit.MILLISECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
RequestBody requestBody = RequestBody.create(
MediaType.parse("application/json; charset=utf-8"),
JSON.toJSONString(body));
Request okRequest = new Request.Builder()
.url(url)
.post(requestBody)
.addHeader("Authorization", "Bearer " + config.getApiKey())
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.build();
try {
client.newCall(okRequest).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
logger.error("Doubao chat stream request failed", e);
SseEmitterUtil.completeWithError(emitter, e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (!response.isSuccessful() || response.body() == null) {
String errBody = response.body() != null ? response.body().string() : "";
logger.error("Doubao stream failed: {} {}", response.code(), errBody);
SseEmitterUtil.completeWithError(emitter,
new RuntimeException("豆包流式请求失败: " + response.code()));
return;
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String json = line.substring(6).trim();
if ("[DONE]".equals(json) || json.isEmpty()) {
break;
}
SseEmitterUtil.send(emitter, json);
}
}
SseEmitterUtil.complete(emitter);
} catch (Exception e) {
logger.error("Doubao chat stream read error", e);
SseEmitterUtil.completeWithError(emitter, e);
}
}
});
} catch (Exception e) {
logger.error("Doubao chat stream error", e);
SseEmitterUtil.completeWithError(emitter, e);
}
return emitter;
}
}

View File

@@ -0,0 +1,31 @@
package com.zbkj.service.service.tool;
import com.zbkj.common.request.doubao.DoubaoChatRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
/**
* 豆包(火山引擎 Ark服务接口
* +----------------------------------------------------------------------
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
public interface ToolDoubaoService {
/**
* 豆包对话(非流式)
*
* @param request 对话请求参数
* @return 对话响应
*/
Map<String, Object> chat(DoubaoChatRequest request);
/**
* 豆包对话(流式 SSE
*
* @param request 对话请求参数
* @return SSE 事件流
*/
SseEmitter chatStream(DoubaoChatRequest request);
}