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
@@ -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。
*
* <p>★ 与同名的「校园公告」({@link CampusNoticeTools},模块 key={@code campusnotice},前缀 /campusnotice)
* <b>不是一个应用</b>:校园公告按「班级」下发、带审批流与草稿箱;本模块(通知公告)按「用户」下发,
* 页面只有 收件箱 / 已发送 / 全部通知 / 发通知 / 详情 / 应用设置,<b>没有</b>审批、草稿、推送范围。
* 两者的发布字段截然不同,改这里时不要照搬 CampusNoticeTools。
*
* <p>发布表单逐项对齐 {@code NoticeFront/src/views/notice/NewNotice.vue#doAdd}:
* {@code title / content / receiveList / deptId / fileIds / smsFlag / timingSendTime}。
* 其中模块页的 {@code smsFlag} 恒为 0(模板里没有对应控件),故这里不放出该字段。
*
* <p>注意:本模块的用户态接口只认浏览器 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<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
addInbox(list);
addPublishLoop(list);
addDictionaries(list);
return list;
}
/* ==================== 收件箱(所有用户) ==================== */
private static void addInbox(List<ToolDef> 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<ToolDef> 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<ToolDef> 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"));
}
/**
* 「发通知」页的完整字段(发布用)。
*
* <p>★ 与模块页 {@code NewNotice.vue#doAdd} 构造的 {@code NoticeRequest} 逐项对齐:
* {@code title / content / receiveList / deptId / fileIds / timingSendTime}。
* 模块页不传 {@code deptName}(校园公告那套才传),故这里也不设镜像字段;
* {@code smsFlag} 虽在请求体里,但页面没有对应控件、恒为 0,故不作为表单字段放出。
*/
private static ToolParam[] noticePublishParams() {
List<ToolParam> 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]);
}
}