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,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;
}
}