feat: 提交校园智能助手后端工程源码(Spring Boot 2.7.18 + 平台 starter + JPA + Nacos)
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:
lijing
2026-10-12 11:18:29 +08:00
parent 8589afd69a
commit 8303b857e9
58 changed files with 6883 additions and 2 deletions
@@ -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;
}
}