lijing cb8764082d
build / build-push (push) Successful in 53s
fix(form): 附件上传地址改用模块真实路由前缀,修请假佐证材料 404
用户上传请假佐证材料时请求打到 /homeleave/api/base/FileUpload,被网关直接 404。

根因:默认上传地址按 "/" + ToolDef.module + "/api/base/FileUpload" 拼 ——
把「模块 key」当成了路由前缀。而请假的 key 是 homeleave、真实前缀是
/home-leave(差一个连字符)。实测 /home-leave/... 回 302 走 CAS,
/homeleave/... 回 caddy 404。

- 新增 AgentOrchestrator.pathPrefix(module, baseUrl):从 agent.modules 的配置
  地址推出站点根下的路径前缀;未配置时退回 key,尾部斜杠会被吃掉
  (否则会拼出 //api/base/FileUpload)
- buildForm 的上传地址改用它拼接(作业仍走 uploadPathOverride,不受影响)
- 其余 4 个模块的 key 与前缀同名,行为不变(curl 实测 /campusnotice、
  /dormnotice、/familyactivity、/coursehomework/api/file/Upload 均 302 可达)
- 补 2 条契约测试锁住这个行为;并修正 ModuleFormParityTest 里被一并写错的
  homeleave 地址 —— 正是它把同一个错误复制进了测试,导致一直测不出来
2026-10-13 16:36:15 +08:00

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":"我要请假"}(学生身份)

{
  "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)——「请假可以了、其他模块没接通」

用户反馈请假能通、其他模块不通。复核结论:不是模块差异,请假也没有通。

  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
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 / 无状态码明确失败; ⑦ 非包装响应(导出文本等)原样成功。

S
Description
managed by projman
Readme
1.2 MiB
Languages
Java 99.8%
Dockerfile 0.1%