diff --git a/docs/模块能力盘点与工具补全建议.md b/docs/模块能力盘点与工具补全建议.md new file mode 100644 index 0000000..74bb6fa --- /dev/null +++ b/docs/模块能力盘点与工具补全建议.md @@ -0,0 +1,454 @@ +# 校园智能助手 · 五模块能力盘点与工具补全建议 + +> 盘点日期:2026-10-12 范围:AgentService 已对接的 5 个业务模块(请假 / 校园公告 / 宿舍通知 / 家校活动 / 课程作业) +> 方法:逐模块通读前端 `src/api/module/*.ts` + `src/router/routes.ts` + 权限守卫,与 AgentService 现有 `tool/defs/*.java` 做差集。 +> 目标:① 说清每个模块的**功能与权限**怎么划;② 指出**哪些写操作可以做成表单**;③ 挖出**模块有、助手还没接**的能力。 + +--- + +## 0. 结论速览 + +| 模块 | 已接工具 | 建议新增 | 一句话结论 | +|---|---:|---:|---| +| `homeleave` 请假 | 12 | **+6** | 最完整的一个,缺的是「删除 / 类型字典维护 / 按班查列表」 | +| `campusnotice` 校园公告 | 9 | **+14** | 发得出,但**收不回**——撤回/置顶/催读/阅读情况全缺 | +| `dormnotice` 宿舍通知 | 9 | **+18** | 家长侧齐了,**管理侧几乎空白**;模块还自带一整套「床位上床台账」完全没接 | +| `familyactivity` 家校活动 | 7 | **+13** | 家长报名齐了,**学校侧(发活动 / 确认报名)全缺** | +| `coursehomework` 课程作业 | 4 | **+20** | 缺口最大:**只接了学生端,教师端一整套能力 0 接入** | +| 合计 | **41** | **+71** | — | +| (`profession` 教师专业发展,教师专属,附带参考) | 9 | +12 | 只缺「编辑/删除记录、评论点赞、统计报表」 | + +**一句话总纲**:四个「发布型」模块(公告 / 宿舍 / 活动 / 作业)其实是**同一套骨架**—— +`我发布的 → 全部 → 详情 → 新建/编辑 → 回执或提交名单 → 统计 → 应用设置`, +再各自加一个 `/par/*` 家长端或 `/stu/*` 学生端。 +助手目前普遍只做了**「读列表 + 我提交」**这一层, +而骨架里价值最高的**「发布 / 撤回 / 盯回执 / 批阅 / 催办」**基本没接。 + +--- + +## 1. 先说权限:能力边界的四层判定 + +### 1.1 助手侧(AgentService) + +| 层 | 组件 | 作用 | +|---|---|---| +| 1 | `RoleResolver` | 只读平台 `BoaUser`,**只认「当前身份」`currentRole`**(不做并集)→ 教师 / 学生 / 家长 / 管理员 / 访客 | +| 2 | `ToolRegistry` | 只把**本身份可见**的工具写进模型 schema —— 模型"看不见"就不会选错 | +| 3 | `ToolExecutor` | 调用前再校验一次,越权直接拒绝并打 `[越权拦截]` | +| 4 | 浏览器代执行(A 通道) | 写操作由**前端带用户登录态**执行,服务端不代持身份 | + +`RolePolicy` 现状(一张集中表): + +- `STAFF_ONLY`(教师+管理员):请假登记/审批/统计/批量通过、公告已发/发文部门/发布/审批、宿舍楼栋房间、活动班级树 +- `STUDENT_ONLY`:作业四件套、请假申请、销假 +- `STUDENT_OR_PARENT`:活动报名 / 取消报名 +- `SHARED`:撤销请假 +- **未列入任何名单 = 所有已识别角色都可用** + +> ★★ **最大风险点**:新增工具若忘了登记,**默认是"人人可用"**。 +> 例如把 `homework.review`(教师打分)漏登记,学生就会在目录里看到"给作业打分"。 +> 本报告第 5 节给出建议分桶表,实施时必须同步改 `RolePolicy`。 + +### 1.2 模块侧(5 个前端怎么分权限) + +结论:**五个模块的前端都几乎不做基于 `roleId` 的业务判定**,统一靠三件事: + +1. **平台菜单授权 `GET /api/base/GetUserMod`** —— 决定"能不能进这个入口页"(`modId` 是稳定契约); +2. **URL 前缀** —— `/par/*` = 家长端、`/stu/*` = 学生端,其余 = 教职工端(`roleOfPath()`); +3. **后端数据范围** —— 同一接口按身份收敛(学生看自己 / 教师看任教班 / 管理员看全校)。 + +| 模块 | 端分离方式 | 入口 `modId` 契约 | +|---|---|---| +| homeleave | 纯菜单(无 URL 前缀) | 2 申请 / 3 我的 / 4 审批 / 5 登记 / 6 统计 / 7 设置 | +| campusnotice | 纯菜单 | 1 我的公告 / 2 已发管理 / 3 全部公告 / 4 统计 / 5 审批中心 / 21 设置 | +| dormnotice | `/par/*` | 2 我发的 / 3 全部 / 25 回执情况 / 4 统计 / 21 设置 ‖ **31 通知箱 / 32 我的回执** | +| familyactivity | `/par/*` | 2 我发的 / 3 全部 / 25 报名情况 / 4 统计 / 21 设置 ‖ **31 活动广场 / 32 我的报名** | +| coursehomework | `/stu/*` | 教师端无 `/stu` 前缀(列表/详情/提交情况/批改中心/统计);学生端 今日作业 / 待订正 / 详情 | + +> 注意:`/list/new`、`/list/detail`、`/signups`、`/grade` 这类**二级落地页一律不判权限**(只要路由声明了 `meta.parentId`)。 +> 所以"有权限发公告"≠"有权限进审批中心",两者是不同 `modId`。 + +--- + +## 2. 逐模块盘点 + +图例:**角色** = 建议归属(学/教/管 = 学生/教师/管理员,家 = 家长);**类型** = 只读 / 单参写 / 表单写。 +`⭐` = 高频高价值,建议优先。 + +### 2.1 `homeleave` 请假 —— 已接 12,建议 +6 + +**已接**:`leave.types` `myClasses` `classStudents` `myLeaves` `detail` `stats` +`submit`(学生申请) `register`(教师登记) `withdraw` `reportBack` `audit` `batchPass` + +**模块能力全景**(`/api/leave/*` + `/api/dict/*` + `/api/leavestats/*`): + +| 页面 | 角色 | 能力 | +|---|---|---| +| `/apply` 请假申请 | 学生 | 选班级/类型/时间/事由/附件 → 提交 | +| `/my` 我的请假 | 学生 | 列表+筛选、详情、撤销、申请销假 | +| `/audit` 请假审批 | 教师/管理员 | 列表、批量通过、本班一键通过、通过/拒绝、撤销审批、销假审批、删除 | +| `/register` 请假登记 | 教师/管理员 | 选班级→学生→类型→时间 → 免审登记 | +| `/stats` 请假统计 | 教师/管理员 | 时间/班级/类型筛选 + 分布 | +| `/setting` 应用设置 | 管理员 | **请假类型字典**增删改启停排序 | + +**建议新增:** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `leave.delete` ⭐ | `POST /api/leave/Delete?id=` | 教/管 | 单参写 | 只允许删「已撤销/已销假/已拒绝」;补全审批页的删除 | +| `leave.typeList` | `GET /api/dict/LeaveTypeList` | 管 | 只读 | 含**停用**项的管理列表(现有 `leave.types` 只有启用项) | +| `leave.typeAdd` | `POST /api/dict/LeaveTypeAdd?name=` | 管 | 表单写 | "加一个『竞赛假』" | +| `leave.typeUpdate` | `POST /api/dict/LeaveTypeUpdate?serid=&name=&enable=` | 管 | 表单写 | 改名 / 启停 | +| `leave.typeDelete` | `POST /api/dict/LeaveTypeDelete?serid=` | 管 | 单参写 | 被引用时后端会拒绝(要有友好提示) | +| `leave.typeSort` | `POST /api/dict/LeaveTypeSort?ids=` | 管 | 单参写 | 调整下拉顺序 | +| (扩展)`leave.myLeaves` | 同上,**加** `classId/stuId/type` 参数 | 教/管 | 只读 | 现在教师问"三班今天有谁请假"没法筛 | + +> 说明:`leave.stats` / `audit` / `batchPass` 已有,是五个模块里做得最完整的一个。 + +--- + +### 2.2 `campusnotice` 校园公告 —— 已接 9,建议 +14 + +**已接**:`notice.received` `readTabCount` `sent` `detail` `deptDics` `publish` `approvalList` `approve` `reject` + +**模块能力全景**: + +| 页面 | 角色 | 能力 | +|---|---|---| +| `/` 我的公告 | 全员 | 收件箱、未读/已读筛选、一键已读、上/下篇、打印、附件 | +| `/list` 已发管理 | 发布者 | 撤回 / 置顶 / **催读** / 转发 / 编辑重提 / 删除 / **阅读回执** | +| `/list/new` 写公告 | 发布者 | 富文本+附件+置顶+短信+**阅读回执**+定时发送;直接发布 / 存草稿 / 提交审批 | +| `/drafts` 草稿箱 | 发布者 | 继续编辑、提交审批、删除 | +| `/noticelist` 全部公告 | 管理员 | 全量查询 + 删除 | +| `/approval` 审批中心 | 审批人 | 待审/已通过/已驳回/全部 + 通过/驳回 + 审批轨迹 | +| `/setting` 应用设置 | 管理员 | **发文部门字典** + **自定义联系组** | + +**建议新增:** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `notice.revoke` ⭐ | `POST /api/notice/UserPull?serid=` | 教/管 | 单参写 | "发错了,撤回" | +| `notice.setTopmost` ⭐ | `POST /api/notice/SetTopmost?serid=&topmost=` | 教/管 | 单参写 | 置顶 / 取消 | +| `notice.expedite` ⭐ | `POST /api/notice/Expedite?serid=` | 教/管 | 单参写 | **催读**,管理刚需 | +| `notice.readDetail` ⭐ | `GET /api/notice/ReadDetail?serid=` | 教/管 | 只读 | **谁读了 / 谁没读**(教师维度分组) | +| `notice.readReceipt` | `GET /api/notice/ReadReceipt?serid=` | 教/管 | 只读 | 年级→班级→学生 + 教师组的回执快照 | +| `notice.readAll` | `POST /api/notice/InitializeRed` | 全员 | 单参写 | 一键全部已读("我还有多少没看"→顺手清) | +| `notice.delete` | `POST /api/notice/Delete` | 教/管 | 单参写 | body 是 id **数组**,支持批量 | +| `notice.drafts` | `GET /api/notice/DraftList` | 教/管 | 只读 | 草稿箱 | +| `notice.saveDraft` | `POST /api/notice/SaveDraft` | 教/管 | 表单写 | 先存着 | +| `notice.edit` | `POST /api/notice/Edit` | 教/管 | 表单写 | 编辑(`flow=[]` 清流程) | +| `notice.submitApproval` ⭐ | `POST /api/notice/Submit` | 教/管 | 表单写 | 提交审批,`steps[].approverTeaIds` 来自**教师树** | +| `notice.appendReceivers` | `POST /api/notice/ReceiveUsers?serid=&users=` | 教/管 | 单参写 | 追加接收人(补发) | +| `notice.stats` | `GET /api/notice/Statis` | 管 | 只读 | 区间统计(页面 `/stats` 目前还是占位页,但接口是活的) | +| `notice.deptManage` | `/api/dict/{ManageList,Add,Update,Delete,Sort}` | 管 | 写 | 发文部门维护 | + +> 老师最常问的两句是「**这条通知谁还没看**」和「**催一下没看的**」,对应 `readDetail` + `expedite`,建议 P0。 + +--- + +### 2.3 `dormnotice` 宿舍通知 —— 已接 9,建议 +18(缺口第二大) + +**已接**:`dorm.myNotices` `myNotice` `sendList` `submitReceipt` `cancelReceipt` `buildings` `rooms` `roomStudents` `dormTree` + +**模块能力全景**(三条业务线): + +| 页面 | 角色 | 能力 | +|---|---|---| +| `/par/notices` `/par/mine` | 家长 | 通知箱、阅读、提交/修改/取消**回执** | +| `/list` `/noticelist` `/list/new` | 宿管/教师 | **发布通知**(含回执方式/回执单据/接收宿舍/短信/定时)、撤回、置顶、催办、编辑 | +| `/receipts` 回执情况 | 宿管/管理员 | 回执名单(按楼栋·房间)、**代为确认 / 取消 / 退回**、催办、导出 CSV | +| `/setting` 宿舍设置 | 管理员 | 通知类型字典、发布部门字典、**楼栋/房间台账 + 住宿生床位安排**、自定义联系组 | + +**建议新增(A. 通知管理侧):** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `dorm.publish` ⭐ | `POST /api/notice/Create` | 教/管 | 表单写 | **宿管发通知**。字段:标题/类型/部门/紧急度/生效时间/回执截止/**回执方式**(none/confirm/fill)/**回执单据**(stay/visit/adjust/general)/**接收宿舍**/附件 | +| `dorm.edit` | `POST /api/notice/Edit` | 教/管 | 表单写 | 编辑(含 `sourceId`) | +| `dorm.revoke` | `POST /api/notice/UserPull?serid=` | 教/管 | 单参写 | 撤回 | +| `dorm.setTopmost` | `POST /api/notice/SetTopmost?serid=&topmost=` | 教/管 | 单参写 | 置顶 | +| `dorm.delete` | `POST /api/notice/Delete` | 教/管 | 单参写 | 批量删除(级联删回执) | +| `dorm.receipts` ⭐ | `GET /api/notice/Receipts?serid=&status=` | 教/管 | 只读 | **回执名单 + 汇总**(应收/已回/已确认/已填报/未回执/回执率) | +| `dorm.receiptDetail` | `GET /api/notice/ReceiptDetail?receiptId=` | 教/管 | 只读 | 单条回执详情 | +| `dorm.reviewReceipt` ⭐ | `POST /api/notice/Review` | 教/管 | 单参写 | `{receiptId, action: confirm/cancel/reset}` —— 代确认 / 取消 / 退回 | +| `dorm.remindReceipt` ⭐ | `POST /api/notice/Remind?serid=` | 教/管 | 单参写 | 催未回执家长 | +| `dorm.remindStudent` | `POST /api/notice/RemindStudent?serid=&studentId=` | 教/管 | 单参写 | 定向催某个学生 | +| `dorm.markRead` | `POST /api/notice/MarkRead?serid=&studentId=` | 教/管 | 单参写 | 代标记已读(线下已通知的情况) | +| `dorm.exportReceipts` | `GET /api/notice/ExportReceipts?serid=` | 教/管 | 只读 | 导出 CSV(返回文本,可回传前端下载) | +| `dorm.stats` | `GET /api/notice/Statis` | 教/管 | 只读 | 通知统计 | +| `dorm.formTemplates` | `GET /api/notice/FormTemplates` | 教/管 | 只读 | 回执单据模板(**动态字段**:key/label/type/required/options) | +| `dorm.noticeTypes` | `GET /api/dict/List?type=noticeType` | 教/管 | 只读 | 通知类型下拉 | + +**建议新增(B. 床位台账 —— 模块自带的"没想到的能力"):** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `dorm.pendingStudents` ⭐ | `GET /api/dorm/PendingStudents?keyword=&gender=` | 教/管 | 只读 | **待安排床位学生池** | +| `dorm.assignStudent` ⭐ | `POST /api/dorm/AssignStudent?roomId=&studentIds=` | 教/管 | 表单写 | **安排入住**(房间 + 学生多选) | +| `dorm.removeStudent` | `POST /api/dorm/RemoveStudent?roomId=&studentId=` | 教/管 | 单参写 | 退宿 | +| `dorm.transferStudent` | `POST /api/dorm/TransferStudent` | 教/管 | 表单写 | 调整宿舍(回显是否原宿舍长 + 床位号) | +| `dorm.setMonitor` | `POST /api/dorm/SetMonitor?roomId=&studentId=` | 教/管 | 单参写 | 设/取消宿舍长(取消传 0) | +| `dorm.addBuilding` | `POST /api/dorm/AddBuilding` | 管 | 表单写 | 新增楼栋(可**批量铺房间**:层数×每层房间数×床位数) | +| `dorm.editBuilding` / `dorm.delBuilding` | 同名接口 | 管 | 写 | 改 / 删(删除需先清空房间) | +| `dorm.addRoom` / `editRoom` / `delRoom` | 同名接口 | 管 | 写 | 房间增删改 | +| `dorm.buildingTree` | 组合 `BuildingList` → `RoomList(buildingId)` | 教/管 | 只读 | 楼栋→楼层→房间三级(前端按 `floor` 分组) | + +> 这三条联动链是模块里最有价值、也最"没人想到助手能做"的部分: +> **楼栋 → 房间 → 住宿生**、**待安排池 → 安排入住**、**回执名单 → 代确认/催办**。 + +--- + +### 2.4 `familyactivity` 家校活动 —— 已接 7,建议 +13 + +**已接**:`activity.plaza` `list` `detail` `mySignup` `signup` `cancelSignup` `classTree` + +**模块能力全景**:闭环 = 发布(草稿/定时/复用)→ 触达(班级定向/短信/置顶)→ 已读追踪 → **报名回执**(仅通知 / 需报名 / 需确认)→ 学校确认 / 催办 / 导出 → 统计 → 字典与联系组。 + +**建议新增:** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `activity.publish` ⭐ | `POST /api/activity/Create` | 教/管 | 表单写 | **发活动**。字段:标题/分类/主办部门/**接收班级**/时间地点/回执方式/附件/短信/定时 | +| `activity.edit` | `POST /api/activity/Edit` | 教/管 | 表单写 | 编辑 | +| `activity.revoke` | `POST /api/activity/UserPull?serid=` | 教/管 | 单参写 | 撤回(家长端不可见) | +| `activity.setTopmost` | `POST /api/activity/SetTopmost` | 教/管 | 单参写 | 置顶 | +| `activity.delete` | `POST /api/activity/Delete` | 教/管 | 单参写 | 批量删除 | +| `activity.signups` ⭐ | `GET /api/activity/Signups?serid=&signupStatus=` | 教/管 | 只读 | **报名名单**(按班级分组 + 汇总) | +| `activity.signupDetail` | `GET /api/activity/SignupDetail?signupId=` | 教/管 | 只读 | 单条回执详情 | +| `activity.reviewSignup` ⭐ | `POST /api/activity/Review` | 教/管 | 单参写 | `{signupId, action: confirm/cancel/waitlist, remark}` —— 学校确认 / 取消请假 / 待定 | +| `activity.remind` ⭐ | `POST /api/activity/Remind?serid=` | 教/管 | 单参写 | 催报名 | +| `activity.exportSignups` | `GET /api/activity/ExportSignups?serid=` | 教/管 | 只读 | 导出 CSV 文本(带 BOM) | +| `activity.readDetail` | `GET /api/activity/ReadDetail?serid=` | 教/管 | 只读 | 已读/未读(年级→班级) | +| `activity.stats` | `GET /api/activity/Statis` | 教/管 | 只读 | 活动统计 | +| `activity.categories` | `GET /api/dict/List?type=2` | 全员 | 只读 | 活动分类下拉(发布时用) | +| `activity.receiveClasses` | `POST /api/activity/ReceiveClasses?serid=&classes=` | 教/管 | 单参写 | 追加接收班级(补发) | + +--- + +### 2.5 `coursehomework` 课程作业 —— 已接 4,建议 +20(**缺口最大**) + +**已接(全是学生端)**:`homework.myList` `detail` `result` `submit` + +> ★ 这个模块是**端分离**的:`/stu/*` = 学生,其余 = 教师。 +> 助手目前只接了 `/api/student/homeworks*` 四件事,**`/api/teacher/homeworks/*` 一整套 0 接入**。 +> 而教师的日常恰恰全在这一侧:布置、催交、批阅、打分、打回。 + +**建议新增(A. 教师端 V2,`/api/teacher/homeworks`):** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `homework.create` ⭐ | `POST /api/teacher/homeworks` | 教师 | 表单写 | **布置作业**(先落草稿)。字段:标题/学科/**接收班级**/截止时间/提交方式/逾期可补交/截止提醒/附件 | +| `homework.editDraft` | `PUT /api/teacher/homeworks/{id}` | 教师 | 表单写 | 仅草稿可改 | +| `homework.deleteDraft` | `DELETE /api/teacher/homeworks/{id}` | 教师 | 单参写 | 仅草稿可删 | +| `homework.publishNow` ⭐ | `POST .../{id}/publish` | 教师 | 单参写 | 草稿 → 进行中(触发学生通知) | +| `homework.schedule` | `POST .../{id}/schedule` | 教师 | 单参写 | 定时发布 / 取消定时 | +| `homework.drafts` | `GET /api/teacher/homeworks/drafts` | 教师 | 只读 | 草稿箱 | +| `homework.published` | `GET /api/teacher/homeworks/published` | 教师 | 只读 | 已发布列表(可按学科筛) | +| `homework.remind` ⭐ | `POST .../{id}/remind` | 教师 | 单参写 | **催交**:`{type: NOT_READ\|NOT_SUBMITTED, studentIds?}` | +| `homework.extend` | `POST .../{id}/extend` | 教师 | 单参写 | 延长截止(新截止须晚于当前) | +| `homework.copy` | `POST .../{id}/copy` | 教师 | 单参写 | 复制为新草稿 | +| `homework.archive` | `POST .../{id}/archive` | 教师 | 单参写 | 归档(只读,不再提醒) | +| `homework.teacherDetail` | `GET .../{id}` | 教师 | 只读 | 作业详情 + 汇总统计 + 分班进度 | +| `homework.teacherStats` ⭐ | `GET .../{id}/stats` | 教师 | 只读 | "这份作业交了多少" | +| `homework.students` ⭐ | `GET .../{id}/students` | 教师 | 只读 | **学生矩阵**:谁读了/谁交了/谁没交/谁待批 | +| `homework.review` ⭐ | `POST .../{id}/submissions/{studentId}/review` | 教师 | 表单写 | **批阅打分 + 评语**(score/comment 至少一项) | +| `homework.returnSubmission` ⭐ | `POST .../{id}/submissions/{studentId}/return` | 教师 | 单参写 | **打回订正**(comment 必填) | +| `homework.versions` | `GET .../{id}/submissions/{studentId}/versions` | 教师 | 只读 | 历史提交版本 | +| `homework.export` | `GET .../{id}/export` | 教师 | 只读 | 导出学生矩阵 Excel(直链) | +| `homework.subjects` | `GET /api/teacher/homeworks/subjects/teaching` | 教师 | 只读 | 学科下拉 | +| `homework.classList` | `GET /api/teacher/homeworks/classes?subject=` | 教师 | 只读 | **年级→班级树,依赖所选学科** | + +**建议新增(B. 学生端补全 + 通知中心):** + +| 建议工具 | 接口 | 角色 | 类型 | 说明 | +|---|---|---|---|---| +| `homework.withdrawSubmission` | `PUT .../submissions/{submissionId}` `action=withdraw` | 学生 | 单参写 | 撤回提交(截止前) | +| `homework.resubmit` | `PUT ...` `action=resubmit` | 学生 | 单参写 | **打回后重交**(与 `result` 配对) | +| `homework.read` | `POST /api/student/homeworks/{id}/read` | 学生 | 单参写 | 阅读上报(幂等) | +| `homework.notifications` | `GET /api/notifications` | 学生/教师 | 只读 | **通知中心**(接口已封装但前端无页面,是"有接口无 UI"的隐藏能力) | +| `homework.readAllNotifications` | `POST /api/notifications/read-all` | 同上 | 单参写 | 全部已读 | + +> 另外 V1 冻结管理端还有 `GET /api/homework/SendList`(全部人作业)、`POST /api/homework/Delete`、`GET /api/homework/Statis`, +> 供"管理/上级教师"口径使用,可选接。 + +--- + +### 2.6 `profession` 教师专业发展(第 6 个模块,教师专属,已接 9,建议 +12) + +**已接**:`homeRecords` `list` `moduleRecords` `myRecords` `detail` `moduleTree` `myPoints` `pointsRank` `publish` + +| 建议工具 | 接口 | 类型 | 说明 | +|---|---|---|---| +| `profession.editRecord` | `POST /api/profession/Edit` | 表单写 | 编辑自己的记录 | +| `profession.deleteRecord` | `GET /api/profession/Delete?ids=` | 单参写 | 删除 | +| `profession.comments` | `GET /api/profession/DiscussList?sid=` | 只读 | 评论列表 | +| `profession.comment` | `POST /api/profession/DiscussCreate` | 单参写 | 发表评论 | +| `profession.deleteComment` | `GET /api/profession/DiscussDel?id=` | 单参写 | 删评论 | +| `profession.like` | `POST /api/profession/LikeCreate?recordId=` | 单参写 | 点赞 / 取消(toggle) | +| `profession.timeline` | `GET /api/profession/MyTimeline?userid=` | 只读 | 个人时间轴 | +| `profession.spaceOverview` | `GET /api/profession/SpaceOverview` | 只读 | 个人空间聚合(积分/记录/获赞/被访问) | +| `profession.pointsSource` | `GET /api/profession/PointsSourceList` | 只读 | **积分来源明细**("我这 20 分怎么来的") | +| `profession.grades` / `subjects` | `GetGradeList` / `GetSubList` | 只读 | 年级段 / 学科字典(发布记录时用) | +| `profession.stats` | `GET /api/stats/{TeacherStat,ModuleStat,PointsStat}` | 只读 | 校级统计(管理员) | +| `profession.modules` | `GET /api/moudule/MouduleList` | 只读 | 栏目列表(含 `readonly` 只读标记) | + +> 注:**该模块没有审批流**(记录发布即生效,`isCheck` 是栏目启停不是记录审核),别按"提交审核"设计。 + +--- + +## 3. 跨模块共性缺口 —— "各模块都没想到的能力" + +这 7 条是**重复出现在多个模块**、做一次就能复用多次的能力: + +| # | 共性能力 | 出现在 | 助手现状 | 价值 | +|---|---|---|---|---| +| 1 | **撤回 / 置顶 / 催办三件套** | 公告、宿舍、活动、作业 | 全缺 | ★★★ 发错了能收回、重要的能置顶、没看的能催 —— 都是**单参数 POST**,成本极低 | +| 2 | **回执 / 提交名单闭环** | 公告、宿舍、活动、作业 | 只做了"我提交",没做"**谁没提交**" | ★★★ 教师最高频诉求 | +| 3 | **统计数据** | 公告、宿舍、活动、作业都有 `Statis` | 只接了 `leave.stats` | ★★ 一句话出数字 | +| 4 | **字典维护**(在"应用设置"里) | 公告(发文部门)、宿舍(通知类型)、活动(主办部门/分类)、请假(类型) | 只做了"读下拉",没做"改" | ★★ 管理员可用自然语言维护 | +| 5 | **附件上传/预览/下载** | 5 模块统一 `/api/file/{Upload,Preview,Down}` | 写操作暂不支持附件 | ★ 侧重点:写时提示"需附件请到页面补传",只读加 `Preview/Down` 直链 | +| 6 | **级联下拉链** | 作业、宿舍、活动、公告 | **基建已有**(`optionCall` + `valueKey/labelKey` + `dependsOn`) | ★★ 直接复用,见第 4 节 | +| 7 | **审批人选择** | 公告(多步审批流 `steps`) | 缺 | ★ `getTeaTree` 教师树 + 多选 | + +--- + +## 4. 落地设计:复用现有基建,**零新增管线** + +这一节是"**怎么做**",全部基于已经跑通的机制: + +### 4.1 写操作 —— 自动走浏览器代执行 + +服务端**无法**代持用户登录态调模块 `/api/*`(下游 CAS 过滤器只认浏览器容器会话,必回 `code=302`)。 +现有 A 通道(commit `ab87a4b`)已在跑:服务端挂起下发 `browserTool.calls` → 前端用**用户自己的登录态** fetch → 回传结果续跑。 +**所以上面所有"写"工具,只需要在 `ToolDef` 里声明 `.write(landingPath)`,不需要任何新的传输管线。** + +### 4.2 表单下拉 —— 用现成的 `optionCall` 机制 + +后端只下发"取数说明"(含 `{{占位符}}`),前端带登录态取,再按后端给的 `valueKey/labelKey` 映射: + +```java +ToolParam.required("stuId", "integer", "学生 id") + .label("学生") + .optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName") + .dependsOn("classId"); // ← 选完班级才解锁 +``` + +**需要新写的级联链清单**(照这个模式加): + +| 模块 | 联动链 | 依赖关系 | +|---|---|---| +| 课程作业 | 学科 → 年级班级树 | `homework.classList` `dependsOn("subject")` | +| 课程作业 | 已发布作业 → 班级 → 学生 | `stats.byClass` → `students?classId` | +| 宿舍通知 | **楼栋 → 房间 → 住宿生** | `RoomList` `dependsOn("buildingId")`;`RoomStudents` `dependsOn("roomId")` | +| 宿舍通知 | 楼栋 → 房间(接收宿舍多选) | 同上,前端按 `floor` 分组 | +| 家校活动 | 接收班级(年级→班级) | `GetClassTree` 分组下拉,选"全部班级"→ `receiveList=ALL` | +| 校园公告 | 发文部门 / 接收范围 / 审批人 | `dict/List?type=1` / `getStuTree` / `getTeaTree` | +| 请假 | 班级 → 学生(**已完成**) | 参考实现 | + +### 4.3 裸意图直达 —— 可扩展 + +已有 `IntentShortcut`(去标点 + 剥前后缀正好等于标准说法 + ≤12 字 → 跳过 LLM 直接弹表单)。 +可照葫芦画瓢加: + +| 说法 | 直达工具 | +|---|---| +| 「我要交作业」「看今天作业」 | `homework.myList` / 学生提交表单 | +| 「布置作业」 | `homework.create` | +| 「我要报名」 | `activity.signup`(先弹活动选择) | +| 「查宿舍」 | `dorm.myNotices` | +| 「发通知」 | 视模块 → `notice.publish` / `dorm.publish` / `activity.publish`(**有歧义,需按目录可见性消歧**) | + +> ⚠️ 「发通知」「我要报名」这类**跨模块同义**的说法不要做裸意图直达, +> 交给模型按 `catalog` 里可见的工具自己选,否则会猜错模块。 + +--- + +## 5. 角色分桶建议(实施时必须同步改 `RolePolicy`) + +| 工具族 | 建议桶 | +|---|---| +| 写:发布/编辑/撤回/置顶/催办/删除/审批/统计/字典维护/床位台账/批阅打分/打回/催交 | `STAFF_ONLY` | +| 写:请假申请、交作业、阅读上报、撤回提交、重交 | `STUDENT_ONLY` | +| 写:活动报名 / 取消报名 | `STUDENT_OR_PARENT` | +| 写:一键全部已读、宿舍回执提交/取消 | 不限(所有人) | +| 只读:各类列表/详情/统计/下拉 | 按模块判定(**教师专属模块**如 `profession.*` 用前缀规则) | + +当前 `profession.*` 已用模块前缀规则(`STAFF_MODULE_PREFIXES`)整模块归教职工,新增模块可沿用。 + +**几条容易踩的:** + +- `homework.*` 现在被整桶放进 `STUDENT_ONLY`。**新增教师端工具必须改名字或用显式 `.allow(...)`**,否则会被误判成学生工具。 + 建议改成:`homework.myList/detail/result/submit/read/...`(学生)保留在 `STUDENT_ONLY`, + 而 `homework.create/publish/review/...` 显式登记 `STAFF_ONLY`。 +- 「回执/名单查询」(`dorm.receipts`、`activity.signups`、`notice.readDetail`)**必须** `STAFF_ONLY` —— 那是全班/全校数据。 +- 「一键已读」「提交自己的回执」这类**只动自己的**,不要限制角色。 + +--- + +## 6. 建议实施顺序(4 批,风险递增) + +| 批次 | 内容 | 风险 | 为什么先做 | +|---|---|---|---| +| **批 1** | 只读补全:统计(4 个模块)、阅读情况、回执/报名名单、草稿箱、`homework.students` 学生矩阵、下拉字典 | 极低 | 纯 GET,不改变任何数据;立刻让"谁没交/谁没看"能问 | +| **批 2** | 单参数写:撤回 / 置顶 / 催读 / 催办 / 催报名 / 删除 / 一键已读 / 代确认回执 / 打回 / 归档 / 发布草稿 | 低 | 单字段 POST,表单只需"确认";覆盖最高频的"发错了/催一下" | +| **批 3** | 表单写:布置作业、发活动、发宿舍通知、公告草稿+编辑+提交审批、请假类型字典维护 | 中 | 正文级表单,需要完整字段 + 级联下拉 | +| **批 4** | 复杂交互:批阅打分(分数+评语+历史版本)、床位安排(楼栋→房间→学生)、多步审批人选人、导出报表 | 中高 | 多步 + 多级联动,建议单独设计交互 | + +--- + +## 附录 A:接口路径速查 + +``` +# 请假 homeleave +GET /api/leave/List|Details|MyClasses|ClassStudents /api/leavestats/Stats +POST /api/leave/Save|Audit|BatchPass|ReportBack /api/leave/Withdraw|Delete (form) + /api/dict/LeaveTypeOptions|List|Add|Update|Delete|Sort + +# 校园公告 campusnotice +GET /api/notice/List|SendList|Detail|GetDetail|GetNextPrev|DraftList + /api/notice/ReadDetail|ReadReceipt|ApprovalList|ApprovalCount|Statis +POST /api/notice/Create|Edit|SaveDraft|Submit|Delete|Approve|Reject (JSON) + /api/notice/UserPull|SetTopmost|Expedite|InitializeRed|ReceiveUsers (form) + /api/dict/{List,ManageList,Add,Update,Delete,Sort} /api/contacts/* + +# 宿舍通知 dormnotice +GET /api/notice/MyNotices|MyNotice|List|SendList|Detail|ReadDetail + /api/notice/Receipts|ReceiptDetail|ExportReceipts|FormTemplates|Statis + /api/dorm/BuildingList|RoomList|RoomStudents|PendingStudents /api/base/GetDormTree +POST /api/notice/SubmitReceipt|CancelReceipt|Create|Edit|Delete|Review (JSON) + /api/notice/UserPull|SetTopmost|Expedite|Remind|RemindStudent|MarkRead (form) + /api/dorm/AddBuilding|EditBuilding|DelBuilding|AddRoom|EditRoom|DelRoom + /api/dorm/AssignStudent|RemoveStudent|TransferStudent|SetMonitor + +# 家校活动 familyactivity +GET /api/activity/MyActivities|List|SendList|Detail|GetDetail|ReadDetail + /api/activity/Signups|SignupDetail|ExportSignups|Statis|MySignup +POST /api/activity/Signup|CancelSignup|Create|Edit|Delete|Review (JSON) + /api/activity/UserPull|SetTopmost|Remind|ReceiveClasses|InitializeRed|Expedite (form) + /api/dict/List?type=1|2 /api/base/GetClassTree + +# 课程作业 coursehomework +GET /api/student/homeworks?tab= /api/student/homeworks/{id}|/{id}/result +GET /api/teacher/homeworks/drafts|published|classes?subject=|subjects/teaching|assigned + /api/teacher/homeworks/{id}|/{id}/stats|/{id}/students|/{id}/export + /api/teacher/homeworks/{id}/submissions/{studentId}/versions +GET /api/homework/SendList|Detail|ReadDetail|Statis (V1 管理端) +POST /api/student/homeworks/{id}/read|/{id}/submissions +PUT /api/student/homeworks/{id}/submissions/{submissionId} (withdraw|resubmit) +POST /api/teacher/homeworks | /{id}/publish|schedule|remind|extend|copy|archive + /{id}/submissions/{studentId}/review|return +PUT /api/teacher/homeworks/{id} DELETE /api/teacher/homeworks/{id} +GET /api/notifications POST /api/notifications/{id}/read|read-all + +# 专业发展 profession +GET /api/profession/HomeModuleRecords|ProfessionList|ModuleRecordList|MyModuleRecordList + /api/profession/ProfessionDetail|MyTimeline|MyPoints|PointsSourceList|PointsRank + /api/profession/SpaceOverview|DiscussList|VisitList|GetGradeList|GetSubList +POST /api/profession/Create|Edit|DiscussCreate|DiscussEdit|VisitCreate|LikeCreate +GET /api/profession/Delete|DiscussDel /api/moudule/* /api/points/* /api/stats/* + +# 全模块通用 +GET /api/base/GetUserInfo|GetUserMod|GetSchoolAll|GetClassTree|getStuTree|getTeaTree +POST /api/base/SetRole|FileUpload|Logout +GET /api/file/Preview|Down|List POST /api/file/Upload|UploadImage +``` diff --git a/src/main/java/com/yunhai/agent/auth/RolePolicy.java b/src/main/java/com/yunhai/agent/auth/RolePolicy.java index 43532f0..e090b71 100644 --- a/src/main/java/com/yunhai/agent/auth/RolePolicy.java +++ b/src/main/java/com/yunhai/agent/auth/RolePolicy.java @@ -33,8 +33,8 @@ public final class RolePolicy { private static final Set STAFF_ONLY = new HashSet<>(Arrays.asList( // 请假:登记(教师给学生请假,type=1 免审)、审批与统计 "leave.register", "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass", - // 公告:发布与审批 - "notice.sent", "notice.deptDics", "notice.publish", + // 公告:发布与审批(另含存草稿与审批人选人的教师树) + "notice.sent", "notice.deptDics", "notice.publish", "notice.saveDraft", "notice.teaTree", "notice.approvalList", "notice.approve", "notice.reject", // 宿舍通知:发布 / 编辑 / 阅读情况 / 回执处理 / 催办 / 统计 "dorm.publish", "dorm.editNotice", "dorm.sendList", @@ -52,7 +52,10 @@ public final class RolePolicy { "activity.readDetail", "activity.signups", "activity.signupDetail", "activity.review", "activity.remind", "activity.expedite", "activity.pull", "activity.top", "activity.statistics", // 家校活动:发布表单的字典与选班级 - "activity.deptDic", "activity.categoryDic", "activity.classTree" + "activity.deptDic", "activity.categoryDic", "activity.classTree", + // 课程作业:教师端发布(发布/定时/我的作业/草稿)与发布表单的学科、班级树 + "homework.publish", "homework.schedule", "homework.published", "homework.drafts", + "homework.teachingSubjects", "homework.classTree" )); /** 学生专属 */ diff --git a/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java b/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java index 56f13b7..d7e8519 100644 --- a/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java +++ b/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java @@ -52,5 +52,27 @@ public class BrowserToolRequest { private String body; /** POST 时的 Content-Type */ private String contentType; + + /** + * 两步提交的第二步(可选):首步成功后,用首步响应的 {@code data} 替换 + * {@link FollowUp#pathAndQuery} 里的 {@code {data}} 再发一次。 + * + *

只用于「模块没有一步到位的接口」的场景(如作业:先落草稿 → 再发布/定时)。 + * 第二步的地址由服务端在准备阶段生成(参数已替换完毕),前端不参与拼接。 + */ + private FollowUp followUp; + } + + /** 首步成功后的续跑请求(路径里的 {@code {data}} 用首步响应的 data 替换) */ + @Data + public static class FollowUp { + /** GET / POST_JSON / POST_FORM */ + private String method; + /** 路径模板,可含 {data}(首步响应的 data,如新作业 id) */ + private String pathAndQuery; + /** 请求体(可含 {{参数名}} 占位,已在准备阶段替换) */ + private String body; + /** Content-Type */ + private String contentType; } } diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index e7855ab..2765f46 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -663,9 +663,12 @@ public class AgentOrchestrator { } for (ToolParam p : shown) { FormField f = p.toFormField(); - // 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST) + // 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。 + // 缺省按 「/<模块>/api/base/FileUpload」,个别模块不同(作业是 /api/file/Upload)时由参数覆盖。 if ("file".equals(f.getWidget())) { - f.setUploadPath("/" + def.getModule() + "/api/base/FileUpload"); + f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride()) + ? p.getUploadPathOverride() + : "/" + def.getModule() + "/api/base/FileUpload"); } // 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。 // @@ -677,8 +680,10 @@ public class AgentOrchestrator { BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller); if (optionCall != null) { f.setOptionCall(optionCall); - // 多选字段保持 multiselect(接收班级/宿舍/审批人),其余走单选下拉 - if (!"multiselect".equals(f.getWidget())) { + // ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、 + // flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。 + String w = f.getWidget(); + if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) { f.setWidget("select"); } // 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖 @@ -688,7 +693,10 @@ public class AgentOrchestrator { List> opts = dictOptions(p.getOptionsFrom(), caller, dictCache); if (!opts.isEmpty()) { f.setOptions(opts); - f.setWidget("select"); + String w2 = f.getWidget(); + if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) { + f.setWidget("select"); + } f.setOptionsFallback(false); } } diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index 2c1209e..af72755 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -57,6 +57,35 @@ public class FormField { */ private Integer scopeType; + /** + * 「镜像字段」:本字段不由用户填写({@code hidden}),提交时自动填入同一表单中 + * {@code mirrorOf} 指定字段**选中项的显示名**。 + * + *

★ 用途:公告/宿舍/活动三个模块页在提交时会用字典把部门 id 反查成名称一并发出 + * ({@code deptId} + {@code deptName},后端字典缺失时回退该名称)。助手只有下拉选中的 id, + * 但前端手里就有选项的 label —— 由它顺手镜像过去,提交体才和模块页一模一样。 + */ + private String mirrorOf; + + /** + * 「连续请假」展开说明:{@code {count: 天数字段, start: 开始时间字段, end: 结束时间字段}}。 + * + *

作用在请假工具上:当本字段({@code continuousLeave})取 1 时,前端按 + * {@code continuousLeaveNum} 天把 {@code startDate}/{@code endDate} 复制成逐天时间段, + * 生成模块要的 {@code continuousLeaveTime}({@code [{startDate,endDate}]})—— + * 与模块页 {@code LeaveTimeSection.regenerate()} 的口径一致。 + */ + private Map dailyExpand; + + /** + * {@code widget=file} 且为 true 时,提交的不是 fileId 数组,而是模块要的**附件对象数组**。 + * + *

作业模块(coursehomework V2)的 {@code attachments} 形如 + * {@code [{fileId,fileUrl,fileType,fileName,sort}]},与其余模块的 {@code fileIds="1,2"} 不同, + * 故由前端按上传结果组装成对象。 + */ + private boolean fileAsObject; + private boolean required; /** 说明(来自工具定义,写清格式与含义) */ diff --git a/src/main/java/com/yunhai/agent/tool/ToolDef.java b/src/main/java/com/yunhai/agent/tool/ToolDef.java index 86a6359..af37323 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolDef.java +++ b/src/main/java/com/yunhai/agent/tool/ToolDef.java @@ -100,6 +100,34 @@ public class ToolDef { */ private boolean formAll; + /** + * 「两步提交」的第二步路径模板(可选)。首步成功后,用首步返回的 {@code data} 续跑。 + * + *

★ 用途:作业模块(coursehomework V2)没有"一步发布"的接口 —— 模块页自己也是 + * {@code Create(落草稿) → Publish/Schedule}。为了让用户在助手里填一次表单就发出去, + * 把第二步声明在这里:路径里的 {@code {data}} 用首步返回的作业 id 替换, + * {@link #followUpBody} 里的 {@code {{参数名}}} 在准备阶段就用表单实参替换好。 + */ + private String followUpPath; + + /** 第二步的请求体模板(JSON 字符串,可含 {@code {{参数名}}} 占位),为空表示无请求体 */ + private String followUpBody; + + /** 第二步的请求方式,缺省 POST */ + private String followUpMethod = "POST"; + + /** + * 声明两步提交的第二步:首步成功后用它接着发一次请求。 + * + * @param pathTemplate 路径模板,可含 {@code {data}}(首步响应的 data,如新作业 id) + * @param bodyTemplate 请求体模板,可含 {@code {{参数名}}};不需要请求体传 null + */ + public ToolDef then(String pathTemplate, String bodyTemplate) { + this.followUpPath = pathTemplate; + this.followUpBody = bodyTemplate; + return this; + } + public ToolDef() { } @@ -222,6 +250,10 @@ public class ToolDef { JSONObject props = new JSONObject(); JSONArray required = new JSONArray(); for (ToolParam p : params) { + // 「镜像字段」(如 deptName)由前端按所选部门的显示名自动带上,不让模型填 + if (p.getMirrorLabelOf() != null) { + continue; + } props.put(p.getName(), p.toSchema()); if (p.isRequired()) { required.add(p.getName()); diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java index a947c2e..bf3f014 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -143,7 +143,12 @@ public class ToolExecutor { } long cost = System.currentTimeMillis() - start; - return parseResponse(def, resp, cost); + ToolResult first = parseResponse(def, resp, cost); + // ★ 两步提交(如作业「先落草稿 → 再发布/定时」):首步成功才续跑,首步失败原样返回 + if (StringUtils.isNotBlank(def.getFollowUpPath()) && first.isSuccess()) { + return runFollowUpOnServer(def, resp, merged, base, headers, start); + } + return first; } catch (Exception e) { long cost = System.currentTimeMillis() - start; @@ -225,9 +230,116 @@ public class ToolExecutor { throw new IllegalArgumentException("不支持的请求方式:" + method); } call.setMethod(method); + call.setFollowUp(buildFollowUp(def, merged, base)); return call; } + /* ==================== 两步提交(先落草稿 → 再发布/定时) ==================== */ + + /** + * 组装「首步成功后的续跑请求」。 + * + *

模块里有些动作没有一步到位的接口(作业发布就是 {@code Create → Publish|Schedule})。 + * 声明 {@link ToolDef#then} 后,准备阶段就把第二步的地址与请求体拼好, + * 只留 {@code {data}}(首步返回的资源 id)给执行期替换 —— 前端与服务端同一套逻辑。 + * + * @param merged 已合并固定参数的实参表(用于替换请求体里的 {@code {{参数名}}}) + * @param base 模块根地址(用于拼出带模块前缀的相对路径) + */ + private com.yunhai.agent.model.BrowserToolRequest.FollowUp buildFollowUp( + ToolDef def, Map merged, String base) { + if (StringUtils.isBlank(def.getFollowUpPath())) { + return null; + } + com.yunhai.agent.model.BrowserToolRequest.FollowUp fu = + new com.yunhai.agent.model.BrowserToolRequest.FollowUp(); + String m = StringUtils.isBlank(def.getFollowUpMethod()) ? "POST" : def.getFollowUpMethod().toUpperCase(); + fu.setMethod(m); + fu.setPathAndQuery(stripOrigin(followUpFullPath(def, base))); + if (StringUtils.isNotBlank(def.getFollowUpBody())) { + fu.setBody(fillPlaceholders(def.getFollowUpBody(), merged)); + } + if ("POST_JSON".equals(m)) { + fu.setContentType("application/json;charset=UTF-8"); + } else if ("POST_FORM".equals(m)) { + fu.setContentType("application/x-www-form-urlencoded;charset=UTF-8"); + } + return fu; + } + + /** 第二步的完整地址(模块根 + 模板),供服务端直调与相对路径裁剪共用 */ + private String followUpFullPath(ToolDef def, String base) { + return trimEnd(base) + def.getFollowUpPath(); + } + + /** 把 {@code {{参数名}}} 替换成实参值(用于第二步请求体) */ + private String fillPlaceholders(String template, Map merged) { + String out = template; + if (merged != null) { + for (Map.Entry e : merged.entrySet()) { + String v = e.getValue() == null ? "" : String.valueOf(e.getValue()); + out = out.replace("{{" + e.getKey() + "}}", v); + } + } + return out; + } + + /** 首步响应里的业务数据(用于 {@code {data}}),取不到返回 null */ + private String extractDataNode(String body) { + if (StringUtils.isBlank(body)) return null; + try { + JSONObject o = JSON.parseObject(body); + Object d = o == null ? null : o.get("data"); + if (d == null) return null; + // 常见两种形态:直接回 id(作业创建接口 data=123),或回一个对象(data={id:123,...})。 + // 对象形态必须取出 id 再填进路径,直接 JSON 序列化会拼出 /homeworks/{"id":123}/publish。 + if (d instanceof JSONObject) { + Object id = ((JSONObject) d).get("id"); + if (id == null) return null; + String s = String.valueOf(id); + return StringUtils.isBlank(s) || "null".equals(s) ? null : s; + } + String s = String.valueOf(d); + return StringUtils.isBlank(s) || "null".equals(s) ? null : s; + } catch (Exception ignore) { + return null; + } + } + + /** + * 服务端侧的两步提交:首步成功后用返回的 id 续跑第二步,返回第二步的结果 + * (首步拿到 id 却发不出去时,如实报错而不谎报成功)。 + */ + private ToolResult runFollowUpOnServer(ToolDef def, ResponseEntity firstResp, + Map merged, String base, + HttpHeaders headers, long start) { + String dataId = extractDataNode(firstResp.getBody()); + if (StringUtils.isBlank(dataId)) { + return ToolResult.fail(def, "第一步已提交,但没拿回记录 id,无法继续「" + def.getTitle() + "」的第二步", + System.currentTimeMillis() - start); + } + com.yunhai.agent.model.BrowserToolRequest.FollowUp fu = buildFollowUp(def, merged, base); + String url = followUpFullPath(def, base).replace("{data}", dataId); + try { + HttpHeaders h = new HttpHeaders(); + h.putAll(headers); + HttpEntity entity; + if (fu.getBody() != null) { + h.setContentType(MediaType.parseMediaType(fu.getContentType())); + entity = new HttpEntity<>(fu.getBody(), h); + } else { + entity = new HttpEntity<>(h); + } + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.valueOf(fu.getMethod()), + entity, String.class); + return parseResponse(def, resp, System.currentTimeMillis() - start); + } catch (Exception e) { + log.error("工具 {} 第二步失败 url={} err={}", def.getName(), url, e.toString()); + return ToolResult.fail(def, "第一步已提交,但第二步失败:" + e.getMessage(), + System.currentTimeMillis() - start); + } + } + /** * 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致 * ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。 @@ -486,9 +598,82 @@ public class ToolExecutor { } out.put(e.getKey(), v); } + // 数组参数却给了标量(模型偶尔写 "image" 而不是 ["image"])→ 包成单元素数组, + // 免得后端 @RequestBody List 反序列化直接 400 + for (ToolParam p2 : def.getParams()) { + Object v = out.get(p2.getName()); + if (v == 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)))); + } + } + out.putAll(expandDailyArgs(def, out)); return out; } + /** + * 「连续请假」派生字段:{@code continuousLeave=1} 时,按 {@code continuousLeaveNum} 天 + * 把 {@code startDate}/{@code endDate} 复制成逐天时间段,产出模块要的 + * {@code continuousLeaveTime:[{startDate,endDate}]}。 + * + *

与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。 + * 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。 + */ + private Map expandDailyArgs(ToolDef def, Map args) { + Map extra = new LinkedHashMap<>(); + for (ToolParam p : def.getParams()) { + Map spec = p.getDailyExpandSpec(); + if (spec == null || spec.isEmpty()) continue; + Object flag = args.get(p.getName()); + if (flag == null || "0".equals(String.valueOf(flag).trim()) + || "false".equalsIgnoreCase(String.valueOf(flag).trim())) { + continue; + } + String countField = spec.get("count"); + String startField = spec.get("start"); + String endField = spec.get("end"); + String s = args.get(startField) == null ? null : String.valueOf(args.get(startField)); + String e = args.get(endField) == null ? null : String.valueOf(args.get(endField)); + if (StringUtils.isBlank(s) || StringUtils.isBlank(e)) continue; + int days = 1; + try { + days = Integer.parseInt(String.valueOf(args.get(countField)).trim()); + } catch (Exception ignore) { + /* 天数缺失/非法 → 按 1 天处理 */ + } + days = Math.max(1, Math.min(30, days)); + List> rows = new ArrayList<>(); + for (int i = 0; i < days; i++) { + Map row = new LinkedHashMap<>(); + row.put("startDate", shiftDays(s, i)); + row.put("endDate", shiftDays(e, i)); + rows.add(row); + } + extra.put("continuousLeaveTime", rows); + // 首段的起止时间对齐第一天(与模块页 payload() 的 startDate/endDate 口径一致) + if (!rows.isEmpty()) { + extra.put("startDate", rows.get(0).get("startDate")); + extra.put("endDate", rows.get(0).get("endDate")); + } + } + return extra; + } + + /** 把 {@code yyyy-MM-dd HH:mm:ss} 往后推 n 天(解析失败原样返回) */ + private String shiftDays(String dt, int n) { + if (n == 0 || StringUtils.isBlank(dt)) return dt; + try { + java.text.SimpleDateFormat f = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + java.util.Calendar c = java.util.Calendar.getInstance(); + c.setTime(f.parse(dt)); + c.add(java.util.Calendar.DAY_OF_MONTH, n); + return f.format(c.getTime()); + } catch (Exception e) { + return dt; + } + } + /** 把数组/集合统一成字符串列表(供 join 与「全部」哨兵判断用) */ private List toStringList(Object v) { List out = new ArrayList<>(); diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java index a0f6db0..42a5339 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolParam.java +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -108,6 +108,18 @@ public class ToolParam { */ private Integer scopeType; + /** 「镜像字段」指向的源字段名(隐藏字段,提交时填入源字段选中项的显示名,如 deptId → deptName) */ + private String mirrorLabelOf; + + /** 「连续请假」展开说明:{@code {count,start,end}} 三个字段名(见 {@link #expandDaily}) */ + private Map dailyExpandSpec; + + /** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */ + private boolean fileObject; + + /** 附件上传地址覆盖(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */ + private String uploadPathOverride; + /** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */ private boolean hidden; @@ -265,6 +277,58 @@ public class ToolParam { return this; } + /** + * 声明为「审批流」控件(校园公告的 {@code flow})。 + * + *

表单会渲染成:是否走审批 + 多步(每步可选或签/会签)+ 每步多选审批人 + * (候选人来自 {@link #optionsFrom} 指向的教师树工具);提交时前端拼成 + * {@code [{name:"第1步",vote:"any"|"all",approverTeaIds:[...]}]}。 + */ + public ToolParam flow() { + this.widget = "flow"; + return this; + } + + /** + * 声明本字段为**隐藏的镜像字段**:用户不填,提交时自动带上源字段选中项的显示名。 + * + *

用于 {@code deptName} 这类「模块页会随包提交、但界面上没有对应输入框」的字段: + * 三个模块页都是「选部门 → 用字典把 id 反查成名称 → 一起提交」。 + */ + public ToolParam mirrorLabelOf(String sourceField) { + this.mirrorLabelOf = sourceField; + return this; + } + + /** + * 连续请假展开:本字段取 1 时,前端按 {@code countField} 的天数把 + * {@code startField}/{@code endField} 复制成逐天时间段,生成 {@code continuousLeaveTime}。 + * + * @param countField 连续天数字段名(如 continuousLeaveNum) + * @param startField 基准开始时间字段名(如 startDate) + * @param endField 基准结束时间字段名(如 endDate) + */ + public ToolParam expandDaily(String countField, String startField, String endField) { + this.dailyExpandSpec = new LinkedHashMap<>(); + this.dailyExpandSpec.put("count", countField); + this.dailyExpandSpec.put("start", startField); + this.dailyExpandSpec.put("end", endField); + return this; + } + + /** 附件控件提交**对象数组**(作业模块 {@code attachments:[{fileId,fileUrl,fileType,fileName,sort}]}) */ + public ToolParam asAttachments() { + this.widget = "file"; + this.fileObject = true; + return this; + } + + /** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */ + public ToolParam upload(String path) { + this.uploadPathOverride = path; + return this; + } + /** 表单里显示的名字 */ public String labelOf() { return StringUtils.isNotBlank(label) ? label : name; @@ -355,6 +419,9 @@ public class ToolParam { f.setAllToken(allToken); f.setAllTokenLabel(allTokenLabel); f.setScopeType(scopeType); + f.setMirrorOf(mirrorLabelOf); + f.setDailyExpand(dailyExpandSpec); + f.setFileAsObject(fileObject); // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 f.setOptionsFallback(isUsingFallbackOptions()); return f; diff --git a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java index ac1d9c7..9c98ce9 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java @@ -112,12 +112,16 @@ public final class ActivityTools { .label("活动标题").eg("秋季亲子运动会"), ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)") .label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"), - ToolParam.required("category", "string", "活动分类,来自 activity.categoryDic 的 k 字段") + ToolParam.required("category", "string", "活动分类:**分类名称**(模块页存的是名称,不是 id)") .label("活动分类") - .optionsFrom("activity.categoryDic").optionsKeys("k", "v"), + .optionsFrom("activity.categoryDic").optionsKeys("v", "v"), ToolParam.required("deptId", "string", "主办部门 id,来自 activity.deptDic 的 k 字段") .label("主办部门") .optionsFrom("activity.deptDic").optionsKeys("k", "v"), + // 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去 + ToolParam.optional("deptName", "string", "主办部门名称(由所选部门自动带出,无需填写)") + .label("主办部门名称") + .mirrorLabelOf("deptId"), ToolParam.required("venue", "string", "活动地点") .label("活动地点").eg("学校操场"), ToolParam.required("startTime", "string", "活动开始时间 yyyy-MM-dd HH:mm:ss") diff --git a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java index 7e9c37e..24e87ce 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java @@ -63,48 +63,38 @@ public final class CampusNoticeTools { list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告", "发布一条校园公告。字段与校园公告模块的「新建公告」页**完全一致**:" - + "标题、重要程度、来源部门、接收班级、置顶、短信提醒、学员阅读回执、附件、正文、定时发送、发布方式。" + + "标题、重要程度、来源部门、接收班级、置顶、短信提醒、学员阅读回执、附件、正文、审批流程、定时发送、发布方式。" + "调用前必须确认:标题 title、正文 content、发文部门 deptId;" + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + "发文部门必须来自字典,不要编造。" + "接收班级 pushScopes 默认「全部班级」;要定向到具体班级时由表单多选。" - + "发布方式 publishType:direct 直接发布(默认)/ draft 存为草稿。" + + "发布方式 publishType:direct 直接发布(默认)/ approve 提交审批(此时须同时给出 flow 审批流程)。" + + "只存草稿请改用「存为公告草稿」工具(模块页的存草稿走的是另一个接口)。" + "timingSendTime 非空 = 定时发送。", "POST_JSON", "/api/notice/Create") - .p(ToolParam.required("title", "string", "公告标题(200 字以内)") - .label("公告标题").eg("关于国庆假期安排的通知"), - ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)") - .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"), - ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") - .label("重要程度").dft("1") - .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}), - ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段") - .label("发文部门") - .optionsFrom("notice.deptDics").optionsKeys("k", "v"), - ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级") - .label("接收班级") - .optionsFrom("notice.stuTree").optionsKeys("id", "name") - .scopes(4).allOption("ALL", "全部班级"), - ToolParam.optional("topmost", "integer", "是否置顶") - .label("置顶").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒") - .label("短信提醒").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("receiptFlag", "integer", "是否需要学员阅读回执(要回执时收件人需点确认)") - .label("学员阅读回执").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") - .label("添加附件").as("file").multiple(true).join(","), - ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)") - .label("定时发送").eg("2026-10-13 08:00:00"), - ToolParam.optional("publishType", "string", "发布方式:direct 直接发布 / draft 存为草稿") - .label("发布方式").dft("direct") - .options(new String[]{"direct", "draft"}, new String[]{"直接发布", "存为草稿"})) + .p(noticePublishParams()) .fullForm() .write("/list/new") .modId(null)); + list.add(ToolDef.of("notice.saveDraft", MODULE, "存为公告草稿", + "把一条校园公告存为草稿(不发布,可在公告模块的草稿箱继续编辑)。" + + "字段与「新建公告」页完全一致;只想先打个底稿时用本工具。", + "POST_JSON", "/api/notice/SaveDraft") + .p(noticePublishParams(false)) + .fullForm() + .write("/drafts") + .modId(null)); + + list.add(ToolDef.of("notice.teaTree", MODULE, "组织树(教师)", + "查询本校教师组织树(学部 / 年级 / 教研组 / 教师),用于配置公告审批人。" + + "仅在用户明确要「按组织范围选审批人」时使用;" + + "发布公告时**不要**先调它 —— 表单的「审批流程」选人会自动代取该树。", + "GET", "/api/base/getTeaTree") + .fix("toUser", true) + .readAt("/list/new") + .modId(null)); + list.add(ToolDef.of("notice.stuTree", MODULE, "组织树(学员)", "查询本校组织树(学部 / 年级 / 班级),用于发布公告时选择接收班级。" + "仅在用户明确要「按组织范围选人」时使用;" @@ -144,4 +134,66 @@ public final class CampusNoticeTools { return list; } + + /** + * 「新建公告」页的完整字段(发布与存草稿共用)。 + * + *

★ 与模块页 {@code NewNotice.vue#buildBody} 逐项对齐:{@code title/content/type/deptId/ + * deptName/pushScopes/topmost/receiptFlag/fileIds/smsFlag/timingSendTime/flow/publishType}。 + * + * @param withPublishType 是否带「发布方式」—— 存草稿走的是 {@code SaveDraft} 接口, + * 模块页在这一步并不传 {@code publishType} + */ + private static ToolParam[] noticePublishParams(boolean withPublishType) { + List ps = new ArrayList<>(); + ps.add(ToolParam.required("title", "string", "公告标题(200 字以内)") + .label("公告标题").eg("关于国庆假期安排的通知")); + ps.add(ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)") + .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……")); + ps.add(ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") + .label("重要程度").dft("1") + .options(new String[]{"1", "2"}, new String[]{"普通", "重要"})); + ps.add(ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段") + .label("发文部门") + .optionsFrom("notice.deptDics").optionsKeys("k", "v")); + // 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去 + ps.add(ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)") + .label("发文部门名称") + .mirrorLabelOf("deptId")); + ps.add(ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级") + .label("接收班级") + .optionsFrom("notice.stuTree").optionsKeys("id", "name") + .scopes(4).allOption("ALL", "全部班级")); + ps.add(ToolParam.optional("topmost", "integer", "是否置顶") + .label("置顶").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"})); + ps.add(ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒") + .label("短信提醒").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"})); + ps.add(ToolParam.optional("receiptFlag", "integer", "是否需要学员阅读回执(要回执时收件人需点确认)") + .label("学员阅读回执").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"})); + ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") + .label("添加附件").as("file").multiple(true).join(",")); + ps.add(ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)") + .label("定时发送").eg("2026-10-13 08:00:00")); + // 审批流:模块页的 ApprovalFlowEditor(可多步,每步或签/会签,审批人来自教师树) + ps.add(ToolParam.optional("flow", "array", + "审批流程:走审批时填写。提交时按「走审批流程 + 多步 + 每步审批人与表决方式」组装成 " + + "[{name,vote,approverTeaIds}];不填 = 不走审批(直接发布)") + .label("审批流程") + .flow() + // 教师树叶子形如 {id,name,...},其中 id 即 teaId(与模块页选人弹窗口径一致) + .optionsFrom("notice.teaTree").optionsKeys("id", "name")); + if (withPublishType) { + ps.add(ToolParam.optional("publishType", "string", "发布方式:direct 直接发布 / approve 提交审批") + .label("发布方式").dft("direct") + .options(new String[]{"direct", "approve"}, new String[]{"直接发布", "提交审批"})); + } + return ps.toArray(new ToolParam[0]); + } + + private static ToolParam[] noticePublishParams() { + return noticePublishParams(true); + } } diff --git a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java index b45ecae..9272a66 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java @@ -113,6 +113,10 @@ public final class DormNoticeTools { ToolParam.required("deptId", "string", "发文部门 id,来自 dorm.deptDic 的 k 字段") .label("发文部门") .optionsFrom("dorm.deptDic").optionsKeys("k", "v"), + // 模块页会用字典把部门 id 反查成名称一起提交,这里由前端按所选下拉项的名称镜像过去 + ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)") + .label("发文部门名称") + .mirrorLabelOf("deptId"), ToolParam.optional("level", "string", "紧急程度:normal 普通 / important 重要 / urgent 紧急") .label("紧急程度").dft("normal") .options(new String[]{"normal", "important", "urgent"}, diff --git a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java index b89b0dc..5c52ef3 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java @@ -7,20 +7,42 @@ import java.util.ArrayList; import java.util.List; /** - * 课程作业模块工具(学生端 V2)—— 后端 CourseHomework,前缀 /api/student/homeworks。 + * 课程作业模块工具(后端 CourseHomework)。 * - *

学生端接口路径带资源 id({@code /{id}/...}),执行器支持 {@code {name}} 路径占位。 + *

两侧接口并列: + *

*/ public final class HomeworkTools { public static final String MODULE = "coursehomework"; + private static final String TEACHER = "/api/teacher/homeworks"; + + /** 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同) */ + private static final String UPLOAD_PATH = "/coursehomework/api/file/Upload"; + private HomeworkTools() { } public static List defs() { List list = new ArrayList<>(); + addStudentSide(list); + addTeacherPublishSide(list); + return list; + } + + /* ==================== 学生端 ==================== */ + + private static void addStudentSide(List list) { + list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表", "查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。", "GET", "/api/student/homeworks") @@ -51,7 +73,112 @@ public final class HomeworkTools { ToolParam.optional("text", "string", "作答文本内容") .label("作答内容").as("textarea").eg("我的解题思路是……")) .write("/stu/today")); + } - return list; + /* ==================== 教师端:发布作业(模块页 页 1) ==================== */ + + private static void addTeacherPublishSide(List list) { + + list.add(ToolDef.of("homework.teachingSubjects", MODULE, "我的任教学科", + "查询当前教师的任教学科(发布作业时选「学科」用)。" + + "仅在用户明确问「我教哪些学科」时使用;" + + "发布作业时**不要**先调它 —— 表单的「学科」下拉会自动带出可选值。", + "GET", TEACHER + "/subjects/teaching") + .readAt("/new")); + + list.add(ToolDef.of("homework.classTree", MODULE, "任教班级树", + "年级 → 班级 树(叶子是班级,仅含本人任教班级;发布作业时选接收班级用)。" + + "仅在用户明确要「按年级选班」时使用;" + + "发布作业时**不要**先调它 —— 表单的「接收班级」多选会自动代取该树。", + "GET", TEACHER + "/classes") + .readAt("/new")); + + // ---------- 立即发布(Create 落草稿 → Publish) ---------- + list.add(ToolDef.of("homework.publish", MODULE, "发布作业", + "教师发布一条作业(学生端随即可见)。字段与课程作业模块的「新建作业」页**完全一致**:" + + "标题、学科、接收班级、截止时间、提交方式、逾期提交、截止提醒、附件、作业内容。" + + "调用前必须确认:标题 title、学科 subject、接收班级 classIds;" + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + + "学科与班级必须来自本人任教范围,不要编造。" + + "提交方式 submitType 可多选(拍照/上传附件/文字作答),勾「仅查阅」则学生无需提交;" + + "「仅查阅」与其余三种互斥。" + + "需要定时发布请改用「定时发布作业」。", + "POST_JSON", TEACHER) + .p(homeworkPublishParams(false)) + // ★ 模块没有一步发布的接口:先 Create 落草稿,再用返回的作业 id 发布 + .then(TEACHER + "/{data}/publish", null) + .fullForm() + .write("/new")); + + // ---------- 定时发布(Create 落草稿 → Schedule) ---------- + list.add(ToolDef.of("homework.schedule", MODULE, "定时发布作业", + "教师定时发布一条作业(到点自动发布)。字段与「新建作业」页完全一致," + + "额外需要定时发布时间 scheduledAt(必须晚于当前时间)。", + "POST_JSON", TEACHER) + .p(homeworkPublishParams(true)) + .then(TEACHER + "/{data}/schedule", "{\"scheduledAt\":\"{{scheduledAt}}\"}") + .fullForm() + .write("/new")); + + list.add(ToolDef.of("homework.published", MODULE, "我发布的作业", + "分页查询本人已发布的作业(含进度)。用户问「我发过哪些作业」「那条作业交得怎么样」时用它。", + "GET", TEACHER + "/published") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "作业标题关键词"), + ToolParam.optional("subject", "string", "按学科筛选")) + .readAt("/list")); + + list.add(ToolDef.of("homework.drafts", MODULE, "我的作业草稿", + "分页查询本人的作业草稿(尚未发布的)。", + "GET", TEACHER + "/drafts") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "作业标题关键词")) + .readAt("/drafts")); + } + + /** + * 「新建作业」页的完整字段({@code HomeworkV2Request})。 + * + * @param withSchedule 是否带「定时发布时间」—— 只有定时发布工具需要 + */ + private static ToolParam[] homeworkPublishParams(boolean withSchedule) { + List ps = new ArrayList<>(); + ps.add(ToolParam.required("title", "string", "作业标题(50 字以内)") + .label("作业标题").eg("第三章课后练习")); + ps.add(ToolParam.required("subject", "string", "学科:来自本人任教学科(subId)") + .label("学科") + .optionsFrom("homework.teachingSubjects").optionsKeys("subId", "subName")); + ps.add(ToolParam.required("classIds", "array", "接收班级:班级 id 列表(仅本人任教班级,可多选)") + .label("接收班级") + .optionsFrom("homework.classTree").optionsKeys("id", "name")); + ps.add(ToolParam.optional("description", "string", "作业内容(纯文本,对应模块页的富文本编辑器)") + .label("作业内容").as("textarea").eg("完成课本 P32 第 1~5 题,写出解题过程。")); + ps.add(ToolParam.optional("deadlineAt", "string", "截止时间 yyyy-MM-dd HH:mm:ss(留空=不设截止)") + .label("截止时间").eg("2026-10-20 22:00:00")); + ps.add(ToolParam.optional("submitType", "array", + "提交方式(可多选):image 拍照/图片 / attach 上传附件 / text 文字作答 / none 仅查阅(不必提交)。" + + "勾「none」表示学生只预览、教师无需批改,与其余三种互斥") + .label("提交方式") + .dft("image") + .options(new String[]{"image", "attach", "text", "none"}, + new String[]{"拍照/图片", "上传附件", "文字作答", "仅查阅(不必提交)"})); + ps.add(ToolParam.optional("allowLate", "integer", "是否允许逾期提交(截止后仍可补交)") + .label("逾期提交").dft("0") + .options(new String[]{"0", "1"}, new String[]{"不允许", "允许"})); + ps.add(ToolParam.optional("remind1d", "integer", "是否在截止前 1 天提醒未交学生") + .label("截止前 1 天提醒").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"})); + ps.add(ToolParam.optional("remind1h", "integer", "是否在截止前 1 小时提醒未交学生") + .label("截止前 1 小时提醒").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"})); + ps.add(ToolParam.optional("attachments", "array", "作业附件(由表单的附件上传得到)") + .label("添加附件").asAttachments().multiple(true).upload(UPLOAD_PATH)); + if (withSchedule) { + ps.add(ToolParam.required("scheduledAt", "string", "定时发布时间 yyyy-MM-dd HH:mm:ss(须晚于当前时间)") + .label("定时发布时间").eg("2026-10-20 08:00:00")); + } + return ps.toArray(new ToolParam[0]); } } diff --git a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java index ca793b7..22e1638 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java @@ -130,11 +130,15 @@ public final class LeaveTools { .optionsFrom("leave.myClasses").optionsKeys("classId", "className"), ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") .label("连续请假").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + .options(new String[]{"0", "1"}, new String[]{"否", "是"}) + // 选「是」时按下面的连续天数把起止时间复制成逐天时间段, + // 生成模块要的 continuousLeaveTime(与模块页 LeaveTimeSection 一致) + .expandDaily("continuousLeaveNum", "startDate", "endDate"), ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") .label("连续天数").dft("1"), - ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)") - .label("佐证材料").as("file").multiple(false).join(",")) + // ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join + ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)") + .label("佐证材料").as("file").multiple(true)) .fix("type", 2) .fullForm() .write("/my") @@ -166,11 +170,13 @@ public final class LeaveTools { .label("请假事由").as("textarea").eg("家中有事,需请假一天"), ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") .label("连续请假").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + .options(new String[]{"0", "1"}, new String[]{"否", "是"}) + .expandDaily("continuousLeaveNum", "startDate", "endDate"), ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") .label("连续天数").dft("1"), - ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)") - .label("佐证材料").as("file").multiple(false).join(",")) + // ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join + ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)") + .label("佐证材料").as("file").multiple(true)) .fix("type", 1) .fullForm() .write("/register") diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java new file mode 100644 index 0000000..f335aa1 --- /dev/null +++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java @@ -0,0 +1,273 @@ +package com.yunhai.agent; + +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.model.BrowserToolRequest; +import com.yunhai.agent.tool.FormField; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolExecutor; +import com.yunhai.agent.tool.ToolParam; +import com.yunhai.agent.tool.ToolRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 「五个模块的发布页字段与助手表单完全对齐」的契约断言。 + * + *

逐条钉住本轮从模块源码里抠出来的差异 —— 这些都是**会真的提交出问题**的地方, + * 不是风格问题: + *

    + *
  1. 公告 {@code publishType} 合法值是 {@code direct / approve}(存草稿走 {@code SaveDraft} 接口);
  2. + *
  3. 活动 {@code category} 存的是分类**名称**(模块页写的是 {@code :value="String(c.v)"});
  4. + *
  5. 请假 {@code fileIds} 是 {@code number[]},**不能**拼成逗号串;
  6. + *
  7. 公告/宿舍/活动三处的 {@code deptName} 要按所选部门镜像过去;
  8. + *
  9. 作业「发布」是两步(先落草稿 → 再发布/定时),附件是对象数组;
  10. + *
  11. 公告审批流 {@code flow} 与教师树 {@code notice.teaTree}。
  12. + *
+ */ +class ModuleFormParityTest { + + private ToolRegistry registry; + private AgentSettings settings; + private ToolExecutor executor; + + @BeforeEach + void setUp() { + settings = new AgentSettings(); + 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"); + settings.getModules().put("homeleave", "http://base.yunhaixingtong.com/homeleave"); + settings.getModules().put("coursehomework", "http://base.yunhaixingtong.com/coursehomework"); + registry = new ToolRegistry(settings); + registry.init(); + executor = new ToolExecutor(settings, new RestTemplate()); + } + + /* ==================== ① 公告:发布方式值域 / 存草稿独立接口 ==================== */ + + @Test + void noticePublishTypeOnlyAllowsDirectOrApprove() { + ToolParam publishType = param(registry.get("notice.publish"), "publishType"); + assertNotNull(publishType, "公告发布要有「发布方式」"); + assertEquals(Arrays.asList("direct", "approve"), publishType.getEnumValues(), + "模块页 publishType 只有 direct / approve —— 草稿走的是 SaveDraft 接口,不是 Create 的一个取值"); + } + + @Test + void noticeSaveDraftIsItsOwnEndpointWithoutPublishType() { + ToolDef draft = registry.get("notice.saveDraft"); + assertNotNull(draft, "公告要有独立的「存为草稿」工具"); + assertEquals("/api/notice/SaveDraft", draft.getPath(), "存草稿走模块自己的 SaveDraft 接口"); + assertFalse(draft.isReadOnly(), "存草稿是写操作"); + assertTrue(draft.isFormAll(), "存草稿表单同样渲染全部字段"); + assertNull(param(draft, "publishType"), "SaveDraft 不传 publishType(模块页这一步也不传)"); + // 其余字段与发布工具保持一致 + for (String name : Arrays.asList("title", "content", "deptId", "deptName", "pushScopes", + "topmost", "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "flow")) { + assertNotNull(param(draft, name), "存草稿表单缺字段:" + name); + } + } + + @Test + void noticePublishCarriesApprovalFlowAndTeacherTree() { + ToolDef def = registry.get("notice.publish"); + ToolParam flow = param(def, "flow"); + assertNotNull(flow, "公告要有审批流字段(模块页的 ApprovalFlowEditor)"); + assertEquals("flow", flow.resolveWidget(), "审批流要用专用控件"); + assertEquals("notice.teaTree", flow.getOptionsFrom(), "审批人候选来自教师树"); + assertEquals("id", flow.getOptionValueKey()); + assertEquals("name", flow.getOptionLabelKey()); + + ToolDef teaTree = registry.get("notice.teaTree"); + assertNotNull(teaTree, "要有教师树字典工具"); + assertEquals("/api/base/getTeaTree", teaTree.getPath()); + assertEquals(Boolean.TRUE, teaTree.getFixed().get("toUser"), "审批人取的是「用户态」教师叶子"); + } + + /* ==================== ② 三个模块:部门名称镜像 ==================== */ + + @Test + void deptNameIsMirroredFromDeptIdEverywhere() { + for (String tool : Arrays.asList("notice.publish", "dorm.publish", "activity.publish")) { + ToolParam deptName = param(registry.get(tool), "deptName"); + assertNotNull(deptName, tool + " 要带上部门名称字段"); + assertEquals("deptId", deptName.getMirrorLabelOf(), + tool + " 的 deptName 应由所选部门镜像得到(模块页就是这么做的)"); + // 镜像字段不能让模型去编 —— 它必须从模型的工具 schema 里排除 + String schema = registry.get(tool).toOpenAiTool().toString(); + assertFalse(schema.contains("\"deptName\""), tool + " 的 deptName 不该出现在给模型的 schema 里"); + } + } + + /* ==================== ③ 活动:分类存名称 ==================== */ + + @Test + void activityCategoryStoresNameNotId() { + ToolParam category = param(registry.get("activity.publish"), "category"); + assertNotNull(category); + assertEquals("v", category.getOptionValueKey(), + "模块页 NewActivity.vue 写的是 :value=\"String(c.v)\" —— 存的是分类名称,不是字典 k"); + } + + /* ==================== ④ 请假:fileIds 是数组 + 连续请假展开 ==================== */ + + @Test + void leaveFileIdsStaysArrayAndSupportsMultiple() { + for (String tool : Arrays.asList("leave.submit", "leave.register")) { + ToolParam fileIds = param(registry.get(tool), "fileIds"); + assertNotNull(fileIds, tool + " 要有佐证材料"); + assertEquals("file", fileIds.resolveWidget(), tool + " 的佐证材料应是上传控件"); + assertNull(fileIds.getJoinSeparator(), + tool + " 的 fileIds 在模块里是 number[](JSON 数组),不能拼成逗号串"); + assertTrue(fileIds.getMultiple(), tool + " 的佐证材料可多份"); + } + } + + @Test + void leaveContinuousLeaveDeclaresDailyExpand() { + for (String tool : Arrays.asList("leave.submit", "leave.register")) { + ToolParam flag = param(registry.get(tool), "continuousLeave"); + assertNotNull(flag, tool + " 要有连续请假开关"); + Map spec = flag.getDailyExpandSpec(); + assertNotNull(spec, tool + " 的连续请假要声明逐天展开,否则生成不出 continuousLeaveTime"); + assertEquals("continuousLeaveNum", spec.get("count")); + assertEquals("startDate", spec.get("start")); + assertEquals("endDate", spec.get("end")); + } + } + + @Test + void continuousLeaveExpandsIntoPerDayRanges() { + ToolDef def = registry.get("leave.submit"); + Map req = new LinkedHashMap<>(); + req.put("leaveType", 1); + req.put("remarks", "发烧就医"); + req.put("startDate", "2026-10-13 08:00:00"); + req.put("endDate", "2026-10-13 12:00:00"); + req.put("continuousLeave", 1); + req.put("continuousLeaveNum", 3); + + BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req); + String body = call.getBody(); + assertTrue(body.contains("continuousLeaveTime"), "要派生出 continuousLeaveTime:" + body); + assertTrue(body.contains("2026-10-13 08:00:00"), body); + assertTrue(body.contains("2026-10-14 08:00:00"), "第 2 天要顺延一天:" + body); + assertTrue(body.contains("2026-10-15 12:00:00"), "第 3 天要顺延两天:" + body); + // 非连续请假时不产生该字段 + req.put("continuousLeave", 0); + assertFalse(executor.prepareBrowserCall(def, req).getBody().contains("continuousLeaveTime"), + "没勾连续请假就不该派生出连续时间段"); + } + + /* ==================== ⑤ 作业:教师端发布(两步 + 对象数组附件) ==================== */ + + @Test + void homeworkPublishToolContract() { + ToolDef def = registry.get("homework.publish"); + assertNotNull(def, "作业模块要有教师端「发布作业」工具"); + assertFalse(def.isReadOnly(), "发布作业是写操作"); + assertTrue(def.isFormAll(), "发布表单渲染全部字段,与「新建作业」页一致"); + assertEquals("/api/teacher/homeworks", def.getPath()); + assertEquals("/api/teacher/homeworks/{data}/publish", def.getFollowUpPath(), + "模块没有一步发布的接口:要先 Create 落草稿,再用返回的 id 发布"); + + for (String name : Arrays.asList("title", "subject", "classIds", "deadlineAt", + "submitType", "allowLate", "remind1d", "remind1h", "attachments", "description")) { + assertNotNull(param(def, name), "发布作业缺字段:" + name); + } + assertTrue(param(def, "title").isRequired()); + assertTrue(param(def, "classIds").isRequired()); + assertEquals("array", param(def, "classIds").getType(), "接收班级是数组(模块是 classIds: string[])"); + assertEquals("homework.classTree", param(def, "classIds").getOptionsFrom()); + } + + @Test + void homeworkScheduleToolDeclaresSecondStep() { + ToolDef def = registry.get("homework.schedule"); + assertNotNull(def, "要有定时发布作业"); + assertEquals("/api/teacher/homeworks", def.getPath()); + assertEquals("/api/teacher/homeworks/{data}/schedule", def.getFollowUpPath()); + assertTrue(def.getFollowUpBody().contains("scheduledAt"), "第二步要把定时时间带过去"); + assertTrue(param(def, "scheduledAt").isRequired(), "定时发布必须给出定时时间"); + } + + @Test + void homeworkAttachmentsAreObjectsUploadedToFileContext() { + ToolParam att = param(registry.get("homework.publish"), "attachments"); + assertNotNull(att); + assertEquals("file", att.resolveWidget()); + assertTrue(att.isFileObject(), "作业的 attachments 是 [{fileId,fileUrl,fileType,fileName,sort}] 对象数组"); + assertEquals("/coursehomework/api/file/Upload", att.getUploadPathOverride(), + "作业模块的附件上传走 file 域,与其余模块的 base/FileUpload 不同"); + } + + @Test + void homeworkPublishFollowUpKeepsTheDraftId() { + ToolDef def = registry.get("homework.publish"); + Map req = new LinkedHashMap<>(); + req.put("title", "第三章课后练习"); + req.put("subject", "数学"); + req.put("classIds", Arrays.asList("3", "4")); + BrowserToolRequest.BrowserCall call = executor.prepareBrowserCall(def, req); + assertNotNull(call.getFollowUp(), "发布要挂上第二步"); + assertEquals("/coursehomework/api/teacher/homeworks/{data}/publish", + call.getFollowUp().getPathAndQuery(), "第二步地址要带模块前缀,并留 {data} 给首步的 id"); + } + + /* ==================== ⑥ 附件控件渲染 ==================== */ + + @Test + void scalarValueIsWrappedForArrayParams() { + ToolDef def = registry.get("homework.publish"); + Map req = new LinkedHashMap<>(); + req.put("title", "标题"); + req.put("subject", "语文"); + req.put("classIds", "3"); + req.put("submitType", "image"); + String body = executor.prepareBrowserCall(def, req).getBody(); + assertTrue(body.contains("\"classIds\":[\"3\"]"), "标量要包成数组:" + body); + assertTrue(body.contains("\"submitType\":[\"image\"]"), "标量要包成数组:" + body); + } + + /* ==================== 辅助 ==================== */ + + private ToolParam param(ToolDef def, String name) { + if (def == null || def.getParams() == null) { + return null; + } + for (ToolParam p : def.getParams()) { + if (name.equals(p.getName())) { + return p; + } + } + return null; + } + + /** 供将来表单字段断言使用(保持与 FormField 的耦合可见) */ + @SuppressWarnings("unused") + private FormField field(ToolDef def, String name) { + ToolParam p = param(def, name); + return p == null ? null : p.toFormField(); + } + + @SuppressWarnings("unused") + private List paramNames(ToolDef def) { + List names = new java.util.ArrayList<>(); + for (ToolParam p : def.getParams()) { + names.add(p.getName()); + } + return names; + } +}