# 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`),而工具执行器只构造对象体;且删除破坏性强,留待需要时单列。 ### 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 defs() { List 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__` 生成 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 / 无状态码明确失败; ⑦ 非包装响应(导出文本等)原样成功。