feat(通知公告): 接入 NoticeFront(模块 notice、工具前缀 notify.*)
build / build-push (push) Successful in 1m7s

★「通知公告」与既有「校园公告」是两个应用,不是同一个:
  · 校园公告 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 绿。
This commit is contained in:
lijing
2026-10-14 12:27:31 +08:00
parent c3477c6588
commit 9410599008
7 changed files with 317 additions and 1 deletions
@@ -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<String, String[]> 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} 请求体逐项钉死,
* 防止两套字段互相串。
*
* <p>历史教训:对齐任何模块之前,先确认自己打开的是**哪个**前端工程 —— 这两个工程
* 路由与页面名几乎同名(都是 /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<String> 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());
}
/**
* 通知公告发布表单的**提交形态**(浏览器代执行的最后一步)。
*
* <p>表单里用户是「多选用户」,而模块的 {@code NoticeRequest.receiveList} 在 wire 上必须是
* <b>逗号串</b>({@code NewNotice.vue} 的 {@code UserCheckInput} v-model 就是 {@code "101,102"});
* 这一步由 {@code ToolExecutor#normalizeJsonArgs} 在**代执行前**完成,
* 漏了就是「页面上选得到、提交过去判非法」。
*
* <p>本地真机脚本抓不到这个差异:mock 的 {@code browserCall.body} 是原样 {@code JSON.stringify(args)},
* 于是真机看到的是数组 {@code ["101","102"]}、真实后端发的是 {@code "101,102"} ——
* <b>又一次「夹具与真实形态不一致」</b>。所以断言放在这里。
*/
@Test
void notifyPublishSubmitsCommaJoinedReceiveListAtTheModuleUrl() {
ToolDef def = registry.get("notify.publish");
Map<String, Object> 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"),
"哨兵与具体项互斥:同时选中时具体项优先");
}
/* ==================== 辅助 ==================== */