Files
lijing b6a8a63b0e
build / build-push (push) Successful in 51s
feat(dorm,activity): 补齐宿舍通知与家校活动的发布/回执/台账能力 + 两个裸意图直达表单
两个模块与「校园公告」同构(发布 → 阅读情况 → 回执闭环),原先只接了「读列表 + 我提交」
一层,价值最高的 发布/盯回执/催办/统计 全空。本轮补齐。

宿舍通知(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 意图对照表补三行。
2026-10-12 19:49:52 +08:00

622 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AgentService · 校园智能助手
> 把「用户的一句话」变成「系统里一次真实的业务操作」的服务端大脑。
> 用户说「我要请假」,它会调用请假模块的接口真正提交申请;说「看看我有哪些作业没做」,它会去查作业接口。
>
> **2026-10 重构**:框架对齐 **NoticeService(通知公告)** —— Spring Boot 2.7.18 + `com.yunhai.platform` 基础平台
> starter(CAS 单点登录 + 统一响应 + 用户中心门面)+ JPA 落库 + Nacos 配置中心,
> 并新增 **学生 / 教师角色权限** 与 **会话、工具调用审计落库**。
## 1. 它是什么 / 不是什么
**是**:一个独立的 Spring Boot 微服务,负责
1. 对接大模型(DeepSeek,OpenAI 兼容协议),把自然语言解析成「调用哪个业务接口、带什么参数」;
2. 持有一张**工具注册表**(每个业务模块的 REST 接口 = 一个工具),按模型指令逐个执行;
3. **按「学生 / 教师 / 管理员」角色收敛能力**:模型侧只暴露该角色可用的工具,服务端执行前再强校验一次(越权直接拒绝);
4. 把执行结果回灌给模型,生成给用户看的自然语言回复;
5. ★ **信息不全时自动生成补全表单**:不靠模型口头追问(格式、必填项都不稳定),
而是从工具定义**确定性地**推导出还缺哪些字段,连同中文标签、格式示例、下拉选项
(可现取业务字典接口)一起下发给前端渲染成表单;用户填完直接提交执行 —— 填什么就提交什么。
**不是**:不承载任何业务数据、不复制业务逻辑、不做业务权限的最终裁决。
所有业务读写都转发到**原有各模块服务**,并**原样透传当前用户身份**(SSO 会话 Cookie + `currentRole` 等请求头),
因此各模块原有的数据范围收敛(学生只看本人、教师看任教班级…)继续生效 —— 业务权限权威永远在被调用的模块。
## 2. 技术栈(与通知公告项目一致)
| 维度 | 选型 |
|---|---|
| 框架 | Spring Boot **2.7.18**(parent),Java 8 |
| 平台能力 | `com.yunhai.platform:yh-spring-boot-starter`(CAS SSO + 统一响应 + 用户中心门面 + 应用间签名鉴权 + actuator) |
| 包名 | `com.yunhai.agent` |
| 持久化 | Spring Data **JPA** + MySQL 8(`bsch_agent` 库) |
| 配置中心 | Nacos(`bootstrap.yml`,不可达时回落本地 `application.yml`) |
| JSON | fastjson 1.2.80(主类开启安全模式) |
| 文档 | springdoc-openapi 1.7.0 |
| 部署 | Gitea CI(`.gitea/workflows/build.yml`)+ Docker(`docker/`),镜像 `agent-service` |
**单点登录**:CAS。`yh.sso.*` 配置接入平台 SSO,`/api/*` 走登录校验;
当前登录用户由平台参数解析器注入 `BoaUser`(姓名 / 账号 / 角色 / 学校),与 NoticeService 完全同款写法。
## 3. 目录结构
```
AgentService/
├── pom.xml Spring Boot 2.7.18 + yh-platform
├── docker/ 运行镜像 / 生产兜底配置 / 启动脚本
├── .gitea/workflows/build.yml CI:maven 构建 → 镜像 → projman 建档
└── src/main/
├── java/com/yunhai/agent/
│ ├── AgentApplication.java 启动类(fastjson 安全模式 + scanBasePackages)
│ ├── auth/ ★ 角色权限
│ │ ├── AgentRole.java 角色枚举(学生 / 教师 / 管理员 / 访客)
│ │ ├── RoleResolver.java BoaUser → 角色(★ 只认 currentRole 当前身份)
│ │ └── RolePolicy.java ★ 工具 → 角色 的策略表(集中可读、按校可调)
│ ├── config/
│ │ ├── Config.java 路径常量(/{platform}/agent)
│ │ ├── AgentSettings.java ★ 全部可配项(大模型 + 模块地址 + 角色映射)
│ │ └── RestTemplateConfig.java HTTP 客户端(大模型 / 业务模块)
│ ├── common/listener/ JPA 审计监听(自动填创建人/时间)
│ ├── entity/ ★ 落库实体
│ │ ├── BaseEntity.java 自增主键 + 创建/更新审计字段
│ │ ├── AgentSession.java 会话(agent_session)
│ │ ├── AgentMessage.java 会话消息(agent_message)
│ │ └── AgentToolCallLog.java 工具调用审计(agent_tool_call_log)
│ ├── repository/ 三个实体的 JPA 仓储
│ ├── llm/
│ │ ├── LlmClient.java 模型客户端抽象(可换供应商)
│ │ ├── DeepSeekClient.java DeepSeek / OpenAI 兼容实现
│ │ └── dto/ ChatMessage / ToolCall / ChatResult
│ ├── tool/
│ │ ├── ToolDef.java 工具定义(模块 + 路径 + 参数 + ★ 角色)
│ │ ├── ToolParam.java 参数定义 → 自动生成 JSON Schema 与表单字段
│ │ ├── ToolRegistry.java 工具注册表(★ 按角色 + 授权模块过滤)
│ │ ├── ToolExecutor.java 统一执行器(拼 URL、透传身份、★ 角色强校验、解包 {code,msg,data})
│ │ ├── CallerContext.java 当前调用者身份(透传用)
│ │ ├── ToolResult.java 执行结果
│ │ └── defs/ ★ 各模块工具定义(新增模块只改这里)
│ ├── service/
│ │ ├── AgentOrchestrator.java 编排核心(思考 → 调工具 → 再思考)
│ │ ├── SessionStore.java 会话上下文(JPA 落库 + 空闲过期)
│ │ ├── AgentAuditService.java 工具调用审计落库
│ │ └── AgentEvent.java / EventSink.java SSE 阶段事件
│ ├── model/ ChatRequest / ChatResponse / ToolCallTrace
│ └── controller/
│ ├── AgentController.java /api/agent/* 对外接口
│ └── BaseApiController.java /api/base/GetUserInfo|GetUserMod(平台壳层)
└── resources/
├── application.yml ★ 上线只改这个
└── bootstrap.yml Nacos 配置中心
```
## 4. 角色权限(学生 / 教师)
### 4.1 角色从哪来(★ 以平台「当前身份」为准)
平台里**一个账号可以同时挂多个身份**:
| 字段 | 含义 |
|---|---|
| `BoaUser.roleIds` | 该账号拥有的**全部**角色号(可能同时是学生 + 教师 + 管理员) |
| `BoaUser.currentRole` | 用户在门户里**当前激活**的那一个身份(`roleId` / `roleName` / `schId`),等价于 `YschBaseOpenApi.getCurrentIdentity(userId)` |
`RoleResolver` 的判定顺序:
1. **只认当前身份**:取 `currentRole.roleId`,命中 `agent.roles.student/teacher/admin` 即获得该角色,
**且只获得这一个身份** —— 与学校门户、其它业务模块口径一致;
2. 当前身份是管理员时,按 `agent.roles.admin-also-teacher` 追加教师能力;
3. 当前身份号**未在配置里声明**(或平台没下发 `currentRole`)→ 回退按 `roleIds` / `type` 的旧口径判定,
并打一条 WARN 提示补配置(避免误判成访客而"什么都做不了");
4. 什么都识别不出 → 访客(仅公共只读能力)。
> **为什么不做并集**:实测账号 `10008` 同时是「学生 + 管理员 + 教师」,若把全部身份并集,
> 权限只增不减、能力边界完全模糊(人人都是全量 54 项)。改成当前身份后,
> 同一账号在「学生」身份下只看到学生能力,在「教师」身份下只看到教师能力。
> 身份切换在**学校门户**完成(平台提供 `POST /oapi/identity/switch`),智能助手自动跟随,无需在应用内再做一个切换器。
角色号在 `application.yml` 的 `agent.roles.*` 配置,**上线前须按本校 UserBase 角色体系校准**(默认 学生=1、教师=2/3、管理员=4/5/6/7)。
解析结果只在**身份变化时**记一次 INFO 日志(`[身份] userId=… 当前身份 → 教师(roleId=2, schId=3)`),方便现场核对。
`GET /api/agent/tools` 也额外返回 `identity`(`userId` / `userName` / `schoolId` / `roleId` / `roleName` / `roles`)便于前端展示与排查。
### 4.2 能力划分(`auth/RolePolicy`)
| 名单 | 工具 | 说明 |
|---|---|---|
| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.*`(发布/编辑/我发布的/阅读情况/回执名单与处理/催办/催回执/代标记/撤回/置顶/统计/字典模板 **+ 楼栋房间床位台账**;家长侧 5 个除外)、`activity.*`(发布/编辑/我发布的/阅读情况/报名名单与处理/催办/上报/撤回/置顶/统计/字典 + 选班级;家长侧除外)、`profession.*`(全部) | 请假登记/审批、发布公告、**宿舍通知发布与回执管理 + 床位台账**、**活动发布与报名处理**、专业发展 |
| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 |
| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) |
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
> ★ **请假为何分两个工具**:模块只有一个写接口 `POST /api/leave/Save`,靠 `type` 区分业务——
> `type=2` 学生**申请**(待班主任审核)、`type=1` 教师**登记**(免审直接生效,且必须指明班级+学生)。
> 合成一个工具时写死了 `type=2`,教师「帮学生请假」会被提交成待审核的学生申请;
> 而且「班级/学生」是可选参数、不满足"缺参才出表单"的条件,表单里根本没有这两项。
> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外,
> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
> ★ **宿舍通知 / 家校活动补全了哪些能力**:这两个模块与「校园公告」是**同一套骨架**
> —— *发布 → 阅读情况 → 回执闭环*,原先只接了「读列表 + 家长提交」这一层,
> 价值最高的 *发布 / 盯回执 / 催办 / 统计* 全空。本轮补齐:
> - **发布闭环**:`dorm.publish` / `activity.publish`(+ `edit` / 我发布的 / 阅读情况 / 统计)、
> 发布表单下拉所需的字典工具(`dorm.noticeTypeDic` `dorm.deptDic` `activity.deptDic` `activity.categoryDic`);
> - **回执闭环**:`dorm.receipts` / `dorm.reviewReceipt` / `dorm.remind` / `activity.signups` / `activity.review` / `activity.remind`
> —— 回答「还有谁没交 / 没回执」,并支持确认、取消、催办;
> - **宿舍特有(模块自带但一直没接)的床位台账**:`dorm.pendingStudents` → `dorm.assign`(安排入住)
> → `dorm.removeStudent`(退宿)/ `dorm.transfer`(调宿)/ `dorm.setMonitor`(设宿舍长),
> 以及楼栋房间的增改(`dorm.addBuilding` `dorm.editBuilding` `dorm.addRoom` `dorm.editRoom`)。
>
> 写操作**不需要新传输层**:统一走已跑通的浏览器代执行(前端带用户登录态调 `/api/*`),
> 表单下拉复用请假的 `optionCall + valueKeys + dependsOn` 通道。
> ⚠️ **删除类(`/api/notice/Delete`、`/api/activity/Delete`)暂未接入**:它们的请求体是
> **JSON 数组**(`@RequestBody List<Integer>`),而工具执行器只构造对象体;且删除破坏性强,留待需要时单列。
### 4.3 两道闸门
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
模型不会「看见」越权能力;
2. **服务端强校验**(硬):`ToolExecutor.execute` 在执行前再校验一次 `ToolDef.allowsAny(roles)`,
越权则**拒绝执行、不发真实请求**、写一条 FORBIDDEN 审计日志。
—— 由 `agent.enforce-role-permission: true` 控制(默认开)。
## 5. 对外接口
> ⚠️ 返回体由平台 `ApiResponseAdvice` **统一包装**为 `{code, msg, data}`
> (与 NoticeService / 各业务模块一致),真实数据在 `data` 里。SSE 接口除外。
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/agent/chat` | 一问一答,`data` 为 `{sessionId, reply, toolCalls[], wroteSomething, landingPath, form?}` |
| POST | `/api/agent/chat/stream` | SSE,实时推送 `thinking / tool_call / tool_result / form / message / done / error`(不包装) |
| GET | `/api/agent/tools` | 能力目录(按当前角色过滤后按模块分组),前端首屏用 |
| POST | `/api/agent/session/clear` | 清空会话上下文 |
| GET | `/api/base/GetUserInfo` | 当前登录用户(与其它模块同形,前端外壳复用) |
| GET | `/api/base/GetUserMod` | 当前用户已授权模块 |
### 5.1 表单化补参(两个请求形态)
#### 5.1.0 裸意图直达(`service/IntentShortcut`)
「只有意图、没有细节」的一句话(典型:**「我要请假」**、**「我要发布校园公告」**)由后端**直接**按身份弹表单,
不经过大模型。原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串
类型列表、没有表单);② 调了写工具却自行**编造**参数直接提交;③ 用文字反问。用户的预期是立刻看到表单。
命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀
与「吧/了/一下」后缀后,**正好等于**标准说法、且原句 ≤12 字。因此带信息的句子
(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`、`发一条关于社团招新的通知`)
**不会**被劫持,照常交给模型。
**加新意图只需三步**(不用动 `AgentOrchestrator`):① 一条 `Intent`(工具 + 引导语);
② 一个「标准说法」短语集;③ 往 `RULES` 里追加一条 `Rule`。
同一意图按身份落到不同工具:
| 意图 | 身份 | 工具 | 表单字段 | 提交后 |
|---|---|---|---|---|
| 请假 | 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 |
| 请假 | 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 |
| 发布校园公告 | 教职工 | `notice.publish` | 公告标题 / 公告正文 / **发文部门**(下拉) | **立即发布、全站可见** |
| 发布校园公告 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
| 发布宿舍通知 | 教职工 | `dorm.publish` | 通知标题 / 通知正文 / **通知类型 + 发文部门**(下拉)/ 回执方式 | 发给**全部宿舍**的家长(房间级定向到模块页面勾选) |
| 发布宿舍通知 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员(宿管)身份 |
| 发布家校活动 | 教职工 | `activity.publish` | 活动标题 / 说明 / **活动分类 + 主办部门**(下拉)/ 地点 / 起止时间 / 报名方式 | 发给**全部班级**的家长 |
| 发布家校活动 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
⚠️ **发布类意图必须带模块限定**。2026-10 起宿舍通知也接上了发布工具,因此
`发通知`/`发布通知` 成了**跨模块歧义**(校园公告 / 宿舍通知都可能),已**移出**公告短语集;
现在只认 `发布公告`/`发个公告`/`我要发布校园公告`/`发布校园通知`(带「公告」或「校园」限定)
与 `发布宿舍通知`/`发宿舍通知`(带「宿舍」限定)。光说「公告」「通知」更像**查询**,本就不进短语表。
⚠️ **不要再给 `notice.publish` 加「发布方式」参数**:后端 `/api/notice/Create` 就是**直接发布**
(是否转审批由学校「应用设置」里的流程绑定决定,请求侧绕不过),并不存在 `publishType` 字段 ——
声明它会让用户以为选了「存草稿」却仍然直接发出去。需要草稿/送审请走公告模块页面
(`/api/notice/SaveDraft` 与 `/api/notice/Submit`,后者还需要先有草稿 id,属另一条链路)。
#### 5.1.1 缺参 → 下发补全表单
`POST /api/agent/chat {"message":"我要请假"}`(学生身份)
```jsonc
{
"code": 0, "msg": "",
"data": {
"sessionId": "...",
"reply": "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,提交后由班主任审核。",
"form": {
"tool": "leave.submit",
"title": "提交请假申请",
"write": true,
"landingPath": "/my",
"missing": ["请假类型", "开始时间", "结束时间", "请假事由"],
"fields": [
{ "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true,
"optionsFrom": "leave.types", "valueKey": "k", "labelKey": "v",
"optionsFallback": true,
"options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假及其他" }],
"optionCall": { "name": "leave.types", "method": "GET",
"pathAndQuery": "/homeleave/api/dict/LeaveTypeOptions" } },
{ "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true,
"description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" }
]
}
}
}
```
**教师身份**下同一句「我要请假」得到的是登记表单,`classId` / `stuId` 用占位符地址做联动下拉:
```jsonc
{ "name": "classId", "label": "班级", "widget": "select", "type": "integer", "required": true,
"optionsFrom": "leave.myClasses", "valueKey": "classId", "labelKey": "className",
"optionCall": { "name": "leave.myClasses", "method": "GET",
"pathAndQuery": "/homeleave/api/leave/MyClasses" } },
{ "name": "stuId", "label": "学生", "widget": "select", "type": "integer", "required": true,
"dependsOn": ["classId"], "optionsFrom": "leave.classStudents", "valueKey": "stuId", "labelKey": "stuName",
"optionCall": { "name": "leave.classStudents", "method": "GET",
"pathAndQuery": "/homeleave/api/leave/ClassStudents?classId={{classId}}" } }
```
> **★ 为什么下拉选项要由前端代取**:模块的字典接口(`MyClasses` / `ClassStudents` / `LeaveTypeOptions`)
> 只认浏览器 CAS 会话,服务端直调必然 `code=302`。以前"服务端取一次、失败就退化成数字输入框",
> 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取**
> (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典,
> 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。
>
> **字典工具的 `fixed` 参数会被一并拼上** —— 多个模块的字典接口靠它选字典种类,
> 例如公告的发文部门取自 `/api/dict/List?type=1`(`{k,v}` 形状,故 `deptId` 声明
> `.optionsKeys("k","v")`)。早先漏拼固定参数,前端取回的是「没指定类型」的结果,下拉是空的。
**② 填完提交 → 直接执行**:`POST /api/agent/chat`
```jsonc
{ "sessionId": "...",
"formSubmit": { "tool": "leave.submit",
"args": { "leaveType": 1, "startDate": "2026-10-12 08:00:00",
"endDate": "2026-10-12 17:00:00", "remarks": "发烧就医" } } }
```
服务端**不再让模型二次决策**,直接执行该工具(并再校验一次角色),再用真实结果生成自然语言回复。
**字段从哪来**:全部由 `ToolParam` 的表单元数据推导 ——
`label(...)` 中文标签、`eg(...)` 格式示例、`as("textarea"|"datetime"|...)` 指定控件、
`options(values, labels)` 静态枚举、`optionsFrom("leave.types")` 动态字典。
- 控件自动推断:有枚举 → 下拉;参数名含 `date/time` → 日期时间选择器;含 `remark/reason/content` → 多行文本。
- `optionsFrom` 会**先执行该只读字典工具**,把返回项填成下拉选项(解析失败自动退化为普通输入框)。
- **只在「写操作」缺必填参数时拦截**(查询类缺参不打扰用户),且拦截发生在执行任何工具之前。
## 6. 数据落库
| 表 | 用途 |
|---|---|
| `agent_session` | 会话:sessionId / userId / schId / 标题 / 角色快照 / 最后访问 |
| `agent_message` | 会话消息:按 seq_no 保序的 role/content/tool_calls/tool_call_id |
| `agent_tool_call_log` | **工具调用审计**:谁 / 什么角色 / 哪个会话 / 哪个模块的什么工具 / 参数 / 成功与否 / 耗时 / 越权被拒 |
- 会话与 `userId` 绑定,跨用户复用同一 `sessionId` 会被拒绝并重建;
- 消息空闲超时(默认 120 分钟)由定时任务清理;
- 审计写入用独立事务(失败不影响主对话链路)。
`spring.jpa.hibernate.ddl-auto=update` 会自动建表。
## 7. 部署
### 7.1 前置
```sql
CREATE DATABASE bsch_agent DEFAULT CHARSET utf8mb4;
```
依赖:MySQL 8、Nacos(可选,不可达时回落本地配置)、Redis(应用间验签用;健康检查已关闭)、内网 Maven 仓库(nexus.yunhaixingtong.com)。
### 7.2 配置(`application.yml`)
```yaml
agent:
llm:
host: https://api.deepseek.com/chat/completions
api-key: sk-你的DeepSeek密钥 # 或用环境变量 DEEPSEEK_API_KEY 注入;
# 推荐放 src/main/resources/application-local.yml(已被 .gitignore 忽略)
model: deepseek-flash # ★ 以官方实时清单为准:GET https://api.deepseek.com/models
# 2026-10-10 实测仅 deepseek-flash / deepseek-v4-pro
roles: # ★ 按本校 UserBase 角色体系校准
student: [1]
teacher: [2, 3]
admin: [4, 5, 6, 7]
enforce-role-permission: true # 角色强校验
modules: # ★ 每个模块的服务根地址(不含 /api)
homeleave: http://base.yunhaixingtong.com/home-leave
campusnotice: http://base.yunhaixingtong.com/campusnotice
dormnotice: http://base.yunhaixingtong.com/dormnotice
familyactivity: http://base.yunhaixingtong.com/familyactivity
coursehomework: http://base.yunhaixingtong.com/coursehomework
profession: http://base.yunhaixingtong.com/profession
```
### 7.3 打包运行
```bash
mvn -DskipTests clean package
java -jar target/agent-0.0.1.jar
# 默认端口 8093(本地);生产 context-path=/agent,端口以 Nacos / projman 注入为准
```
### 7.4 上线(Gitea CI + projman)
推送 `main` → CI 构建镜像 `agent-service` 推 Nexus → projman 建档 → 运维在「后端发布」手动部署。
## 8. 怎么新增一个模块的接入(本设计的重点)
「全量接入」不应该退化成复制粘贴。新增模块**只要写一个类**:
```java
// tool/defs/XxxTools.java
public final class XxxTools {
public static final String MODULE = "xxx"; // 与 application.yml 的 modules key 一致
public static List<ToolDef> defs() {
List<ToolDef> list = new ArrayList<>();
list.add(ToolDef.of("xxx.queryList", MODULE, "查询XX列表", "……", "GET", "/api/xxx/List")
.p(ToolParam.optional("keyword", "string", "关键词")));
list.add(ToolDef.of("xxx.submit", MODULE, "提交XX", "……", "POST_JSON", "/api/xxx/Save")
.p(ToolParam.required("name", "string", "名称").label("名称").eg("示例值"),
ToolParam.required("type", "integer", "类型")
.label("类型").options(new String[]{"1", "2"}, new String[]{"普通", "重要"}),
ToolParam.required("reason", "string", "事由").label("事由").as("textarea"))
.fix("type", "2") // 接口要求但对话无需用户提供的固定参数
.allow(AgentRole.TEACHER, AgentRole.ADMIN) // ★ 可选:声明限定角色(不写=所有角色)
.write("/my")); // 声明为写操作 + 落地路由
return list;
}
}
```
然后在 `ToolRegistry#build()` 加一行 `register(XxxTools.defs());`;
若不给工具声明角色,则在 `auth/RolePolicy` 的名单里登记(或保持「所有角色可用」)。
**执行、鉴权透传、角色校验、schema 生成、错误处理、参数校验、表单生成、审计落库全部自动复用**,无需再写代码。
路径支持占位符:`"/api/student/homeworks/{id}/permissions"` 会自动用参数 `id` 填充。
## 9. 安全与权限
- **单点登录**:CAS(`yh.sso`),`/api/*` 登录校验;当前用户由平台注入 `BoaUser`。
- **角色收敛**:模型侧过滤 + 服务端强校验(见 §4.3)。
- **身份透传**:`CallerContext` 把 SSO Cookie 与 `currentRole / userId / roleId / schoolId / yschreferer`
原样转发给下游模块。下游按原有逻辑判定身份与数据范围 —— 智能体无法越权。
- **会话隔离**:会话与 `userId` 绑定,跨用户复用同一 `sessionId` 会被拒绝并重建。
- **审计**:所有工具调用(含越权被拒)落 `agent_tool_call_log`;返回给前端的 `toolCalls` 也是可审计轨迹。
## 10. 换大模型
`agent.llm.host` 改成任一 OpenAI 兼容地址即可,无需改代码:
| 供应商 | host | 推荐 model |
|---|---|---|
| DeepSeek | `https://api.deepseek.com/chat/completions` | `deepseek-flash`(V4.1-Flash)/ `deepseek-v4-pro` |
| 豆包方舟 | `https://ark.cn-beijing.volces.com/api/v3/chat/completions` | `doubao-seed-1-6-*` |
| 千问兼容模式 | `https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions` | `qwen-plus` |
> 注意:必须选**支持 function calling(工具调用)**的模型。历史上的 `deepseek-reasoner` 不支持工具调用
> (本服务会自动跳过 tools 参数),该模型名已随 V3/R1 下线;V4 系列把"深度思考"改成请求参数 `effort`
> (low/high/max),不再是独立模型名。模型标识一律以 `GET https://api.deepseek.com/models` 的实时清单为准。
> 另:DeepSeek API 为**预付费**,账户无赠金时调用一律返回 HTTP 402 Insufficient Balance,需先充值。
---
## 11. ★ 模块接口鉴权:为什么服务端调不通各模块的 `/api/*`
> 2026-10-10 实测结论。**不是配置错误,是平台的鉴权架构约束。**
### 11.1 现象
后端工具去调各业务模块的用户态接口(`http://base.yunhaixingtong.com/<模块>/api/...`),
HTTP 状态都是 **200**,但响应体固定是:
```json
{"code":302,"urlToRedirectTo":"http://sso.yunhaixingtong.com/login?service=http%3A%2F%2Fbase..."}
```
七个模块(home-leave / campusnotice / dormnotice / familyactivity / coursehomework / profession)行为一致。
### 11.2 原因(实测证据)
平台的**用户态接口只认浏览器 CAS 会话**:
- 下游 `CasSsoApiRedirectFilter` 检查 `_const_cas_assertion_`(会话)与 `ticket` 参数,
两者都没有 → 直接返回上面那段 `{code:302}`。
- `CasSessionAuthStrategy` 取的是 `request.getRemoteUser()` —— 即**该应用自己的容器会话**,
转发别的应用的 `JSESSIONID` 毫无意义。
**应用间签名救不了它**(已实测排除):
| 实验 | 结果 |
|---|---|
| 用本应用 appKey 签名请求 `/userbase/oapi/probe`(规范串**去掉 contextPath**) | **404** → 验签**通过**(只是没有该路由) |
| 同一路径带上 contextPath 签名 / 不签名 | **401** → 验签不通过 |
| 用**同一个(已验证有效的)appKey** 正确签名请求 `/campusnotice/api/notice/List` | 仍然是 **`{code:302}`** |
即:**签名是有效的,但 `CasSsoApiRedirectFilter` 是 Servlet 过滤器,比验签更早拦下请求**,
`/<模块>/api/*` 这类路径签名无法替代用户会话。
### 11.3 平台正规的机机通道是 `/oapi/*`
- `/<模块>/oapi/*`:不签名 → **401**;正确签名 → 放行(应用间签名的设计落点)。
- `yh.app-auth.server.enforce-patterns: [/oapi/*]` 也印证:只有 `/oapi/*` 强制验签。
### 11.4 票据通道存在,但没法签发
下游对**带 `ticket` 参数**的请求会转入 CAS 票据校验(实测:带一个假 ticket → HTTP **500**,而不是 302),
说明 `CasTicketAuthStrategy` 通道是活的。但:
- 平台 starter(`yh-openapi-client` / `yh-api-std`)**没有任何签发 service ticket 的 API**;
- `CasServiceTicketValidator` 只做 `validate`,且校验时**不带 pgtUrl**,拿不到 PGT → 无法派生 proxy ticket。
### 11.5 结论与两条出路
**结论:服务端无法代表用户持有业务系统登录态去调 `/<模块>/api/*`。** 需要二选一:
| 方案 | 做法 | 代价 |
|---|---|---|
| **A. 前端代执行**(改动可控) | 后端把「method + URL + 参数」经 SSE 推给前端,前端**带浏览器登录态**发起真实请求,再把结果回传后端继续喂给模型 | 前后端各改一块;必须**同站部署**(跨站 HTTP 下 SameSite 会挡 Cookie),且需模块放开 CORS |
| **B. 模块提供 `/oapi/*` 接口**(平台规范解) | 各业务模块按平台规范补 `/oapi/*` 查询接口,入参显式带 `userId/roleId/schoolId`,AgentService 用应用签名直调 | 需 7 个模块团队配合,工作量最大,但最干净、可审计 |
> 另有一个**疑似配置问题**:`agent.modules.resourcelibrary` 当前指向
> `http://base.yunhaixingtong.com/resourcelibrary`,实测该路径 **404**;
> 前端 `ResourceLibraryFront/.env.dev` 里资源库真实主机是 `https://base.bestsch.com/BschZyk`。
> 需与资源库团队确认正确的 base(含 context)后再改。
> **★ 2026-10-12 处理**:该模块(`ResourceTools` 及配置)已整体移除,**尚未接通**,待部署位置确认后再接回。
### 11.6 复核补记(2026-10-10 19:0x)——「请假可以了、其他模块没接通」
用户反馈请假能通、其他模块不通。复核结论:**不是模块差异,请假也没有通。**
1. **数据库工具调用日志**:表 `agent_tool_call_log`(`session_id / tool_name / module / success / cost_ms / error / user_id / create_time`),
与 `agent_session`、`agent_message` 同库。全量查询共 **26 条,成功 0 条**;
请假(`leave.types`、`leave.myClasses`,19:01:55)与公告(`notice.readTabCount`、`notice.received`,19:02:03)**同样失败**。
2. **「请假可以了」的真相**:智能体只是**弹出了请假申请表单**(写工具带必填项 → 前端渲染表单)。
表单点「确认提交」会走 `POST /home-leave/api/leave/Save` → **仍返回 `{code:302}`**(已实测)。
⇒ **表单 UI ≠ 数据打通**。
3. **七模块服务端探针一致**(均带 `yschapirequest: 1`):
`/home-leave/api/...`、`/campusnotice/api/...`、`/dormnotice/api/...`、`/familyactivity/api/...`、
`/coursehomework/api/...`、`/profession/api/...` **全部 `{code:302}`**;
写接口(`/api/leave/Save`、`/api/leave/Withdraw`、`/api/notice/Create`)**同样 302**。
4. **业务模块不自带 `/oapi/*`**:平台 `/oapi/*`(`/oapi/user/*`、`/oapi/group/*`、`/oapi/app/*`、`/oapi/DbClass/*`、`/oapi/flow/*`)
只存在于**平台自带服务**(userbase / userclass / flow-center / usermsg)。
业务模块探 `/home-leave/oapi/*`、`/campusnotice/oapi/*` **全部 404** ⇒ 走 B 方案**必须各模块自己补**。
5. **模块前端页面可达性**:`/{ctx}/front/` 六个模块 **200**,仅 `resourcelibrary` **404**(与上条指向错误吻合)。
6. **本地开发天然不可行**:模块的 CAS 会话 Cookie **域绑定在 `base.yunhaixingtong.com`**,
而开发期页面在 `localhost:8081` → 跨站,浏览器不会带上该 Cookie。
⇒ **A 方案(前端代执行)只在"同站部署"下才成立**:页面与模块同域时,`fetch(/home-leave/api/...)`
是同站请求,浏览器会带上 `/home-leave` 路径下的会话 Cookie,模块才认。
### 11.7 采纳方案 C:**深链兜底**(2026-10-10 已实现)
既然"服务端拿到用户登录态"这件事短期无解(A 需同站部署、B 需 7 个模块配合),
先做**零依赖、当天见效**的 C 方案:**调不通就带用户去能调通的地方。**
#### 做法
| 层 | 改动 |
|---|---|
| `AgentSettings#moduleLanding` | **新增**:模块 → 默认落地页(前端 hash 路由)映射表,出厂值见下 |
| `ToolDef#readAt(path)` | **新增**:**只读**工具也能声明落地页(不改变 `readOnly` 语义,前端不弹二次确认) |
| `ToolRegistry#register` | 注册期给**没声明落地页**的工具回填模块默认页 —— 保证"每个工具都有落点" |
| `ToolResult#fail` | **失败结果也带 `landingPath`**(原来只有成功才带)→ 前端才能在失败时给按钮 |
| `ToolExecutor#parseResponse` | `code=302` 的文案改为明确指向「去系统里查看」按钮 |
| `ToolTraceCard.vue` | 按钮出现条件由 `write && success && landingPath` 放宽为 **`write || !success`**;失败文案「去系统里自己查看」+ 一句话解释 |
| `modules.ts` | 新增 `MODULE_LANDING` 前端兜底表;`moduleUrl()` 在 `landingPath` 为空时回退到它 |
#### 模块默认落地页(已按各模块前端 `src/router/routes.ts` 核实)
| 模块 | 默认页面 | 路由 |
|---|---|---|
| homeleave | 我的请假 | `/my` |
| campusnotice | 我的公告(收件箱) | `/` |
| dormnotice | 宿舍通知箱 | `/par/notices` |
| familyactivity | 活动广场 | `/par/activities` |
| coursehomework | 今日作业 | `/stu/today` |
| profession | 发展首页 | `/home` |
> ★ 2026-10-12:`resourcelibrary`(资源库)已**按用户要求暂时移除**(该模块前端在 base 域 **404**、真实主机疑为
> `https://base.bestsch.com/BschZyk`,尚未接通)——已从 `ResourceTools`、`agent.modules`、
> `agent.module-landing` 与前端 `MODULE_*` 表中一并去掉;待其部署位置确认后再接回。
运维可在 `application.yml` 用 `agent.module-landing.<模块>=<路由>` 覆盖;
工具自身声明(`write(...)` / `readAt(...)`)优先级更高。
#### ★ 顺带修掉 6 处「落在不存在的路由上」的历史缺陷
原来只有写操作声明了落地页,且**部分指向的路由在该模块里并不存在**(点进去 404):
| 工具 | 原值 | 现改为 | 说明 |
|---|---|---|---|
| `notice.publish` | `/send` ❌ | `/list/new` | 公告「写公告」 |
| `dorm.submitReceipt` / `dorm.cancelReceipt` | `/mine` ❌ | `/par/mine` | 宿舍「我的回执」 |
| `activity.signup` / `activity.cancelSignup` | `/my` ❌ | `/par/mine` | 活动「我的报名」 |
| `homework.submit` | `/list` ⚠️ | `/stu/today` | `/list` 是教师「我布置的」,学生交作业应落学生侧 |
| `resource.collect` | `/mine` ❌ | `/resource/collect` | 资源「我的收藏」 |
| `profession.publish` | `/space` ❌ | `/record/new` | 专业发展「发布记录」 |
并给**教职工侧只读工具**补了落地页(模块默认页是学生/家长视角,对教师不适用):
`leave.classStudents → /register`、`leave.stats → /stats`、`notice.sent → /list`、
`notice.approvalList → /approval`、`dorm.buildings/rooms/roomStudents → /setting`、
`dorm.sendList → /list`、`dorm.dormTree → /list/new`、`activity.list → /list` 等。
#### 回归测试
`ToolLandingPathTest`(6 条,纯单元):
① 每个工具都有落地页;② 落地页必须是模块内相对路由(不是完整 URL);
③ 未声明时回填模块默认、声明了则不被覆盖;④ 工具引用到的模块都在 `module-landing` 里有键(抓拼写错);
⑤ `readAt` 不改变 `readOnly`;⑥ **失败结果必须携带 `landingPath`**。
#### 效果与边界
- **效果**:任意模块查询失败(含 `code=302`)时,轨迹卡上会出现「去系统里自己查看」,
点开就是该模块的真实页面,**用用户自己的浏览器登录态**,数据是真的。
- **边界**:这只解决"看得到",**不解决"智能体替你办"**。要让智能体真正读写模块数据,
仍需 A(同站部署 + 前端代执行)或 B(模块补 `/oapi/*`)。
## 12. ★ 浏览器代执行(方案 A):让「浏览器」替服务端去调模块接口
第 11 节的结论是:模块的 `/<模块>/api/*` 只认**浏览器 CAS 会话**,服务端代调必然 `code=302`。
方案 A 换掉调用方——**服务端不直调,而是把接口描述挂起下发给前端,由浏览器用用户自己的登录态执行,
再把原始响应回传服务端续跑**。模块侧零改动。
### 12.1 协议(两段式)
**第一段:挂起**。模型决定调用模块类工具时,服务端不执行,而是返回:
```jsonc
{
"sessionId": "92f0…",
"browserTool": {
"notice": "「我收到的公告」需要用你的登录态获取,正在通过你的浏览器执行…",
"calls": [{
"name": "notice.received", // 工具内部名(点分)
"title": "我收到的公告",
"arguments": { "pageNo": 1 },
"write": false,
"method": "GET", // GET | POST_JSON | POST_FORM
"pathAndQuery": "/campusnotice/api/notice/List?pageNo=1", // ★ 相对路径,不含协议与域名
"body": null, // POST_* 时才有
"contentType": "application/json;charset=UTF-8"
}]
}
}
```
前端拼 `location.origin + pathAndQuery` 发起 fetch(**同源 ⇒ 自动带 CAS Cookie**),
并带上 `yschapirequest: 1`(模块据此回 JSON 302 而非裸 302)。
**第二段:续跑**。前端把 `{name, arguments, status, body|error}` 数组通过
`POST /api/agent/chat` 的 `toolResult` 字段回传,服务端构造合法的
`assistant(tool_calls) + tool` 消息喂回模型继续——模型可以整理成最终回答,也可以发起下一批调用(链式查询)。
### 12.2 服务端做了什么(关键设计)
| 关注点 | 做法 |
|---|---|
| **不信任前端** | 只接受「工具名 + 参数 + 状态码 + 响应体」,路径/方法/模块一律由服务端按注册表重新解析 |
| **角色校验不绕过** | 续跑时对回传的每个工具重跑 `checkAccess`;越权照样拒绝并留痕 |
| **解析规则同源** | `parseBrowserResponse` 与服务端直调共用 `parseResponse`(`{code,msg,data}` 拆包、`code=302` 识别) |
| **失败可回退** | 准备批次失败(未知工具 / 模块未配置地址 / 缺参 / 越权)→ 回退服务端直调老路径,行为与历史版本一致 |
| **上下文合法性** | 挂起时撤销本轮的 `assistant(tool_calls)`;续跑时按 `br_<ts>_<i>` 生成 tool_call_id 严格配对 |
| **审计一致** | 浏览器执行同样写 `agent_tool_call_log` |
### 12.3 开关
```yaml
agent:
browser-exec:
enabled: true # false = 维持服务端直调的旧行为
```
### 12.4 生效前提(必须同站部署)
浏览器只会给**同源请求**带 Cookie。把本应用部署到与模块同一域名下
(如 `base.yunhaixingtong.com/agent/`),前端 fetch `/campusnotice/api/...` 才会自动带上
用户在该域的 CAS 会话。**本地 `localhost` 拿不到 base 域的 Cookie —— 这是浏览器安全模型,代码绕不过**,
所以本地只能验证"协议两段跑通",真实生效要等部署。
### 12.5 回归测试
`BrowserExecProtocolTest`(7 条,纯单元):
① 默认开启;② GET 合并固定参数、剥掉域名、只下发相对路径;
③ POST_JSON 路径占位替换 + 请求体 + Content-Type + 写标记;
④ 缺必填参数时准备阶段就抛错(调用方据此回退);
⑤ 回传 `code=302` 判定失败;⑥ 正常 `{code:0,data}` 取 data,非 2xx / 无状态码明确失败;
⑦ 非包装响应(导出文本等)原样成功。