feat: 提交校园智能助手后端工程源码(Spring Boot 2.7.18 + 平台 starter + JPA + Nacos)
build / build-push (push) Successful in 40s
build / build-push (push) Successful in 40s
- com.yunhai.agent,端口 8093,context-path /agent,制品 target/agent.jar;与 NoticeService 同构 - CAS 单点登录 + 三层角色权限(学生/教师/管理员/访客)+ 会话与工具调用审计落库 - DeepSeek 大模型编排 + 7 个业务模块工具(请假/公告/宿舍/活动/作业/资源库/教师发展) - 浏览器代执行协议(服务端挂起 → 前端用登录态代调 → toolResult 续跑)+ 调用失败深链兜底 - 登出修复:服务端真正 session.invalidate() + 前端两步登出 + logout-guard - 保留 projman 注入的 .gitea/workflows/build.yml 与 docker/Dockerfile;docker/application-prod.yml、run-app.sh 随工程入库
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package com.yunhai.agent.llm;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.agent.config.AgentSettings;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ChatResult;
|
||||
import com.yunhai.agent.llm.dto.ToolCall;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 大模型客户端(OpenAI 兼容协议):默认对接 DeepSeek 官方
|
||||
* {@code https://api.deepseek.com/chat/completions},模型 {@code deepseek-flash}。
|
||||
* <p>模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。
|
||||
*
|
||||
* <p>协议要点:
|
||||
* <ul>
|
||||
* <li>请求体 {@code {model, messages, tools, tool_choice, max_tokens, temperature}}</li>
|
||||
* <li>响应 {@code choices[0].message.content} 为正文,
|
||||
* {@code choices[0].message.tool_calls[]} 为工具调用请求</li>
|
||||
* <li>模型要求调用工具时 {@code finish_reason == "tool_calls"},此时 content 通常为空</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>换供应商只要改 {@code agent.llm.host}(豆包方舟 / 千问兼容模式 / 自有网关都遵循该协议)。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class DeepSeekClient implements LlmClient {
|
||||
|
||||
private static final String TOOL_CHOICE_AUTO = "auto";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final AgentSettings settings;
|
||||
|
||||
public DeepSeekClient(@Qualifier("yhRestTemplate") RestTemplate restTemplate, AgentSettings settings) {
|
||||
this.restTemplate = restTemplate;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatResult chat(List<ChatMessage> messages, JSONArray tools) {
|
||||
AgentSettings.Llm llm = settings.getLlm();
|
||||
if (StringUtils.isBlank(llm.getApiKey())) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
"未配置大模型 api-key(agent.llm.api-key)");
|
||||
}
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("model", llm.getModel());
|
||||
body.put("max_tokens", llm.getMaxTokens());
|
||||
|
||||
JSONArray msgArr = new JSONArray();
|
||||
for (ChatMessage m : messages) {
|
||||
msgArr.add(m.toJson());
|
||||
}
|
||||
body.put("messages", msgArr);
|
||||
|
||||
// 现役 V4 系列(deepseek-flash / deepseek-v4-pro)temperature 与 tools 都支持。
|
||||
// 保留 isReasoner 判断只为兼容历史上"reasoner 不支持二者"的模型名,正常情况下恒为 false。
|
||||
if (!isReasoner(llm.getModel())) {
|
||||
body.put("temperature", llm.getTemperature());
|
||||
if (tools != null && !tools.isEmpty()) {
|
||||
body.put("tools", tools);
|
||||
body.put("tool_choice", TOOL_CHOICE_AUTO);
|
||||
}
|
||||
}
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization", "Bearer " + llm.getApiKey());
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
String url = StringUtils.isNotBlank(llm.getHost()) ? llm.getHost() : llm.getFallbackHost();
|
||||
String requestBody = JSON.toJSONString(body);
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
ResponseEntity<String> resp;
|
||||
try {
|
||||
resp = restTemplate.postForEntity(url, new HttpEntity<>(requestBody, headers), String.class);
|
||||
} catch (Exception e) {
|
||||
log.error("调用大模型失败 url={} model={} err={}", url, llm.getModel(), e.toString());
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanize(e, url));
|
||||
}
|
||||
long cost = System.currentTimeMillis() - start;
|
||||
|
||||
String raw = resp.getBody();
|
||||
if (StringUtils.isBlank(raw)) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回为空");
|
||||
}
|
||||
|
||||
JSONObject json;
|
||||
try {
|
||||
json = JSON.parseObject(raw);
|
||||
} catch (Exception e) {
|
||||
log.error("大模型返回非 JSON:{}", StringUtils.abbreviate(raw, 500));
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回格式异常");
|
||||
}
|
||||
|
||||
if (json.containsKey("error")) {
|
||||
JSONObject err = json.getJSONObject("error");
|
||||
String msg = err == null ? null : err.getString("message");
|
||||
String code = err == null ? null : err.getString("code");
|
||||
log.error("大模型返回错误:code={} msg={}", code, msg);
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanizeApiError(msg));
|
||||
}
|
||||
|
||||
ChatResult result = new ChatResult();
|
||||
result.setRaw(raw);
|
||||
|
||||
JSONArray choices = json.getJSONArray("choices");
|
||||
if (choices == null || choices.isEmpty()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型未返回任何选择");
|
||||
}
|
||||
JSONObject message = choices.getJSONObject(0).getJSONObject("message");
|
||||
if (message != null) {
|
||||
// 部分模型把 content 放在嵌套结构里(兼容历史网关),这里做一次宽容取值
|
||||
Object contentObj = message.get("content");
|
||||
if (contentObj instanceof String) {
|
||||
result.setContent((String) contentObj);
|
||||
} else if (contentObj != null) {
|
||||
result.setContent(JSON.toJSONString(contentObj));
|
||||
}
|
||||
JSONArray toolCalls = message.getJSONArray("tool_calls");
|
||||
if (toolCalls != null) {
|
||||
List<ToolCall> list = new ArrayList<>();
|
||||
for (int i = 0; i < toolCalls.size(); i++) {
|
||||
JSONObject tc = toolCalls.getJSONObject(i);
|
||||
ToolCall call = new ToolCall();
|
||||
call.setId(tc.getString("id"));
|
||||
call.setType(tc.getString("type"));
|
||||
JSONObject fn = tc.getJSONObject("function");
|
||||
if (fn != null) {
|
||||
ToolCall.Function f = new ToolCall.Function();
|
||||
f.setName(fn.getString("name"));
|
||||
f.setArguments(fn.getString("arguments"));
|
||||
call.setFunction(f);
|
||||
}
|
||||
list.add(call);
|
||||
}
|
||||
result.setToolCalls(list);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject usage = json.getJSONObject("usage");
|
||||
if (usage != null) {
|
||||
result.setPromptTokens(usage.getInteger("prompt_tokens"));
|
||||
result.setCompletionTokens(usage.getInteger("completion_tokens"));
|
||||
}
|
||||
|
||||
log.info("大模型调用完成 cost={}ms tools={} tokens={}/{}",
|
||||
cost, result.getToolCalls().size(),
|
||||
result.getPromptTokens(), result.getCompletionTokens());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把底层 HTTP 异常翻译成使用者能看懂的话。
|
||||
*
|
||||
* <p>典型场景:密钥无效时服务端回 401,而请求体是流式发送的,JDK 的 {@code HttpURLConnection}
|
||||
* 会抛出 {@code HttpRetryException: cannot retry due to server authentication, in streaming mode}
|
||||
* —— 这句话直接抛给前端毫无信息量,需要还原成「密钥无效」。
|
||||
*/
|
||||
private static String humanize(Throwable e, String url) {
|
||||
Integer status = findStatus(e);
|
||||
if (status != null && (status == 401 || status == 403)) {
|
||||
return "大模型鉴权失败(HTTP " + status + "):api-key 无效、已过期或无权限,"
|
||||
+ "请检查 agent.llm.api-key(或环境变量 DEEPSEEK_API_KEY)";
|
||||
}
|
||||
if (status != null && status == 402) {
|
||||
return "大模型账户余额不足(HTTP 402):请前往大模型平台充值后重试";
|
||||
}
|
||||
if (status != null && status == 429) {
|
||||
return "大模型请求过于频繁或账户余额不足(HTTP 429),请稍后重试或检查账户额度";
|
||||
}
|
||||
if (status != null && status >= 500) {
|
||||
return "大模型服务端异常(HTTP " + status + "),请稍后重试";
|
||||
}
|
||||
|
||||
String m = e.getMessage() == null ? e.toString() : e.getMessage();
|
||||
if (m.contains("cannot retry due to server authentication")) {
|
||||
return "大模型鉴权失败:api-key 无效或未配置,请检查 agent.llm.api-key";
|
||||
}
|
||||
if (m.contains("UnknownHost")) {
|
||||
return "无法解析大模型地址 " + url + ",请检查网络或 agent.llm.host 配置";
|
||||
}
|
||||
if (m.contains("onnection refused") || m.contains("connect timed out")) {
|
||||
return "无法连接大模型地址 " + url + ",请检查网络与代理设置";
|
||||
}
|
||||
if (m.contains("imed out") || m.contains("imeout")) {
|
||||
return "调用大模型超时,请稍后重试(可调大 yh.http.read-timeout-ms)";
|
||||
}
|
||||
return "大模型服务不可用:" + m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把大模型返回体里的 {@code error.message} 翻成人话。
|
||||
*
|
||||
* <p>该分支拿不到 HTTP 状态码(平台 RestTemplate 对 4xx 不回抛),只能按文案特征识别。
|
||||
* 典型:余额为 0 时 DeepSeek 回 {@code Insufficient Balance}(对应 HTTP 402),
|
||||
* 原样抛给使用者等于没说清「要充值」。
|
||||
*/
|
||||
private static String humanizeApiError(String msg) {
|
||||
String m = msg == null ? "" : msg;
|
||||
String lower = m.toLowerCase();
|
||||
if (lower.contains("insufficient balance") || lower.contains("insufficient_quota")
|
||||
|| lower.contains("exceeded your current quota") || lower.contains("payment required")) {
|
||||
return "大模型账户余额不足(" + m + "):请前往大模型平台充值后再试";
|
||||
}
|
||||
if (lower.contains("authentication fails") || lower.contains("invalid api key")
|
||||
|| lower.contains("invalid_api_key") || lower.contains("unauthorized")) {
|
||||
return "大模型鉴权失败(" + m + "):api-key 无效或已过期,请检查 agent.llm.api-key";
|
||||
}
|
||||
if (lower.contains("rate limit") || lower.contains("too many requests")) {
|
||||
return "大模型请求过于频繁(" + m + "),请稍后重试";
|
||||
}
|
||||
if (lower.contains("model not exist") || lower.contains("model_not_found")
|
||||
|| lower.contains("invalid model") || lower.contains("does not exist")) {
|
||||
return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models "
|
||||
+ "核对 agent.llm.model(当前应为 deepseek-flash 或 deepseek-v4-pro)";
|
||||
}
|
||||
return "大模型错误:" + m;
|
||||
}
|
||||
|
||||
/** 从异常链里挖出 HTTP 状态码(HttpRetryException / HttpStatusCodeException 两种常见载体) */
|
||||
private static Integer findStatus(Throwable e) {
|
||||
for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) {
|
||||
if (t instanceof java.net.HttpRetryException) {
|
||||
int code = ((java.net.HttpRetryException) t).responseCode();
|
||||
if (code > 0) {
|
||||
return code;
|
||||
}
|
||||
} else if (t instanceof org.springframework.web.client.HttpStatusCodeException) {
|
||||
return ((org.springframework.web.client.HttpStatusCodeException) t).getRawStatusCode();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isReasoner(String model) {
|
||||
return model != null && model.toLowerCase().contains("reasoner");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user