From 94105990083fcd00cb38f5b97ed1e3e7263ad306 Mon Sep 17 00:00:00 2001 From: lijing Date: Wed, 14 Oct 2026 12:27:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=80=9A=E7=9F=A5=E5=85=AC=E5=91=8A):=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20NoticeFront=EF=BC=88=E6=A8=A1=E5=9D=97=20n?= =?UTF-8?q?otice=E3=80=81=E5=B7=A5=E5=85=B7=E5=89=8D=E7=BC=80=20notify.*?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ★「通知公告」与既有「校园公告」是两个应用,不是同一个: · 校园公告 CampusAnnouncement → key=campusnotice、前缀 /campusnotice、按班级下发、带审批流/草稿箱(既有 notice.*) · 通知公告 NoticeFront → key=notice、前缀 /notice、按用户下发,无审批/草稿/推送范围(本次新增 notify.*) 两者路由与页面名几乎同名(/list、/list/new、已收到/已发送),极易拿错源码,故工具前缀避开已占用的 notice.*。 新增 NoticeTools(9 个工具):收件箱/详情/一键已读、发布/编辑/我发出的/阅读情况、发文部门字典、组织树。 发布表单逐项对齐 NoticeFront/src/views/notice/NewNotice.vue#doAdd: title / content / deptId / receiveList / fileIds / timingSendTime (smsFlag 在页面里没有对应控件、恒为 0,故不放出;receiveList 的「全部用户」哨兵是 all。) 配套:ToolRegistry 注册、RolePolicy 把发布闭环与两个字典工具列为教职工专属、 application.yml 补地址 http://base.yunhaixingtong.com/notice 与默认落地页 / (AgentSettings 出厂默认表也要补 notice —— 契约测试用的是 new AgentSettings(),不读 yml)。 fix(ToolExecutor): 已按 joinSeparator 拼成逗号串的字段不再被包回数组 normalizeJsonArgs 的第二个循环「数组参数给了标量 → 包成单元素数组」会作用到 刚拼好的 receiveList/fileIds 上,把 101,102 变成 [101,102], 而模块侧 NoticeRequest.receiveList 是 String ⇒ 直接判非法。 此前一直没暴露:本地 mock 的提交体是原样 JSON.stringify(args)(本来就是数组), 看不出差异,只有真实后端才走到这一步。现在 join 过的字段直接跳过该循环。 测试:ModuleFormParityTest 新增「通知公告发布字段集不与校园公告互串」+ 「代取地址带 /notice 前缀」+「提交体是逗号串、哨兵互斥、提交路径带前缀」;全量 126/126 绿。 --- .../com/yunhai/agent/auth/RolePolicy.java | 3 + .../yunhai/agent/config/AgentSettings.java | 2 + .../com/yunhai/agent/tool/ToolExecutor.java | 8 + .../com/yunhai/agent/tool/ToolRegistry.java | 2 + .../yunhai/agent/tool/defs/NoticeTools.java | 188 ++++++++++++++++++ src/main/resources/application.yml | 2 + .../yunhai/agent/ModuleFormParityTest.java | 113 ++++++++++- 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java diff --git a/src/main/java/com/yunhai/agent/auth/RolePolicy.java b/src/main/java/com/yunhai/agent/auth/RolePolicy.java index e090b71..470e000 100644 --- a/src/main/java/com/yunhai/agent/auth/RolePolicy.java +++ b/src/main/java/com/yunhai/agent/auth/RolePolicy.java @@ -36,6 +36,9 @@ public final class RolePolicy { // 公告:发布与审批(另含存草稿与审批人选人的教师树) "notice.sent", "notice.deptDics", "notice.publish", "notice.saveDraft", "notice.teaTree", "notice.approvalList", "notice.approve", "notice.reject", + // 通知公告(另一个应用,前缀 /notice):发布 / 编辑 / 我发出的 / 阅读情况 / 部门字典与选人树 + "notify.publish", "notify.editNotice", "notify.sent", "notify.readDetail", + "notify.deptDics", "notify.schoolAll", // 宿舍通知:发布 / 编辑 / 阅读情况 / 回执处理 / 催办 / 统计 "dorm.publish", "dorm.editNotice", "dorm.sendList", "dorm.readDetail", "dorm.receipts", "dorm.receiptDetail", "dorm.reviewReceipt", diff --git a/src/main/java/com/yunhai/agent/config/AgentSettings.java b/src/main/java/com/yunhai/agent/config/AgentSettings.java index aa6149e..dd7ce3e 100644 --- a/src/main/java/com/yunhai/agent/config/AgentSettings.java +++ b/src/main/java/com/yunhai/agent/config/AgentSettings.java @@ -48,6 +48,7 @@ public class AgentSettings { *
      * homeleave        → /my             我的请假
      * campusnotice     → /              我的公告(收件箱)
+     * notice           → /              通知公告「已收到」(收件箱)★ 与 campusnotice 是两个应用
      * dormnotice       → /par/notices   宿舍通知箱
      * familyactivity   → /par/activities 活动广场
      * coursehomework   → /stu/today     今日作业
@@ -63,6 +64,7 @@ public class AgentSettings {
         Map m = new LinkedHashMap<>();
         m.put("homeleave", "/my");
         m.put("campusnotice", "/");
+        m.put("notice", "/");
         m.put("dormnotice", "/par/notices");
         m.put("familyactivity", "/par/activities");
         m.put("coursehomework", "/stu/today");
diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java
index b6689b5..e0c7bdd 100644
--- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java
+++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java
@@ -603,6 +603,14 @@ public class ToolExecutor {
         for (ToolParam p2 : def.getParams()) {
             Object v = out.get(p2.getName());
             if (v == null) continue;
+            // ★ 上面刚按 joinSeparator 序列化成「逗号串」的字段,**不能**再当标量包成数组。
+            //   例:receiveList 声明 array + .join(","),用户多选 ["101","102"] 已经变成
+            //   "101,102";这一圈若再包一次就发成 ["101,102"](整串塞进一个元素里),
+            //   而模块的 NoticeRequest.receiveList / fileIds 是 String —— 直接判非法。
+            //   漏这一条是因为本地 mock 的提交体是原样 JSON.stringify(args)(本来就是数组),
+            //   看不出差异;真实后端才走到这里。由 ModuleFormParityTest
+            //   #notifyPublishSubmitsCommaJoinedReceiveListAtTheModuleUrl 兜住。
+            if (p2.getJoinSeparator() != null) continue;
             if ("array".equalsIgnoreCase(StringUtils.trimToEmpty(p2.getType()))
                     && !(v instanceof List) && !(v instanceof JSONArray)) {
                 out.put(p2.getName(), new ArrayList<>(java.util.Collections.singletonList(String.valueOf(v))));
diff --git a/src/main/java/com/yunhai/agent/tool/ToolRegistry.java b/src/main/java/com/yunhai/agent/tool/ToolRegistry.java
index e8e2afc..1325818 100644
--- a/src/main/java/com/yunhai/agent/tool/ToolRegistry.java
+++ b/src/main/java/com/yunhai/agent/tool/ToolRegistry.java
@@ -9,6 +9,7 @@ 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.NoticeTools;
 import com.yunhai.agent.tool.defs.ProfessionTools;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
@@ -71,6 +72,7 @@ public class ToolRegistry {
     private void build() {
         register(LeaveTools.defs());
         register(CampusNoticeTools.defs());
+        register(NoticeTools.defs());
         register(DormNoticeTools.defs());
         register(ActivityTools.defs());
         register(HomeworkTools.defs());
diff --git a/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java
new file mode 100644
index 0000000..bce7993
--- /dev/null
+++ b/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java
@@ -0,0 +1,188 @@
+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;
+
+/**
+ * 通知公告模块工具 —— 后端 NoticeFront,线上部署前缀 /notice,接口前缀 /api/notice、/api/dict、/api/base。
+ *
+ * 

★ 与同名的「校园公告」({@link CampusNoticeTools},模块 key={@code campusnotice},前缀 /campusnotice) + * 不是一个应用:校园公告按「班级」下发、带审批流与草稿箱;本模块(通知公告)按「用户」下发, + * 页面只有 收件箱 / 已发送 / 全部通知 / 发通知 / 详情 / 应用设置,没有审批、草稿、推送范围。 + * 两者的发布字段截然不同,改这里时不要照搬 CampusNoticeTools。 + * + *

发布表单逐项对齐 {@code NoticeFront/src/views/notice/NewNotice.vue#doAdd}: + * {@code title / content / receiveList / deptId / fileIds / smsFlag / timingSendTime}。 + * 其中模块页的 {@code smsFlag} 恒为 0(模板里没有对应控件),故这里不放出该字段。 + * + *

注意:本模块的用户态接口只认浏览器 CAS 会话,服务端直调会拿到 {@code code=302}, + * 凡是需要「取字典给下拉」的字段都用 {@code optionsFrom + optionsKeys} 声明,由前端带用户登录态代取 + * (见 AgentOrchestrator#buildForm)。 + */ +public final class NoticeTools { + + public static final String MODULE = "notice"; + + /** 发文部门字典的 type 值({@code /api/dict/List?type=1},与模块页 {@code noticeApi.getSysDic()} 一致) */ + private static final String DICT_TYPE_DEPT = "1"; + + /** 附件上传地址 —— 模块页 NoticeUpload.vue 用的是 {@code /api/base/FileUpload}; + * 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */ + private static final String UPLOAD_PATH = "/api/base/FileUpload"; + + private NoticeTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + addInbox(list); + addPublishLoop(list); + addDictionaries(list); + return list; + } + + /* ==================== 收件箱(所有用户) ==================== */ + + private static void addInbox(List list) { + list.add(ToolDef.of("notify.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("readType", "integer", "阅读状态:0 全部 / 1 未读 / 2 已读") + .dft("0"), + ToolParam.optional("orderByTime", "string", "排序:1 时间倒序 / 2 时间正序") + .dft("1")) + .readAt("/")); + + list.add(ToolDef.of("notify.detail", MODULE, "通知详情", + "查看一条通知的正文、附件与阅读进度(应读 / 已读人数)。", + "GET", "/api/notice/Detail") + .p(ToolParam.required("serid", "integer", "通知 id") + .label("通知"), + ToolParam.optional("type", "integer", "1 查看并标记已读(默认) / 0 仅查看不标读") + .dft("1")) + .readAt("/")); + + list.add(ToolDef.of("notify.initializeRed", MODULE, "一键已读全部通知", + "把我收到的通知一次性全部标记为已读(模块页「已收到」列表上的一键已读)。", + "POST_FORM", "/api/notice/InitializeRed") + .write("/")); + } + + /* ==================== 发布闭环(教职工) ==================== */ + + private static void addPublishLoop(List list) { + list.add(ToolDef.of("notify.publish", MODULE, "发布通知", + "发布一条通知(可定时发送)。字段与通知公告模块的「发通知」页**完全一致**:" + + "通知标题、来源部门、通知对象、附件、正文、定时发送。" + + "调用前必须确认:标题 title、来源部门 deptId、通知对象 receiveList;" + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + + "来源部门必须来自字典,不要编造。" + + "通知对象 receiveList 默认「全部用户」;要只发给部分人时由表单多选(用户 id 逗号串)。" + + "timingSendTime 非空 = 定时发送。", + "POST_JSON", "/api/notice/Create") + .p(noticePublishParams()) + .fullForm() + .write("/list/new")); + + list.add(ToolDef.of("notify.editNotice", MODULE, "编辑通知", + "修改一条已发送的通知(标题、正文、来源部门、通知对象、附件、定时发送)。" + + "调用前须确认 sourceId(要改的那条通知 id,可先用 notify.received / notify.sent 查到)。", + "POST_JSON", "/api/notice/Edit") + .p(ToolParam.required("sourceId", "integer", "要编辑的通知 id") + .label("要编辑的通知"), + ToolParam.required("title", "string", "通知标题(50 字以内)") + .label("通知标题"), + ToolParam.optional("content", "string", "通知正文(纯文本)") + .label("通知正文").as("textarea"), + ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段") + .label("来源部门") + .optionsFrom("notify.deptDics").optionsKeys("k", "v"), + ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户") + .label("通知对象") + .optionsFrom("notify.schoolAll").optionsKeys("id", "name") + .join(",").allOption("all", "全部用户"), + ToolParam.optional("fileIds", "array", "附件文件 id 列表") + .label("添加附件").as("file").multiple(true).join(",") + .upload(UPLOAD_PATH), + ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发送)") + .label("定时发送")) + .write("/list")); + + list.add(ToolDef.of("notify.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("keyword", "string", "标题关键词"), + ToolParam.optional("deptId", "string", "来源部门 id"), + ToolParam.optional("startTime", "string", "开始时间 yyyy-MM-dd").eg("2026-10-01"), + ToolParam.optional("endTime", "string", "结束时间 yyyy-MM-dd").eg("2026-10-31"), + ToolParam.optional("orderByTime", "string", "排序:1 时间倒序 / 2 时间正序").dft("1")) + .readAt("/list")); + + list.add(ToolDef.of("notify.readDetail", MODULE, "通知阅读情况", + "查询某条通知的阅读情况(按组织分组列出已读 / 未读人员)。", + "GET", "/api/notice/ReadDetail") + .p(ToolParam.required("serid", "integer", "通知 id") + .label("通知")) + .readAt("/list/detail")); + } + + /* ==================== 字典 / 组织树(发布表单下拉的来源) ==================== */ + + private static void addDictionaries(List list) { + list.add(ToolDef.of("notify.deptDics", MODULE, "发文部门字典", + "查询本校本模块可选的来源部门(发布通知时填 deptId 用)。" + + "仅在用户明确问「有哪些发文部门」时使用;" + + "发布通知时**不要**先调它 —— 表单的「来源部门」下拉会自动带出可选值。", + "GET", "/api/dict/List") + .fix("type", DICT_TYPE_DEPT) + .readAt("/list/new")); + + list.add(ToolDef.of("notify.schoolAll", MODULE, "组织树(选择通知对象)", + "查询本校组织树(部门 → 教师),用于发布通知时选择接收用户。" + + "仅在用户明确要「按组织范围选人」时使用;" + + "发布通知时**不要**先调它 —— 表单的「通知对象」多选会自动代取该树。", + "GET", "/api/base/GetSchoolAll") + .readAt("/list/new")); + } + + /** + * 「发通知」页的完整字段(发布用)。 + * + *

★ 与模块页 {@code NewNotice.vue#doAdd} 构造的 {@code NoticeRequest} 逐项对齐: + * {@code title / content / receiveList / deptId / fileIds / timingSendTime}。 + * 模块页不传 {@code deptName}(校园公告那套才传),故这里也不设镜像字段; + * {@code smsFlag} 虽在请求体里,但页面没有对应控件、恒为 0,故不作为表单字段放出。 + */ + private static ToolParam[] noticePublishParams() { + List ps = new ArrayList<>(); + ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)") + .label("通知标题").eg("关于本周宿舍熄灯时间调整的通知")); + ps.add(ToolParam.optional("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)") + .label("通知正文").as("textarea").eg("各位老师:经学校研究决定……")); + ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段") + .label("来源部门") + .optionsFrom("notify.deptDics").optionsKeys("k", "v")); + // 模块页的 UserCheckInput:v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string) + ps.add(ToolParam.required("receiveList", "array", "通知对象:留空或含全部 = 全部用户;也可多选具体用户") + .label("通知对象") + .optionsFrom("notify.schoolAll").optionsKeys("id", "name") + .join(",").allOption("all", "全部用户")); + ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") + .label("添加附件").as("file").multiple(true).join(",") + .upload(UPLOAD_PATH)); + ps.add(ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发送)") + .label("定时发送").eg("2026-10-15 08:00:00")); + return ps.toArray(new ToolParam[0]); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index daeab94..2e3cf2f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -132,6 +132,7 @@ agent: modules: homeleave: http://base.yunhaixingtong.com/home-leave campusnotice: http://base.yunhaixingtong.com/campusnotice + notice: http://base.yunhaixingtong.com/notice dormnotice: http://base.yunhaixingtong.com/dormnotice familyactivity: http://base.yunhaixingtong.com/familyactivity coursehomework: http://base.yunhaixingtong.com/coursehomework @@ -146,6 +147,7 @@ agent: module-landing: homeleave: /my # 我的请假 campusnotice: / # 我的公告(收件箱) + notice: / # 通知公告:已收到(收件箱) dormnotice: /par/notices # 宿舍通知箱 familyactivity: /par/activities # 活动广场 coursehomework: /stu/today # 今日作业 diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java index 3cf504c..e54ac93 100644 --- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java +++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java @@ -12,10 +12,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.client.RestTemplate; +import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -47,6 +49,8 @@ class ModuleFormParityTest { void setUp() { settings = new AgentSettings(); settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice"); + // 通知公告:**另一个应用**(NoticeFront),部署前缀 /notice —— 别与 campusnotice 混 + settings.getModules().put("notice", "http://base.yunhaixingtong.com/notice"); settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice"); settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity"); // ★ 前缀是 /home-leave(差一个连字符)—— 早先这里跟着代码一起写成 /homeleave, @@ -283,6 +287,8 @@ class ModuleFormParityTest { void everyPublishToolUploadPathMatchesItsModuleRealEndpoint() { Map cases = new LinkedHashMap<>(); cases.put("notice.publish", new String[]{"/campusnotice/api/base/FileUpload", "NoticeUpload.vue"}); + cases.put("notify.publish", new String[]{"/notice/api/base/FileUpload", + "NoticeFront 的 components/NoticeUpload.vue(与校园公告是两个工程)"}); cases.put("dorm.publish", new String[]{"/dormnotice/api/base/FileUpload", "AttachmentUpload.vue"}); cases.put("activity.publish", new String[]{"/familyactivity/api/base/FileUpload", "AttachmentUpload.vue"}); cases.put("leave.register", new String[]{"/home-leave/api/base/FileUpload", "AttachmentUpload.vue"}); @@ -508,7 +514,112 @@ class ModuleFormParityTest { + "模型会照字面先反问用户要发到哪个栏目/填什么,把一件事拆成两轮。" + "实际说明:" + def.getDescription()); } - assertEquals(5, checked, "注册表里应有 5 个发布工具(公告/宿舍/活动/作业/专业发展)"); + assertEquals(6, checked, "注册表里应有 6 个发布工具(公告/通知公告/宿舍/活动/作业/专业发展)"); + } + + /** + * 通知公告(NoticeFront,前缀 /notice)与「校园公告」({@code campusnotice}) 是**两个应用**, + * 发布字段截然不同。这里把 NoticeFront 的 {@code NewNotice.vue#doAdd} 请求体逐项钉死, + * 防止两套字段互相串。 + * + *

历史教训:对齐任何模块之前,先确认自己打开的是**哪个**前端工程 —— 这两个工程 + * 路由与页面名几乎同名(都是 /list、/list/new、已收到/已发送),极容易拿错源码。 + */ + @Test + void noticeFrontPublishOwnsItsFieldSetAndDoesNotBorrowTheCampusAnnouncementOne() { + ToolDef def = registry.get("notify.publish"); + assertNotNull(def, "通知公告的发布工具应当注册"); + assertEquals("notice", def.getModule()); + assertEquals("/api/notice/Create", def.getPath()); + + List names = def.getParams().stream().map(ToolParam::getName).collect(Collectors.toList()); + assertEquals(Arrays.asList("title", "content", "deptId", "receiveList", "fileIds", "timingSendTime"), + names, "字段与顺序对齐 NoticeFront/src/views/notice/NewNotice.vue#doAdd"); + + // 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue) + ToolParam receive = param(def, "receiveList"); + assertNotNull(receive, "通知公告按用户下发,必须有 receiveList"); + // ★ 用 resolveWidget() 而不是 getWidget():receiveList 没有显式 .as("multiselect"), + // 它是「type=array + optionsFrom 字典」自然推断出的多选(见 ToolParam#resolveWidget)。 + assertEquals("multiselect", receive.resolveWidget()); + assertEquals(",", receive.getJoinSeparator()); + assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all"); + assertEquals("notify.schoolAll", receive.getOptionsFrom()); + + // 校园公告那一套字段一个都不该出现在这里 + assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段"); + assertNull(param(def, "receiptFlag"), "「学员阅读回执」是校园公告的字段"); + assertNull(param(def, "publishType"), "「发布方式 / 审批」是校园公告的字段"); + assertNull(param(def, "flow"), "审批流是校园公告的字段"); + assertNull(param(def, "deptName"), "模块页不发 deptName(校园公告才发镜像字段)"); + + assertEquals("notify.deptDics", param(def, "deptId").getOptionsFrom()); + assertTrue(def.isFormAll(), "发布表单要渲染全部字段(fullForm)"); + + /* ★ 代取地址必须带**模块真实前缀** —— 这一条**只能在后端测**: + * mock 的 optionCall 一律写裸 /api/*(靠 vite 的 /api 代理转到 mock), + * 本地真机脚本断言不到前缀,于是「绑错前缀」这类事故只能靠这里兜住。 + * 模块 key 是 notice、配置里的地址是 .../notice ⇒ 前缀 /notice。 */ + BrowserToolRequest.BrowserCall deptCall = + executor.prepareOptionCall(registry.get("notify.deptDics"), java.util.Collections.emptyList()); + assertNotNull(deptCall, "发文部门字典要能生成代取描述"); + assertEquals("/notice/api/dict/List?type=1", deptCall.getPathAndQuery(), + "push 了 fix(type=1) 也要在代取地址里带上,且前缀是 /notice"); + + BrowserToolRequest.BrowserCall userCall = + executor.prepareOptionCall(registry.get("notify.schoolAll"), java.util.Collections.emptyList()); + assertNotNull(userCall, "组织树要能生成代取描述"); + assertEquals("/notice/api/base/GetSchoolAll", userCall.getPathAndQuery()); + } + + /** + * 通知公告发布表单的**提交形态**(浏览器代执行的最后一步)。 + * + *

表单里用户是「多选用户」,而模块的 {@code NoticeRequest.receiveList} 在 wire 上必须是 + * 逗号串({@code NewNotice.vue} 的 {@code UserCheckInput} v-model 就是 {@code "101,102"}); + * 这一步由 {@code ToolExecutor#normalizeJsonArgs} 在**代执行前**完成, + * 漏了就是「页面上选得到、提交过去判非法」。 + * + *

本地真机脚本抓不到这个差异:mock 的 {@code browserCall.body} 是原样 {@code JSON.stringify(args)}, + * 于是真机看到的是数组 {@code ["101","102"]}、真实后端发的是 {@code "101,102"} —— + * 又一次「夹具与真实形态不一致」。所以断言放在这里。 + */ + @Test + void notifyPublishSubmitsCommaJoinedReceiveListAtTheModuleUrl() { + ToolDef def = registry.get("notify.publish"); + + Map args = new LinkedHashMap<>(); + args.put("title", "关于本周晚自习调整的通知"); + args.put("content", "各位老师:经学校研究决定……"); + args.put("deptId", "D003"); + args.put("receiveList", Arrays.asList("101", "102")); + args.put("fileIds", new ArrayList<>()); + + BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, args); + assertEquals("POST_JSON", call.getMethod()); + assertEquals("/notice/api/notice/Create", call.getPathAndQuery(), + "代执行提交要打模块真实前缀(配置地址是 .../notice)"); + + com.alibaba.fastjson.JSONObject body = com.alibaba.fastjson.JSON.parseObject(call.getBody()); + assertEquals("关于本周晚自习调整的通知", body.getString("title")); + assertEquals("D003", body.getString("deptId")); + assertEquals("101,102", body.getString("receiveList"), "多选在 wire 上是逗号串,不是数组"); + assertFalse(body.containsKey("fileIds"), "空的附件数组不该发出去(模块页空即不传)"); + + // 附件同理:多选文件 id 也要拼成逗号串,绝不能变成 ["11,12"] + args.put("fileIds", Arrays.asList("11", "12")); + assertEquals("11,12", com.alibaba.fastjson.JSON + .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("fileIds")); + + // 「全部用户」哨兵:只选它 ⇒ 发 all;与具体项同时选中 ⇒ 以具体项为准(哨兵折叠掉) + args.put("receiveList", java.util.Collections.singletonList("all")); + assertEquals("all", com.alibaba.fastjson.JSON + .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList")); + + args.put("receiveList", Arrays.asList("all", "101")); + assertEquals("101", com.alibaba.fastjson.JSON + .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"), + "哨兵与具体项互斥:同时选中时具体项优先"); } /* ==================== 辅助 ==================== */