feat(通知公告): 发布表单支持模块页的富文本正文与选人控件
build / build-push (push) Successful in 2m1s

前端已把 NoticeFront 的 wangEditor 与自绘「通知对象」控件 1:1 搬进助手表单,
后端补上这两个控件所需的契约:

- ToolParam / FormField 新增:
  · pickerSources:选人弹窗的**数据源页签**(形如 {name, 模块内相对地址}),
    由 AgentOrchestrator 拼上模块前缀后下发(沿用「只写相对路径」的铁律);
  · richTextUploadPath / richTextPreviewPath:正文插图的上传与预览地址。
- AgentOrchestrator:把 resolveUploadPath 抽出通用形态 resolveModulePath(幂等、只接受
  模块内相对路径);buildForm 为 widget=userpicker 下发 pickerSources;
  optionCall 强制改写 widget 的白名单放行 userpicker(否则会被降级成 select)。
- NoticeTools:notify.publish 与 notify.editNotice 的 receiveList 改为 userpicker,
  两个数据源为「基础平台」/api/base/GetSchoolAll 与「自定义组」
  /api/contacts/GetGroupTree?type=1;content 走 richtext。

测试:NotifyIntentFormTest / IntentDirectFormTest 补 pickerSources 断言
(**只写相对路径**,不带前缀 —— 前缀拼错是本项目反复出过的坑),
ModuleFormParityTest 补 userpicker 取值口径断言(字符串原样透传,不得包成数组)。
mvn -o test:Tests run: 135, Failures: 0, Errors: 0,BUILD SUCCESS。
This commit is contained in:
lijing
2026-10-14 15:19:24 +08:00
parent 8f0f8a04b3
commit c084c978aa
7 changed files with 273 additions and 30 deletions
@@ -692,15 +692,32 @@ public class AgentOrchestrator {
* @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload} * @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload}
*/ */
public static String resolveUploadPath(String module, String moduleBaseUrl, String override) { public static String resolveUploadPath(String module, String moduleBaseUrl, String override) {
return resolveModulePath(module, moduleBaseUrl, override, "/api/base/FileUpload");
}
/**
* 把「模块内相对路径」拼成「相对站点根」的路径({@link #resolveUploadPath} 的通用形态)。
*
* <p>附件上传、正文图片、选人弹窗的数据源都走这一条 —— 它们的共同点是
* 「地址只写模块内相对部分,前缀由编排层拼」。早先 {@code profession.publish} 把
* {@code /api/file/Upload} 当完整路径直接下发,线上打到站点根 ⇒ 404(老坑,别再来一次)。
*
* @param relFallback {@code rel} 为空时的默认相对路径
*/
public static String resolveModulePath(String module, String moduleBaseUrl, String rel, String relFallback) {
String prefix = pathPrefix(module, moduleBaseUrl); String prefix = pathPrefix(module, moduleBaseUrl);
String rel = StringUtils.isNotBlank(override) ? override.trim() : "/api/base/FileUpload"; String path = StringUtils.isNotBlank(rel) ? rel.trim() : relFallback;
if (!rel.startsWith("/")) { if (StringUtils.isBlank(path)) {
rel = "/" + rel; return "";
} }
if (rel.equals(prefix) || rel.startsWith(prefix + "/")) { if (!path.startsWith("/")) {
return rel; path = "/" + path;
} }
return prefix + rel; // 已经带了前缀的写法保持幂等(/notice/api/... 不会再被拼成 /notice/notice/api/...)
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
return path;
}
return prefix + path;
} }
/** 由工具定义生成补全表单 */ /** 由工具定义生成补全表单 */
@@ -740,6 +757,38 @@ public class AgentOrchestrator {
f.setUploadPath(resolveUploadPath(def.getModule(), f.setUploadPath(resolveUploadPath(def.getModule(),
settings.getModules().get(def.getModule()), p.getUploadPathOverride())); settings.getModules().get(def.getModule()), p.getUploadPathOverride()));
} }
// 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview),
// 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。
if ("richtext".equals(f.getWidget())) {
String baseUrl = settings.getModules().get(def.getModule());
f.setRichTextUploadPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextUploadOverride()));
f.setRichTextPreviewPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextPreviewOverride()));
}
// 「通知对象」自绘控件(userpicker)的选人弹窗数据源:模块页有两个页签
// (基础平台组织树 / 自定义组),optionCall 只能描述一个地址,其余由这里补齐。
// 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。
if ("userpicker".equals(f.getWidget()) && p.getPickerSources() != null) {
String baseUrl = settings.getModules().get(def.getModule());
List<FormField.PickerSource> srcs = new ArrayList<>();
for (String[] ps : p.getPickerSources()) {
if (ps == null || ps.length < 2 || StringUtils.isBlank(ps[1])) {
continue;
}
BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall();
call.setName("picker." + ps[0]);
call.setTitle("选择用户 · " + ps[0]);
call.setWrite(false);
call.setMethod("GET");
call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null));
FormField.PickerSource src = new FormField.PickerSource();
src.setName(ps[0]);
src.setCall(call);
srcs.add(src);
}
if (!srcs.isEmpty()) {
f.setPickerSources(srcs);
}
}
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。 // 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
// //
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话, // ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
@@ -750,10 +799,14 @@ public class AgentOrchestrator {
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller); BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
if (optionCall != null) { if (optionCall != null) {
f.setOptionCall(optionCall); f.setOptionCall(optionCall);
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、 // ★ 多值/自绘控件必须保留原样:
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。 // multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流)
// 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力;
// userpicker(通知公告的「通知对象」)是**自绘控件**(radio 全部/指定 + 选人弹窗),
// 它自己会用 optionCall 去取组织树 —— 降级成 select 就等于把它改回普通下拉。
String w = f.getWidget(); String w = f.getWidget();
if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) { if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)
&& !"userpicker".equals(w)) {
f.setWidget("select"); f.setWidget("select");
} }
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖 // 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
@@ -764,7 +817,8 @@ public class AgentOrchestrator {
if (!opts.isEmpty()) { if (!opts.isEmpty()) {
f.setOptions(opts); f.setOptions(opts);
String w2 = f.getWidget(); String w2 = f.getWidget();
if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) { if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)
&& !"userpicker".equals(w2)) {
f.setWidget("select"); f.setWidget("select");
} }
f.setOptionsFallback(false); f.setOptionsFallback(false);
@@ -18,7 +18,7 @@ public class FormField {
/** 中文标签,如「开始时间」 */ /** 中文标签,如「开始时间」 */
private String label; private String label;
/** 控件类型:text / textarea / number / date / datetime / select / multiselect / file / scopes / flow / radio / switch */ /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file / scopes / flow / radio / switch / richtext / userpicker */
private String widget; private String widget;
/** 数据类型:string / integer / number / boolean / array */ /** 数据类型:string / integer / number / boolean / array */
@@ -40,6 +40,37 @@ public class FormField {
/** {@code widget=file} 时是否允许多个附件 */ /** {@code widget=file} 时是否允许多个附件 */
private boolean multiple; private boolean multiple;
/**
* {@code widget=richtext}(富文本正文)时,「插入图片」的上传地址(**已带模块前缀**的绝对相对路径),
* 如 {@code /notice/api/file/UploadImage}。
* <p>与附件同理:模块的上传接口只认浏览器 CAS 会话,必须由前端带用户登录态 POST(multipart)。
*/
private String richTextUploadPath;
/**
* {@code widget=richtext} 时,正文内图片的预览地址模板(**已带模块前缀**,含 {@code {id}} 占位),
* 如 {@code /notice/api/file/Preview?id={id}} —— 前端拿到上传返回的文件 id 后替换占位符,
* 得到可直接放进 {@code <img src>} 的地址。
*/
private String richTextPreviewPath;
/**
* {@code widget=userpicker}(通知对象)的**数据源页签**,形如
* {@code [{name:"基础平台", call:{...}}, {name:"自定义组", call:{...}}]}。
*
* <p>搬运自通知公告模块页 {@code components/UserCheckModal.vue} 的两个页签,
* 每个 {@code call} 都已经是「**带模块前缀**的浏览器代取调用」(同 {@link #optionCall} 的形态),
* 由前端用用户登录态去取原始**嵌套树**(不做拍平 —— 拍平会丢掉层级,树就画不出来)。
*/
private List<PickerSource> pickerSources;
/** {@code widget=userpicker} 的一个数据源页签:页签名 + 浏览器代取调用 */
@lombok.Data
public static class PickerSource {
private String name;
private com.yunhai.agent.model.BrowserToolRequest.BrowserCall call;
}
/** /**
* 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。 * 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。
* <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]}; * <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
@@ -129,6 +129,36 @@ public class ToolParam {
*/ */
private String uploadPathOverride; private String uploadPathOverride;
/**
* {@code widget=richtext} 时,正文编辑器里「插入图片」用的上传地址(**模块内相对路径**)。
*
* <p>与 {@link #uploadPathOverride} 同约定:只写相对路径,前缀由
* {@code AgentOrchestrator.resolveUploadPath} 拼。通知公告模块页用的是
* {@code /api/file/UploadImage}(返回 {@code data[0].{id,fileTrueName}}),
* 与附件的 {@code /api/base/FileUpload} **不是同一个接口**。
*/
private String richTextUploadOverride;
/**
* {@code widget=richtext} 时,正文内图片的预览地址模板(**模块内相对路径**,含 {@code {id}} 占位)。
*
* <p>模块页把图片 src 写成 {@code /api/file/Preview?id=<fileid>}(inline 输出图片,
* 而 {@code /api/file/Down} 是 attachment,不能内嵌)。
*/
private String richTextPreviewOverride;
/**
* {@code widget=userpicker} 的**数据源页签**:每项是 {@code {页签名, 模块内相对地址}}。
*
* <p>通知公告模块页的选人弹窗有两个页签 —— 「基础平台」({@code /api/base/GetSchoolAll} 组织树)
* 与「自定义组」({@code /api/contacts/GetGroupTree?type=1})。要 1:1 搬运控件,两个页签都得给。
* {@code optionCall} 只能描述**一个**地址,所以额外用这里下发。
*
* <p>★ 与 {@link #uploadPathOverride} 同约定:只写模块内相对路径(可带 query),
* 前缀一律由编排层拼 —— 自己带前缀就是线上 404 的老坑。
*/
private List<String[]> pickerSources;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */ /** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
private boolean hidden; private boolean hidden;
@@ -476,6 +506,38 @@ public class ToolParam {
return this; return this;
} }
/**
* {@code widget=richtext}(富文本正文):声明「插入图片」的上传地址与预览地址模板。
*
* <p>两者都只写**模块内相对路径**(前缀由编排层拼,与 {@link #upload} 同约定)。
* 例:{@code .richText("/api/file/UploadImage", "/api/file/Preview?id={id}")}
*
* <p>★ 为什么必须成对下发:模块的图片上传接口回的是 {@code data[0].id}(文件服务 id),
* 而能内嵌到正文里的地址是另一个接口({@code /api/file/Preview},inline)。
* 前端拿到 id 后要按模板拼出可直接放进 {@code <img src>} 的地址。
*/
public ToolParam richText(String uploadPath, String previewPath) {
this.richTextUploadOverride = uploadPath;
this.richTextPreviewOverride = previewPath;
return this;
}
/**
* {@code widget=userpicker}(通知对象):追加一个「选人弹窗」的数据源页签。
*
* <p>与 {@link #richText} / {@link #upload} 同约定:{@code pathAndQuery} 只写**模块内相对路径**
* (可带 query,如 {@code /api/contacts/GetGroupTree?type=1}),前缀由编排层拼。
*
* <p>调用顺序即页签顺序 —— 通知公告模块页是「基础平台」在前、「自定义组」在后。
*/
public ToolParam pickerSource(String tabName, String pathAndQuery) {
if (pickerSources == null) {
pickerSources = new ArrayList<>();
}
pickerSources.add(new String[]{tabName, pathAndQuery});
return this;
}
/** 表单里显示的名字 */ /** 表单里显示的名字 */
public String labelOf() { public String labelOf() {
return StringUtils.isNotBlank(label) ? label : name; return StringUtils.isNotBlank(label) ? label : name;
@@ -578,6 +640,9 @@ public class ToolParam {
f.setSwitchOff(switchOff); f.setSwitchOff(switchOff);
f.setRows(rows); f.setRows(rows);
f.setMaxLength(maxLength); f.setMaxLength(maxLength);
// 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
f.setRichTextUploadPath(richTextUploadOverride);
f.setRichTextPreviewPath(richTextPreviewOverride);
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
f.setOptionsFallback(isUsingFallbackOptions()); f.setOptionsFallback(isUsingFallbackOptions());
return f; return f;
@@ -33,6 +33,24 @@ public final class NoticeTools {
* 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */ * 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */
private static final String UPLOAD_PATH = "/api/base/FileUpload"; private static final String UPLOAD_PATH = "/api/base/FileUpload";
/** 正文富文本里「插入图片」的上传地址(模块页 NewNotice.vue 的 editorConfig.MENU_CONF.uploadImage)。 */
private static final String RICHTEXT_UPLOAD_PATH = "/api/file/UploadImage";
/** 正文内图片的预览地址模板 —— 必须是 {@code Preview}(inline 输出图片),
* {@code Down} 是 attachment,放进 {@code <img src>} 显示不出来。 */
private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}";
/**
* 「通知对象」选人弹窗的两个数据源页签(模块内相对路径,前缀由编排层拼)。
*
* <p>逐项对齐 {@code UserCheckInput.vue} 的 {@code sources}:
* 「基础平台」= {@code baseApi.getSchoolAll()}({@code /api/base/GetSchoolAll}),
* 「自定义组」= {@code settingApi.getGroupTree()}({@code /api/contacts/GetGroupTree?type=1})。
* 弹窗里的树要用**原始嵌套结构**,所以这里必须给两个地址,而不是把树拍平成下拉选项。
*/
private static final String[] PICKER_SOURCE_BASE = {"基础平台", "/api/base/GetSchoolAll"};
private static final String[] PICKER_SOURCE_GROUP = {"自定义组", "/api/contacts/GetGroupTree?type=1"};
private NoticeTools() { private NoticeTools() {
} }
@@ -99,14 +117,17 @@ public final class NoticeTools {
.label("要编辑的通知"), .label("要编辑的通知"),
ToolParam.required("title", "string", "通知标题(50 字以内)") ToolParam.required("title", "string", "通知标题(50 字以内)")
.label("通知标题"), .label("通知标题"),
ToolParam.optional("content", "string", "通知正文(纯文本)") ToolParam.optional("content", "string", "通知正文(富文本 HTML)")
.label("通知正文").as("textarea"), .label("通知正文").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH),
ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段") ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
.label("来源部门") .label("来源部门")
.optionsFrom("notify.deptDics").optionsKeys("k", "v"), .optionsFrom("notify.deptDics").optionsKeys("k", "v"),
ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户") ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户")
.label("通知对象") .label("通知对象").as("userpicker")
.optionsFrom("notify.schoolAll").optionsKeys("id", "name") .optionsFrom("notify.schoolAll").optionsKeys("id", "name")
.pickerSource(PICKER_SOURCE_BASE[0], PICKER_SOURCE_BASE[1])
.pickerSource(PICKER_SOURCE_GROUP[0], PICKER_SOURCE_GROUP[1])
.join(",").allOption("all", "全部用户"), .join(",").allOption("all", "全部用户"),
ToolParam.optional("fileIds", "array", "附件文件 id 列表") ToolParam.optional("fileIds", "array", "附件文件 id 列表")
.label("添加附件").as("file").multiple(true).join(",") .label("添加附件").as("file").multiple(true).join(",")
@@ -168,15 +189,22 @@ public final class NoticeTools {
List<ToolParam> ps = new ArrayList<>(); List<ToolParam> ps = new ArrayList<>();
ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)") ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)")
.label("通知标题").eg("关于本周宿舍熄灯时间调整的通知")); .label("通知标题").eg("关于本周宿舍熄灯时间调整的通知"));
ps.add(ToolParam.optional("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)") ps.add(ToolParam.optional("content", "string", "通知正文(富文本 HTML,对应模块页的 wangEditor)")
.label("通知正文").as("textarea").eg("各位老师:经学校研究决定……")); .label("通知正文").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
.eg("各位老师:经学校研究决定……"));
ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段") ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
.label("来源部门") .label("来源部门")
.optionsFrom("notify.deptDics").optionsKeys("k", "v")); .optionsFrom("notify.deptDics").optionsKeys("k", "v"));
// 模块页的 UserCheckInput:v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string) // 模块页的 UserCheckInput:radio「全部用户 / 指定用户」+ 选人弹窗(左组织树 / 右已选列表)。
ps.add(ToolParam.required("receiveList", "array", "通知对象:留空或含全部 = 全部用户;也可多选具体用户") // v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string),**取值契约与多选完全一致**
.label("通知对象") // —— 换的只是控件外观。选项仍由 notify.schoolAll 代取(弹窗里的树直接用它取原始嵌套结构,
// 不走 parseOptionItems 的拍平,否则层级会丢)。
ps.add(ToolParam.required("receiveList", "array", "通知对象:\"all\" 或用户 id 逗号串")
.label("通知对象").as("userpicker")
.optionsFrom("notify.schoolAll").optionsKeys("id", "name") .optionsFrom("notify.schoolAll").optionsKeys("id", "name")
.pickerSource(PICKER_SOURCE_BASE[0], PICKER_SOURCE_BASE[1])
.pickerSource(PICKER_SOURCE_GROUP[0], PICKER_SOURCE_GROUP[1])
.join(",").allOption("all", "全部用户")); .join(",").allOption("all", "全部用户"));
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
.label("添加附件").as("file").multiple(true).join(",") .label("添加附件").as("file").multiple(true).join(",")
@@ -228,9 +228,14 @@ class IntentDirectFormTest {
assertEquals("/list/new", resp.getForm().getLandingPath()); assertEquals("/list/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply()); assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply());
// 标题(单行)+ 正文(多行) // 标题(单行)+ 正文(富文本,与模块页的 wangEditor 一致)
assertEquals("text", field(resp, "title").getWidget()); assertEquals("text", field(resp, "title").getWidget());
assertEquals("textarea", field(resp, "content").getWidget()); FormField content = field(resp, "content");
assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
assertEquals("/notice/api/file/UploadImage", content.getRichTextUploadPath(),
"正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)");
assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类) // 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
FormField deptId = field(resp, "deptId"); FormField deptId = field(resp, "deptId");
@@ -241,11 +246,32 @@ class IntentDirectFormTest {
assertEquals("k", deptId.getValueKey()); assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey()); assertEquals("v", deptId.getLabelKey());
// 通知对象:按用户多选(与校园公告的「推送范围」按班级完全不同) // 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。
// ★ 它也有 optionCall,必须确认**没有被降级成 select** ——
// AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框),
// userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。
FormField receiveList = field(resp, "receiveList"); FormField receiveList = field(resp, "receiveList");
assertEquals("multiselect", receiveList.getWidget()); assertEquals("userpicker", receiveList.getWidget(),
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址"); "通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select");
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)");
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery()); assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值");
// ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。
// 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
// 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。
assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端");
assertEquals(2, receiveList.getPickerSources().size());
FormField.PickerSource tab0 = receiveList.getPickerSources().get(0);
assertEquals("基础平台", tab0.getName());
assertNotNull(tab0.getCall());
assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET");
assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(),
"代取地址必须带模块前缀 /notice");
FormField.PickerSource tab1 = receiveList.getPickerSources().get(1);
assertEquals("自定义组", tab1.getName());
assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(),
"自定义组树同样要带前缀,且 type=1 要保留");
// 校园公告独有的字段绝不能串到通知公告的表单上 // 校园公告独有的字段绝不能串到通知公告的表单上
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) { for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
@@ -539,12 +539,22 @@ class ModuleFormParityTest {
// 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue) // 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue)
ToolParam receive = param(def, "receiveList"); ToolParam receive = param(def, "receiveList");
assertNotNull(receive, "通知公告按用户下发,必须有 receiveList"); assertNotNull(receive, "通知公告按用户下发,必须有 receiveList");
// ★ 用 resolveWidget() 而不是 getWidget():receiveList 没有显式 .as("multiselect"), // ★ 2026-10-14:控件换成模块页同款的自绘组件(radio 全部/指定 + 选人弹窗),
// 它是「type=array + optionsFrom 字典」自然推断出的多选(见 ToolParam#resolveWidget)。 // 故这里是**显式** .as("userpicker"),不再靠 type/optionsFrom 推断。
assertEquals("multiselect", receive.resolveWidget()); assertEquals("userpicker", receive.resolveWidget(),
"通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗)");
assertEquals(",", receive.getJoinSeparator()); assertEquals(",", receive.getJoinSeparator());
assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all"); assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all");
assertEquals("notify.schoolAll", receive.getOptionsFrom()); assertEquals("notify.schoolAll", receive.getOptionsFrom(),
"弹窗里的组织树仍由 notify.schoolAll 代取(前端取**原始嵌套树**,不拍平)");
// 正文换成富文本(模块页 NewNotice.vue 用 wangEditor v5),并带上「插入图片」的两个地址
ToolParam content = param(def, "content");
assertEquals("richtext", content.resolveWidget(), "正文用富文本编辑器");
assertEquals("/api/file/UploadImage", content.getRichTextUploadOverride(),
"图片上传是独立接口(不是附件的 FileUpload);只写相对路径,前缀由编排层拼");
assertEquals("/api/file/Preview?id={id}", content.getRichTextPreviewOverride(),
"正文内图片必须用 Preview(inline),Down 是 attachment 内嵌显示不出来");
// 校园公告那一套字段一个都不该出现在这里 // 校园公告那一套字段一个都不该出现在这里
assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段"); assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段");
@@ -620,6 +630,19 @@ class ModuleFormParityTest {
assertEquals("101", com.alibaba.fastjson.JSON assertEquals("101", com.alibaba.fastjson.JSON
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"), .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
"哨兵与具体项互斥:同时选中时具体项优先"); "哨兵与具体项互斥:同时选中时具体项优先");
/* ★ 2026-10-14:前端换成 UserCheckInput 后,表单里存的就是**字符串**("all" / "101,102"),
* 不再是数组。这条短路极易出错:normalizeJsonArgs 的第二个循环会把「array 型参数却是标量」
* 包成 ["101,102"](整串塞进一个元素),而模块的 NoticeRequest.receiveList 是 String —— 判非法。
* 之所以现在没事,是因为第一个循环按 joinSeparator 拼过之后就 continue 了。
* 把这个「字符串原样透传」的口径钉住,防止谁把那个 continue 删掉。 */
args.put("receiveList", "101,102");
assertEquals("101,102", com.alibaba.fastjson.JSON
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
"userpicker 直接给逗号串时不能再被包成数组");
args.put("receiveList", "all");
assertEquals("all", com.alibaba.fastjson.JSON
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"));
} }
/* ==================== 辅助 ==================== */ /* ==================== 辅助 ==================== */
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestTemplate;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -131,13 +132,28 @@ class NotifyIntentFormTest {
void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() { void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() {
ToolDef publish = registry.get("notify.publish"); ToolDef publish = registry.get("notify.publish");
ToolParam receiveList = param(publish, "receiveList"); ToolParam receiveList = param(publish, "receiveList");
assertEquals("multiselect", receiveList.resolveWidget(), assertEquals("userpicker", receiveList.resolveWidget(),
"通知对象按用户多选(校园公告那边是按班级的 scopes 控件)"); "通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗),"
+ "不再是通用多选下拉;取值契约不变");
assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串"); assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串");
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all"); assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all");
assertEquals("notify.schoolAll", receiveList.getOptionsFrom()); assertEquals("notify.schoolAll", receiveList.getOptionsFrom());
assertEquals("id", receiveList.getOptionValueKey()); assertEquals("id", receiveList.getOptionValueKey());
assertEquals("name", receiveList.getOptionLabelKey()); assertEquals("name", receiveList.getOptionLabelKey());
// ★ 选人弹窗的**两个页签**(照搬 UserCheckInput.vue 的 sources):
// optionCall 只能描述一个地址,所以「自定义组」由 pickerSource 补。
// 两者都只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveModulePath 拼 ——
// 这里钉住这一点:漏拼前缀就是线上 404(附件上传已经因此出过两次事)。
List<String[]> sources = receiveList.getPickerSources();
assertNotNull(sources, "通知对象的选人弹窗要有数据源页签");
assertEquals(2, sources.size(), "模块页有两个页签:基础平台 / 自定义组");
assertEquals("基础平台", sources.get(0)[0]);
assertEquals("/api/base/GetSchoolAll", sources.get(0)[1],
"只写模块内相对路径,不带 /notice 前缀");
assertEquals("自定义组", sources.get(1)[0]);
assertEquals("/api/contacts/GetGroupTree?type=1", sources.get(1)[1],
"自定义组树带 type=1(与 settingApi.getGroupTree 一致)");
} }
/* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */ /* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */