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");
}
}