Files
yunhai-agent-service/src/main/java/com/yunhai/agent/tool/ToolParam.java
T
lijing c16356db6e
build / build-push (push) Successful in 1m2s
feat(form): 发布表单与各模块「新建发布」页逐字段对齐(含审批流与作业两步发布)
把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:

- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
  新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
  选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
  提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
  存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
  optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
  连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
  teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
  (Create 落草稿 → Publish | Schedule);附件是对象数组
  attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
  /coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
  首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约

回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
2026-10-13 11:29:03 +08:00

454 lines
18 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 / multiselect / file;
* 缺省按参数名与类型自动推断。
*
* <ul>
* <li>{@code multiselect}:多选下拉(配合 {@code type=array}),用于「接收班级 / 宿舍 / 审批人」这类
* 模块里是多选的字段。</li>
* <li>{@code file}:附件上传(对齐模块页的「添加附件」),上传后得到 fileid 列表。</li>
* </ul>
*/
private String widget;
/**
* 数组值的序列化分隔符(仅 {@code POST_JSON} 生效)。
*
* <p>★ 为什么需要:模块的很多字段在 wire 上是「逗号串」而不是 JSON 数组
* (如宿舍通知 {@code receiveList="1,2,3"}、附件 {@code fileIds="11,12"}),
* 但表单里用户是多选的(天然是数组)。声明 {@code .join(",")} 后,执行器会在发请求前
* 把数组拼成模块要的字符串,避免"页面里选得到、提交后端判非法"。
*/
private String joinSeparator;
/** {@code widget=file} 时是否允许多个附件(缺省 true);对齐模块页 AttachmentUpload 的 multiple */
private Boolean multiple;
/**
* 「全部」哨兵值。
*
* <p>★ 用途:模块的接收对象多选里都有一个「全部班级 / 全部宿舍」开关,wire 上是 {@code "ALL"}。
* 声明后:① 表单把它作为第一个选项摆进多选;② 提交时若用户既没选具体项、又没选它,
* 视作「全部」(保持"默认发全体"的语义);③ 若同时选了具体项与它,以**具体项**为准(折叠掉它)。
*/
private String allToken;
/** 「全部」选项在表单里显示的中文名,如「全部班级」/「全部宿舍」 */
private String allTokenLabel;
/**
* 「推送范围」控件(widget=scopes)的节点类型。
*
* <p>校园公告的接收对象 {@code pushScopes} 在 wire 上是
* {@code [{scopeType,scopeId,scopeName}]}(1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员)。
* 声明后前端把多选结果拼成这个对象数组;选「全部」则发出 {@code [{scopeType:1,scopeId:0,scopeName:"全部班级"}]}。
*/
private Integer scopeType;
/** 「镜像字段」指向的源字段名(隐藏字段,提交时填入源字段选中项的显示名,如 deptId → deptName) */
private String mirrorLabelOf;
/** 「连续请假」展开说明:{@code {count,start,end}} 三个字段名(见 {@link #expandDaily}) */
private Map<String, String> dailyExpandSpec;
/** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */
private boolean fileObject;
/** 附件上传地址覆盖(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
private String uploadPathOverride;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
private boolean hidden;
/**
* 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。
* <p>服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 ——
* 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。
*/
private String optionsFrom;
/**
* 选项取自字典返回对象的哪个字段(如 {@code classId} / {@code stuId})。
*
* <p>★ 为什么必须显式声明:模块字典返回的是业务 VO({@code TeachingClassVo{classId,className}}、
* {@code ClassStudentVo{stuId,stuName}}),字段名五花八门,靠「猜常见键名」不可靠。
* 显式声明后,前端在浏览器里取到原始数据也能**确定性地**映射成下拉项。
*/
private String optionValueKey;
/** 选项显示名取自字典返回对象的哪个字段(如 {@code className} / {@code stuName}) */
private String optionLabelKey;
/**
* 本字段的选项依赖哪些其它字段(如「学生」下拉依赖先选好「班级」)。
*
* <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项;
* ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。
*/
private List<String> dependsOn;
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;
}
/**
* 字典返回对象里,取哪个字段做「值」、哪个字段做「显示名」。
* <p>例:{@code optionsKeys("classId","className")} 对应 {@code {classId,className}} 这种业务 VO。
*/
public ToolParam optionsKeys(String valueKey, String labelKey) {
this.optionValueKey = valueKey;
this.optionLabelKey = labelKey;
return this;
}
/** 本字段的选项依赖的先序字段(用于联动下拉,如「学生」依赖「班级」) */
public ToolParam dependsOn(String... fieldNames) {
this.dependsOn = Arrays.asList(fieldNames);
return this;
}
/**
* 数组值序列化分隔符:{@code POST_JSON} 时把多选的数组拼成模块要的「逗号串」。
* 例:{@code optional("fileIds","array",...).join(",")} → {@code "11,12,13"}。
*/
public ToolParam join(String separator) {
this.joinSeparator = separator;
return this;
}
/** 附件是否可多选(仅 {@code widget=file} 有意义) */
public ToolParam multiple(boolean value) {
this.multiple = value;
return this;
}
/**
* 「全部」哨兵值(如 {@code "ALL"}):表单会把它作为多选的第一个选项,
* 提交时与具体项互斥(见 {@link #allToken} 字段说明)。
*/
public ToolParam allOption(String token, String label) {
this.allToken = token;
this.allTokenLabel = label;
return this;
}
/**
* 声明为「推送范围」多选:提交时把选中的节点拼成
* {@code [{scopeType,scopeId,scopeName}]}(校园公告的 {@code pushScopes})。
*
* @param type 节点类型:1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员
*/
public ToolParam scopes(int type) {
this.widget = "scopes";
this.scopeType = type;
return this;
}
/**
* 声明为「审批流」控件(校园公告的 {@code flow})。
*
* <p>表单会渲染成:是否走审批 + 多步(每步可选或签/会签)+ 每步多选审批人
* (候选人来自 {@link #optionsFrom} 指向的教师树工具);提交时前端拼成
* {@code [{name:"第1步",vote:"any"|"all",approverTeaIds:[...]}]}。
*/
public ToolParam flow() {
this.widget = "flow";
return this;
}
/**
* 声明本字段为**隐藏的镜像字段**:用户不填,提交时自动带上源字段选中项的显示名。
*
* <p>用于 {@code deptName} 这类「模块页会随包提交、但界面上没有对应输入框」的字段:
* 三个模块页都是「选部门 → 用字典把 id 反查成名称 → 一起提交」。
*/
public ToolParam mirrorLabelOf(String sourceField) {
this.mirrorLabelOf = sourceField;
return this;
}
/**
* 连续请假展开:本字段取 1 时,前端按 {@code countField} 的天数把
* {@code startField}/{@code endField} 复制成逐天时间段,生成 {@code continuousLeaveTime}。
*
* @param countField 连续天数字段名(如 continuousLeaveNum)
* @param startField 基准开始时间字段名(如 startDate)
* @param endField 基准结束时间字段名(如 endDate)
*/
public ToolParam expandDaily(String countField, String startField, String endField) {
this.dailyExpandSpec = new LinkedHashMap<>();
this.dailyExpandSpec.put("count", countField);
this.dailyExpandSpec.put("start", startField);
this.dailyExpandSpec.put("end", endField);
return this;
}
/** 附件控件提交**对象数组**(作业模块 {@code attachments:[{fileId,fileUrl,fileType,fileName,sort}]}) */
public ToolParam asAttachments() {
this.widget = "file";
this.fileObject = true;
return this;
}
/** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
public ToolParam upload(String path) {
this.uploadPathOverride = path;
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();
boolean isArray = "array".equalsIgnoreCase(StringUtils.trimToEmpty(type));
if (isArray) {
// 数组参数:有选项来源(枚举或字典)→ 多选下拉;否则退化成文本(用户手填逗号串)
if ((vals != null && !vals.isEmpty()) || StringUtils.isNotBlank(optionsFrom)) return "multiselect";
return "text";
}
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.setDependsOn(dependsOn);
f.setValueKey(optionValueKey);
f.setLabelKey(optionLabelKey);
f.setJoinSeparator(joinSeparator);
f.setMultiple(multiple == null || multiple);
f.setAllToken(allToken);
f.setAllTokenLabel(allTokenLabel);
f.setScopeType(scopeType);
f.setMirrorOf(mirrorLabelOf);
f.setDailyExpand(dailyExpandSpec);
f.setFileAsObject(fileObject);
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
f.setOptionsFallback(isUsingFallbackOptions());
return f;
}
/** 转成 JSON Schema 的属性节点(给模型看) */
public JSONObject toSchema() {
JSONObject o = new JSONObject();
o.put("type", type);
if ("array".equalsIgnoreCase(StringUtils.trimToEmpty(type))) {
JSONObject items = new JSONObject();
items.put("type", "string");
o.put("items", items);
}
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;
}
}