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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.agent.llm;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.yunhai.agent.llm.dto.ChatMessage;
|
||||
import com.yunhai.agent.llm.dto.ChatResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 大模型客户端抽象:便于在 DeepSeek / 豆包 / 自有网关之间切换(配置驱动)。
|
||||
*/
|
||||
public interface LlmClient {
|
||||
|
||||
/**
|
||||
* 发起一次对话补全。
|
||||
*
|
||||
* @param messages 对话上下文(含 system)
|
||||
* @param tools 可调用的工具 schema(OpenAI tools 格式);为空表示本轮不开放工具
|
||||
* @return 模型结果(正文 + 工具调用请求)
|
||||
*/
|
||||
ChatResult chat(List<ChatMessage> messages, JSONArray tools);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一条对话消息(OpenAI / DeepSeek chat 格式)。
|
||||
*
|
||||
* <p>四种角色:
|
||||
* <ul>
|
||||
* <li>{@code system} — 系统提示词</li>
|
||||
* <li>{@code user} — 用户输入</li>
|
||||
* <li>{@code assistant} — 模型输出(可能带 {@link #toolCalls})</li>
|
||||
* <li>{@code tool} — 工具执行结果回填(必须带 {@link #toolCallId})</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChatMessage {
|
||||
|
||||
public static final String ROLE_SYSTEM = "system";
|
||||
public static final String ROLE_USER = "user";
|
||||
public static final String ROLE_ASSISTANT = "assistant";
|
||||
public static final String ROLE_TOOL = "tool";
|
||||
|
||||
private String role;
|
||||
private String content;
|
||||
|
||||
/** assistant 消息里的工具调用请求 */
|
||||
private List<ToolCall> toolCalls;
|
||||
|
||||
/** role=tool 时必填:对应哪一次工具调用 */
|
||||
private String toolCallId;
|
||||
|
||||
public ChatMessage(String role, String content) {
|
||||
this.role = role;
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public static ChatMessage system(String content) {
|
||||
return new ChatMessage(ROLE_SYSTEM, content);
|
||||
}
|
||||
|
||||
public static ChatMessage user(String content) {
|
||||
return new ChatMessage(ROLE_USER, content);
|
||||
}
|
||||
|
||||
public static ChatMessage tool(String toolCallId, String content) {
|
||||
ChatMessage m = new ChatMessage(ROLE_TOOL, content);
|
||||
m.setToolCallId(toolCallId);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 序列化为 DeepSeek 要求的消息结构(null 字段不输出) */
|
||||
public JSONObject toJson() {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("role", role);
|
||||
if (content != null) {
|
||||
o.put("content", content);
|
||||
} else if (ROLE_ASSISTANT.equals(role)) {
|
||||
// 带工具调用的 assistant 消息 content 允许为 null,但部分网关要求显式空串
|
||||
o.put("content", "");
|
||||
}
|
||||
if (toolCalls != null && !toolCalls.isEmpty()) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (ToolCall tc : toolCalls) {
|
||||
arr.add(tc.toJson());
|
||||
}
|
||||
o.put("tool_calls", arr);
|
||||
}
|
||||
if (StringUtils.isNotBlank(toolCallId)) {
|
||||
o.put("tool_call_id", toolCallId);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次模型调用的结果:要么是自然语言回复,要么是若干工具调用请求(也可能两者都有)。
|
||||
*/
|
||||
@Data
|
||||
public class ChatResult {
|
||||
|
||||
/** 模型回答正文(可能为空:当它只想调工具时) */
|
||||
private String content;
|
||||
|
||||
/** 模型要调用的工具 */
|
||||
private List<ToolCall> toolCalls = new ArrayList<>();
|
||||
|
||||
/** 原始响应(排障用) */
|
||||
private String raw;
|
||||
|
||||
/** token 用量,便于成本观测 */
|
||||
private Integer promptTokens;
|
||||
private Integer completionTokens;
|
||||
|
||||
public boolean hasToolCalls() {
|
||||
return toolCalls != null && !toolCalls.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.agent.llm.dto;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 模型请求调用某个工具。
|
||||
*/
|
||||
@Data
|
||||
public class ToolCall {
|
||||
|
||||
/** 调用 id,回填结果时原样带回 */
|
||||
private String id;
|
||||
|
||||
/** 固定为 function */
|
||||
private String type = "function";
|
||||
|
||||
private Function function;
|
||||
|
||||
@Data
|
||||
public static class Function {
|
||||
/** 工具名(对应 ToolRegistry 里注册的 name) */
|
||||
private String name;
|
||||
/** 参数 JSON 字符串(由模型生成,不一定合法,执行前要容错解析) */
|
||||
private String arguments;
|
||||
}
|
||||
|
||||
public JSONObject toJson() {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("id", id);
|
||||
o.put("type", type == null ? "function" : type);
|
||||
JSONObject f = new JSONObject();
|
||||
f.put("name", function == null ? null : function.getName());
|
||||
f.put("arguments", function == null ? "{}" : function.getArguments());
|
||||
o.put("function", f);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user