feat(dorm,activity): 补齐宿舍通知与家校活动的发布/回执/台账能力 + 两个裸意图直达表单
build / build-push (push) Successful in 51s

两个模块与「校园公告」同构(发布 → 阅读情况 → 回执闭环),原先只接了「读列表 + 我提交」
一层,价值最高的 发布/盯回执/催办/统计 全空。本轮补齐。

宿舍通知(9 → 35 个工具)
- 发布闭环:dorm.publish(立即发布、默认发给全部宿舍)/ dorm.editNotice /
  dorm.sendList(改指 /api/notice/SendList,我发布的)/ dorm.readDetail / dorm.statistics;
  字典:dorm.noticeTypeDic(type=noticeType)/ dorm.deptDic(type=dept)/ dorm.formTemplates。
- 回执闭环:dorm.receipts / dorm.receiptDetail / dorm.reviewReceipt / dorm.remind /
  dorm.expedite / dorm.remindStudent / dorm.markRead / dorm.pull / dorm.top;
  家长侧补 dorm.initializeRed(一键已读)。
- 宿舍特有的床位台账(模块自带、此前完全没接):dorm.pendingStudents → dorm.assign
  → dorm.removeStudent / dorm.transfer / dorm.setMonitor,以及楼栋房间增改
  (dorm.addBuilding / dorm.editBuilding / dorm.addRoom / dorm.editRoom)。

家校活动(7 → 22 个工具)
- 发布闭环:activity.publish / activity.edit / activity.sent / activity.readDetail /
  activity.statistics;字典:activity.deptDic(type=1)/ activity.categoryDic(type=2)。
- 报名闭环:activity.signups / activity.signupDetail / activity.review /
  activity.remind / activity.expedite / activity.pull / activity.top;
  家长侧补 activity.initializeRed。

裸意图直达(IntentShortcut)
- 新增 DORM_PUBLISH(仅教职工)与 ACTIVITY_PUBLISH(仅教职工),学生落回模型如实说明。
- ★ 修跨模块歧义:「发通知 / 发布通知」不带限定时校园公告与宿舍通知都成立,
  已从公告短语集移除,改由模型按上下文选;只认带「公告/校园」或「宿舍」「活动」限定的说法。

角色(RolePolicy)
- 发布/编辑/阅读情况/回执处理/催办/统计/字典/床位台账 → STAFF_ONLY;
  家长侧(通知箱、回执提交、活动广场、报名、一键已读)保持不限。

表单下拉复用请假的 optionCall + valueKeys + dependsOn 通道(模块 /api/* 只认浏览器会话,
服务端必然 302)。发布表单的接收对象固定为「全部」(receiveType/receiveList 用 fix 写死),
避免表单里出现难用的房间/班级 id 输入框。

测试:新增 DormActivityPublishIntentTest(10);IntentDirectFormTest +4(教师得表单且不过大模型、
学生落回模型);NoticeIntentFormTest 增加「发通知不再被公告劫持」;
RolePermissionTest 补新工具的可见性断言。全量 86/86 通过。

README:4.2 能力划分更新 + 新增「宿舍通知/家校活动补全了哪些能力」;5.1.0 意图对照表补三行。
This commit is contained in:
lijing
2026-10-12 19:49:52 +08:00
parent d2c6c185b5
commit b6a8a63b0e
10 changed files with 918 additions and 31 deletions
@@ -77,6 +77,8 @@ class IntentDirectFormTest {
settings = new AgentSettings();
settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave");
settings.getModules().put("campusnotice", "http://base.yunhaixingtong.com/campusnotice");
settings.getModules().put("dormnotice", "http://base.yunhaixingtong.com/dormnotice");
settings.getModules().put("familyactivity", "http://base.yunhaixingtong.com/familyactivity");
registry = new ToolRegistry(settings);
registry.init();
@@ -206,6 +208,79 @@ class IntentDirectFormTest {
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布宿舍通知 ==================== */
@Test
void teacherSayingPublishDormNoticeGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布宿舍通知", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("dorm.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath());
// 标题 + 正文 + 通知类型(下拉)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget());
assertEquals("textarea", field(resp, "content").getWidget());
FormField noticeType = field(resp, "noticeType");
assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号");
assertNotNull(noticeType.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=noticeType",
noticeType.getOptionCall().getPathAndQuery(), "字典地址必须带 type=noticeType");
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/dormnotice/api/dict/List?type=dept", deptId.getOptionCall().getPathAndQuery());
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
}
@Test
void studentSayingPublishDormNoticeFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布宿舍通知", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布宿舍通知表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 教师:发布家校活动 ==================== */
@Test
void teacherSayingPublishActivityGetsPublishFormWithoutCallingLlm() {
ChatResponse resp = chat(EXPLODING_LLM, "我要发布活动", caller(AgentRole.TEACHER));
assertNotNull(resp.getForm(), "应直接下发表单");
assertEquals("activity.publish", resp.getForm().getTool());
assertTrue(resp.getForm().isWrite());
assertEquals("/list/new", resp.getForm().getLandingPath());
// 活动表单:标题 + 说明(多行)+ 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间
assertEquals("text", field(resp, "title").getWidget());
assertEquals("textarea", field(resp, "content").getWidget());
FormField category = field(resp, "category");
assertEquals("select", category.getWidget());
assertNotNull(category.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=2", category.getOptionCall().getPathAndQuery());
FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget());
assertNotNull(deptId.getOptionCall());
assertEquals("/familyactivity/api/dict/List?type=1", deptId.getOptionCall().getPathAndQuery());
assertNotNull(field(resp, "venue"));
assertNotNull(field(resp, "startTime"));
assertNotNull(field(resp, "endTime"));
}
@Test
void studentSayingPublishActivityFallsThroughToModel() {
ChatResponse resp = chat(ECHO_LLM, "我要发布活动", caller(AgentRole.STUDENT));
assertNull(resp.getForm(), "学生不该拿到发布活动表单");
assertEquals("(模型回复)", resp.getReply());
}
/* ==================== 不该被劫持的情况 ==================== */
@Test