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,35 @@
package com.yunhai.agent;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
/**
* 校园智能助手启动类(与 NoticeService / UserBase 同族)。
*
* <p>Spring Boot 2.7.18 + {@code yh-spring-boot-starter}:统一响应 {@code {code,msg,data}}
* 由平台 {@code ApiResponseAdvice} 包装;单点登录(CAS/SSO)与当前用户上下文
* ({@code BoaUser} 方法参数、{@code UserContextHolder})由平台 starter 自动装配。
*
* <p>本服务承载两类职责:
* <ol>
* <li>把用户自然语言交给大模型(DeepSeek),由模型决定调用哪些工具;</li>
* <li>把模型给出的工具调用,真正转发到对应业务模块的 REST 接口(带用户身份),
* 并按「学生 / 教师」角色在服务端做能力过滤与越权拦截。</li>
* </ol>
*/
@SpringBootApplication(scanBasePackages = "com.yunhai.agent")
@EnableTransactionManagement
@EnableAsync
@EnableScheduling
public class AgentApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(AgentApplication.class, args);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.agent.auth;
/**
* 智能体侧的角色分类(由平台 {@code BoaUser} 解析而来)。
*
* <p>用途:决定「这个身份能用哪些工具」——学生侧(请假申请 / 交作业 / 报名活动 / 看公告)
* 与教职工侧(发公告 / 审批请假 / 发布资源 / 专业发展)严格分开。
*/
public enum AgentRole {
/** 学生 */
STUDENT("学生"),
/** 教师(含班主任) */
TEACHER("教师"),
/** 管理员(校管 / 分管领导 / 教务 / 校长) */
ADMIN("管理员"),
/** 访客:未登录或无法识别角色,只能使用公共只读能力 */
GUEST("访客");
private final String label;
AgentRole(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,89 @@
package com.yunhai.agent.auth;
import com.yunhai.agent.tool.ToolDef;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
/**
* 角色能力策略:集中声明「哪些工具面向学生、哪些面向教职工」。
*
* <p>为什么集中在一处:角色划分本质是<b>一所学校的治理策略</b>(会随学校调整),
* 集中成一张可读的表比散落在 7 个模块的工具定义里更容易审阅与改动;
* 若某个工具需要就近声明,用 {@code ToolDef.allow(...)} 显式指定即可(本策略会尊重已有声明)。
*
* <p>三类名单:
* <ul>
* <li>{@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):审批、发布、统计、宿管、专业发展;</li>
* <li>{@link #STUDENT_ONLY} —— 学生侧:交作业、报名活动、销假;</li>
* <li>{@link #SHARED} —— 学生与教职工都可能发起的:请假申请/撤销。</li>
* </ul>
* 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询)。
*/
public final class RolePolicy {
private RolePolicy() {
}
/** 教职工专属(教师 + 管理员) */
private static final Set<String> STAFF_ONLY = new HashSet<>(Arrays.asList(
// 请假:审批与统计
"leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass",
// 公告:发布与审批
"notice.sent", "notice.deptDics", "notice.publish",
"notice.approvalList", "notice.approve", "notice.reject",
// 宿舍通知:楼栋/房间/住宿生管理侧
"dorm.buildings", "dorm.rooms", "dorm.roomStudents", "dorm.dormTree",
// 家校活动:发布时选班级
"activity.classTree"
));
/** 学生专属 */
private static final Set<String> STUDENT_ONLY = new HashSet<>(Arrays.asList(
"homework.myList", "homework.detail", "homework.result", "homework.submit",
"activity.signup", "activity.cancelSignup",
"leave.reportBack"
));
/** 学生与教职工共用(发起/撤销自己的事项) */
private static final Set<String> SHARED = new HashSet<>(Arrays.asList(
"leave.submit", "leave.withdraw"
));
/** 模块级规则:整个模块仅教职工可用 */
private static final String[] STAFF_MODULE_PREFIXES = {"profession."};
/**
* 为未显式声明角色的工具套用默认角色策略。
*/
public static void apply(Collection<ToolDef> tools) {
if (tools == null) {
return;
}
for (ToolDef d : tools) {
if (d.getRoles() != null && !d.getRoles().isEmpty()) {
continue; // 工具自己声明过,尊重
}
String n = d.getName();
if (matchesPrefix(n, STAFF_MODULE_PREFIXES) || STAFF_ONLY.contains(n)) {
d.allow(AgentRole.TEACHER, AgentRole.ADMIN);
} else if (STUDENT_ONLY.contains(n)) {
d.allow(AgentRole.STUDENT);
} else if (SHARED.contains(n)) {
d.allow(AgentRole.STUDENT, AgentRole.TEACHER, AgentRole.ADMIN);
}
// 其余 = 不限制(所有已识别角色可用)
}
}
private static boolean matchesPrefix(String name, String[] prefixes) {
for (String p : prefixes) {
if (name.startsWith(p)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,154 @@
package com.yunhai.agent.auth;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 角色解析器:把平台 {@code BoaUser} 映射成智能体的角色集合。
*
* <h3>为什么以「当前身份」为准</h3>
* 平台里一个账号可以同时挂多个身份:{@code BoaUser.roleIds} 是<b>全部</b>身份的角色号,
* {@code BoaUser.currentRole} 是用户在门户里<b>当前激活</b>的那个身份
* (等价于 {@code YschBaseOpenApi.getCurrentIdentity(userId)})。
* 例如实测账号 10008 同时是「学生 + 管理员 + 教师」——若把所有身份并集,
* 权限只增不减、能力边界完全模糊(所有人都是全量 54 项)。
* 因此这里<b>只认当前身份</b>:与学校门户、其它业务模块的口径保持一致。
*
* <h3>判定规则(角色号可在 {@code agent.roles.*} 配置)</h3>
* <ol>
* <li>优先取 {@code currentRole.getRoleId()},命中 {@code agent.roles.student/teacher/admin}
* 即获得对应角色,且<b>只</b>获得这一个身份;</li>
* <li>当前身份是管理员时,按 {@code agent.roles.admin-also-teacher=true} 追加教师能力;</li>
* <li>当前身份号未在配置里声明(或账号没有当前身份)→ 回退按 {@code roleIds}/{@code type}
* 的旧口径判定,并打 WARN 日志提示补配置;</li>
* <li>什么都识别不出 → 访客(仅公共只读能力)。</li>
* </ol>
*/
@Slf4j
@Component
public class RoleResolver {
private final AgentSettings settings;
/** 每个用户上次记录过的身份描述,避免每请求刷屏(只在身份变化时打一次 INFO) */
private final Map<Integer, String> lastLogged = new ConcurrentHashMap<>();
public RoleResolver(AgentSettings settings) {
this.settings = settings;
}
/** 解析用户角色集合(永不为空,至少含 GUEST) */
public Set<AgentRole> resolve(BoaUser user) {
Set<AgentRole> roles = new LinkedHashSet<>();
if (user == null) {
roles.add(AgentRole.GUEST);
return roles;
}
AgentSettings.Roles cfg = settings.getRoles();
// 1) ★ 平台「当前身份」优先 —— 一个账号只生效一个身份
BoaRoleInfo current = user.getCurrentRole();
if (current != null && current.getRoleId() != null) {
Set<AgentRole> hit = match(current.getRoleId(), cfg);
if (!hit.isEmpty()) {
applyAdminAlsoTeacher(hit, cfg);
logResolved(user, current, hit, "当前身份");
return hit;
}
log.warn("[身份] 当前身份 roleId={} roleName={} 未命中 agent.roles.* 配置,"
+ "暂按账号全部身份兜底(userId={}, roleIds={})—— 建议把该角色号补进配置",
current.getRoleId(), current.getRoleName(), user.getId(), user.getRoleIds());
}
// 2) 兜底:没有当前身份,或当前身份号未配置
if (user.getType() != null && user.getType() == 1) {
roles.add(AgentRole.ADMIN);
}
List<Integer> roleIds = user.getRoleIds();
if (roleIds != null) {
for (Integer rid : roleIds) {
if (rid != null) {
roles.addAll(match(rid, cfg));
}
}
}
applyAdminAlsoTeacher(roles, cfg);
if (roles.isEmpty()) {
roles.add(AgentRole.GUEST);
}
logResolved(user, current, roles,
current == null ? "无当前身份,按账号全部身份" : "当前身份未配置,按账号全部身份");
return roles;
}
/** 角色集合的可读描述,如「教师」/「管理员+教师」 */
public String describe(Set<AgentRole> roles) {
if (roles == null || roles.isEmpty()) {
return "访客";
}
StringBuilder sb = new StringBuilder();
for (AgentRole r : roles) {
if (sb.length() > 0) {
sb.append("+");
}
sb.append(r.getLabel());
}
return sb.toString();
}
/** 单个角色号 → 智能体角色(一个号理论上可命中多个分组,全部保留) */
private Set<AgentRole> match(Integer roleId, AgentSettings.Roles cfg) {
Set<AgentRole> hit = new LinkedHashSet<>();
if (roleId == null) {
return hit;
}
if (contains(cfg.getAdmin(), roleId)) {
hit.add(AgentRole.ADMIN);
}
if (contains(cfg.getTeacher(), roleId)) {
hit.add(AgentRole.TEACHER);
}
if (contains(cfg.getStudent(), roleId)) {
hit.add(AgentRole.STUDENT);
}
return hit;
}
private void applyAdminAlsoTeacher(Set<AgentRole> roles, AgentSettings.Roles cfg) {
if (roles.contains(AgentRole.ADMIN) && cfg.isAdminAlsoTeacher()) {
roles.add(AgentRole.TEACHER);
}
}
/** 身份只在「变化时」记一次 INFO,避免每个请求刷屏,同时便于核对角色号配置 */
private void logResolved(BoaUser user, BoaRoleInfo current, Set<AgentRole> roles, String source) {
String desc = describe(roles);
String line = source + " → " + desc
+ (current == null ? "" : "(roleId=" + current.getRoleId()
+ ", roleName=" + current.getRoleName() + ", schId=" + current.getSchId() + ")");
Integer uid = user.getId();
if (uid == null) {
log.info("[身份] {}", line);
return;
}
String prev = lastLogged.put(uid, desc);
if (!desc.equals(prev)) {
log.info("[身份] userId={} {}", uid, line);
}
}
private boolean contains(List<Integer> list, int v) {
return list != null && list.contains(v);
}
}
@@ -0,0 +1,47 @@
package com.yunhai.agent.common.listener;
import com.yunhai.agent.entity.BaseEntity;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
* 当前登录人取自基础平台 {@link UserContextHolder}(SSE 异步线程里可能为空,此时只填时间)。
*/
@Component
public class AgentAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
String account = currentAccount();
if (account != null) {
if (entity.getCreateBy() == null) {
entity.setCreateBy(account);
}
entity.setUpdateBy(account);
}
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateTime(LocalDateTime.now());
String account = currentAccount();
if (account != null) {
entity.setUpdateBy(account);
}
}
private String currentAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
}
@@ -0,0 +1,171 @@
package com.yunhai.agent.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 智能体配置:大模型 + 各业务模块基地址 + 角色权限映射。
*
* <p>全部落在 {@code application.yml} 的 {@code agent.*} 下,便于运维按环境改配置而不动代码。
*/
@Data
@Component
@ConfigurationProperties(prefix = "agent")
public class AgentSettings {
/** 大模型配置 */
private Llm llm = new Llm();
/** 编排参数 */
private Loop loop = new Loop();
/** 角色权限映射(学生 / 教师 / 管理员 的角色号) */
private Roles roles = new Roles();
/**
* 业务模块基地址表:key = 模块标识(工具定义里引用),value = 该模块的根地址(不含 /api)。
* <p>例:{@code homeleave: http://base.yunhaixingtong.com/home-leave}
* <p>本地联调把 value 改成 {@code http://localhost:8106} 之类即可。
*/
private Map<String, String> modules = new LinkedHashMap<>();
/**
* 各业务模块的「默认落地页」——即该模块前端的一个路由(如 {@code /my})。
*
* <p><b>★ 深链兜底(2026-10 新增)</b>:业务模块的用户态接口 {@code /<模块>/api/*} 只认
* <b>浏览器 CAS 会话</b>,服务端调用拿不到,必然回 {@code code=302}(详见 README 第 11 节)。
* 此时智能体不再只丢一句报错,而是把<b>模块页面地址</b>随轨迹回传前端,
* 让用户用<b>自己的浏览器登录态</b>一键打开真实页面看数据 —— 这比"死等打通"务实得多。
*
* <p>取值已按各模块前端 {@code src/router/routes.ts} 的真实路由核对(2026-10-10):
* <pre>
* homeleave → /my 我的请假
* campusnotice → / 我的公告(收件箱)
* dormnotice → /par/notices 宿舍通知箱
* familyactivity → /par/activities 活动广场
* coursehomework → /stu/today 今日作业
* resourcelibrary → /resource 资源管理
* profession → /home 发展首页
* </pre>
* 运维可在 {@code application.yml} 用 {@code agent.module-landing.<模块>=<路由>} 覆盖单个模块;
* 工具自身声明的 {@code landingPath}({@code write(...)} / {@code readAt(...)})优先级更高。
*/
private Map<String, String> moduleLanding = defaultModuleLanding();
/** 模块默认落地页的出厂值(见 {@link #moduleLanding}) */
private static Map<String, String> defaultModuleLanding() {
Map<String, String> m = new LinkedHashMap<>();
m.put("homeleave", "/my");
m.put("campusnotice", "/");
m.put("dormnotice", "/par/notices");
m.put("familyactivity", "/par/activities");
m.put("coursehomework", "/stu/today");
m.put("resourcelibrary", "/resource");
m.put("profession", "/home");
return m;
}
/**
* 是否按「用户已授权模块」过滤工具(modId 维度)。
* <p>默认 false:不向模型隐藏工具,越权与否由各业务模块按透传身份自行裁决。
* <p>置 true 时:用平台 GetUserMod 的 modId 与 {@code ToolDef.moduleId} 比对,未授权模块的工具不暴露。
*/
private boolean filterToolsByAuthorizedModule = false;
/**
* 是否开启「角色强校验」。
* <p>默认 true:模型若调用了当前角色无权使用的工具,服务端直接拒绝执行(不发真实请求),
* 并在轨迹里留下 FORBIDDEN 记录 —— 这是「分学生、教师权限」的硬约束。
* <p>置 false 可临时放开(排障用),此时仅按角色过滤列表,不做执行拦截。
*/
private boolean enforceRolePermission = true;
/**
* 浏览器代执行(方案 A,2026-10-12 新增)。
*
* <p>业务模块的用户态接口只认<b>浏览器 CAS 会话</b>,服务端调用必然 {@code code=302}
* (README 第 11 节的四条实测证据)。开启后,模块类工具不再由服务端发请求,而是:
* <ol>
* <li>模型决定调用 → 服务端挂起,把「接口描述(方法/路径/参数)」下发前端;</li>
* <li>前端用<b>用户自己的登录态</b>调用模块接口(同站部署 ⇒ 同源,Cookie 自动携带);</li>
* <li>结果回传服务端 → 喂回模型 → 生成最终回答。</li>
* </ol>
* 本地 dev(localhost:8081)因跨域取不到 base 域会话,代执行会失败并自动落回
* 「去系统里查看」的深链兜底;同站部署后即真实生效。
*/
private BrowserExec browserExec = new BrowserExec();
@Data
public static class BrowserExec {
/** 是否启用浏览器代执行(false = 维持服务端直调的旧行为) */
private boolean enabled = true;
}
@Data
public static class Llm {
/** OpenAI 兼容的 chat/completions 地址(DeepSeek 官方:https://api.deepseek.com/chat/completions) */
private String host = "https://api.deepseek.com/chat/completions";
/** 多模态/推理兼用的兜底地址(可选,默认同上) */
private String fallbackHost;
private String apiKey;
/** 默认模型:deepseek-flash(DeepSeek-V4.1-Flash,支持 function calling)。
* 以官方实时清单为准:GET https://api.deepseek.com/models(2026-10-10:deepseek-flash / deepseek-v4-pro) */
private String model = "deepseek-flash";
private int maxTokens = 2048;
private double temperature = 0.3;
/** 单次 HTTP 超时(毫秒)。⚠️ 实际出站超时已由平台统一客户端接管,见 {@code yh.http.read-timeout-ms};本字段保留仅作文档参考 */
private int timeout = 60000;
/** 是否开启深度思考(旧的 deepseek-reasoner 独立模型已下线;V4 系列改为在请求里带 effort 档位,本开关暂未接线) */
private boolean thinking = false;
}
@Data
public static class Loop {
/** 单轮对话内最多允许的「模型思考 → 调工具」循环次数,防死循环 */
private int maxRounds = 6;
/** 每个工具调用结果塞回模型时的最大字符数(防撑爆上下文) */
private int toolResultMaxChars = 6000;
/** 会话上下文最多保留的历史消息条数 */
private int historyMaxMessages = 20;
/** 会话空闲过期时间(分钟) */
private int sessionTtlMinutes = 120;
}
/**
* 角色映射:UserBase 的角色号(roleId)→ 智能体角色。
* <p><b>★ 上线前必须按本校 UserBase 的角色体系校准</b>:
* 平台 {@code BoaUser.getType() == 1} 恒为超管/校管;其余按 {@code roleIds} 命中下面的集合判定。
* 一个用户可同时命中多个(如既是教师又是管理员),能力取并集。
*/
@Data
public static class Roles {
/** 学生角色号(默认 1;请按 UserBase 校准) */
private List<Integer> student = new ArrayList<>(Arrays.asList(1));
/** 教师角色号(默认 2=教师、3=班主任;请按 UserBase 校准) */
private List<Integer> teacher = new ArrayList<>(Arrays.asList(2, 3));
/** 管理员角色号(默认 4=校管、5=分管领导、6=教务、7=校长;请按 UserBase 校准) */
private List<Integer> admin = new ArrayList<>(Arrays.asList(4, 5, 6, 7));
/** 管理员是否天然拥有教师能力(默认 true:校管/领导可用教师侧工具) */
private boolean adminAlsoTeacher = true;
}
}
@@ -0,0 +1,21 @@
package com.yunhai.agent.config;
/**
* 全局路径常量。与 UserBase / Notice 一致:API 统一挂在 {@code /{platform}/} 之下
* (platform = api / manage / mobiapi),前端固定走 {@code /api/...} 同源代理。
*/
public enum Config {
Inst;
/** 平台统一前缀:manage / api / mobiapi,前面有 / 后面无 /,拼接子路径时用 / 分隔 */
public static final String API_PATH = "/{platform}";
/** 智能体域 */
public static final String AGENT = "/agent";
/** 平台壳层域(GetUserInfo / GetUserMod / Logout,与各业务模块同形) */
public static final String BASE = "/base";
/** 应用间开放域(签名鉴权,留给后续对第三方开放用) */
public static final String OPEN_API_PATH = "/oapi";
}
@@ -0,0 +1,214 @@
package com.yunhai.agent.controller;
import com.alibaba.fastjson.JSON;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.auth.RoleResolver;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.config.Config;
import com.yunhai.agent.model.ChatRequest;
import com.yunhai.agent.model.ChatResponse;
import com.yunhai.agent.service.AgentEvent;
import com.yunhai.agent.service.AgentOrchestrator;
import com.yunhai.agent.service.SessionStore;
import com.yunhai.agent.tool.CallerContext;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolRegistry;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
/**
* 智能体对外接口(路径前缀 {@code /{platform}/agent/},前端固定走 {@code /api/agent/...})。
*
* <ul>
* <li>POST {@code /api/agent/chat} —— 一问一答(阻塞返回完整结果)</li>
* <li>POST {@code /api/agent/chat/stream} —— SSE,实时推送「正在思考 / 正在调用 XX / 最终回复」</li>
* <li>GET {@code /api/agent/tools} —— 能力目录(按当前角色过滤后返回)</li>
* <li>POST {@code /api/agent/session/clear} —— 清空当前会话上下文</li>
* </ul>
*
* <p>返回裸对象,由平台 {@code ApiResponseAdvice} 自动包装为 {@code {code, msg, data}}(与 NoticeService 一致)。
* 当前登录用户由平台 CAS 会话注入 {@link BoaUser},角色由 {@link RoleResolver} 解析。
*/
@Slf4j
@RestController
@RequestMapping(Config.API_PATH + Config.AGENT + "/")
public class AgentController {
@Autowired
private AgentOrchestrator orchestrator;
@Autowired
private ToolRegistry registry;
@Autowired
private SessionStore sessionStore;
@Autowired
private AgentSettings settings;
@Autowired
private RoleResolver roleResolver;
/** 一问一答 */
@PostMapping("chat")
public ChatResponse chat(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) {
CallerContext caller = buildCaller(authUser, request);
return orchestrator.chat(req, describeUser(authUser, caller), caller,
authorizedModIds(authUser), null);
}
/** SSE 流式:阶段事件实时推送 */
@PostMapping(value = "chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) {
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
CallerContext caller = buildCaller(authUser, request);
String userDesc = describeUser(authUser, caller);
Set<Integer> mods = authorizedModIds(authUser);
CompletableFuture.runAsync(() -> {
try {
orchestrator.chat(req, userDesc, caller, mods, event -> {
try {
emitter.send(SseEmitter.event().name(event.getType())
.data(JSON.toJSONString(event), MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException e) {
// 客户端断开:后续事件丢弃即可
}
});
emitter.complete();
} catch (Exception e) {
log.error("SSE 对话异常", e);
try {
AgentEvent err = AgentEvent.of("error", e.getMessage() == null ? "服务异常" : e.getMessage());
emitter.send(SseEmitter.event().name("error")
.data(JSON.toJSONString(err), MediaType.APPLICATION_JSON));
} catch (Exception ignore) {
// 忽略
}
emitter.completeWithError(e);
}
});
// 客户端断开时释放
emitter.onTimeout(emitter::complete);
emitter.onError(e -> log.debug("SSE 连接错误:{}", e.toString()));
return emitter;
}
/** 能力目录:按当前角色 + 授权模块过滤后,按模块分组,前端用来渲染快捷指令与"能做什么" */
@GetMapping("tools")
public Map<String, Object> tools(BoaUser authUser, HttpServletRequest request) {
CallerContext caller = buildCaller(authUser, request);
Set<Integer> mods = authorizedModIds(authUser);
Set<AgentRole> roles = caller.getRoles();
Map<String, Object> result = new LinkedHashMap<>();
Map<String, List<ToolDef>> grouped = registry.groupByModule(mods, roles);
Map<String, Object> modules = new LinkedHashMap<>();
for (Map.Entry<String, List<ToolDef>> e : grouped.entrySet()) {
List<Map<String, Object>> items = new ArrayList<>();
for (ToolDef d : e.getValue()) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("name", d.getName());
item.put("title", d.getTitle());
item.put("description", d.getDescription());
item.put("write", !d.isReadOnly());
item.put("landingPath", d.getLandingPath());
item.put("roles", d.roleLabel());
items.add(item);
}
modules.put(e.getKey(), items);
}
result.put("modules", modules);
result.put("total", registry.visibleTools(mods, roles).size());
result.put("roles", roleResolver.describe(roles));
// 当前身份明细:前端展示「我现在是谁」,也方便现场核对角色号配置
Map<String, Object> identity = new LinkedHashMap<>();
identity.put("userId", caller.getUserId());
identity.put("userName", caller.getUserName());
identity.put("schoolId", caller.getSchoolId());
identity.put("roleId", caller.getRoleId());
identity.put("roleName", caller.getCurrentRoleName());
identity.put("roles", roleResolver.describe(roles));
result.put("identity", identity);
return result;
}
/** 清空会话 */
@PostMapping("session/clear")
public Map<String, Object> clearSession(@RequestBody(required = false) ChatRequest req) {
Map<String, Object> ok = new LinkedHashMap<>();
if (req != null) {
sessionStore.clear(req.getSessionId());
}
ok.put("cleared", true);
return ok;
}
/* ==================== 内部:身份与权限 ==================== */
/** 组装调用者身份:请求头 + 平台 BoaUser(后者权威),并解析角色 */
private CallerContext buildCaller(BoaUser authUser, HttpServletRequest request) {
CallerContext caller = CallerContext.from(request);
caller.fillFrom(authUser, roleResolver.resolve(authUser));
return caller;
}
/** 用户身份描述,写进 system prompt */
private String describeUser(BoaUser user, CallerContext caller) {
StringBuilder sb = new StringBuilder();
String name = user != null ? user.getName() : caller.getUserName();
if (StringUtils.isNotBlank(name)) {
sb.append("姓名 ").append(name);
} else {
sb.append("用户ID ").append(caller.getUserId());
}
sb.append(",身份 ").append(roleResolver.describe(caller.getRoles()));
if (caller.schoolIdAsInt() != null) {
sb.append(",学校ID ").append(caller.getSchoolId());
}
return sb.toString();
}
/** 已授权 modId 集合;未开启过滤或无身份时返回 null(=不按 modId 过滤) */
private Set<Integer> authorizedModIds(BoaUser user) {
if (!settings.isFilterToolsByAuthorizedModule() || user == null || user.getId() == null) {
return null;
}
try {
Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null;
List<BoaModule> mods = YschBaseOpenApi.getUserModule(user.getId(), schId);
if (mods == null) {
return new HashSet<>();
}
Set<Integer> ids = new HashSet<>();
for (BoaModule m : mods) {
if (m.getId() != null) {
ids.add(m.getId());
}
}
return ids;
} catch (Exception e) {
log.warn("获取用户授权模块失败,本轮不按 modId 过滤工具:{}", e.toString());
return null;
}
}
}
@@ -0,0 +1,83 @@
package com.yunhai.agent.controller;
import com.yunhai.agent.config.Config;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Collections;
import java.util.List;
/**
* 平台壳层接口(路径前缀 {@code /{platform}/base/},与各业务模块同名同义,前端外壳 layout 复用同一套调用)。
*
* <p>{@code BoaUser} 由平台 starter 的参数解析器从 CAS 会话注入(与 NoticeService 完全一致)。
*/
@Slf4j
@RestController
@RequestMapping(Config.API_PATH + Config.BASE + "/")
public class BaseApiController {
/** 当前登录用户信息 */
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
/** 当前用户已授权的模块列表(左侧菜单 / 权限过滤数据源) */
@GetMapping("GetUserMod")
public List<BoaModule> getUserMod(BoaUser user) {
if (user == null || user.getId() == null) {
return Collections.emptyList();
}
Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null;
return YschBaseOpenApi.getUserModule(user.getId(), schId);
}
/**
* 退出登录:销毁本应用的 CAS 会话。
*
* <p>★ 必须真的把会话销毁掉,不能只留痕。本应用的登录态就是服务端 session 里的
* {@code _const_cas_assertion_}(平台 {@code CasSsoApiRedirectFilter} 就是拿它判断
* 「登录了没」,{@code CasSessionAuthStrategy} 再经 {@code getRemoteUser()} 解析出身份)。
* 前端跳到 CAS 的 {@code /logout} 只能销毁 SSO 侧的票据,本应用的 JSESSIONID 依旧有效,
* 返回页面时仍是登录态 —— 用户看到的现象就是「点了退出没用 / 退不出去」。
*
* <p>为什么不能指望 CAS 的 SLO 回推:平台虽然注册了 {@code SingleSignOutFilter},
* 但没注册配套的会话监听,且回推要求本服务地址在 CAS 侧登记过(本地联调是
* {@code http://localhost:8093},必然登记不了)。平台自带的业务模块
* (如 HomeSchLeave 的 {@code LoginUserController#logout})同样是自己
* {@code session.invalidate()},这里与其保持同形。
*
* <p>同时保留 GET / POST 两种方法:前端外壳走 POST,平台老壳层沿用 GET。
*/
@RequestMapping(value = "Logout", method = {RequestMethod.GET, RequestMethod.POST})
public void logout(HttpServletRequest request) {
LoginUser user = UserContextHolder.get();
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
invalidateSession(request);
}
/** 销毁当前会话;会话本来就没有/已失效时不报错(登出接口必须幂等) */
private void invalidateSession(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return;
}
try {
session.invalidate();
log.info("[logout] 本地会话已销毁");
} catch (IllegalStateException e) {
log.debug("[logout] 会话已失效,无需重复销毁");
}
}
}
@@ -0,0 +1,51 @@
package com.yunhai.agent.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Lob;
import javax.persistence.Table;
/**
* 会话里的一条消息(system / user / assistant / tool),按 seq_no 保序。
*
* <p>assistant 消息可能携带工具调用请求,序列化进 {@link #toolCalls};
* tool 消息用于回填工具执行结果,关联 {@link #toolCallId}。
*/
@Entity
@Table(name = "agent_message", indexes = {
@Index(name = "idx_agent_msg_sid", columnList = "session_id,seq_no")
})
@Getter
@Setter
public class AgentMessage extends BaseEntity {
/** 所属会话 id */
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
/** 会话内顺序(从 0 递增) */
@Column(name = "seq_no")
private Integer seqNo;
/** 角色:system / user / assistant / tool */
@Column(name = "role", length = 16)
private String role;
/** 正文(可能为空:assistant 只想调工具时) */
@Lob
@Column(name = "content", columnDefinition = "TEXT")
private String content;
/** assistant 的 tool_calls(JSON 数组字符串) */
@Lob
@Column(name = "tool_calls", columnDefinition = "TEXT")
private String toolCalls;
/** role=tool 时关联的调用 id */
@Column(name = "tool_call_id", length = 64)
private String toolCallId;
}
@@ -0,0 +1,50 @@
package com.yunhai.agent.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 智能体会话:一个用户在「一段连续对话」里的上下文台账。
*
* <p>会话与 userId 绑定(跨用户复用同一 sessionId 会被拒绝并重建),承载多轮对话的归属与审计信息;
* 具体消息见 {@link AgentMessage}。
*/
@Entity
@Table(name = "agent_session", indexes = {
@Index(name = "idx_agent_session_sid", columnList = "session_id", unique = true),
@Index(name = "idx_agent_session_user", columnList = "user_id")
})
@Getter
@Setter
public class AgentSession extends BaseEntity {
/** 会话 id(前端持有并复用) */
@Column(name = "session_id", nullable = false, length = 64)
private String sessionId;
/** 归属用户 id */
@Column(name = "user_id")
private Integer userId;
/** 学校 id */
@Column(name = "sch_id")
private Integer schId;
/** 会话标题(取首条用户输入,便于列表展示) */
@Column(name = "title", length = 200)
private String title;
/** 角色快照(学生 / 教师 / 管理员),审计留痕用 */
@Column(name = "role_snapshot", length = 64)
private String roleSnapshot;
/** 最后访问时间(空闲过期依据) */
@Column(name = "last_access")
private LocalDateTime lastAccess;
}
@@ -0,0 +1,81 @@
package com.yunhai.agent.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Lob;
import javax.persistence.Table;
/**
* 工具调用审计:每次对业务模块的真实调用留一条痕(含越权被拒的调用)。
*
* <p>用途:可追溯「谁、什么身份、在哪个会话、调了哪个模块的什么接口、传了什么、成没成、耗时多少」,
* 既是运维排障依据,也是「分学生、教师权限」的合规凭证。
*/
@Entity
@Table(name = "agent_tool_call_log", indexes = {
@Index(name = "idx_agent_log_sid", columnList = "session_id"),
@Index(name = "idx_agent_log_tool", columnList = "tool_name"),
@Index(name = "idx_agent_log_user", columnList = "user_id")
})
@Getter
@Setter
public class AgentToolCallLog extends BaseEntity {
/** 所属会话 id */
@Column(name = "session_id", length = 64)
private String sessionId;
/** 调用者用户 id */
@Column(name = "user_id")
private Integer userId;
/** 学校 id */
@Column(name = "sch_id")
private Integer schId;
/** 调用者角色(学生 / 教师 / 管理员) */
@Column(name = "role", length = 64)
private String role;
/** 业务模块标识 */
@Column(name = "module", length = 64)
private String module;
/** 工具名,如 leave.submit */
@Column(name = "tool_name", length = 128)
private String toolName;
/** 工具中文名 */
@Column(name = "tool_title", length = 128)
private String toolTitle;
/** 是否写操作 */
@Column(name = "is_write")
private Boolean write;
/** 调用参数(JSON) */
@Lob
@Column(name = "arguments", columnDefinition = "TEXT")
private String arguments;
/** 是否成功(越权拦截为 false) */
@Column(name = "success")
private Boolean success;
/** 失败原因 */
@Lob
@Column(name = "error", columnDefinition = "TEXT")
private String error;
/** 耗时(毫秒) */
@Column(name = "cost_ms")
private Long costMs;
/** 建议跳转路由 */
@Column(name = "landing_path", length = 128)
private String landingPath;
}
@@ -0,0 +1,46 @@
package com.yunhai.agent.entity;
import com.yunhai.agent.common.listener.AgentAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link AgentAuditListener} 自动填充)。
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(AgentAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 创建人登录账号 */
@Column(name = "create_by", updatable = false)
private String createBy;
/** 创建时间 */
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
/** 更新人登录账号 */
@Column(name = "update_by")
private String updateBy;
/** 更新时间 */
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -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;
}
}
@@ -0,0 +1,56 @@
package com.yunhai.agent.model;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 「浏览器代执行」挂起载荷(方案 A)。
*
* <p>业务模块的用户态接口只认浏览器 CAS 会话,服务端代调必然 302。开启代执行后,
* 模型决定调用模块工具时,服务端<b>不执行</b>,而是把接口描述放进本载荷下发前端;
* 前端用用户自己的登录态调用模块接口,再把结果通过
* {@link ChatRequest.ToolResultItem} 回传,服务端续跑模型生成最终回答。
*
* <p>★ 安全边界:本载荷里的 {@code pathAndQuery} 只供前端调用使用;
* 回传续跑时服务端<b>只信任工具名</b>(重新查注册表拿方法/路径并做角色校验),
* 前端回传的任何路径信息都不会被采信,防止被改写成任意地址。
*/
@Data
public class BrowserToolRequest {
/** 本批代执行的工具调用(同一轮模型可能连调多个,如先查列表再查详情) */
private List<BrowserCall> calls = new ArrayList<>();
/** 给用户看的一句话说明(前端在等待期展示) */
private String notice;
/**
* 一次代执行调用的完整描述。
* <p>{@code pathAndQuery} 以 {@code /} 开头({@code /<模块ctx>/api/...?query}),
* 前端拼上当前页面的 {@code location.origin} 即为最终地址 ——
* 同站部署时天然同源;本地 dev 则会打到 vite 代理(404)→ 自动落回深链兜底。
*/
@Data
public static class BrowserCall {
/** 工具内部名(点分),回传结果时原样带回 */
private String name;
/** 中文简称(进度展示) */
private String title;
/** 模型给的参数(回显给用户看「在查什么」;回传时原样带回) */
private Map<String, Object> arguments;
/** 是否写操作 */
private boolean write;
/** GET / POST_JSON / POST_FORM */
private String method;
/** 相对当前站点根的路径与查询串,如 /home-leave/api/activity/List?keyword=运动会 */
private String pathAndQuery;
/** POST 时的请求体(GET 为 null) */
private String body;
/** POST 时的 Content-Type */
private String contentType;
}
}
@@ -0,0 +1,61 @@
package com.yunhai.agent.model;
import lombok.Data;
import java.util.Map;
/**
* 前端发起的一次对话请求。
*/
@Data
public class ChatRequest {
/** 会话 id;首次为空,服务端生成并在响应里带回,前端后续复用 */
private String sessionId;
/** 用户输入的自然语言 */
private String message;
/** 是否允许调用工具(默认允许);调试/纯问答可置 false */
private Boolean enableTools = true;
/** 用户对上一次写操作的确认结果(预留:二次确认场景) */
private Boolean confirmed;
/**
* 表单提交:用户在补全表单里填完直接提交。
* <p>非空时服务端<b>直接执行</b>指定工具(不再让模型猜该不该调、参数是什么),
* 保证「填了就一定按填的提交」。
*/
private FormSubmit formSubmit;
/**
* 浏览器代执行的结果回传(方案 A):与 {@link #formSubmit} 同级的「续跑」入口。
* <p>前端用用户登录态调用模块接口后,把每个调用的原始响应带回;
* 服务端重新查注册表解析结果、喂回模型生成最终回答。
*/
private java.util.List<ToolResultItem> toolResult;
/** 一次代执行调用的回传项(与挂起载荷里的 BrowserCall 按 name 对应) */
@Data
public static class ToolResultItem {
/** 工具内部名(点分),与挂起时一致 */
private String name;
/** 模型参数原样带回(审计与轨迹展示用) */
private Map<String, Object> arguments;
/** HTTP 状态码;网络层失败时为 null */
private Integer status;
/** 响应体原文 */
private String body;
/** 网络层错误(fetch 失败/超时/跨域),有值时 status 无意义 */
private String error;
}
@Data
public static class FormSubmit {
/** 目标工具名,如 leave.submit */
private String tool;
/** 用户填写的参数(键与业务接口参数名一致) */
private Map<String, Object> args;
}
}
@@ -0,0 +1,48 @@
package com.yunhai.agent.model;
import com.yunhai.agent.tool.FormRequest;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 一次对话的完整结果。
*/
@Data
public class ChatResponse {
private String sessionId;
/** 智能体最终回复(自然语言) */
private String reply;
/** 本轮执行过的工具调用(按顺序) */
private List<ToolCallTrace> toolCalls = new ArrayList<>();
/** 是否发生了写操作(前端据此提示「已为你提交」) */
private boolean wroteSomething;
/** 建议跳转的路由(写操作后给「去查看」按钮用) */
private String landingPath;
/** 循环轮数 / token 用量,便于观测 */
private int rounds;
private Integer promptTokens;
private Integer completionTokens;
/** 会话被重置或异常时的提示 */
private String warning;
/**
* 信息不全时下发的「补全表单」:前端渲染成可填卡片,用户填完直接提交。
* <p>本轮正常执行完毕(或纯问答)时为 null。
*/
private FormRequest form;
/**
* 「浏览器代执行」挂起载荷(方案 A):非空表示本轮工具调用需要
* 前端用用户登录态调用模块接口并回传结果({@code toolResult})续跑。
*/
private BrowserToolRequest browserTool;
}
@@ -0,0 +1,32 @@
package com.yunhai.agent.model;
import lombok.Data;
import java.util.Map;
/**
* 一次工具调用的执行轨迹(回传前端渲染「智能体做了什么」)。
*/
@Data
public class ToolCallTrace {
private String name;
private String title;
/** 业务模块标识(前端据此拼出该模块页面地址做「去处理」跳转) */
private String module;
/** 模型给出的参数(已脱敏:不含身份信息) */
private Map<String, Object> arguments;
private boolean success = true;
private String error;
/** 是否产生副作用的写操作 */
private boolean write;
/** 执行后建议跳转的前端路由 */
private String landingPath;
private long costMs;
}
@@ -0,0 +1,23 @@
package com.yunhai.agent.repository;
import com.yunhai.agent.entity.AgentMessage;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 会话消息仓储。
*/
public interface AgentMessageRepository extends JpaRepository<AgentMessage, Integer> {
List<AgentMessage> findBySessionIdOrderBySeqNoAsc(String sessionId);
@Transactional
@Modifying
@Query("delete from AgentMessage m where m.sessionId = :sessionId")
int deleteBySessionId(@Param("sessionId") String sessionId);
}
@@ -0,0 +1,34 @@
package com.yunhai.agent.repository;
import com.yunhai.agent.entity.AgentSession;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 智能体会话仓储。
*/
public interface AgentSessionRepository extends JpaRepository<AgentSession, Integer> {
Optional<AgentSession> findBySessionId(String sessionId);
/** 空闲过期扫描:最后访问时间早于给定时刻的会话 */
List<AgentSession> findByLastAccessBefore(LocalDateTime time);
/** 窄列更新最后访问时间 */
@Transactional
@Modifying
@Query("update AgentSession s set s.lastAccess = :now where s.sessionId = :sessionId")
int touch(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now);
@Transactional
@Modifying
@Query("delete from AgentSession s where s.sessionId = :sessionId")
int deleteBySessionId(@Param("sessionId") String sessionId);
}
@@ -0,0 +1,18 @@
package com.yunhai.agent.repository;
import com.yunhai.agent.entity.AgentToolCallLog;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 工具调用审计仓储。
*/
public interface AgentToolCallLogRepository extends JpaRepository<AgentToolCallLog, Integer> {
List<AgentToolCallLog> findBySessionIdOrderByIdAsc(String sessionId);
long countByToolName(String toolName);
long countBySuccess(Boolean success);
}
@@ -0,0 +1,69 @@
package com.yunhai.agent.service;
import com.alibaba.fastjson.JSON;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.entity.AgentToolCallLog;
import com.yunhai.agent.repository.AgentToolCallLogRepository;
import com.yunhai.agent.tool.CallerContext;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.Set;
/**
* 工具调用审计服务:把每一次工具执行(含被角色拦截的)写入 {@code agent_tool_call_log}。
*
* <p>用独立事务({@code REQUIRES_NEW})落库,避免审计写入失败影响主对话链路。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentAuditService {
private final AgentToolCallLogRepository logRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String sessionId, CallerContext caller, ToolDef def,
Map<String, Object> args, ToolResult result) {
try {
AgentToolCallLog e = new AgentToolCallLog();
e.setSessionId(sessionId);
e.setUserId(caller == null ? null : caller.userIdAsInt());
e.setSchId(caller == null ? null : caller.schoolIdAsInt());
e.setRole(roleText(caller == null ? null : caller.getRoles()));
e.setModule(def.getModule());
e.setToolName(def.getName());
e.setToolTitle(def.getTitle());
e.setWrite(!def.isReadOnly());
e.setArguments(args == null ? null : JSON.toJSONString(args));
e.setSuccess(result.isSuccess());
e.setError(result.getError());
e.setCostMs(result.getCostMs());
e.setLandingPath(def.getLandingPath());
logRepository.save(e);
} catch (Exception ex) {
// 审计失败不阻断主流程
log.warn("工具调用审计落库失败:{}", ex.toString());
}
}
private String roleText(Set<AgentRole> roles) {
if (roles == null || roles.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (AgentRole r : roles) {
if (sb.length() > 0) {
sb.append(",");
}
sb.append(r.name());
}
return sb.toString();
}
}
@@ -0,0 +1,34 @@
package com.yunhai.agent.service;
import lombok.Data;
/**
* 编排过程中的一个阶段事件(用于 SSE 推送给前端做「正在思考 / 正在调用 XX」的实时反馈)。
*/
@Data
public class AgentEvent {
/** thinking / tool_call / tool_result / message / done / error */
private String type;
/** 事件文本(展示用) */
private String text;
/** 结构化负载(工具名、参数、结果等) */
private Object payload;
public static AgentEvent of(String type, String text) {
AgentEvent e = new AgentEvent();
e.type = type;
e.text = text;
return e;
}
public static AgentEvent of(String type, String text, Object payload) {
AgentEvent e = new AgentEvent();
e.type = type;
e.text = text;
e.payload = payload;
return e;
}
}
@@ -0,0 +1,930 @@
package com.yunhai.agent.service;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.llm.LlmClient;
import com.yunhai.agent.llm.dto.ChatMessage;
import com.yunhai.agent.llm.dto.ChatResult;
import com.yunhai.agent.llm.dto.ToolCall;
import com.yunhai.agent.model.BrowserToolRequest;
import com.yunhai.agent.model.ChatRequest;
import com.yunhai.agent.model.ChatResponse;
import com.yunhai.agent.model.ToolCallTrace;
import com.yunhai.agent.tool.CallerContext;
import com.yunhai.agent.tool.FormField;
import com.yunhai.agent.tool.FormRequest;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolExecutor;
import com.yunhai.agent.tool.ToolParam;
import com.yunhai.agent.tool.ToolRegistry;
import com.yunhai.agent.tool.ToolResult;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 智能体编排核心(ReAct 风格的工具调用循环):
*
* <pre>
* 用户输入
* └─► 组装 system + 历史 + 本轮输入
* └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
* ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
* │ └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
* │ ├─ 角色越权 ─► 服务端直接拒绝(留痕)
* │ └─► 结果回填为 role=tool 消息 ─► 再次调模型
* └─ 模型返回普通文本 ─► 这就是最终回复,结束
* </pre>
*
* <p>表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:<b>直接执行</b>指定工具,
* 不再让模型二次决策,保证「用户填了什么就提交什么」。
*/
@Slf4j
@Service
public class AgentOrchestrator {
private final AgentSettings settings;
private final LlmClient llmClient;
private final ToolRegistry registry;
private final ToolExecutor executor;
private final SessionStore sessionStore;
private final AgentAuditService auditService;
@Autowired
public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry,
ToolExecutor executor, SessionStore sessionStore,
AgentAuditService auditService) {
this.settings = settings;
this.llmClient = llmClient;
this.registry = registry;
this.executor = executor;
this.sessionStore = sessionStore;
this.auditService = auditService;
}
/**
* 执行一轮对话。
*
* @param req 请求(含用户输入、会话 id,或表单提交)
* @param userDesc 用户身份描述(姓名/角色/学校),用于写进 system prompt
* @param caller 调用者身份(透传给业务模块 + 角色过滤/校验)
* @param authorizedModIds 已授权模块号集合(可空 = 不过滤)
* @param sink 阶段事件出口(同步传 NOOP,SSE 传真实推送)
*/
public ChatResponse chat(ChatRequest req, String userDesc, CallerContext caller,
Set<Integer> authorizedModIds, EventSink sink) {
EventSink out = sink == null ? EventSink.NOOP : sink;
// 浏览器代执行结果回传:续跑(方案 A)
if (req != null && req.getToolResult() != null && !req.getToolResult().isEmpty()) {
return resumeBrowser(req, caller, authorizedModIds, out);
}
// 表单提交:短路直达执行
if (req != null && req.getFormSubmit() != null
&& StringUtils.isNotBlank(req.getFormSubmit().getTool())) {
return submitForm(req, userDesc, caller, authorizedModIds, out);
}
if (req == null || StringUtils.isBlank(req.getMessage())) {
throw new IllegalArgumentException("请输入内容");
}
Set<AgentRole> roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
session.setTitle(abbreviate(req.getMessage()));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
List<ChatMessage> messages = session.getMessages();
if (messages.isEmpty()) {
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
}
messages.add(ChatMessage.user(req.getMessage()));
boolean enableTools = req.getEnableTools() == null || req.getEnableTools();
return runLoop(session, messages, resp, caller, authorizedModIds, roles, enableTools, false, out);
}
/**
* 模型主循环(ReAct):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。
*
* <p>★ 浏览器代执行(方案 A):业务模块的用户态接口只认<b>浏览器 CAS 会话</b>,
* 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled}
* 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser});
* 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。
*/
private ChatResponse runLoop(SessionStore.SessionState session, List<ChatMessage> messages,
ChatResponse resp, CallerContext caller, Set<Integer> authorizedModIds,
Set<AgentRole> roles, boolean enableTools, boolean resuming, EventSink out) {
JSONArray tools = enableTools ? registry.toOpenAiTools(authorizedModIds, roles) : null;
int maxRounds = settings.getLoop().getMaxRounds();
int totalPrompt = 0;
int totalCompletion = 0;
String lastContent = null;
for (int round = 1; round <= maxRounds; round++) {
resp.setRounds(round);
String thinking = round == 1
? (resuming ? "正在根据查询结果整理回答…" : "正在理解你的需求…")
: "正在根据结果继续处理…";
out.emit(AgentEvent.of("thinking", thinking));
ChatResult result;
try {
result = llmClient.chat(messages, tools);
} catch (Exception e) {
String msg = e.getMessage() == null ? "大模型调用失败" : e.getMessage();
out.emit(AgentEvent.of("error", msg));
resp.setReply("抱歉,我这边连接智能服务失败了:" + msg);
resp.setWarning(msg);
return resp;
}
totalPrompt += nz(result.getPromptTokens());
totalCompletion += nz(result.getCompletionTokens());
lastContent = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : lastContent;
if (!result.hasToolCalls()) {
// 没有工具调用 → 这一条就是最终回复
String reply = StringUtils.isNotBlank(result.getContent())
? result.getContent()
: "(模型没有返回内容,请换个说法再试一次)";
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
resp.setReply(reply);
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
// 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法)
ChatMessage assistantMsg = new ChatMessage(ChatMessage.ROLE_ASSISTANT, result.getContent());
assistantMsg.setToolCalls(result.getToolCalls());
messages.add(assistantMsg);
// ★ 预检:只要有一个「写操作」缺必填参数,就整体中断并改出表单。
// 放在执行任何工具之前,避免消息历史里出现「有 tool_call 却没有对应 tool 结果」的非法上下文。
ChatResponse pending = interceptMissing(result.getToolCalls(), messages, caller,
session.getSessionId(), out);
if (pending != null) {
pending.setSessionId(session.getSessionId());
pending.setRounds(round);
finish(pending, session, totalPrompt, totalCompletion, out);
return pending;
}
// ★ 浏览器代执行:整批可准备成功 → 挂起(撤销本轮 assistant(tool_calls),等浏览器回传续跑)
if (settings.getBrowserExec().isEnabled()) {
List<BrowserToolRequest.BrowserCall> batch =
tryPrepareBrowserBatch(result.getToolCalls(), caller);
if (batch != null) {
messages.remove(messages.size() - 1);
BrowserToolRequest bt = new BrowserToolRequest();
bt.setCalls(batch);
String titles = batch.get(0).getTitle()
+ (batch.size() > 1 ? " 等 " + batch.size() + " 项查询" : "");
bt.setNotice("「" + titles + "」需要用你的登录态获取,正在通过你的浏览器执行…");
resp.setBrowserTool(bt);
resp.setReply(bt.getNotice());
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
log.info("浏览器代执行挂起:session={} 工具={}", session.getSessionId(),
batch.stream().map(BrowserToolRequest.BrowserCall::getName)
.collect(java.util.stream.Collectors.joining(",")));
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
}
for (ToolCall call : result.getToolCalls()) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = toolName == null ? null : registry.get(toolName);
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
if (def == null) {
// 模型幻觉出了不存在的工具:如实告知,让它改正
String err = "不存在的工具:" + toolName;
out.emit(AgentEvent.of("tool_result", "工具不可用:" + toolName));
messages.add(ChatMessage.tool(call.getId(), "{\"success\":false,\"error\":\"" + err + "\"}"));
ToolCallTrace trace = new ToolCallTrace();
trace.setName(String.valueOf(toolName));
trace.setTitle("未知工具");
trace.setSuccess(false);
trace.setError(err);
resp.getToolCalls().add(trace);
continue;
}
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(),
payloadOf(def, args)));
ToolResult tr = executor.execute(def, args, caller);
auditService.record(session.getSessionId(), caller, def, args, tr);
messages.add(ChatMessage.tool(call.getId(), tr.getContent()));
ToolCallTrace trace = buildTrace(def, args, tr);
resp.getToolCalls().add(trace);
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
out.emit(AgentEvent.of("tool_result",
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
payloadOf(def, args)));
log.info("工具 {} 执行 {} 耗时 {}ms", def.getName(),
tr.isSuccess() ? "成功" : "失败(" + tr.getError() + ")", tr.getCostMs());
}
// 继续下一轮,让模型基于工具结果生成自然语言回复
}
// 超过最大轮数:把最后一次内容兜底返回,避免"空转"
String reply = StringUtils.isNotBlank(lastContent)
? lastContent
: "这个需求需要多步操作,我已经执行到这里,建议你到对应页面确认一下结果。";
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
resp.setReply(reply);
resp.setWarning("已达到单轮最大处理步数");
finish(resp, session, totalPrompt, totalCompletion, out);
return resp;
}
/**
* 尝试把本轮所有工具调用准备成一个「浏览器代执行」批次。
*
* @return 批次;null = 无法整批代执行(存在未知工具 / 模块未配置地址 / 参数不合法 / 越权),
* 调用方回退服务端直调老路径 —— 保证任何情况下消息历史的 tool_call/tool 结果配对完整。
*/
private List<BrowserToolRequest.BrowserCall> tryPrepareBrowserBatch(List<ToolCall> calls,
CallerContext caller) {
List<BrowserToolRequest.BrowserCall> batch = new ArrayList<>();
for (ToolCall call : calls) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = registry.get(toolName);
if (def == null || !settings.getModules().containsKey(def.getModule())) {
return null;
}
// 越权的调用不进批次(浏览器白跑一趟没有意义),交回老路径立即拒绝
if (executor.checkAccess(def, caller) != null) {
return null;
}
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
try {
batch.add(executor.prepareBrowserCall(def, args));
} catch (Exception e) {
log.info("代执行批次准备失败({}),回退服务端执行:{}", def.getName(), e.getMessage());
return null;
}
}
return batch;
}
/**
* 浏览器代执行的续跑:前端已用<b>用户自己的登录态</b>调完模块接口并带回原始响应。
*
* <p>服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源,
* <b>不信任</b>前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息
* 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。
*/
private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller,
Set<Integer> authorizedModIds, EventSink out) {
Set<AgentRole> roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
List<ChatMessage> messages = session.getMessages();
if (messages.isEmpty()) {
resp.setReply("会话已过期,请重新说一次你的需求。");
resp.setWarning("browser-resume:会话没有历史消息(服务重启或已过期)");
finish(resp, session, 0, 0, out);
return resp;
}
String idPrefix = "br_" + System.currentTimeMillis() + "_";
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
List<ToolCall> calls = new ArrayList<>();
List<String> contents = new ArrayList<>();
int idx = 0;
for (ChatRequest.ToolResultItem item : req.getToolResult()) {
Map<String, Object> args = item.getArguments() == null
? new LinkedHashMap<>() : new LinkedHashMap<>(item.getArguments());
ToolDef def = registry.get(item.getName());
ToolResult tr;
if (def == null) {
tr = unknownToolResult(item.getName());
} else {
ToolResult denied = executor.checkAccess(def, caller);
if (denied != null) {
tr = denied;
} else if (StringUtils.isNotBlank(item.getError())) {
tr = ToolResult.fail(def, "浏览器调用未完成:" + item.getError(), 0);
} else {
tr = executor.parseBrowserResponse(def, item.getStatus(), item.getBody(), 0);
}
auditService.record(session.getSessionId(), caller, def, args, tr);
resp.getToolCalls().add(buildTrace(def, args, tr));
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
}
out.emit(AgentEvent.of("tool_result",
(def == null ? item.getName() : def.getTitle())
+ (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError())));
log.info("浏览器代执行回传 {} {} ", item.getName(), tr.isSuccess() ? "成功" : "失败");
ToolCall tc = new ToolCall();
tc.setId(idPrefix + idx);
ToolCall.Function fn = new ToolCall.Function();
fn.setName(def != null ? def.getName() : item.getName());
fn.setArguments(JSON.toJSONString(args));
tc.setFunction(fn);
calls.add(tc);
contents.add(tr.getContent());
idx++;
}
assistant.setToolCalls(calls);
messages.add(assistant);
for (int i = 0; i < contents.size(); i++) {
messages.add(ChatMessage.tool(idPrefix + i, contents.get(i)));
}
return runLoop(session, messages, resp, caller, authorizedModIds, roles, true, true, out);
}
/** 前端回传了未注册的工具名(正常不会发生):构造统一的失败结果回填给模型 */
private ToolResult unknownToolResult(String name) {
ToolResult tr = new ToolResult();
tr.setToolName(name);
tr.setTitle("未知工具");
tr.setSuccess(false);
tr.setError("不存在的工具:" + name);
tr.setContent("{\"success\":false,\"error\":\"不存在的工具:" + name + "\"}");
return tr;
}
/* ==================== 表单:缺参拦截 与 直提交 ==================== */
/**
* 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。
*
* @return 需要用户补全信息时返回响应(含 form);一切正常返回 null
*/
private ChatResponse interceptMissing(List<ToolCall> calls, List<ChatMessage> messages,
CallerContext caller, String sessionId, EventSink out) {
for (ToolCall call : calls) {
String toolName = call.getFunction() == null ? null : call.getFunction().getName();
ToolDef def = toolName == null ? null : registry.get(toolName);
if (def == null || def.isReadOnly()) {
continue;
}
// 越权的写操作不走表单(表单填了也执行不了),交由执行期统一拒绝并告知
if (settings.isEnforceRolePermission() && !def.allowsAny(caller.getRoles())) {
continue;
}
Map<String, Object> args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments());
List<ToolParam> missing = missingRequired(def, args);
if (missing.isEmpty()) {
continue;
}
// 撤销本轮的 assistant(tool_calls),保持历史自洽(用户可能不填表、转而说别的)
if (!messages.isEmpty()) {
messages.remove(messages.size() - 1);
}
FormRequest form = buildForm(def, args, missing, caller);
ChatResponse resp = new ChatResponse();
resp.setForm(form);
resp.setReply(askText(def, missing));
out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()),
payloadOf(def, args)));
return resp;
}
return null;
}
/**
* 表单直提交:用户已填好参数 → 直接执行工具 → 再由模型把结果说成自然语言。
*/
private ChatResponse submitForm(ChatRequest req, String userDesc, CallerContext caller,
Set<Integer> authorizedModIds, EventSink out) {
ChatRequest.FormSubmit fs = req.getFormSubmit();
Set<AgentRole> roles = caller.getRoles();
SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(),
caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles));
ChatResponse resp = new ChatResponse();
resp.setSessionId(session.getSessionId());
ToolDef def = registry.get(fs.getTool());
if (def == null) {
resp.setReply("抱歉,这个操作暂时不可用。");
resp.setWarning("未注册的工具:" + fs.getTool());
return resp;
}
// 角色门禁:提交时再校验一次(模型侧过滤可被绕过,这里是硬约束)
if (!def.allowsAny(roles)) {
resp.setReply("抱歉,当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。");
resp.setWarning("越权操作已被拒绝:" + def.getName());
return resp;
}
Map<String, Object> args = fs.getArgs() == null
? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs());
// 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口
List<ToolParam> missing = missingRequired(def, args);
if (!missing.isEmpty()) {
resp.setForm(buildForm(def, args, missing, caller));
resp.setReply(askText(def, missing));
return resp;
}
List<ChatMessage> messages = session.getMessages();
if (messages.isEmpty()) {
messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles)));
}
// 把"用户填表提交了什么"写入历史,让后续对话有上下文
messages.add(ChatMessage.user(renderSubmission(def, args)));
out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), payloadOf(def, args)));
// ★ 浏览器代执行(方案 A):模块接口只认浏览器会话,服务端提交必然 302。
// 表单提交同样挂起给前端用用户登录态执行,结果回传后由 resumeBrowser 续跑;
// 准备失败(缺参/模块未配置)时回退服务端直调老路径。
if (settings.getBrowserExec().isEnabled()
&& settings.getModules().containsKey(def.getModule())) {
try {
BrowserToolRequest.BrowserCall bc = executor.prepareBrowserCall(def, args);
BrowserToolRequest bt = new BrowserToolRequest();
bt.setCalls(new ArrayList<>(Collections.singletonList(bc)));
bt.setNotice("「" + def.getTitle() + "」需要用你的登录态提交,正在通过你的浏览器执行…");
resp.setBrowserTool(bt);
resp.setReply(bt.getNotice());
out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt));
log.info("表单提交转浏览器代执行:session={} 工具={}", session.getSessionId(), def.getName());
finish(resp, session, 0, 0, out);
return resp;
} catch (Exception e) {
log.warn("表单代执行准备失败,回退服务端执行:{} - {}", def.getName(), e.toString());
}
}
ToolResult tr = executor.execute(def, args, caller);
auditService.record(session.getSessionId(), caller, def, args, tr);
// 构造合法的 assistant(tool_calls) + tool 结果,模型据此组织语言
String callId = "form_" + System.currentTimeMillis();
ToolCall tc = new ToolCall();
tc.setId(callId);
ToolCall.Function fn = new ToolCall.Function();
fn.setName(def.getName());
fn.setArguments(JSON.toJSONString(args));
tc.setFunction(fn);
ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null);
assistant.setToolCalls(Collections.singletonList(tc));
messages.add(assistant);
messages.add(ChatMessage.tool(callId, tr.getContent()));
resp.getToolCalls().add(buildTrace(def, args, tr));
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath());
}
}
out.emit(AgentEvent.of("tool_result",
def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()),
payloadOf(def, args)));
log.info("表单提交执行 {} {} 耗时 {}ms", def.getName(), tr.isSuccess() ? "成功" : "失败", tr.getCostMs());
String reply;
int promptTokens = 0;
int completionTokens = 0;
try {
ChatResult r = llmClient.chat(messages, null);
promptTokens = nz(r.getPromptTokens());
completionTokens = nz(r.getCompletionTokens());
reply = StringUtils.isNotBlank(r.getContent()) ? r.getContent() : fallbackReply(def, tr);
} catch (Exception e) {
log.warn("表单提交后生成回复失败,改用兜底话术:{}", e.toString());
reply = fallbackReply(def, tr);
}
messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply));
resp.setReply(reply);
finish(resp, session, promptTokens, completionTokens, out);
return resp;
}
/** 找出缺失的必填参数(fixed 固定参数视为已提供) */
private List<ToolParam> missingRequired(ToolDef def, Map<String, Object> args) {
Map<String, Object> effective = new LinkedHashMap<>();
if (def.getFixed() != null) {
effective.putAll(def.getFixed());
}
if (args != null) {
effective.putAll(args);
}
List<ToolParam> missing = new ArrayList<>();
for (ToolParam p : def.getParams()) {
if (!p.isRequired() || p.isHidden()) {
continue;
}
Object v = effective.get(p.getName());
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
missing.add(p);
}
}
return missing;
}
/** 由工具定义生成补全表单 */
private FormRequest buildForm(ToolDef def, Map<String, Object> args, List<ToolParam> missing,
CallerContext caller) {
FormRequest form = new FormRequest();
form.setTool(def.getName());
form.setTitle(def.getTitle());
form.setModule(def.getModule());
form.setWrite(!def.isReadOnly());
form.setLandingPath(def.getLandingPath());
// 本次构建内的字典缓存:同一字典只请求一次(字段下拉与已识别回显共用)
Map<String, List<Map<String, String>>> dictCache = new LinkedHashMap<>();
List<FormField> fields = new ArrayList<>();
List<String> missingLabels = new ArrayList<>();
for (ToolParam p : missing) {
FormField f = p.toFormField();
// 字典类字段(如请假类型):先跑一次它的来源工具,把可选项直接摆到下拉框里
// 判据是「声明了来源」而不是「当前没有选项」—— 参数可能已带内置兜底,
// 实时字典取到就覆盖兜底,取不到则保留兜底(下拉框不会退化成数字框)。
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
List<Map<String, String>> opts = dictOptions(p.getOptionsFrom(), caller, dictCache);
if (!opts.isEmpty()) {
f.setOptions(opts);
f.setWidget("select");
f.setOptionsFallback(false);
}
}
fields.add(f);
missingLabels.add(p.labelOf());
}
form.setFields(fields);
form.setMissing(missingLabels);
// 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对)
Map<String, Object> preset = new LinkedHashMap<>();
List<Map<String, String>> recognized = new ArrayList<>();
if (args != null) {
for (Map.Entry<String, Object> e : args.entrySet()) {
Object v = e.getValue();
if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) {
continue;
}
preset.put(e.getKey(), v);
ToolParam p = findParam(def, e.getKey());
Map<String, String> row = new LinkedHashMap<>();
row.put("name", e.getKey());
row.put("label", p != null ? p.labelOf() : e.getKey());
row.put("text", humanize(p, v, caller, dictCache));
recognized.add(row);
}
}
form.setPreset(preset);
form.setRecognized(recognized);
form.setDescription("补齐下面的信息,我就直接帮你执行「" + def.getTitle() + "」。");
form.setHint("带 * 为必填;时间类字段请选到具体时刻,我会按你选的时间提交。");
return form;
}
private ToolParam findParam(ToolDef def, String name) {
for (ToolParam p : def.getParams()) {
if (name.equals(p.getName())) {
return p;
}
}
return null;
}
/** 参数值 → 可读文本:有静态枚举或动态字典时翻译成中文 */
private String humanize(ToolParam p, Object value, CallerContext caller,
Map<String, List<Map<String, String>>> cache) {
String text = String.valueOf(value);
if (p == null) {
return text;
}
if (p.getEnumValues() != null && !p.getEnumValues().isEmpty()) {
int idx = p.getEnumValues().indexOf(text);
if (idx >= 0 && p.getEnumLabels() != null && idx < p.getEnumLabels().size()) {
return p.getEnumLabels().get(idx);
}
return text;
}
if (StringUtils.isNotBlank(p.getOptionsFrom())) {
for (Map<String, String> o : dictOptions(p.getOptionsFrom(), caller, cache)) {
if (text.equals(o.get("value"))) {
return o.get("label");
}
}
}
return text;
}
/** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */
private List<Map<String, String>> dictOptions(String toolName, CallerContext caller,
Map<String, List<Map<String, String>>> cache) {
if (cache.containsKey(toolName)) {
return cache.get(toolName);
}
List<Map<String, String>> opts = fetchDictOptions(toolName, caller);
cache.put(toolName, opts == null ? new ArrayList<>() : opts);
return cache.get(toolName);
}
/**
* 执行字典工具并尽力解析成 [{value,label}]。
* <p>解析失败(结构不认识、接口不通)一律返回 null,前端退化成普通输入框,不影响主流程。
*/
private List<Map<String, String>> fetchDictOptions(String toolName, CallerContext caller) {
try {
ToolDef dict = registry.get(toolName);
if (dict == null || !dict.isReadOnly()) {
return null;
}
// 字典工具也受角色约束:越权则取不到选项,退化为普通输入框
if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) {
return null;
}
ToolResult tr = executor.execute(dict, new LinkedHashMap<>(), caller);
if (!tr.isSuccess()) {
return null;
}
JSONArray arr = findArray(JSON.toJSON(tr.getData()), 0);
if (arr == null || arr.isEmpty()) {
return null;
}
List<Map<String, String>> options = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
Object item = arr.get(i);
if (!(item instanceof JSONObject)) {
continue;
}
JSONObject o = (JSONObject) item;
String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key");
if (StringUtils.isBlank(value)) {
continue;
}
String label = firstOf(o, "name", "label", "text", "title", "dictName", "value");
Map<String, String> one = new LinkedHashMap<>();
one.put("value", value);
one.put("label", StringUtils.isBlank(label) ? value : label);
options.add(one);
}
return options.isEmpty() ? null : options;
} catch (Exception e) {
log.warn("拉取字典选项失败 {}:{}", toolName, e.toString());
return null;
}
}
private String firstOf(JSONObject o, String... keys) {
for (String k : keys) {
Object v = o.get(k);
if (v != null && StringUtils.isNotBlank(String.valueOf(v))) {
return String.valueOf(v);
}
}
return null;
}
/** 递归找第一个数组(限深,避免异常结构下深度遍历) */
private JSONArray findArray(Object node, int depth) {
if (node == null || depth > 3) {
return null;
}
if (node instanceof JSONArray) {
return (JSONArray) node;
}
if (node instanceof JSONObject) {
JSONObject o = (JSONObject) node;
for (String k : o.keySet()) {
JSONArray a = findArray(o.get(k), depth + 1);
if (a != null) {
return a;
}
}
}
return null;
}
/** 缺参时的引导话术(表单之外的文字说明) */
private String askText(ToolDef def, List<ToolParam> missing) {
List<String> parts = new ArrayList<>();
for (ToolParam p : missing) {
String l = p.labelOf();
if (StringUtils.isNotBlank(p.getExample())) {
l = l + "(格式如 " + p.getExample() + ")";
}
parts.add(l);
}
return "要完成「" + def.getTitle() + "」,还差这几项:" + String.join("、", parts)
+ "。\n可以直接在下面的表单里填,填好点「提交」就行。";
}
/** 把表单提交渲染成一条用户消息,写入会话历史 */
private String renderSubmission(ToolDef def, Map<String, Object> args) {
StringBuilder sb = new StringBuilder();
sb.append("我已在表单里填好并提交「").append(def.getTitle()).append("」,参数为:");
for (Map.Entry<String, Object> e : args.entrySet()) {
sb.append("\n- ").append(e.getKey()).append(" = ").append(String.valueOf(e.getValue()));
}
return sb.toString();
}
private ToolCallTrace buildTrace(ToolDef def, Map<String, Object> args, ToolResult tr) {
ToolCallTrace trace = new ToolCallTrace();
trace.setName(def.getName());
trace.setTitle(def.getTitle());
trace.setModule(def.getModule());
trace.setArguments(args);
trace.setSuccess(tr.isSuccess());
trace.setError(tr.getError());
trace.setWrite(!def.isReadOnly());
trace.setLandingPath(def.getLandingPath());
trace.setCostMs(tr.getCostMs());
return trace;
}
/** 大模型不可用时的兜底话术(表单提交场景,结果已经确定) */
private String fallbackReply(ToolDef def, ToolResult tr) {
if (tr.isSuccess()) {
return "「" + def.getTitle() + "」已执行完成。";
}
return "「" + def.getTitle() + "」执行失败:" + (tr.getError() == null ? "未知原因" : tr.getError());
}
private void finish(ChatResponse resp, SessionStore.SessionState session,
int promptTokens, int completionTokens, EventSink out) {
resp.setPromptTokens(promptTokens);
resp.setCompletionTokens(completionTokens);
sessionStore.trim(session);
sessionStore.persist(session);
out.emit(AgentEvent.of("message", resp.getReply()));
out.emit(AgentEvent.of("done", "ok"));
}
private int nz(Integer v) {
return v == null ? 0 : v;
}
/** 容错解析模型给的参数 JSON(模型经常返回非法 JSON 或空串) */
private Map<String, Object> parseArgs(String arguments) {
if (StringUtils.isBlank(arguments)) {
return new LinkedHashMap<>();
}
try {
JSONObject obj = JSON.parseObject(arguments);
if (obj == null) {
return new LinkedHashMap<>();
}
Map<String, Object> map = new LinkedHashMap<>();
for (String k : obj.keySet()) {
map.put(k, obj.get(k));
}
return map;
} catch (Exception e) {
log.warn("工具参数不是合法 JSON,已忽略:{}", arguments);
return new LinkedHashMap<>();
}
}
private Object payloadOf(ToolDef def, Map<String, Object> args) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("name", def.getName());
p.put("title", def.getTitle());
p.put("module", def.getModule());
p.put("arguments", args);
p.put("write", !def.isReadOnly());
return p;
}
/** 构造系统提示词:身份 + 角色 + 能力清单 + 行为准则 */
private String buildSystemPrompt(String userDesc, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
StringBuilder sb = new StringBuilder();
sb.append("你是「云海星通校园平台」的智能助手,帮助用户在系统里直接完成操作,而不是只做问答。\n\n");
sb.append("【当前用户】").append(StringUtils.isBlank(userDesc) ? "未知用户" : userDesc).append("\n");
sb.append("【当前时间】").append(now).append("(星期").append(weekday()).append(")\n\n");
sb.append("【可用能力】以下是你当前身份可以调用的业务能力。调用后系统会把真实结果给你,")
.append("你必须依据结果回答,绝不能编造成功或数据:\n");
Map<String, List<ToolDef>> grouped = registry.groupByModule(authorizedModIds, roles);
for (Map.Entry<String, List<ToolDef>> e : grouped.entrySet()) {
sb.append("- ").append(moduleLabel(e.getKey())).append(":");
List<String> names = new ArrayList<>();
for (ToolDef d : e.getValue()) {
names.add(d.getTitle());
}
sb.append(String.join("、", names)).append("\n");
}
sb.append("\n【行为准则】\n");
sb.append("1. 用户表达一个操作意图(如“我要请假”“帮我看看作业”)时,直接调用对应工具完成,不要只给操作指引。\n");
sb.append("2. ★ 写操作(提交/审批/发布/撤销)缺参数时,**不要用文字反复追问**:\n")
.append(" 请照常调用该工具,把能从用户话里确定的参数填上,**不确定的字段直接省略**。\n")
.append(" 系统会自动弹出带中文标签、格式示例和下拉选项的表单让用户补齐,比口头追问准确得多。\n");
sb.append("3. 时间类参数统一用 yyyy-MM-dd HH:mm:ss;用户说“明天”“下周一”这类相对时间,")
.append("请基于上面的当前时间换算成具体日期时间后再填,并在回答里说明你换算成的日期。\n");
sb.append("4. 一次可以调用多个工具(例如先查列表再取详情),但不要重复调用同一个工具做同样的事。\n");
sb.append("5. 工具返回 success=false 时,把失败原因用通俗的话告诉用户,并给出下一步建议;不要假装成功。\n");
sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n");
sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n");
sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求“审批请假”“发布公告”,")
.append("或教师要求“提交学生的请假申请”),要如实说明“该操作需要 XX 身份”,不要试图越权,也不要承诺你会去办。\n");
return sb.toString();
}
private String weekday() {
String[] w = {"日", "一", "二", "三", "四", "五", "六"};
java.util.Calendar c = java.util.Calendar.getInstance();
return w[c.get(java.util.Calendar.DAY_OF_WEEK) - 1];
}
/** 角色集合 → 可读描述(落库快照用) */
private String rolesText(Set<AgentRole> roles) {
if (roles == null || roles.isEmpty()) {
return "访客";
}
StringBuilder sb = new StringBuilder();
for (AgentRole r : roles) {
if (sb.length() > 0) {
sb.append("+");
}
sb.append(r.getLabel());
}
return sb.toString();
}
private String abbreviate(String s) {
if (s == null) {
return null;
}
String t = s.trim().replaceAll("\\s+", " ");
return t.length() <= 60 ? t : t.substring(0, 60);
}
private String moduleLabel(String module) {
switch (module) {
case "homeleave": return "学生请假";
case "campusnotice": return "校园公告";
case "dormnotice": return "宿舍通知";
case "familyactivity": return "家校活动";
case "coursehomework": return "课程作业";
case "resourcelibrary": return "资源库";
case "profession": return "教师专业发展";
default: return module;
}
}
}
@@ -0,0 +1,14 @@
package com.yunhai.agent.service;
/**
* 阶段事件出口:同步接口走空实现,SSE 接口把事件写给浏览器。
*/
@FunctionalInterface
public interface EventSink {
void emit(AgentEvent event);
/** 什么都不做的实现 */
EventSink NOOP = event -> {
};
}
@@ -0,0 +1,199 @@
package com.yunhai.agent.service;
import com.alibaba.fastjson.JSON;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.entity.AgentMessage;
import com.yunhai.agent.entity.AgentSession;
import com.yunhai.agent.llm.dto.ChatMessage;
import com.yunhai.agent.llm.dto.ToolCall;
import com.yunhai.agent.repository.AgentMessageRepository;
import com.yunhai.agent.repository.AgentSessionRepository;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 会话上下文存储(JPA 落库版)。
*
* <p>会话本体({@link AgentSession})与消息({@link AgentMessage})持久化到 MySQL,
* 服务重启后可续接;内存里只做「当前请求内的加载态」。
*
* <p>安全:会话与 {@code userId} 绑定,跨用户读同一 sessionId 会被拒绝并重建。
*/
@Slf4j
@Component
public class SessionStore {
private final AgentSettings settings;
private final AgentSessionRepository sessionRepo;
private final AgentMessageRepository messageRepo;
/** 会话级轻量互斥,避免同一会话并发写导致消息全量重写互相覆盖 */
private final Map<String, Object> locks = new ConcurrentHashMap<>();
public SessionStore(AgentSettings settings,
AgentSessionRepository sessionRepo,
AgentMessageRepository messageRepo) {
this.settings = settings;
this.sessionRepo = sessionRepo;
this.messageRepo = messageRepo;
}
/** 取会话;不存在或不属于该用户则新建(并落库会话行) */
@Transactional
public SessionState getOrCreate(String sessionId, Integer userId, Integer schId, String roleSnapshot) {
if (StringUtils.isNotBlank(sessionId)) {
Optional<AgentSession> exist = sessionRepo.findBySessionId(sessionId);
if (exist.isPresent() && sameUser(exist.get(), userId)) {
sessionRepo.touch(sessionId, LocalDateTime.now());
return load(exist.get());
}
if (exist.isPresent()) {
log.warn("会话 {} 归属不匹配(owner={} 请求者={}),已重建",
sessionId, exist.get().getUserId(), userId);
}
}
AgentSession s = new AgentSession();
s.setSessionId(UUID.randomUUID().toString().replace("-", ""));
s.setUserId(userId);
s.setSchId(schId);
s.setRoleSnapshot(roleSnapshot);
s.setLastAccess(LocalDateTime.now());
sessionRepo.save(s);
SessionState st = new SessionState();
st.setSessionId(s.getSessionId());
st.setUserId(userId);
st.setSchId(schId);
st.setMessages(new ArrayList<>());
return st;
}
private SessionState load(AgentSession s) {
SessionState st = new SessionState();
st.setSessionId(s.getSessionId());
st.setUserId(s.getUserId());
st.setSchId(s.getSchId());
List<ChatMessage> msgs = new ArrayList<>();
for (AgentMessage m : messageRepo.findBySessionIdOrderBySeqNoAsc(s.getSessionId())) {
ChatMessage cm = new ChatMessage(m.getRole(), m.getContent());
if (StringUtils.isNotBlank(m.getToolCalls())) {
cm.setToolCalls(JSON.parseArray(m.getToolCalls(), ToolCall.class));
}
cm.setToolCallId(m.getToolCallId());
msgs.add(cm);
}
st.setMessages(msgs);
return st;
}
/**
* 持久化会话消息(全量重写该会话的消息表 + 刷新访问时间与标题)。
* <p>消息量小(≤ historyMaxMessages),全量重写比增量对账更简单可靠,
* 也天然处理了「拦截表单时撤销一条 assistant 消息」这类回退。
*/
@Transactional
public void persist(SessionState state) {
if (state == null || StringUtils.isBlank(state.getSessionId())) {
return;
}
String sid = state.getSessionId();
synchronized (lockOf(sid)) {
messageRepo.deleteBySessionId(sid);
int seq = 0;
for (ChatMessage m : state.getMessages()) {
AgentMessage em = new AgentMessage();
em.setSessionId(sid);
em.setSeqNo(seq++);
em.setRole(m.getRole());
em.setContent(m.getContent());
em.setToolCallId(m.getToolCallId());
if (m.getToolCalls() != null && !m.getToolCalls().isEmpty()) {
em.setToolCalls(JSON.toJSONString(m.getToolCalls()));
}
messageRepo.save(em);
}
sessionRepo.touch(sid, LocalDateTime.now());
// 标题兜底:取首条用户输入
if (StringUtils.isNotBlank(state.getTitle())) {
sessionRepo.findBySessionId(sid).ifPresent(s -> {
if (StringUtils.isBlank(s.getTitle())) {
s.setTitle(state.getTitle());
sessionRepo.save(s);
}
});
}
}
}
@Transactional
public void clear(String sessionId) {
if (StringUtils.isBlank(sessionId)) {
return;
}
messageRepo.deleteBySessionId(sessionId);
sessionRepo.deleteBySessionId(sessionId);
locks.remove(sessionId);
}
/** 按保留条数裁剪历史(保留最近 N 条,避免上下文无界增长) */
public void trim(SessionState state) {
int max = settings.getLoop().getHistoryMaxMessages();
List<ChatMessage> msgs = state.getMessages();
if (msgs.size() > max) {
// 从头部丢弃最老的消息,但要保证不以 role=tool 开头(否则非法)
int drop = msgs.size() - max;
while (drop < msgs.size() && ChatMessage.ROLE_TOOL.equals(msgs.get(drop).getRole())) {
drop++;
}
state.setMessages(new ArrayList<>(msgs.subList(drop, msgs.size())));
}
}
/** 过期清理:每 10 分钟扫一次,删除空闲超时的会话及其消息 */
@Scheduled(fixedDelay = 600_000, initialDelay = 600_000)
@Transactional
public void evictExpired() {
long ttl = settings.getLoop().getSessionTtlMinutes();
LocalDateTime deadline = LocalDateTime.now().minusMinutes(ttl);
List<AgentSession> expired = sessionRepo.findByLastAccessBefore(deadline);
if (expired.isEmpty()) {
return;
}
for (AgentSession s : expired) {
messageRepo.deleteBySessionId(s.getSessionId());
sessionRepo.delete(s);
locks.remove(s.getSessionId());
}
log.info("会话过期清理:删除 {} 个空闲会话(TTL {} 分钟)", expired.size(), ttl);
}
private boolean sameUser(AgentSession s, Integer userId) {
return s.getUserId() == null ? userId == null : s.getUserId().equals(userId);
}
private Object lockOf(String sid) {
return locks.computeIfAbsent(sid, k -> new Object());
}
/** 内存态会话(含本次请求加载的消息列表) */
@Data
public static class SessionState {
private String sessionId;
private Integer userId;
private Integer schId;
private String title;
private List<ChatMessage> messages;
}
}
@@ -0,0 +1,140 @@
package com.yunhai.agent.tool;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 「谁在对话」——把当前请求的身份原样带着,转发到下游业务模块。
*
* <p>为什么必须转发:各业务模块的用户/学校/角色都由平台 SSO 会话(Cookie)与
* {@code currentRole} 请求头解析。AgentService 只有把这些透传过去,
* 下游才会以「当前登录人」的身份执行,做到权限收敛、数据范围正确。
*
* <p>身份有两个来源:① 平台自动装配的参数解析器注入的 {@link BoaUser}(权威);
* ② 请求头 {@code currentRole/userId/roleId/schoolId}(兼容旧网关)。前者优先。
*/
@Data
public class CallerContext {
private String currentRole;
private String userId;
private String roleId;
private String schoolId;
private String cookie;
private String referer;
private String userAgent;
/** 姓名(来自 BoaUser) */
private String userName;
/** 登录账号(来自 BoaUser) */
private String account;
/** 当前身份的角色名(平台 currentRole.roleName,如「教师」),用于展示「我现在是谁」 */
private String currentRoleName;
/** 解析后的智能体角色(学生 / 教师 / 管理员 / 访客),由 RoleResolver 填充 */
private Set<AgentRole> roles = new LinkedHashSet<>();
/** 是否拿到了可用身份(拿不到就只允许调用公共只读工具) */
public boolean hasIdentity() {
return StringUtils.isNotBlank(userId) || StringUtils.isNotBlank(currentRole);
}
public Integer userIdAsInt() {
return toInt(userId);
}
public Integer roleIdAsInt() {
return toInt(roleId);
}
public Integer schoolIdAsInt() {
return toInt(schoolId);
}
public boolean hasRole(AgentRole role) {
return roles != null && roles.contains(role);
}
/**
* 用平台解析出的 {@link BoaUser} 补齐权威身份(比请求头更可信),并落定角色集合。
*/
public void fillFrom(BoaUser user, Set<AgentRole> resolvedRoles) {
if (resolvedRoles != null && !resolvedRoles.isEmpty()) {
this.roles = resolvedRoles;
}
if (user == null) {
return;
}
if (StringUtils.isNotBlank(user.getName())) {
this.userName = user.getName();
}
if (StringUtils.isNotBlank(user.getAccount())) {
this.account = user.getAccount();
}
if (user.getId() != null) {
this.userId = String.valueOf(user.getId());
}
if (user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null) {
this.schoolId = String.valueOf(user.getCurrentRole().getSchId());
}
// roleId 兜底:优先用「当前身份」的角色号(下游老模块读该头时才是对的),
// 没有当前身份时才退回 roleIds 里的第一个
if (StringUtils.isBlank(this.roleId) && user.getCurrentRole() != null
&& user.getCurrentRole().getRoleId() != null) {
this.roleId = String.valueOf(user.getCurrentRole().getRoleId());
}
if (StringUtils.isBlank(this.roleId) && user.getRoleIds() != null) {
for (Integer rid : user.getRoleIds()) {
if (rid != null) {
this.roleId = String.valueOf(rid);
break;
}
}
}
// 当前身份名(角色名),便于前端展示「我现在是谁」
if (user.getCurrentRole() != null && StringUtils.isNotBlank(user.getCurrentRole().getRoleName())) {
this.currentRoleName = user.getCurrentRole().getRoleName();
}
}
public static CallerContext from(HttpServletRequest request) {
CallerContext c = new CallerContext();
c.currentRole = request.getHeader("currentRole");
c.userId = request.getHeader("userId");
c.roleId = request.getHeader("roleId");
c.schoolId = request.getHeader("schoolId");
c.referer = request.getHeader("yschreferer");
c.userAgent = request.getHeader("User-Agent");
// Cookie 整体透传(平台 SSO 的登录态在 Cookie 里;SSO 亦可接受网关注入的会话)
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
StringBuilder sb = new StringBuilder();
for (Cookie ck : cookies) {
if (sb.length() > 0) {
sb.append("; ");
}
sb.append(ck.getName()).append("=").append(ck.getValue());
}
c.cookie = sb.toString();
}
return c;
}
private static Integer toInt(String s) {
try {
return StringUtils.isBlank(s) ? null : Integer.valueOf(s.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,52 @@
package com.yunhai.agent.tool;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 表单里的一个字段:由 {@link ToolParam} 生成,前端据此选择控件(下拉 / 日期时间 / 多行文本…)。
*/
@Data
public class FormField {
/** 提交时的参数名(与业务接口一致) */
private String name;
/** 中文标签,如「开始时间」 */
private String label;
/** 控件类型:text / textarea / number / date / datetime / select */
private String widget;
/** 数据类型:string / integer / number / boolean */
private String type;
private boolean required;
/** 说明(来自工具定义,写清格式与含义) */
private String description;
/** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */
private String example;
private String defaultValue;
/**
* 下拉选项的动态来源(字典工具名,如 leave.types)。
* <p>有值但 {@link #options} 为空 ⇒ 字典接口没取到(多半是下游 302/超时),
* 前端据此提示用户「可先手填编号」,而不是干瞪眼。
*/
private String optionsFrom;
/**
* 当前 {@link #options} 是否来自内置兜底(而非学校实时字典)。
* <p>为 true 时前端提示「用的是标准类型,自定义类型暂时没取到」,而不是静默当成权威值。
*/
private boolean optionsFallback;
/** 下拉选项 [{value,label}] */
private List<Map<String, String>> options = new ArrayList<>();
}
@@ -0,0 +1,54 @@
package com.yunhai.agent.tool;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 「信息不全」时下发给前端的补全表单。
*
* <p>设计意图:用户说「我要请假」时,与其让大模型自由发挥去追问(格式、必填项都不稳定),
* 不如由后端从工具定义里**确定性地**推导出还缺哪些字段,连同中文名、格式示例、下拉选项
* 一起交给前端渲染成表单 —— 用户填完即提交,链路最短、最准确。
*/
@Data
public class FormRequest {
/** 目标工具名,如 leave.submit */
private String tool;
/** 表单标题,如「提交请假申请」 */
private String title;
/** 引导语,如「这些信息填好我就直接帮你提交」 */
private String description;
private String module;
/** 是否写操作(前端据此做「提交」的视觉强调与二次确认) */
private boolean write;
/** 提交成功后建议跳转的路由 */
private String landingPath;
/** 待补全的字段(只含缺失的必填项;已由用户说出的参数放在 preset 里回显) */
private List<FormField> fields = new ArrayList<>();
/** 已经从对话里确定的参数(表单里以只读方式回显,用户可确认后提交;提交时原样带上) */
private Map<String, Object> preset = new LinkedHashMap<>();
/**
* 上述 preset 的「可读版」:[{name,label,text}],其中 text 已按字典翻译成中文
* (如 leaveType=1 → 病假)。前端直接展示,不用自己维护中文映射。
*/
private List<Map<String, String>> recognized = new ArrayList<>();
/** 缺失字段的中文名,用于生成「还差:开始时间、事由」这类话术 */
private List<String> missing = new ArrayList<>();
/** 补充提示(可选),如「时间格式为 2026-10-11 08:00:00」 */
private String hint;
}
@@ -0,0 +1,211 @@
package com.yunhai.agent.tool;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.yunhai.agent.auth.AgentRole;
import lombok.Data;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 一个「工具」= 一个可被大模型调用的业务动作(对应某个业务模块的一个 REST 接口)。
*
* <p>刻意做成**声明式**:新增模块只需要在 {@code defs} 包里声明工具,不用写新代码,
* 执行统一走 {@link ToolExecutor}。这样"全量接入"才不会退化成复制粘贴。
*
* <p>2026-10 增补:{@link #roles} —— 声明该工具面向哪些身份(学生 / 教师 / 管理员)。
* 未声明 = 所有角色可用;声明后既用于「模型侧过滤」,也用于「服务端越权拦截」。
*/
@Data
public class ToolDef {
/**
* 工具唯一名:{@code 模块.动作},如 leave.save / notice.list(小写点分,内部标识)。
* <p>⚠️ 点号只是「内部」分隔符,**不能直接下发给大模型**,下发前必须过 {@link #wireName()}。
*/
private String name;
/** 业务模块标识,对应 {agent.modules} 的 key,用于拼目标地址 */
private String module;
/** 中文简称(前端展示"正在调用 XX"用) */
private String title;
/** 给模型的能力说明:做什么、什么场景用、必填项的含义 */
private String description;
/** 请求方式:GET / POST_JSON / POST_FORM */
private String httpMethod = "GET";
/** 相对模块根地址的接口路径,如 /api/leave/Save */
private String path;
/** 参数定义 */
private List<ToolParam> params = new ArrayList<>();
/** 固定参数(接口要求但对话里无需用户提供,如学生申请固定 type=2) */
private Map<String, String> fixed = new LinkedHashMap<>();
/**
* 平台菜单模块号(与 GetUserMod 的 modId 对齐)。
* 非空时:该用户未被授权此模块,则不向模型暴露该工具(权限收敛,避免"越权调用"的错觉)。
*/
private Integer moduleId;
/**
* 允许使用该工具的角色集合。空 = 所有角色可用。
* <p>用于「分学生、教师权限」:学生侧工具(请假申请/交作业/报名)与
* 教职工侧工具(审批/发布/登记)互不越界。
*/
private Set<AgentRole> roles = new LinkedHashSet<>();
/** 是否只读(查询类)。false = 会产生副作用(提交/审批/删除),前端会做二次确认提示 */
private boolean readOnly = true;
/**
* 建议前端跳转的模块内路由(模块前端的 hash 路由,如 {@code /my})。
*
* <p>两种来源:
* <ul>
* <li><b>工具显式声明</b>:写操作用 {@link #write(String)}(同时标记 {@code readOnly=false}),
* 只读查询用 {@link #readAt(String)}(只设落地页、不改只读语义);</li>
* <li><b>模块默认兜底</b>:两者都没声明时,由 {@code ToolRegistry} 在注册期用
* {@code agent.module-landing.<模块>} 回填(见 {@code AgentSettings#moduleLanding})。</li>
* </ul>
* 兜底的意义:服务端调不通模块用户态接口(下游 {@code code=302})时,前端仍能给出
* 「去系统里查看」入口,让用户带自己的登录态看到真实数据。
*/
private String landingPath;
public ToolDef() {
}
public static ToolDef of(String name, String module, String title, String description,
String httpMethod, String path) {
ToolDef d = new ToolDef();
d.name = name;
d.module = module;
d.title = title;
d.description = description;
d.httpMethod = httpMethod;
d.path = path;
return d;
}
public ToolDef p(ToolParam... ps) {
this.params.addAll(Arrays.asList(ps));
return this;
}
public ToolDef fix(String key, String value) {
this.fixed.put(key, value);
return this;
}
public ToolDef modId(Integer moduleId) {
this.moduleId = moduleId;
return this;
}
/** 声明该工具面向的角色(可多个)。不调用 = 所有角色可用。 */
public ToolDef allow(AgentRole... rs) {
this.roles.addAll(Arrays.asList(rs));
return this;
}
public ToolDef write(String landingPath) {
this.readOnly = false;
this.landingPath = landingPath;
return this;
}
/**
* 声明「只读工具」的落地页 —— <b>不改变 {@code readOnly} 语义</b>(仍标记为查询,前端不做二次确认)。
*
* <p>用途:查询失败(如下游 {@code code=302} 需要浏览器登录态)时,前端能给出
* 「去系统里查看」入口。典型场景是教职工侧查询(如班级名单、请假统计)与学生侧
* 看到默认页不是同一个页面,需要逐工具指明。
*/
public ToolDef readAt(String landingPath) {
this.landingPath = landingPath;
return this;
}
/** 当前调用者(角色集合)是否可用该工具。未声明角色 = 人人可用。 */
public boolean allowsAny(Set<AgentRole> callerRoles) {
if (roles == null || roles.isEmpty()) {
return true;
}
if (callerRoles == null || callerRoles.isEmpty()) {
return false;
}
for (AgentRole r : callerRoles) {
if (roles.contains(r)) {
return true;
}
}
return false;
}
/** 角色受限的可读描述,如「教师/管理员」 */
public String roleLabel() {
if (roles == null || roles.isEmpty()) {
return "全部角色";
}
StringBuilder sb = new StringBuilder();
for (AgentRole r : roles) {
if (sb.length() > 0) {
sb.append("/");
}
sb.append(r.getLabel());
}
return sb.toString();
}
/**
* 下发给大模型时使用的工具名(wire name)。
*
* <p>★ 大模型对 {@code function.name} 有字符集硬约束:<b>只允许 {@code ^[a-zA-Z0-9_-]+$}</b>。
* 点号是非法字符 —— 直接下发 {@code leave.myClasses} 会被服务端拒绝:
* <pre>Invalid 'tools[0].function.name': string does not match pattern
* . Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.</pre>
* 故对外统一把 {@code .} 替换为 {@code _}({@code leave.myClasses} → {@code leave_myClasses}),
* 内部仍保留点分名(前端目录、审计日志、表单回传都不受影响)。
* 回传的名字由 {@code ToolRegistry#get} 负责还原。
*/
public String wireName() {
return name == null ? null : name.replace('.', '_');
}
/** 转成 OpenAI / DeepSeek 的 tools 元素:{type:function, function:{name,description,parameters}} */
public JSONObject toOpenAiTool() {
JSONObject fn = new JSONObject();
fn.put("name", wireName());
fn.put("description", description);
JSONObject props = new JSONObject();
JSONArray required = new JSONArray();
for (ToolParam p : params) {
props.put(p.getName(), p.toSchema());
if (p.isRequired()) {
required.add(p.getName());
}
}
JSONObject parameters = new JSONObject();
parameters.put("type", "object");
parameters.put("properties", props);
parameters.put("required", required);
fn.put("parameters", parameters);
JSONObject tool = new JSONObject();
tool.put("type", "function");
tool.put("function", fn);
return tool;
}
}
@@ -0,0 +1,471 @@
package com.yunhai.agent.tool;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.yunhai.agent.config.AgentSettings;
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.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工具执行器:把「模型给的参数」变成「对业务模块的一次真实 HTTP 调用」,并把结果规范化。
*
* <p>统一约定(与前端各工程 alova 封装完全一致,保证行为可预期):
* <ul>
* <li>平台响应 {@code {code,msg,data}}:{@code code==0} 视为成功,取 {@code data};否则取 {@code msg} 作为错误</li>
* <li>{@code POST_JSON} ↔ 后端 {@code @RequestBody}</li>
* <li>{@code POST_FORM} ↔ 后端 {@code @RequestParam}(表单)</li>
* </ul>
*
* <p>★ 2026-10 增补:执行前做<b>角色强校验</b>——越权的工具调用直接拒绝、不发真实请求,
* 这是「分学生、教师权限」的服务端硬约束(模型侧过滤只是第一道,这里才是兜底)。
*/
@Slf4j
@Component
public class ToolExecutor {
private final AgentSettings settings;
private final RestTemplate restTemplate;
public ToolExecutor(AgentSettings settings, @Qualifier("yhRestTemplate") RestTemplate restTemplate) {
this.settings = settings;
this.restTemplate = restTemplate;
}
/**
* 角色门禁:当前身份无权使用该工具时返回拒绝结果(不发真实请求),有权返回 {@code null}。
* <p>服务端执行({@link #execute})与浏览器代执行的续跑({@code parseBrowserResponse} 之前)
* 共用这一道硬约束。
*/
public ToolResult checkAccess(ToolDef def, CallerContext caller) {
if (settings.isEnforceRolePermission() && !def.allowsAny(caller == null ? null : caller.getRoles())) {
String who = (caller == null || caller.getRoles() == null || caller.getRoles().isEmpty())
? "访客" : caller.getRoles().toString();
log.warn("[越权拦截] userId={} roles={} 尝试调用受限工具 {}(仅限 {})",
caller == null ? null : caller.getUserId(), who, def.getName(), def.roleLabel());
return ToolResult.fail(def,
"当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。"
+ "如需办理,请使用对应身份的账号,或到相应页面处理。", 0);
}
return null;
}
/**
* 执行一个工具。
*
* @param def 工具定义
* @param args 模型给出的参数(已容错解析成 Map)
* @param caller 调用者身份(用于透传与角色校验)
*/
public ToolResult execute(ToolDef def, Map<String, Object> args, CallerContext caller) {
long start = System.currentTimeMillis();
// ★ 角色强校验:当前身份无权使用该工具 → 拒绝执行,不发真实请求
ToolResult denied = checkAccess(def, caller);
if (denied != null) {
return denied;
}
String base = settings.getModules().get(def.getModule());
if (StringUtils.isBlank(base)) {
return ToolResult.fail(def, "模块「" + def.getModule() + "」未配置服务地址(agent.modules)",
System.currentTimeMillis() - start);
}
// 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定)
Map<String, Object> merged = new LinkedHashMap<>();
if (args != null) {
merged.putAll(args);
}
if (def.getFixed() != null) {
merged.putAll(def.getFixed());
}
// 缺参校验(必填项)
for (ToolParam p : def.getParams()) {
if (p.isRequired() && isBlank(merged.get(p.getName()))) {
if (p.getDefaultValue() != null) {
merged.put(p.getName(), p.getDefaultValue());
} else {
return ToolResult.fail(def, "缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")",
System.currentTimeMillis() - start);
}
}
}
// 路径占位替换:如 /api/student/homeworks/{id}/submissions → 取 id 填进路径,并从参数表中移除
HttpHeaders headers = buildHeaders(caller);
String url = trimEnd(base) + def.getPath();
try {
String resolvedPath = resolvePath(def.getPath(), merged);
url = trimEnd(base) + resolvedPath;
ResponseEntity<String> resp;
String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase();
switch (method) {
case "GET": {
String fullUrl = appendQuery(url, merged, def);
resp = restTemplate.exchange(fullUrl, HttpMethod.GET, new HttpEntity<>(headers), String.class);
break;
}
case "POST_JSON": {
headers.setContentType(MediaType.APPLICATION_JSON);
String body = JSON.toJSONString(normalizeJsonArgs(merged, def));
resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
break;
}
case "POST_FORM": {
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
String body = toFormBody(merged);
resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
break;
}
default:
return ToolResult.fail(def, "不支持的请求方式:" + method, System.currentTimeMillis() - start);
}
long cost = System.currentTimeMillis() - start;
return parseResponse(def, resp, cost);
} catch (Exception e) {
long cost = System.currentTimeMillis() - start;
log.error("工具 {} 调用失败 url={} err={}", def.getName(), url, e.toString());
String msg = e.getMessage() == null ? e.toString() : e.getMessage();
if (msg.contains("Connection refused") || msg.contains("connect timed out")) {
msg = "无法连接业务模块「" + def.getModule() + "」(" + base + "),请确认该服务已启动";
}
return ToolResult.fail(def, msg, cost);
}
}
/* ==================== 浏览器代执行(方案 A) ==================== */
/**
* 把「模型参数」准备成一次可供<b>前端浏览器</b>执行的调用描述。
*
* <p>与服务端 {@link #execute} 完全同源的逻辑:合并固定参数、必填校验、路径占位替换、
* query/请求体构造 —— 差别只在「谁来发这个 HTTP 请求」。参数不合法时抛
* {@link IllegalArgumentException} / {@link IllegalStateException},调用方据此回退服务端执行。
*
* @return 描述里的 {@code pathAndQuery} 以 {@code /} 开头(已去掉协议与域名),
* 前端拼 {@code location.origin} 即为最终地址(同站部署 ⇒ 同源)
*/
public com.yunhai.agent.model.BrowserToolRequest.BrowserCall prepareBrowserCall(
ToolDef def, Map<String, Object> args) {
// 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定)
Map<String, Object> merged = new LinkedHashMap<>();
if (args != null) {
merged.putAll(args);
}
if (def.getFixed() != null) {
merged.putAll(def.getFixed());
}
// 缺参校验(必填项)
for (ToolParam p : def.getParams()) {
if (p.isRequired() && isBlank(merged.get(p.getName()))) {
if (p.getDefaultValue() != null) {
merged.put(p.getName(), p.getDefaultValue());
} else {
throw new IllegalArgumentException(
"缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")");
}
}
}
String base = trimEnd(settings.getModules().get(def.getModule()));
String resolvedPath = resolvePath(def.getPath(), merged);
String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase();
com.yunhai.agent.model.BrowserToolRequest.BrowserCall call =
new com.yunhai.agent.model.BrowserToolRequest.BrowserCall();
call.setName(def.getName());
call.setTitle(def.getTitle());
call.setWrite(!def.isReadOnly());
call.setArguments(args == null ? new LinkedHashMap<>() : args);
switch (method) {
case "GET": {
// 先按完整 URL 拼查询串,再剥掉协议与域名(前端只认相对路径)
String full = appendQuery(base + resolvedPath, merged, def);
call.setPathAndQuery(stripOrigin(full));
break;
}
case "POST_JSON": {
call.setPathAndQuery(stripOrigin(base + resolvedPath));
call.setBody(JSON.toJSONString(normalizeJsonArgs(merged, def)));
call.setContentType("application/json;charset=UTF-8");
break;
}
case "POST_FORM": {
call.setPathAndQuery(stripOrigin(base + resolvedPath));
call.setBody(toFormBody(merged));
call.setContentType("application/x-www-form-urlencoded;charset=UTF-8");
break;
}
default:
throw new IllegalArgumentException("不支持的请求方式:" + method);
}
call.setMethod(method);
return call;
}
/**
* 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致
* ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。
*
* @param status 浏览器 fetch 拿到的 HTTP 状态码(网络层失败时为 null)
* @param body 响应体原文
*/
public ToolResult parseBrowserResponse(ToolDef def, Integer status, String body, long costMs) {
if (status == null) {
return ToolResult.fail(def, "浏览器调用没有完成(未取得模块响应),请稍后重试", costMs);
}
if (status < 200 || status >= 300) {
return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs);
}
// ★ 浏览器通道专属守卫:拿回网页而不是数据,说明请求根本没落到模块接口上
// (未同站部署 / 落到了 SPA 首页或登录页)。此时绝不能当"成功"喂给模型 ——
// 服务端直调不会出现这种形态(它直接打模块 API),故只在本方法里拦。
if (body != null && body.trim().startsWith("<")) {
return ToolResult.fail(def,
"通过浏览器调用「" + def.getTitle() + "」拿回的是网页而不是数据,"
+ "多半是请求没落到模块接口上(本应用与模块不同源,或落到了登录页/单页应用首页)。"
+ "请点下方「去系统里自己查看」,用你自己的登录态直接打开该模块页面。", costMs);
}
org.springframework.http.HttpStatus hs = org.springframework.http.HttpStatus.resolve(status);
if (hs == null) {
return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs);
}
return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs);
}
/** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */
private String stripOrigin(String fullUrl) {
int idx = fullUrl.indexOf("://");
if (idx < 0) {
return fullUrl;
}
int slash = fullUrl.indexOf('/', idx + 3);
return slash < 0 ? "/" : fullUrl.substring(slash);
}
/* ==================== 内部实现 ==================== */
/**
* 把路径里的 {@code {name}} 占位符替换成参数值,并把已消费的参数从参数表移除。
* <p>示例:{@code /api/student/homeworks/{id}/submissions} + {@code {id:12,text:"..."}}
* → 路径 {@code /api/student/homeworks/12/submissions},剩余参数 {@code {text:"..."}}。
*/
private String resolvePath(String path, Map<String, Object> args) {
if (path == null || path.indexOf('{') < 0) {
return path;
}
String result = path;
List<String> consumed = new java.util.ArrayList<>();
for (Map.Entry<String, Object> e : args.entrySet()) {
String token = "{" + e.getKey() + "}";
if (result.contains(token)) {
Object v = e.getValue();
result = result.replace(token, v == null ? "" : urlEncodePath(stringify(v)));
consumed.add(e.getKey());
}
}
for (String k : consumed) {
args.remove(k);
}
if (result.contains("{")) {
// 还有未替换的占位 → 视为参数缺失,交给上层报错更清晰
throw new IllegalStateException("接口路径存在未填充的占位符:" + result);
}
return result;
}
/** 路径段编码:保留常见安全字符,中文等必须编码 */
private String urlEncodePath(String s) {
try {
return URLEncoder.encode(s, "UTF-8").replace("+", "%20");
} catch (UnsupportedEncodingException e) {
return s;
}
}
private HttpHeaders buildHeaders(CallerContext caller) {
HttpHeaders headers = new HttpHeaders();
headers.set("yschapirequest", "1");
if (caller != null) {
if (StringUtils.isNotBlank(caller.getCurrentRole())) {
headers.set("currentRole", caller.getCurrentRole());
}
if (StringUtils.isNotBlank(caller.getUserId())) {
headers.set("userId", caller.getUserId());
}
if (StringUtils.isNotBlank(caller.getRoleId())) {
headers.set("roleId", caller.getRoleId());
}
if (StringUtils.isNotBlank(caller.getSchoolId())) {
headers.set("schoolId", caller.getSchoolId());
}
if (StringUtils.isNotBlank(caller.getCookie())) {
headers.set("Cookie", caller.getCookie());
}
if (StringUtils.isNotBlank(caller.getReferer())) {
headers.set("yschreferer", caller.getReferer());
}
}
return headers;
}
/** 解析平台统一响应 {code,msg,data} */
private ToolResult parseResponse(ToolDef def, ResponseEntity<String> resp, long cost) {
String raw = resp.getBody();
if (StringUtils.isBlank(raw)) {
return ToolResult.ok(def, "{\"success\":true,\"data\":null}", null, cost);
}
JSONObject json;
try {
json = JSON.parseObject(raw);
} catch (Exception e) {
// 非包装响应(如导出 CSV 文本)直接原样返回
return ToolResult.ok(def, truncate(raw), raw, cost);
}
if (json == null || !json.containsKey("code")) {
return ToolResult.ok(def, truncate(raw), json, cost);
}
int code = json.getIntValue("code");
// 302 = 平台 CAS 过滤器要求「浏览器登录态」。服务端调用没有用户的业务系统会话,
// 应用间签名也**不能**替代会话(签名只对 /oapi/* 生效)——把话说清楚,别让使用者以为是网络问题。
if (code == 302) {
return ToolResult.fail(def, "读取「" + def.getTitle() + "」需要你在业务系统里的登录态(下游返回 code=302)。"
+ "服务端无法代表你登录业务模块 —— 点下方「去系统里查看」,用你自己的登录态直接打开该模块页面即可。", cost);
}
if (code != 0) {
String msg = json.getString("msg");
return ToolResult.fail(def, StringUtils.isBlank(msg) ? ("业务错误 code=" + code) : msg, cost);
}
Object data = json.get("data");
String content = data == null ? "{\"success\":true,\"data\":null}"
: truncate(JSON.toJSONString(data));
// 写操作没有返回体时,补一句明确结果,避免模型以为失败
if (data == null && !def.isReadOnly()) {
content = "{\"success\":true,\"message\":\"" + def.getTitle() + " 已执行成功\"}";
}
return ToolResult.ok(def, content, data, cost);
}
/** GET:把参数拼到 query(跳过空值与固定参数里已给的重复项由 Map 天然覆盖) */
private String appendQuery(String url, Map<String, Object> args, ToolDef def) {
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
for (Map.Entry<String, Object> e : args.entrySet()) {
Object v = e.getValue();
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
continue;
}
builder.queryParam(e.getKey(), stringify(v));
}
return builder.build(false).toUriString();
}
/** POST_JSON:只保留非空参数 */
private Map<String, Object> normalizeJsonArgs(Map<String, Object> args, ToolDef def) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> e : args.entrySet()) {
Object v = e.getValue();
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
continue;
}
out.put(e.getKey(), v);
}
return out;
}
private String toFormBody(Map<String, Object> args) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> e : args.entrySet()) {
Object v = e.getValue();
if (v == null || StringUtils.isBlank(String.valueOf(v))) {
continue;
}
if (sb.length() > 0) {
sb.append("&");
}
sb.append(urlEncode(e.getKey())).append("=").append(urlEncode(stringify(v)));
}
return sb.toString();
}
/** 数组 → 逗号串;其余直接 toString */
private String stringify(Object v) {
if (v instanceof List) {
List<?> list = (List<?>) v;
StringBuilder sb = new StringBuilder();
for (Object o : list) {
if (o == null) {
continue;
}
if (sb.length() > 0) {
sb.append(",");
}
sb.append(o);
}
return sb.toString();
}
if (v instanceof JSONArray) {
return String.join(",", ((JSONArray) v).toJavaList(String.class));
}
return String.valueOf(v);
}
private boolean isBlank(Object v) {
return v == null || StringUtils.isBlank(String.valueOf(v));
}
private String urlEncode(String s) {
try {
return URLEncoder.encode(s, "UTF-8");
} catch (UnsupportedEncodingException e) {
return s;
}
}
private String truncate(String s) {
int max = settings.getLoop().getToolResultMaxChars();
if (s == null) {
return null;
}
if (s.length() <= max) {
return s;
}
return s.substring(0, max) + "...[已截断,共 " + s.length() + " 字符]";
}
private String trimEnd(String s) {
if (s == null) {
return "";
}
while (s.endsWith("/")) {
s = s.substring(0, s.length() - 1);
}
return s;
}
}
@@ -0,0 +1,251 @@
package com.yunhai.agent.tool;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 工具参数定义。一份声明,三处复用:
*
* <ol>
* <li>生成给大模型的 JSON Schema({@link #toSchema()})</li>
* <li>入参必填校验</li>
* <li><b>生成让用户直接填写的表单</b>({@link #toFormField()})—— 信息不全时不再靠模型口头追问,
* 而是把字段的中文名、格式示例、可选值直接摆到界面上</li>
* </ol>
*/
@Data
public class ToolParam {
/** 参数名(与后端接口参数名严格一致,大小写敏感) */
private String name;
/** JSON Schema 类型:string / integer / number / boolean / array */
private String type = "string";
/** 给模型看的说明(越具体越好,写明格式与取值范围) */
private String description;
private boolean required;
/** 枚举取值(非空时会在描述与 schema 里同时体现,帮助模型少犯错) */
private List<String> enumValues;
/** 枚举的中文名,与 enumValues 一一对应(表单下拉展示用;缺省时直接显示值) */
private List<String> enumLabels;
/**
* 内置兜底枚举:只在「学校可自定义的字典」上使用,是 {@link #optionsFrom} 取不到实时值时的顶替品。
* <p>有了它,下游字典接口不通时下拉框仍然是下拉框,而不是退化成让用户填「请假类型编号」的数字框。
* 与 {@link #enumValues} 的区别:enumValues 是权威定义(如审批结论),本字段是「大概是这样,以实时字典为准」。
*/
private List<String> fallbackValues;
/** 内置兜底枚举的中文名,与 fallbackValues 一一对应 */
private List<String> fallbackLabels;
/** 默认值(模型没给时兜底) */
private String defaultValue;
/* ==================== 表单渲染元数据 ==================== */
/** 字段中文标签(表单里显示,如「开始时间」);缺省回退到 name */
private String label;
/** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */
private String example;
/** 控件类型:text / textarea / number / date / datetime / select;缺省按参数名与类型自动推断 */
private String widget;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
private boolean hidden;
/**
* 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。
* <p>服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 ——
* 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。
*/
private String optionsFrom;
public ToolParam() {
}
public ToolParam(String name, String type, String description, boolean required) {
this.name = name;
this.type = type;
this.description = description;
this.required = required;
}
public static ToolParam required(String name, String type, String description) {
return new ToolParam(name, type, description, true);
}
public static ToolParam optional(String name, String type, String description) {
return new ToolParam(name, type, description, false);
}
public ToolParam enums(String... values) {
this.enumValues = Arrays.asList(values);
return this;
}
/** 枚举值 + 中文名(一一对应),表单会渲染成带中文选项的下拉框 */
public ToolParam options(String[] values, String[] labels) {
this.enumValues = Arrays.asList(values);
this.enumLabels = Arrays.asList(labels);
return this;
}
/**
* 内置兜底选项:配合 {@link #optionsFrom} 使用。
* <p>实时字典取到 → 用实时值;取不到 → 用这份兜底,下拉框不至于消失。
*/
public ToolParam fallbackOptions(String[] values, String[] labels) {
this.fallbackValues = Arrays.asList(values);
this.fallbackLabels = Arrays.asList(labels);
return this;
}
public ToolParam dft(String value) {
this.defaultValue = value;
return this;
}
/** 字段中文标签 */
public ToolParam label(String value) {
this.label = value;
return this;
}
/** 示例值(帮助用户按格式填写) */
public ToolParam eg(String value) {
this.example = value;
return this;
}
/** 指定控件类型 */
public ToolParam as(String value) {
this.widget = value;
return this;
}
public ToolParam hide() {
this.hidden = true;
return this;
}
/** 下拉选项来自某个只读字典工具 */
public ToolParam optionsFrom(String tool) {
this.optionsFrom = tool;
return this;
}
/** 表单里显示的名字 */
public String labelOf() {
return StringUtils.isNotBlank(label) ? label : name;
}
/** 当前生效的枚举值:权威枚举优先,其次内置兜底 */
private List<String> effectiveValues() {
if (enumValues != null && !enumValues.isEmpty()) return enumValues;
return fallbackValues;
}
/** 当前生效的枚举中文名,与 {@link #effectiveValues()} 对应 */
private List<String> effectiveLabels() {
if (enumValues != null && !enumValues.isEmpty()) return enumLabels;
return fallbackLabels;
}
/** 当前选项是否来自内置兜底(即实时字典没取到,用的是「大概是这样」的值) */
public boolean isUsingFallbackOptions() {
return (enumValues == null || enumValues.isEmpty())
&& fallbackValues != null && !fallbackValues.isEmpty();
}
/**
* 推断控件类型:显式指定优先,其次枚举→下拉,然后按类型与参数名语义判断。
* <p>参数名里的 date/time 是强信号(startDate / endDate / leaveTime),
* 文本类词根(remarks / reason / content)则渲染成多行输入。
*/
public String resolveWidget() {
if (StringUtils.isNotBlank(widget)) return widget;
List<String> vals = effectiveValues();
if (vals != null && !vals.isEmpty()) return "select";
String t = StringUtils.trimToEmpty(type).toLowerCase();
if ("integer".equals(t) || "number".equals(t)) return "number";
if ("boolean".equals(t)) return "select";
String n = StringUtils.trimToEmpty(name).toLowerCase();
if (n.contains("date") || n.contains("time")) return "datetime";
if (n.contains("remark") || n.contains("reason") || n.contains("desc") || n.contains("memo")
|| n.contains("content") || n.contains("address") || n.contains("text")) {
return "textarea";
}
return "text";
}
/** 枚举 → 前端下拉项 [{value,label}](权威枚举优先,其次内置兜底) */
public List<Map<String, String>> optionList() {
List<Map<String, String>> list = new ArrayList<>();
List<String> vals = effectiveValues();
List<String> labs = effectiveLabels();
if (vals == null) return list;
for (int i = 0; i < vals.size(); i++) {
Map<String, String> o = new LinkedHashMap<>();
o.put("value", vals.get(i));
String lab = (labs != null && i < labs.size()) ? labs.get(i) : null;
o.put("label", StringUtils.isNotBlank(lab) ? lab : vals.get(i));
list.add(o);
}
return list;
}
/** 转成前端渲染用的表单字段 */
public FormField toFormField() {
FormField f = new FormField();
f.setName(name);
f.setLabel(labelOf());
f.setWidget(resolveWidget());
f.setType(type);
f.setRequired(required);
f.setDescription(description);
f.setExample(example);
f.setDefaultValue(defaultValue);
f.setOptions(optionList());
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
f.setOptionsFrom(optionsFrom);
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
f.setOptionsFallback(isUsingFallbackOptions());
return f;
}
/** 转成 JSON Schema 的属性节点(给模型看) */
public JSONObject toSchema() {
JSONObject o = new JSONObject();
o.put("type", type);
String desc = description == null ? "" : description;
List<String> vals = effectiveValues();
if (vals != null && !vals.isEmpty()) {
desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "可选值:" + String.join(" / ", vals);
o.put("enum", new JSONArray() {{
addAll(vals);
}});
}
if (StringUtils.isNotBlank(example)) {
desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "示例:" + example;
}
o.put("description", desc);
return o;
}
}
@@ -0,0 +1,174 @@
package com.yunhai.agent.tool;
import com.alibaba.fastjson.JSONArray;
import com.yunhai.agent.auth.AgentRole;
import com.yunhai.agent.auth.RolePolicy;
import com.yunhai.agent.config.AgentSettings;
import com.yunhai.agent.tool.defs.ActivityTools;
import com.yunhai.agent.tool.defs.CampusNoticeTools;
import com.yunhai.agent.tool.defs.DormNoticeTools;
import com.yunhai.agent.tool.defs.HomeworkTools;
import com.yunhai.agent.tool.defs.LeaveTools;
import com.yunhai.agent.tool.defs.ProfessionTools;
import com.yunhai.agent.tool.defs.ResourceTools;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 工具注册表:所有可被大模型调用的业务动作都在这里汇总。
*
* <p>新增一个模块的接入方式:写一个 {@code XxxTools#defs()} 返回 {@code List<ToolDef>},
* 在下面的 {@link #build()} 里加一行。执行、鉴权、schema 生成全部复用通用实现。
*
* <p>可见性由两个维度共同决定:
* <ol>
* <li>{@code modId} —— 平台菜单授权({@code agent.filter-tools-by-authorized-module=true} 时生效);</li>
* <li>{@code roles} —— 学生 / 教师 / 管理员角色(「分学生、教师权限」的落点)。</li>
* </ol>
*/
@Slf4j
@Component
public class ToolRegistry {
private final AgentSettings settings;
/** name → ToolDef(保持插入顺序,便于目录展示) */
private final Map<String, ToolDef> tools = new LinkedHashMap<>();
/**
* 大模型侧工具名(点换下划线)→ 内部点分名。
*
* <p>大模型对 {@code function.name} 只接受 {@code ^[a-zA-Z0-9_-]+$},点号非法,
* 所以下发时用 {@link ToolDef#wireName()},模型回传的也是这个名字,这里负责还原。
*/
private final Map<String, String> wireAliases = new LinkedHashMap<>();
public ToolRegistry(AgentSettings settings) {
this.settings = settings;
}
@PostConstruct
public void init() {
build();
log.info("智能体工具注册完成,共 {} 个:{}", tools.size(), String.join(", ", tools.keySet()));
// 校验:工具引用的模块必须在 agent.modules 里配了地址,否则运行期必然失败
for (ToolDef d : tools.values()) {
if (!settings.getModules().containsKey(d.getModule())) {
log.warn("工具 {} 引用了未配置地址的模块「{}」,请检查 agent.modules", d.getName(), d.getModule());
}
}
}
private void build() {
register(LeaveTools.defs());
register(CampusNoticeTools.defs());
register(DormNoticeTools.defs());
register(ActivityTools.defs());
register(HomeworkTools.defs());
register(ResourceTools.defs());
register(ProfessionTools.defs());
// 套用「学生 / 教师」角色能力策略(未显式声明角色的工具在此落定)
RolePolicy.apply(tools.values());
}
private void register(List<ToolDef> defs) {
for (ToolDef d : defs) {
if (tools.containsKey(d.getName())) {
throw new IllegalStateException("工具名重复:" + d.getName());
}
String wire = d.wireName();
// wire 名撞车会导致模型回传无法还原(点→下划线是单向压缩),注册期就拦下来
if (tools.containsKey(wire)) {
throw new IllegalStateException("工具名冲突:" + d.getName() + " 的模型侧名字「" + wire
+ "」与已有工具同名,请改名");
}
if (wire != null && !wire.equals(d.getName())) {
String prev = wireAliases.put(wire, d.getName());
if (prev != null) {
throw new IllegalStateException("模型侧工具名冲突:" + d.getName() + " 与 " + prev
+ " 换成模型可用名后都是「" + wire + "」,请改名");
}
}
// ★ 深链兜底:工具没声明落地页时,取该模块的默认页面(agent.module-landing)。
// 这样"查询失败 / 越权拦截"也能带着一个真实可打开的模块地址回到前端。
if (StringUtils.isBlank(d.getLandingPath())) {
d.setLandingPath(settings.getModuleLanding().get(d.getModule()));
}
tools.put(d.getName(), d);
}
}
/**
* 按名字取工具:**同时兼容内部点分名与大模型侧下划线名**。
*
* <p>模型回传的 {@code function.name} 是 {@link ToolDef#wireName()}(点已换成下划线),
* 而前端表单回传、字典工具引用用的是内部点分名,两种都要能命中。
*/
public ToolDef get(String name) {
if (name == null) {
return null;
}
ToolDef d = tools.get(name);
if (d != null) {
return d;
}
String real = wireAliases.get(name);
return real == null ? null : tools.get(real);
}
public Collection<ToolDef> all() {
return tools.values();
}
/**
* 按「已授权模块 + 角色」过滤后,转成给模型看的 tools schema。
*
* @param authorizedModIds 当前用户已授权的 modId 集合;为 null 表示不按 modId 过滤
* @param roles 当前用户角色集合;为 null 表示不按角色过滤(排障用)
*/
public JSONArray toOpenAiTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
JSONArray arr = new JSONArray();
for (ToolDef d : tools.values()) {
if (!visible(d, authorizedModIds, roles)) {
continue;
}
arr.add(d.toOpenAiTool());
}
return arr;
}
/** 供前端「我能用这个智能体做什么」的目录展示 */
public List<ToolDef> visibleTools(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
return tools.values().stream()
.filter(d -> visible(d, authorizedModIds, roles))
.collect(Collectors.toList());
}
private boolean visible(ToolDef d, Set<Integer> authorizedModIds, Set<AgentRole> roles) {
// 1) modId 维度:仅在开启「按授权模块过滤」时才过滤(modId 为 null 的工具不参与该维度)
if (d.getModuleId() != null && authorizedModIds != null && !authorizedModIds.contains(d.getModuleId())) {
return false;
}
// 2) 角色维度:未声明角色的工具人人可用
return d.allowsAny(roles);
}
/** 按模块分组统计(目录接口用) */
public Map<String, List<ToolDef>> groupByModule(Set<Integer> authorizedModIds, Set<AgentRole> roles) {
Map<String, List<ToolDef>> map = new LinkedHashMap<>();
for (ToolDef d : visibleTools(authorizedModIds, roles)) {
map.computeIfAbsent(d.getModule(), k -> new ArrayList<>()).add(d);
}
return map;
}
}
@@ -0,0 +1,54 @@
package com.yunhai.agent.tool;
import lombok.Data;
/**
* 一次工具执行的结果。既要回填给模型(content),也要回传给前端(展示"做了什么")。
*/
@Data
public class ToolResult {
private String toolName;
private String title;
private boolean success = true;
private String error;
/** 回填给模型的内容(已截断、已结构化) */
private String content;
/** 回传给前端的原始 data(供渲染卡片 / 跳转) */
private Object data;
private String landingPath;
private long costMs;
public static ToolResult ok(ToolDef def, String content, Object data, long costMs) {
ToolResult r = new ToolResult();
r.toolName = def.getName();
r.title = def.getTitle();
r.content = content;
r.data = data;
r.landingPath = def.getLandingPath();
r.costMs = costMs;
return r;
}
public static ToolResult fail(ToolDef def, String error, long costMs) {
ToolResult r = new ToolResult();
r.toolName = def.getName();
r.title = def.getTitle();
r.success = false;
r.error = error;
// ★ 失败也要带落地页:下游 302(需浏览器登录态)、越权拦截、缺参等情况,
// 前端据此给出「去系统里查看」,让用户用自己的登录态把事办完。
r.landingPath = def.getLandingPath();
// 回填给模型的内容里必须明确写"失败+原因",否则模型会编造成功
r.content = "{\"success\":false,\"error\":\"" + escape(error) + "\"}";
r.costMs = costMs;
return r;
}
private static String escape(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,82 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 家校活动模块工具 —— 后端 FamilyActivity,前缀 /api/activity、/api/base。
*
* <p>核心闭环:活动广场 → 家长报名 → 学校确认。
*/
public final class ActivityTools {
public static final String MODULE = "familyactivity";
private ActivityTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("activity.plaza", MODULE, "活动广场",
"当前家长/学生视角的活动广场(含我的报名状态与分桶计数)。用户问“最近有什么活动”“我能报什么”时用它。",
"GET", "/api/activity/MyActivities")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "活动名称关键词"))
.readAt("/par/activities"));
list.add(ToolDef.of("activity.list", MODULE, "已发布活动列表",
"已发布活动列表(家长端收件箱口径)。",
"GET", "/api/activity/List")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "关键词"))
.readAt("/list"));
list.add(ToolDef.of("activity.detail", MODULE, "活动详情",
"查看活动详情(时间、地点、说明、附件)。",
"GET", "/api/activity/Detail")
.p(ToolParam.required("serid", "integer", "活动 id"),
ToolParam.optional("type", "integer", "1 查看并标读(默认) / 0 仅查看").dft("1"))
.readAt("/par/activities"));
list.add(ToolDef.of("activity.mySignup", MODULE, "我的报名详情",
"查看我对某个活动的报名信息与状态。",
"GET", "/api/activity/MySignup")
.p(ToolParam.required("serid", "integer", "活动 id"))
.readAt("/par/mine"));
list.add(ToolDef.of("activity.signup", MODULE, "报名活动",
"家长为学生报名参加活动。调用前必须确认活动 serid;参加人数 joinCount 与联系人信息可选但建议询问。",
"POST_JSON", "/api/activity/Signup")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"),
ToolParam.optional("joinCount", "integer", "参加人数(含学生本人)")
.label("参加人数").dft("1"),
ToolParam.optional("guardianName", "string", "监护人姓名")
.label("监护人姓名"),
ToolParam.optional("guardianPhone", "string", "监护人电话")
.label("监护人电话").eg("13800000000"),
ToolParam.optional("remark", "string", "备注")
.label("备注").as("textarea"))
.write("/par/mine"));
list.add(ToolDef.of("activity.cancelSignup", MODULE, "取消报名",
"取消我对某个活动的报名。",
"POST_FORM", "/api/activity/CancelSignup")
.p(ToolParam.required("serid", "integer", "活动 id")
.label("活动"))
.write("/par/mine"));
list.add(ToolDef.of("activity.classTree", MODULE, "年级班级树",
"年级 → 班级 树(发布活动时选接收班级用)。",
"GET", "/api/base/GetClassTree")
.readAt("/list/new"));
return list;
}
}
@@ -0,0 +1,120 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 校园公告(校园通知)模块工具 —— 后端 CampusAnnouncement / NoticeFront,前缀 /api/notice。
*/
public final class CampusNoticeTools {
public static final String MODULE = "campusnotice";
private CampusNoticeTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("notice.received", MODULE, "我收到的公告",
"分页查询当前用户收到的校园公告。用户问“最近有什么通知”“有没有未读公告”时用它。",
"GET", "/api/notice/List")
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
ToolParam.optional("keyword", "string", "标题关键词"),
ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要"),
ToolParam.optional("readType", "integer", "阅读状态:0 全部 / 1 未读 / 2 已读"))
.readAt("/")
.modId(null));
list.add(ToolDef.of("notice.readTabCount", MODULE, "公告未读统计",
"查询当前用户公告的已收总数 / 未读数 / 已读数(回答“我还有几条没看”)。",
"GET", "/api/notice/ReadTabCount")
.readAt("/"));
list.add(ToolDef.of("notice.sent", MODULE, "我发出的公告",
"分页查询我发布(或有权限查看)的公告,可按状态筛选(草稿/已发布/待审批/已驳回)。",
"GET", "/api/notice/SendList")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("scope", "string", "传 user 只查自己发布的"),
ToolParam.optional("status", "integer", "0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回"),
ToolParam.optional("keyword", "string", "标题关键词"))
.readAt("/list")
.modId(null));
list.add(ToolDef.of("notice.detail", MODULE, "公告详情",
"查看一条公告的正文、附件与已读情况。",
"GET", "/api/notice/Detail")
.p(ToolParam.required("serid", "integer", "公告 id"),
ToolParam.optional("type", "integer", "1 查看并标记已读(默认) / 0 仅查看不标读").dft("1"))
.readAt("/"));
list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典",
"查询可选的发文部门(发布公告时填 deptId 用)。",
"GET", "/api/dict/List")
.fix("type", "1")
.readAt("/list/new"));
list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告",
"发布一条校园公告。调用前必须确认:标题 title、正文 content、发文部门 deptId;"
+ "缺失就问用户,不要编造部门。",
"POST_JSON", "/api/notice/Create")
.p(ToolParam.required("title", "string", "公告标题")
.label("公告标题").eg("关于国庆假期安排的通知"),
ToolParam.required("content", "string", "公告正文(纯文本)")
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"),
ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics")
.label("发文部门").optionsFrom("notice.deptDics"),
ToolParam.optional("deptName", "string", "发文部门名称")
.label("发文部门名称"),
ToolParam.optional("type", "integer", "重要程度")
.label("重要程度").dft("1")
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"}),
ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执")
.label("需要阅读回执").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("smsFlag", "integer", "是否同时发短信")
.label("同时发短信").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
ToolParam.optional("publishType", "string", "发布方式")
.label("发布方式").dft("direct")
.options(new String[]{"direct", "draft", "approve"},
new String[]{"直接发布", "存草稿", "提交审批"}))
.write("/list/new")
.modId(null));
list.add(ToolDef.of("notice.approvalList", MODULE, "待我审批的公告",
"查询待我审批的公告列表(公告审批中心)。",
"GET", "/api/notice/ApprovalList")
.readAt("/approval")
.p(ToolParam.optional("status", "string", "pending 待审批(默认) / approved 已通过 / rejected 已驳回 / all 全部").dft("pending"),
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10")));
list.add(ToolDef.of("notice.approve", MODULE, "通过公告审批",
"审批通过一条公告(末步通过即真实发布)。",
"POST_FORM", "/api/notice/Approve")
.p(ToolParam.required("serid", "integer", "公告 id")
.label("公告"),
ToolParam.optional("opinion", "string", "审批意见")
.label("审批意见").as("textarea").eg("内容符合要求,同意发布"))
.write("/approval")
.modId(null));
list.add(ToolDef.of("notice.reject", MODULE, "驳回公告审批",
"驳回一条公告审批(意见必填,会退回发布人)。",
"POST_FORM", "/api/notice/Reject")
.p(ToolParam.required("serid", "integer", "公告 id")
.label("公告"),
ToolParam.required("remark", "string", "驳回意见")
.label("驳回意见").as("textarea").eg("标题表述不清,请修改后重新提交"))
.write("/approval")
.modId(null));
return list;
}
}
@@ -0,0 +1,95 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 宿舍通知模块工具 —— 后端 DormNotice,前缀 /api/notice、/api/dorm、/api/base。
*
* <p>家校闭环:宿管发通知 → 家长查看 → 家长回执/填报。
*/
public final class DormNoticeTools {
public static final String MODULE = "dormnotice";
private DormNoticeTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("dorm.myNotices", MODULE, "我的宿舍通知箱",
"当前家长/学生视角的宿舍通知箱(含我的回执状态与各分桶计数)。用户问“宿舍有什么通知”“我要回执”时用它。",
"GET", "/api/notice/MyNotices")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "关键词"))
.readAt("/par/notices"));
list.add(ToolDef.of("dorm.myNotice", MODULE, "宿舍通知详情(含我的回执)",
"查看某条宿舍通知的详情,以及我当前的填报表单与回执状态。",
"GET", "/api/notice/MyNotice")
.p(ToolParam.required("serid", "integer", "通知 id"))
.readAt("/par/notices"));
list.add(ToolDef.of("dorm.sendList", MODULE, "宿舍通知发布列表",
"查询已发布的宿舍通知(家长端收件箱 / 管理端全部)。",
"GET", "/api/notice/List")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "标题关键词"))
.readAt("/list"));
list.add(ToolDef.of("dorm.submitReceipt", MODULE, "提交宿舍通知回执",
"家长提交某条宿舍通知的回执 / 填报。调用前须确认 serid;如该通知需填报,还需填 fillData 内容。",
"POST_JSON", "/api/notice/SubmitReceipt")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"),
ToolParam.optional("guardianName", "string", "监护人姓名")
.label("监护人姓名"),
ToolParam.optional("guardianPhone", "string", "监护人电话")
.label("监护人电话").eg("13800000000"),
ToolParam.optional("relation", "string", "与学生关系")
.label("与学生关系").eg("父亲"),
ToolParam.optional("remark", "string", "备注")
.label("备注").as("textarea"),
ToolParam.optional("fillData", "object", "填报内容,键值对(需填报类通知必传)")
.label("填报内容"))
.write("/par/mine"));
list.add(ToolDef.of("dorm.cancelReceipt", MODULE, "取消宿舍通知回执",
"撤回我已提交的宿舍通知回执。",
"POST_FORM", "/api/notice/CancelReceipt")
.p(ToolParam.required("serid", "integer", "通知 id")
.label("宿舍通知"))
.write("/par/mine"));
list.add(ToolDef.of("dorm.buildings", MODULE, "宿舍楼栋列表",
"查询宿舍楼栋列表(含房间数/床位数/入住数)。",
"GET", "/api/dorm/BuildingList")
.readAt("/setting"));
list.add(ToolDef.of("dorm.rooms", MODULE, "宿舍房间列表",
"查询某楼栋下的房间,或按关键词搜索房间。",
"GET", "/api/dorm/RoomList")
.p(ToolParam.optional("buildingId", "integer", "楼栋 id"),
ToolParam.optional("keyword", "string", "房间号关键词"))
.readAt("/setting"));
list.add(ToolDef.of("dorm.roomStudents", MODULE, "房间住宿生名单",
"查询某个宿舍房间内的住宿生名单(含宿舍长标识)。",
"GET", "/api/dorm/RoomStudents")
.p(ToolParam.required("roomId", "integer", "房间 id"))
.readAt("/setting"));
list.add(ToolDef.of("dorm.dormTree", MODULE, "宿舍楼栋房间树",
"楼栋 → 房间 的完整树(发布宿舍通知时选接收房间用)。",
"GET", "/api/base/GetDormTree")
.readAt("/list/new"));
return list;
}
}
@@ -0,0 +1,57 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 课程作业模块工具(学生端 V2)—— 后端 CourseHomework,前缀 /api/student/homeworks。
*
* <p>学生端接口路径带资源 id({@code /{id}/...}),执行器支持 {@code {name}} 路径占位。
*/
public final class HomeworkTools {
public static final String MODULE = "coursehomework";
private HomeworkTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表",
"查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。",
"GET", "/api/student/homeworks")
.p(ToolParam.optional("tab", "string", "all 全部(默认) / todo 待做 / done 已完成 / closed 已截止").dft("all"),
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "作业标题关键词"))
.readAt("/stu/today"));
list.add(ToolDef.of("homework.detail", MODULE, "作业详情",
"查看一项作业的说明、附件要求与我当前的提交内容。",
"GET", "/api/student/homeworks/{id}")
.p(ToolParam.required("id", "integer", "作业 id"))
.readAt("/stu/today"));
list.add(ToolDef.of("homework.result", MODULE, "作业批阅结果",
"查看我的作业批阅结果与教师评语。",
"GET", "/api/student/homeworks/{id}/result")
.p(ToolParam.required("id", "integer", "作业 id"))
.readAt("/stu/redo"));
list.add(ToolDef.of("homework.submit", MODULE, "提交作业",
"提交一项作业的作答文本。调用前必须确认作业 id 与作答内容;"
+ "若该作业要求上传图片/附件,告知用户需到作业页面上传,智能体只处理文本作答。",
"POST_JSON", "/api/student/homeworks/{id}/submissions")
.p(ToolParam.required("id", "integer", "作业 id")
.label("作业"),
ToolParam.optional("text", "string", "作答文本内容")
.label("作答内容").as("textarea").eg("我的解题思路是……"))
.write("/stu/today"));
return list;
}
}
@@ -0,0 +1,164 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 学生请假模块工具(后端 HomeSchLeave,前缀 /api/leave、/api/dict、/api/leavestats)。
*
* <p>平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / 5 请假登记 / 6 请假统计 / 7 应用设置
*/
public final class LeaveTools {
public static final String MODULE = "homeleave";
/** 日期时间格式:后端统一 yyyy-MM-dd HH:mm:ss */
private static final String DT_HINT = "格式 yyyy-MM-dd HH:mm:ss(如 2026-10-12 08:00:00)";
/**
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
* 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。
*/
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"};
private LeaveTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
// ---------- 只读:辅助信息 ----------
list.add(ToolDef.of("leave.types", MODULE, "请假类型",
"查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。",
"GET", "/api/dict/LeaveTypeOptions")
.readAt("/apply")
.modId(2));
list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级",
"查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。",
"GET", "/api/leave/MyClasses")
.readAt("/apply")
.modId(2));
list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单",
"查询某班级的学生名单(教师给学生登记请假时选人用)。",
"GET", "/api/leave/ClassStudents")
.p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses"))
.readAt("/register")
.modId(5));
// ---------- 只读:查询 ----------
list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录",
"分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。"
+ "用户问“我请了几天假”“上次请假通过没”时用它。",
"GET", "/api/leave/List")
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"),
ToolParam.optional("leaveType", "integer", "按请假类型筛选:1 病假、2 事假及其他")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS),
ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"),
ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"),
ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)"))
.modId(3));
list.add(ToolDef.of("leave.detail", MODULE, "请假详情",
"查询一条请假的完整详情(时间段、销假时间、附件、可执行按钮)。",
"GET", "/api/leave/Details")
.p(ToolParam.required("id", "integer", "请假记录 id,来自 leave.myLeaves"))
.modId(3));
list.add(ToolDef.of("leave.stats", MODULE, "请假统计",
"按日期/班级/类型统计请假情况。",
"GET", "/api/leavestats/Stats")
.p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"),
ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"),
ToolParam.optional("classId", "integer", "班级 id"),
ToolParam.optional("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS))
.readAt("/stats")
.modId(6));
// ---------- 写:提交 / 审批 / 销假 ----------
list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请",
"学生提交一条请假申请(提交后状态为待审核,等班主任审批)。"
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
"POST_JSON", "/api/leave/Save")
.p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他")
.label("请假类型")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
.optionsFrom("leave.types"),
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT)
.label("开始时间").eg("2026-10-12 08:00:00"),
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT)
.label("结束时间").eg("2026-10-12 17:00:00"),
ToolParam.required("remarks", "string", "请假事由")
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"),
ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传")
.label("班级"),
ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)")
.label("学生"))
.fix("type", "2")
.fix("continuousLeave", "0")
.write("/my")
.modId(2));
list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假",
"撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。",
"POST_FORM", "/api/leave/Withdraw")
.p(ToolParam.required("id", "integer", "请假记录 id")
.label("请假记录"))
.write("/my")
.modId(3));
list.add(ToolDef.of("leave.reportBack", MODULE, "申请销假",
"学生请完假返校后申请销假(状态转待销假,教师确认后回写实际时间)。",
"POST_JSON", "/api/leave/ReportBack")
.p(ToolParam.required("id", "integer", "请假记录 id")
.label("请假记录"),
ToolParam.optional("startDate", "string", "实际返校时间," + DT_HINT)
.label("实际返校时间").eg("2026-10-12 08:00:00"),
ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT)
.label("实际结束时间").eg("2026-10-12 17:00:00"))
.fix("continuousLeave", "0")
.write("/my")
.modId(3));
list.add(ToolDef.of("leave.audit", MODULE, "审批请假",
"教师/管理员审批一条请假。",
"POST_JSON", "/api/leave/Audit")
.p(ToolParam.required("id", "integer", "请假记录 id")
.label("请假记录"),
ToolParam.required("status", "integer", "审批结论")
.label("审批结论")
.options(new String[]{"1", "8", "3", "4", "6", "7"},
new String[]{"通过", "拒绝", "撤销通过", "撤销拒绝", "销假通过", "销假拒绝"}))
.write("/audit")
.modId(4));
list.add(ToolDef.of("leave.batchPass", MODULE, "批量通过请假",
"一键通过某班级全部待审核请假,或按 id 列表批量通过。",
"POST_JSON", "/api/leave/BatchPass")
.p(ToolParam.optional("classId", "integer", "班级 id:通过该班全部待审核")
.label("班级"),
ToolParam.optional("ids", "array", "请假 id 数组:仅通过勾选的这些")
.label("请假记录").eg("101,102"))
.write("/audit")
.modId(4));
return list;
}
/** 供文档/目录使用 */
public static List<String> moduleAlias() {
return Arrays.asList("请假", "学生请假", "homeleave");
}
}
@@ -0,0 +1,90 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 教师专业发展模块工具 —— 后端 Profession,前缀 /api/profession、/api/moudule、/api/points、/api/stats。
*/
public final class ProfessionTools {
public static final String MODULE = "profession";
private ProfessionTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("profession.homeRecords", MODULE, "栏目最新记录",
"各栏目最新发布的记录块(专业发展首页内容)。",
"GET", "/api/profession/HomeModuleRecords")
.p(ToolParam.optional("size", "integer", "每栏取几条").dft("6"))
.readAt("/home"));
list.add(ToolDef.of("profession.list", MODULE, "专业发展记录列表",
"后台口径的专业发展记录分页查询,可按栏目/教师/时间筛选。",
"GET", "/api/profession/ProfessionList")
.p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"),
ToolParam.optional("keyword", "string", "标题关键词"),
ToolParam.optional("moduleId", "integer", "栏目 id"),
ToolParam.optional("starttime", "string", "开始时间 yyyy-MM-dd"),
ToolParam.optional("endtime", "string", "结束时间 yyyy-MM-dd"))
.readAt("/module/list"));
list.add(ToolDef.of("profession.moduleRecords", MODULE, "栏目文章列表",
"某个栏目下的文章分页(附同校其他栏目)。",
"GET", "/api/profession/ModuleRecordList")
.p(ToolParam.required("moduleId", "integer", "栏目 id"),
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
.readAt("/module/list"));
list.add(ToolDef.of("profession.myRecords", MODULE, "我的栏目文章",
"查询我在某栏目下发表的文章。",
"GET", "/api/profession/MyModuleRecordList")
.p(ToolParam.required("moduleId", "integer", "栏目 id"),
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
.readAt("/timeline"));
list.add(ToolDef.of("profession.detail", MODULE, "记录详情",
"查看一条专业发展记录的详情。",
"GET", "/api/profession/ProfessionDetail")
.p(ToolParam.required("SerID", "integer", "记录 id"))
.readAt("/module/list"));
list.add(ToolDef.of("profession.moduleTree", MODULE, "栏目树",
"专业发展的栏目树(含我的栏目),发布记录时选栏目用。",
"GET", "/api/moudule/ModuleTree")
.readAt("/record/new"));
list.add(ToolDef.of("profession.myPoints", MODULE, "我的积分",
"查询我的专业发展积分与来源明细。",
"GET", "/api/profession/MyPoints")
.readAt("/timeline"));
list.add(ToolDef.of("profession.pointsRank", MODULE, "积分排行",
"积分排行榜。",
"GET", "/api/profession/PointsRank")
.p(ToolParam.optional("top", "integer", "取前几名").dft("8"))
.readAt("/home"));
list.add(ToolDef.of("profession.publish", MODULE, "发布专业发展记录",
"发布一条专业发展记录(教案/学习资料/反思等)。调用前必须确认栏目 moduleId、标题与内容。",
"POST_FORM", "/api/profession/Create")
.p(ToolParam.required("moduleId", "integer", "栏目 id,来自 profession.moduleTree")
.label("所属栏目").optionsFrom("profession.moduleTree"),
ToolParam.required("title", "string", "记录标题")
.label("记录标题").eg("2026年秋季公开课教学设计"),
ToolParam.optional("content", "string", "记录正文")
.label("记录正文").as("textarea"))
.write("/record/new"));
return list;
}
}
@@ -0,0 +1,59 @@
package com.yunhai.agent.tool.defs;
import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolParam;
import java.util.ArrayList;
import java.util.List;
/**
* 资源库模块工具 —— 后端 ResourceLibrary,前缀 /api/resource、/api/category、/api/textbook。
*/
public final class ResourceTools {
public static final String MODULE = "resourcelibrary";
private ResourceTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("resource.search", MODULE, "检索资源",
"按关键词/分类/学科检索教学资源(课件、教案、试题等)。用户问“找一份 XX 的课件”“有没有 XX 资料”时用它。",
"GET", "/api/resource/List")
.p(ToolParam.optional("keyword", "string", "关键词:资源名称 / 编码 / 上传人"),
ToolParam.optional("categoryId", "integer", "分类节点 id(含子节点)"),
ToolParam.optional("subject", "string", "学科"),
ToolParam.optional("type", "string", "资源类型"),
ToolParam.optional("orderBy", "string", "排序:time_desc 最新 / download_desc 下载最多 / view_desc 浏览最多"),
ToolParam.optional("pageIndex", "integer", "页码").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))
.readAt("/resource"));
list.add(ToolDef.of("resource.detail", MODULE, "资源详情",
"查看一份资源的详细信息(分类、学科、格式、下载量等)。",
"GET", "/api/resource/Detail")
.p(ToolParam.required("id", "integer", "资源 id,来自 resource.search"))
.readAt("/resource"));
list.add(ToolDef.of("resource.categoryTree", MODULE, "资源分类树",
"资源分类树(学段 → 学科 → 年级册次 → 章节),用于按分类筛选资源。",
"GET", "/api/category/Tree")
.readAt("/resource"));
list.add(ToolDef.of("resource.collect", MODULE, "收藏资源",
"收藏或取消收藏一份资源。",
"POST_FORM", "/api/resource/Collect")
.p(ToolParam.required("id", "integer", "资源 id")
.label("资源"))
.write("/resource/collect"));
list.add(ToolDef.of("resource.textbookOptions", MODULE, "教材选项",
"查询可选的教材版本 / 学科 / 出版社。",
"GET", "/api/textbook/Options")
.readAt("/textbook"));
return list;
}
}
+147
View File
@@ -0,0 +1,147 @@
server:
port: 8093
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
datasource:
# 智能体自身落库:会话(agent_session)+ 消息(agent_message)+ 工具调用审计(agent_tool_call_log)
# 首次部署前执行:CREATE DATABASE bsch_agent DEFAULT CHARSET utf8mb4;
url: jdbc:mysql://localhost:3306/bsch_agent?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
#jpa
jpa:
database: MySQL
show-sql: false
hibernate:
# 开发期使用 update 自动建表;生产环境设置成 none
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
# 平台统一 HTTP 客户端(yhRestTemplate,由 yh-http starter 提供)超时:
# 本服务的出站调用(大模型 / 各业务模块)都走它,读超时给足以免大模型慢响应被切断
http:
connect-timeout-ms: 5000
read-timeout-ms: 60000
write-timeout-ms: 60000
sso:
# 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),票据校验=根路径 /serviceValidate
server-url-prefix: http://sso.yunhaixingtong.com/
client-host-url: http://localhost:8093
allow-headers: all
allow-origins: all
url-pattern: /api/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用)。本地联调用 100009(160 UserBase/中央密钥链在册身份)
app-key: 100009
app-secret: dcc36c98299a4d36bb0ec39d42008e8c
server:
enforce-patterns: [/oapi/*]
trust-registry: true
# ★ 开放接口服务目录(用户中心 / YschBaseOpenApi)—— 必需,缺了登录必报「内部服务错误」
# 平台 yh-openapi starter 的 HasOpenApiEndpointCondition 会检查
# yh.open.base-host 或 yh.open.services.userbase:
# 有 → 装配 OpenApiUserDetailsProvider(CAS 登录成功后按登录名反查 BoaUser)
# 无 → 退化为 NoOpUserDetailsProvider,其 loadByLoginName 直接抛
# UnsupportedOperationException → 全局异常处理器返回 code=6「内部服务错误」
# (前端表现:登录成功后「载入用户信息失败,请检查智能体服务是否已启动」)
# 说明:userbase/userclass/usermsg/flow-center 四域走枚举兜底 = base-host + 默认 ctx,
# 故 base-host 一项即可;如需单独指向某域再加 yh.open.services.<域>。
open:
base-host: http://base.yunhaixingtong.com
# ============================================================
# 智能体配置:大模型 + 各业务模块地址 + 角色权限(★ 上线前按环境改这里)
# ============================================================
agent:
# --- 大模型:DeepSeek 官方(OpenAI 兼容协议)---
llm:
host: https://api.deepseek.com/chat/completions
# 豆包方舟示例:https://ark.cn-beijing.volces.com/api/v3/chat/completions
# 千问兼容模式:https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
api-key: ${DEEPSEEK_API_KEY:sk-请替换为你的DeepSeek密钥}
# ★ 模型标识必须以官方实时清单为准:GET https://api.deepseek.com/models
# (2026-10-10 实测只有下面两个;旧的 deepseek-chat / deepseek-reasoner 已不在清单里)
model: deepseek-flash # DeepSeek-V4.1-Flash:性价比最高,支持 function calling(默认)
# 可选:deepseek-v4-pro # DeepSeek-V4-Pro:更强推理,单价更高
max-tokens: 2048
temperature: 0.3
timeout: 60000
# --- 编排参数 ---
loop:
max-rounds: 6 # 单轮最多「思考→调工具」次数,防死循环
tool-result-max-chars: 6000 # 工具结果回填模型前的截断长度
history-max-messages: 20 # 会话保留的历史消息条数
session-ttl-minutes: 120 # 会话空闲过期
# 是否按「用户已授权模块」过滤工具(默认 false:不隐藏,越权由各业务模块按透传身份裁决)
filter-tools-by-authorized-module: false
# ★ 角色强校验:模型若调用了当前角色无权使用的工具,服务端直接拒绝执行(分学生、教师权限的硬约束)
enforce-role-permission: true
# ★ 浏览器代执行(方案 A,2026-10-12):模块类工具不由服务端直调(必然 302),
# 而是挂起下发给前端,用用户自己的浏览器登录态调用模块接口后回传结果续跑。
# 需要与模块同站部署(同源 Cookie 才能带上);本地 dev 跨域会失败并自动落回深链兜底。
browser-exec:
enabled: true
# ★ 角色映射:按本校 UserBase 角色体系校准(BoaUser.type=1 恒为管理员)
roles:
student: [1] # 学生
teacher: [2, 3] # 教师 / 班主任
admin: [4, 5, 6, 7] # 校管 / 分管领导 / 教务 / 校长
admin-also-teacher: true # 管理员同时拥有教师侧能力
# --- 业务模块基地址(key 必须与工具定义里的 MODULE 常量一致)---
# 本地联调:把 value 换成对应服务本机地址(如 http://localhost:8106)
modules:
homeleave: http://base.yunhaixingtong.com/home-leave
campusnotice: http://base.yunhaixingtong.com/campusnotice
dormnotice: http://base.yunhaixingtong.com/dormnotice
familyactivity: http://base.yunhaixingtong.com/familyactivity
coursehomework: http://base.yunhaixingtong.com/coursehomework
resourcelibrary: http://base.yunhaixingtong.com/resourcelibrary
profession: http://base.yunhaixingtong.com/profession
# --- 模块默认落地页(深链兜底)---
# 服务端调不通模块用户态接口时(下游 code=302,只认浏览器 CAS 会话),
# 智能体会把模块页面地址回传真前端,让用户用自己的登录态直接去看真实数据。
# 下面是「出厂默认值」的显式回写(代码 AgentSettings#moduleLanding 已内置同值兜底),
# 改这里即可覆盖单个模块;工具自身声明的 landingPath 优先级更高。
# 路由取值已按各模块前端 src/router/routes.ts 核对(2026-10-10)。
module-landing:
homeleave: /my # 我的请假
campusnotice: / # 我的公告(收件箱)
dormnotice: /par/notices # 宿舍通知箱
familyactivity: /par/activities # 活动广场
coursehomework: /stu/today # 今日作业
resourcelibrary: /resource # 资源管理
profession: /home # 发展首页
# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health
# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用。
management:
health:
redis:
enabled: false
logging:
level:
com.yunhai.agent: info
+33
View File
@@ -0,0 +1,33 @@
spring:
application:
name: agent
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
cloud:
nacos:
config:
enabled: ${NACOS_CONFIG_ENABLED:false}
server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848}
namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc}
# 应用自身配置所在分组
group: OA_APP
# 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 agent.yaml
file-extension: yaml
encode: UTF-8
# nacos 不可达时不强制退出,回退到本地 application.yml 兜底
fail-fast: false
# 只读账号
username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader}
password: ${SPRING_CLOUD_NACOS_PASSWORD:123456}
# 共享公共配置
shared-configs:
# ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml
- data-id: application-default.yaml
group: DEFAULT_GROUP
namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间
refresh: false
# ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 namespace id)的 application-infra.yaml
- data-id: application-infra.yaml
group: DEFAULT_GROUP
refresh: false