- ToolParam 新增 groupByParent(),FormField 新增 groupByParent 字段; 未配 optionsFrom 却声明分组时 toFormField() 直接抛异常 (内置选项是平铺的,没有父节点,留成死配置最难排查) - activity.publish 的 receiveList 声明 groupByParent,对齐 FamilyActivity/src/views/activity/NewActivity.vue 的 OptionGroup 年级分组 - ModuleFormParityTest 新增 2 条: activityReceiveListGroupsOptionsByGrade、 groupByParentRequiresADictionaryThatCanReturnATree(含「没配 optionsFrom 必须抛」的反例) 背景:班级接口返回的是「年级 → 班级」两层树,但助手表单原先把树拍平成叶子, 下拉里只剩一排「一班、二班」,同名班级分不清年级。 验证:mvn -o test 145/145 BUILD SUCCESS
AgentService · 校园智能助手
把「用户的一句话」变成「系统里一次真实的业务操作」的服务端大脑。 用户说「我要请假」,它会调用请假模块的接口真正提交申请;说「看看我有哪些作业没做」,它会去查作业接口。
2026-10 重构:框架对齐 NoticeService(通知公告) —— Spring Boot 2.7.18 +
com.yunhai.platform基础平台 starter(CAS 单点登录 + 统一响应 + 用户中心门面)+ JPA 落库 + Nacos 配置中心, 并新增 学生 / 教师角色权限 与 会话、工具调用审计落库。
1. 它是什么 / 不是什么
是:一个独立的 Spring Boot 微服务,负责
- 对接大模型(DeepSeek,OpenAI 兼容协议),把自然语言解析成「调用哪个业务接口、带什么参数」;
- 持有一张工具注册表(每个业务模块的 REST 接口 = 一个工具),按模型指令逐个执行;
- 按「学生 / 教师 / 管理员」角色收敛能力:模型侧只暴露该角色可用的工具,服务端执行前再强校验一次(越权直接拒绝);
- 把执行结果回灌给模型,生成给用户看的自然语言回复;
- ★ 信息不全时自动生成补全表单:不靠模型口头追问(格式、必填项都不稳定), 而是从工具定义确定性地推导出还缺哪些字段,连同中文标签、格式示例、下拉选项 (可现取业务字典接口)一起下发给前端渲染成表单;用户填完直接提交执行 —— 填什么就提交什么。
不是:不承载任何业务数据、不复制业务逻辑、不做业务权限的最终裁决。
所有业务读写都转发到原有各模块服务,并原样透传当前用户身份(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 的判定顺序:
- 只认当前身份:取
currentRole.roleId,命中agent.roles.student/teacher/admin即获得该角色, 且只获得这一个身份 —— 与学校门户、其它业务模块口径一致; - 当前身份是管理员时,按
agent.roles.admin-also-teacher追加教师能力; - 当前身份号未在配置里声明(或平台没下发
currentRole)→ 回退按roleIds/type的旧口径判定, 并打一条 WARN 提示补配置(避免误判成访客而"什么都做不了"); - 什么都识别不出 → 访客(仅公共只读能力)。
为什么不做并集:实测账号
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.noticeTypeDicdorm.deptDicactivity.deptDicactivity.categoryDic);- 回执闭环:
dorm.receipts/dorm.reviewReceipt/dorm.remind/activity.signups/activity.review/activity.remind—— 回答「还有谁没交 / 没回执」,并支持确认、取消、催办;- 宿舍特有(模块自带但一直没接)的床位台账:
dorm.pendingStudents→dorm.assign(安排入住) →dorm.removeStudent(退宿)/dorm.transfer(调宿)/dorm.setMonitor(设宿舍长), 以及楼栋房间的增改(dorm.addBuildingdorm.editBuildingdorm.addRoomdorm.editRoom)。写操作不需要新传输层:统一走已跑通的浏览器代执行(前端带用户登录态调
/api/*), 表单下拉复用请假的optionCall + valueKeys + dependsOn通道。 ⚠️ 删除类(/api/notice/Delete、/api/activity/Delete)暂未接入:它们的请求体是 JSON 数组(@RequestBody List<Integer>),而工具执行器只构造对象体;且删除破坏性强,留待需要时单列。
4.3 两道闸门
- 模型侧过滤(软):
ToolRegistry.toOpenAiTools(modIds, roles)只把该角色可用的工具放进大模型的tools, 模型不会「看见」越权能力; - 服务端强校验(硬):
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":"我要请假"}(学生身份)
{
"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 用占位符地址做联动下拉:
{ "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
{ "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 前置
CREATE DATABASE bsch_agent DEFAULT CHARSET utf8mb4;
依赖:MySQL 8、Nacos(可选,不可达时回落本地配置)、Redis(应用间验签用;健康检查已关闭)、内网 Maven 仓库(nexus.yunhaixingtong.com)。
7.2 配置(application.yml)
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 打包运行
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. 怎么新增一个模块的接入(本设计的重点)
「全量接入」不应该退化成复制粘贴。新增模块只要写一个类:
// 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,但响应体固定是:
{"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)——「请假可以了、其他模块没接通」
用户反馈请假能通、其他模块不通。复核结论:不是模块差异,请假也没有通。
- 数据库工具调用日志:表
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)同样失败。 - 「请假可以了」的真相:智能体只是弹出了请假申请表单(写工具带必填项 → 前端渲染表单)。
表单点「确认提交」会走
POST /home-leave/api/leave/Save→ 仍返回{code:302}(已实测)。 ⇒ 表单 UI ≠ 数据打通。 - 七模块服务端探针一致(均带
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。 - 业务模块不自带
/oapi/*:平台/oapi/*(/oapi/user/*、/oapi/group/*、/oapi/app/*、/oapi/DbClass/*、/oapi/flow/*) 只存在于平台自带服务(userbase / userclass / flow-center / usermsg)。 业务模块探/home-leave/oapi/*、/campusnotice/oapi/*全部 404 ⇒ 走 B 方案必须各模块自己补。 - 模块前端页面可达性:
/{ctx}/front/六个模块 200,仅resourcelibrary404(与上条指向错误吻合)。 - 本地开发天然不可行:模块的 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 |
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 协议(两段式)
第一段:挂起。模型决定调用模块类工具时,服务端不执行,而是返回:
{
"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 开关
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 / 无状态码明确失败;
⑦ 非包装响应(导出文本等)原样成功。