From 8303b857e936596f50b3064170eed966b8a76abe Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 11:18:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E6=A0=A1=E5=9B=AD?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=8A=A9=E6=89=8B=E5=90=8E=E7=AB=AF=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E6=BA=90=E7=A0=81=EF=BC=88Spring=20Boot=202.7.18=20+?= =?UTF-8?q?=20=E5=B9=B3=E5=8F=B0=20starter=20+=20JPA=20+=20Nacos=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - com.yunhai.agent,端口 8093,context-path /agent,制品 target/agent.jar;与 NoticeService 同构 - CAS 单点登录 + 三层角色权限(学生/教师/管理员/访客)+ 会话与工具调用审计落库 - DeepSeek 大模型编排 + 7 个业务模块工具(请假/公告/宿舍/活动/作业/资源库/教师发展) - 浏览器代执行协议(服务端挂起 → 前端用登录态代调 → toolResult 续跑)+ 调用失败深链兜底 - 登出修复:服务端真正 session.invalidate() + 前端两步登出 + logout-guard - 保留 projman 注入的 .gitea/workflows/build.yml 与 docker/Dockerfile;docker/application-prod.yml、run-app.sh 随工程入库 --- .gitignore | 26 + README.md | 533 +++++++++- docker/application-prod.yml | 64 ++ docker/run-app.sh | 8 + pom.xml | 193 ++++ .../com/yunhai/agent/AgentApplication.java | 35 + .../java/com/yunhai/agent/auth/AgentRole.java | 32 + .../com/yunhai/agent/auth/RolePolicy.java | 89 ++ .../com/yunhai/agent/auth/RoleResolver.java | 154 +++ .../common/listener/AgentAuditListener.java | 47 + .../yunhai/agent/config/AgentSettings.java | 171 ++++ .../java/com/yunhai/agent/config/Config.java | 21 + .../agent/controller/AgentController.java | 214 ++++ .../agent/controller/BaseApiController.java | 83 ++ .../com/yunhai/agent/entity/AgentMessage.java | 51 + .../com/yunhai/agent/entity/AgentSession.java | 50 + .../yunhai/agent/entity/AgentToolCallLog.java | 81 ++ .../com/yunhai/agent/entity/BaseEntity.java | 46 + .../com/yunhai/agent/llm/DeepSeekClient.java | 256 +++++ .../java/com/yunhai/agent/llm/LlmClient.java | 22 + .../com/yunhai/agent/llm/dto/ChatMessage.java | 83 ++ .../com/yunhai/agent/llm/dto/ChatResult.java | 30 + .../com/yunhai/agent/llm/dto/ToolCall.java | 38 + .../agent/model/BrowserToolRequest.java | 56 ++ .../com/yunhai/agent/model/ChatRequest.java | 61 ++ .../com/yunhai/agent/model/ChatResponse.java | 48 + .../com/yunhai/agent/model/ToolCallTrace.java | 32 + .../repository/AgentMessageRepository.java | 23 + .../repository/AgentSessionRepository.java | 34 + .../AgentToolCallLogRepository.java | 18 + .../agent/service/AgentAuditService.java | 69 ++ .../com/yunhai/agent/service/AgentEvent.java | 34 + .../agent/service/AgentOrchestrator.java | 930 ++++++++++++++++++ .../com/yunhai/agent/service/EventSink.java | 14 + .../yunhai/agent/service/SessionStore.java | 199 ++++ .../com/yunhai/agent/tool/CallerContext.java | 140 +++ .../java/com/yunhai/agent/tool/FormField.java | 52 + .../com/yunhai/agent/tool/FormRequest.java | 54 + .../java/com/yunhai/agent/tool/ToolDef.java | 211 ++++ .../com/yunhai/agent/tool/ToolExecutor.java | 471 +++++++++ .../java/com/yunhai/agent/tool/ToolParam.java | 251 +++++ .../com/yunhai/agent/tool/ToolRegistry.java | 174 ++++ .../com/yunhai/agent/tool/ToolResult.java | 54 + .../yunhai/agent/tool/defs/ActivityTools.java | 82 ++ .../agent/tool/defs/CampusNoticeTools.java | 120 +++ .../agent/tool/defs/DormNoticeTools.java | 95 ++ .../yunhai/agent/tool/defs/HomeworkTools.java | 57 ++ .../yunhai/agent/tool/defs/LeaveTools.java | 164 +++ .../agent/tool/defs/ProfessionTools.java | 90 ++ .../yunhai/agent/tool/defs/ResourceTools.java | 59 ++ src/main/resources/application.yml | 147 +++ src/main/resources/bootstrap.yml | 33 + .../yunhai/agent/BrowserExecProtocolTest.java | 146 +++ .../com/yunhai/agent/FormFieldWidgetTest.java | 119 +++ .../com/yunhai/agent/RolePermissionTest.java | 218 ++++ .../com/yunhai/agent/SessionLogoutTest.java | 90 ++ .../com/yunhai/agent/ToolLandingPathTest.java | 113 +++ .../yunhai/agent/ToolNameWireFormatTest.java | 100 ++ 58 files changed, 6883 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 docker/application-prod.yml create mode 100644 docker/run-app.sh create mode 100644 pom.xml create mode 100644 src/main/java/com/yunhai/agent/AgentApplication.java create mode 100644 src/main/java/com/yunhai/agent/auth/AgentRole.java create mode 100644 src/main/java/com/yunhai/agent/auth/RolePolicy.java create mode 100644 src/main/java/com/yunhai/agent/auth/RoleResolver.java create mode 100644 src/main/java/com/yunhai/agent/common/listener/AgentAuditListener.java create mode 100644 src/main/java/com/yunhai/agent/config/AgentSettings.java create mode 100644 src/main/java/com/yunhai/agent/config/Config.java create mode 100644 src/main/java/com/yunhai/agent/controller/AgentController.java create mode 100644 src/main/java/com/yunhai/agent/controller/BaseApiController.java create mode 100644 src/main/java/com/yunhai/agent/entity/AgentMessage.java create mode 100644 src/main/java/com/yunhai/agent/entity/AgentSession.java create mode 100644 src/main/java/com/yunhai/agent/entity/AgentToolCallLog.java create mode 100644 src/main/java/com/yunhai/agent/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/agent/llm/DeepSeekClient.java create mode 100644 src/main/java/com/yunhai/agent/llm/LlmClient.java create mode 100644 src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java create mode 100644 src/main/java/com/yunhai/agent/llm/dto/ChatResult.java create mode 100644 src/main/java/com/yunhai/agent/llm/dto/ToolCall.java create mode 100644 src/main/java/com/yunhai/agent/model/BrowserToolRequest.java create mode 100644 src/main/java/com/yunhai/agent/model/ChatRequest.java create mode 100644 src/main/java/com/yunhai/agent/model/ChatResponse.java create mode 100644 src/main/java/com/yunhai/agent/model/ToolCallTrace.java create mode 100644 src/main/java/com/yunhai/agent/repository/AgentMessageRepository.java create mode 100644 src/main/java/com/yunhai/agent/repository/AgentSessionRepository.java create mode 100644 src/main/java/com/yunhai/agent/repository/AgentToolCallLogRepository.java create mode 100644 src/main/java/com/yunhai/agent/service/AgentAuditService.java create mode 100644 src/main/java/com/yunhai/agent/service/AgentEvent.java create mode 100644 src/main/java/com/yunhai/agent/service/AgentOrchestrator.java create mode 100644 src/main/java/com/yunhai/agent/service/EventSink.java create mode 100644 src/main/java/com/yunhai/agent/service/SessionStore.java create mode 100644 src/main/java/com/yunhai/agent/tool/CallerContext.java create mode 100644 src/main/java/com/yunhai/agent/tool/FormField.java create mode 100644 src/main/java/com/yunhai/agent/tool/FormRequest.java create mode 100644 src/main/java/com/yunhai/agent/tool/ToolDef.java create mode 100644 src/main/java/com/yunhai/agent/tool/ToolExecutor.java create mode 100644 src/main/java/com/yunhai/agent/tool/ToolParam.java create mode 100644 src/main/java/com/yunhai/agent/tool/ToolRegistry.java create mode 100644 src/main/java/com/yunhai/agent/tool/ToolResult.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java create mode 100644 src/main/java/com/yunhai/agent/tool/defs/ResourceTools.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/bootstrap.yml create mode 100644 src/test/java/com/yunhai/agent/BrowserExecProtocolTest.java create mode 100644 src/test/java/com/yunhai/agent/FormFieldWidgetTest.java create mode 100644 src/test/java/com/yunhai/agent/RolePermissionTest.java create mode 100644 src/test/java/com/yunhai/agent/SessionLogoutTest.java create mode 100644 src/test/java/com/yunhai/agent/ToolLandingPathTest.java create mode 100644 src/test/java/com/yunhai/agent/ToolNameWireFormatTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3eab8f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# 日志 +logs +*.log + +# 构建产物 +target/ + +# IDE +.idea/ +*.iml +.vscode/ + +# 本地配置(含密钥,禁止入库) +src/main/resources/application-local.yml +src/main/resources/application-prod.yml + +# AI 工作目录(记忆/技能,非工程内容) +.workbuddy/ + +# IDE 运行日志 +.run/ + +# 备份与残留 +*.bak +*.orig +*.old diff --git a/README.md b/README.md index 5489e4c..9d7399e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,532 @@ -# yunhai-agent-service +# AgentService · 校园智能助手 -managed by projman \ No newline at end of file +> 把「用户的一句话」变成「系统里一次真实的业务操作」的服务端大脑。 +> 用户说「我要请假」,它会调用请假模块的接口真正提交申请;说「看看我有哪些作业没做」,它会去查作业接口。 +> +> **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.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.buildings` `dorm.rooms` `dorm.roomStudents` `dorm.dormTree`、`activity.classTree`、`profession.*`(全部) | 审批、发布、统计、宿管、专业发展 | +| 学生 | `homework.*`(全部)、`activity.signup` `activity.cancelSignup`、`leave.reportBack` | 交作业、报名活动、销假 | +| 学生+教职工共用 | `leave.submit` `leave.withdraw` | 发起/撤销自己的事项 | +| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 | + +### 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 表单化补参(两个请求形态) + +**① 缺参 → 下发补全表单**:`POST /api/agent/chat {"message":"我要请假"}` + +```jsonc +{ + "code": 0, "msg": "", + "data": { + "sessionId": "...", + "reply": "要完成「提交请假申请」,还差这几项:请假类型、开始时间、结束时间……", + "form": { + "tool": "leave.submit", + "title": "提交请假申请", + "write": true, + "landingPath": "/my", + "missing": ["请假类型", "开始时间", "结束时间"], + "preset": { "remarks": "发烧就医" }, + "recognized": [{ "name": "remarks", "label": "请假事由", "text": "发烧就医" }], + "fields": [ + { "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true, + "options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假" }] }, + { "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true, + "description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" } + ] + } + } +} +``` + +**② 填完提交 → 直接执行**:`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 + resourcelibrary: http://base.yunhaixingtong.com/resourcelibrary + 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)后再改。 + +### 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` | +| resourcelibrary | 资源管理 | `/resource` | +| profession | 发展首页 | `/home` | + +运维可在 `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 / 无状态码明确失败; +⑦ 非包装响应(导出文本等)原样成功。 diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..252f251 --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,64 @@ +# Agent 生产配置(线上以 nacos 的 agent.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=agent.yaml、group=OA_APP 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url、agent.modules、agent.llm.api-key)。 +server: + port: 8071 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/agent + context-path: /agent + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + datasource: + # 与 UserBase 共用同一台 mysql8;库名为 bsch_agent(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/bsch_agent?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://base.yunhaixingtong.com/agent + allow-headers: all + allow-origins: all + url-pattern: /api/* + app-auth: + enabled: true + app-key: 100617 + app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + trusted-apps: + 100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + +# 智能体:模块地址与角色映射(生产值由 Nacos agent.yaml 覆盖) +agent: + llm: + host: https://api.deepseek.com/chat/completions + api-key: ${DEEPSEEK_API_KEY:} + model: deepseek-chat + filter-tools-by-authorized-module: false + enforce-role-permission: true + modules: + 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 + resourcelibrary: http://base.yunhaixingtong.com/resourcelibrary + profession: http://base.yunhaixingtong.com/profession diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..b1a125c --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Agent 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting Agent (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \ + --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..987846d --- /dev/null +++ b/pom.xml @@ -0,0 +1,193 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + agent + 0.0.1 + Agent + 校园智能助手子应用:对话式调用各业务模块(请假 / 公告 / 作业 / 宿舍通知 / 家校活动 / 资源库 / 专业发展) + + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + org.apache.commons + commons-lang3 + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + agent + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.agent.AgentApplication + + + + + repackage + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Test.java + **/*IT.java + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + diff --git a/src/main/java/com/yunhai/agent/AgentApplication.java b/src/main/java/com/yunhai/agent/AgentApplication.java new file mode 100644 index 0000000..4b2e6ac --- /dev/null +++ b/src/main/java/com/yunhai/agent/AgentApplication.java @@ -0,0 +1,35 @@ +package com.yunhai.agent; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 校园智能助手启动类(与 NoticeService / UserBase 同族)。 + * + *

Spring Boot 2.7.18 + {@code yh-spring-boot-starter}:统一响应 {@code {code,msg,data}} + * 由平台 {@code ApiResponseAdvice} 包装;单点登录(CAS/SSO)与当前用户上下文 + * ({@code BoaUser} 方法参数、{@code UserContextHolder})由平台 starter 自动装配。 + * + *

本服务承载两类职责: + *

    + *
  1. 把用户自然语言交给大模型(DeepSeek),由模型决定调用哪些工具;
  2. + *
  3. 把模型给出的工具调用,真正转发到对应业务模块的 REST 接口(带用户身份), + * 并按「学生 / 教师」角色在服务端做能力过滤与越权拦截。
  4. + *
+ */ +@SpringBootApplication(scanBasePackages = "com.yunhai.agent") +@EnableTransactionManagement +@EnableAsync +@EnableScheduling +public class AgentApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(AgentApplication.class, args); + } +} diff --git a/src/main/java/com/yunhai/agent/auth/AgentRole.java b/src/main/java/com/yunhai/agent/auth/AgentRole.java new file mode 100644 index 0000000..cf54a05 --- /dev/null +++ b/src/main/java/com/yunhai/agent/auth/AgentRole.java @@ -0,0 +1,32 @@ +package com.yunhai.agent.auth; + +/** + * 智能体侧的角色分类(由平台 {@code BoaUser} 解析而来)。 + * + *

用途:决定「这个身份能用哪些工具」——学生侧(请假申请 / 交作业 / 报名活动 / 看公告) + * 与教职工侧(发公告 / 审批请假 / 发布资源 / 专业发展)严格分开。 + */ +public enum AgentRole { + + /** 学生 */ + STUDENT("学生"), + + /** 教师(含班主任) */ + TEACHER("教师"), + + /** 管理员(校管 / 分管领导 / 教务 / 校长) */ + ADMIN("管理员"), + + /** 访客:未登录或无法识别角色,只能使用公共只读能力 */ + GUEST("访客"); + + private final String label; + + AgentRole(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/com/yunhai/agent/auth/RolePolicy.java b/src/main/java/com/yunhai/agent/auth/RolePolicy.java new file mode 100644 index 0000000..48bf62f --- /dev/null +++ b/src/main/java/com/yunhai/agent/auth/RolePolicy.java @@ -0,0 +1,89 @@ +package com.yunhai.agent.auth; + +import com.yunhai.agent.tool.ToolDef; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * 角色能力策略:集中声明「哪些工具面向学生、哪些面向教职工」。 + * + *

为什么集中在一处:角色划分本质是一所学校的治理策略(会随学校调整), + * 集中成一张可读的表比散落在 7 个模块的工具定义里更容易审阅与改动; + * 若某个工具需要就近声明,用 {@code ToolDef.allow(...)} 显式指定即可(本策略会尊重已有声明)。 + * + *

三类名单: + *

    + *
  • {@link #STAFF_ONLY} —— 教职工侧(教师 + 管理员):审批、发布、统计、宿管、专业发展;
  • + *
  • {@link #STUDENT_ONLY} —— 学生侧:交作业、报名活动、销假;
  • + *
  • {@link #SHARED} —— 学生与教职工都可能发起的:请假申请/撤销。
  • + *
+ * 未列入任何名单的工具 = 所有已识别角色都可用(如各类只读查询)。 + */ +public final class RolePolicy { + + private RolePolicy() { + } + + /** 教职工专属(教师 + 管理员) */ + private static final Set STAFF_ONLY = new HashSet<>(Arrays.asList( + // 请假:审批与统计 + "leave.classStudents", "leave.stats", "leave.audit", "leave.batchPass", + // 公告:发布与审批 + "notice.sent", "notice.deptDics", "notice.publish", + "notice.approvalList", "notice.approve", "notice.reject", + // 宿舍通知:楼栋/房间/住宿生管理侧 + "dorm.buildings", "dorm.rooms", "dorm.roomStudents", "dorm.dormTree", + // 家校活动:发布时选班级 + "activity.classTree" + )); + + /** 学生专属 */ + private static final Set STUDENT_ONLY = new HashSet<>(Arrays.asList( + "homework.myList", "homework.detail", "homework.result", "homework.submit", + "activity.signup", "activity.cancelSignup", + "leave.reportBack" + )); + + /** 学生与教职工共用(发起/撤销自己的事项) */ + private static final Set SHARED = new HashSet<>(Arrays.asList( + "leave.submit", "leave.withdraw" + )); + + /** 模块级规则:整个模块仅教职工可用 */ + private static final String[] STAFF_MODULE_PREFIXES = {"profession."}; + + /** + * 为未显式声明角色的工具套用默认角色策略。 + */ + public static void apply(Collection tools) { + if (tools == null) { + return; + } + for (ToolDef d : tools) { + if (d.getRoles() != null && !d.getRoles().isEmpty()) { + continue; // 工具自己声明过,尊重 + } + String n = d.getName(); + if (matchesPrefix(n, STAFF_MODULE_PREFIXES) || STAFF_ONLY.contains(n)) { + d.allow(AgentRole.TEACHER, AgentRole.ADMIN); + } else if (STUDENT_ONLY.contains(n)) { + d.allow(AgentRole.STUDENT); + } else if (SHARED.contains(n)) { + d.allow(AgentRole.STUDENT, AgentRole.TEACHER, AgentRole.ADMIN); + } + // 其余 = 不限制(所有已识别角色可用) + } + } + + private static boolean matchesPrefix(String name, String[] prefixes) { + for (String p : prefixes) { + if (name.startsWith(p)) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/yunhai/agent/auth/RoleResolver.java b/src/main/java/com/yunhai/agent/auth/RoleResolver.java new file mode 100644 index 0000000..d0d8827 --- /dev/null +++ b/src/main/java/com/yunhai/agent/auth/RoleResolver.java @@ -0,0 +1,154 @@ +package com.yunhai.agent.auth; + +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 角色解析器:把平台 {@code BoaUser} 映射成智能体的角色集合。 + * + *

为什么以「当前身份」为准

+ * 平台里一个账号可以同时挂多个身份:{@code BoaUser.roleIds} 是全部身份的角色号, + * {@code BoaUser.currentRole} 是用户在门户里当前激活的那个身份 + * (等价于 {@code YschBaseOpenApi.getCurrentIdentity(userId)})。 + * 例如实测账号 10008 同时是「学生 + 管理员 + 教师」——若把所有身份并集, + * 权限只增不减、能力边界完全模糊(所有人都是全量 54 项)。 + * 因此这里只认当前身份:与学校门户、其它业务模块的口径保持一致。 + * + *

判定规则(角色号可在 {@code agent.roles.*} 配置)

+ *
    + *
  1. 优先取 {@code currentRole.getRoleId()},命中 {@code agent.roles.student/teacher/admin} + * 即获得对应角色,且只获得这一个身份;
  2. + *
  3. 当前身份是管理员时,按 {@code agent.roles.admin-also-teacher=true} 追加教师能力;
  4. + *
  5. 当前身份号未在配置里声明(或账号没有当前身份)→ 回退按 {@code roleIds}/{@code type} + * 的旧口径判定,并打 WARN 日志提示补配置;
  6. + *
  7. 什么都识别不出 → 访客(仅公共只读能力)。
  8. + *
+ */ +@Slf4j +@Component +public class RoleResolver { + + private final AgentSettings settings; + + /** 每个用户上次记录过的身份描述,避免每请求刷屏(只在身份变化时打一次 INFO) */ + private final Map lastLogged = new ConcurrentHashMap<>(); + + public RoleResolver(AgentSettings settings) { + this.settings = settings; + } + + /** 解析用户角色集合(永不为空,至少含 GUEST) */ + public Set resolve(BoaUser user) { + Set roles = new LinkedHashSet<>(); + if (user == null) { + roles.add(AgentRole.GUEST); + return roles; + } + + AgentSettings.Roles cfg = settings.getRoles(); + + // 1) ★ 平台「当前身份」优先 —— 一个账号只生效一个身份 + BoaRoleInfo current = user.getCurrentRole(); + if (current != null && current.getRoleId() != null) { + Set hit = match(current.getRoleId(), cfg); + if (!hit.isEmpty()) { + applyAdminAlsoTeacher(hit, cfg); + logResolved(user, current, hit, "当前身份"); + return hit; + } + log.warn("[身份] 当前身份 roleId={} roleName={} 未命中 agent.roles.* 配置," + + "暂按账号全部身份兜底(userId={}, roleIds={})—— 建议把该角色号补进配置", + current.getRoleId(), current.getRoleName(), user.getId(), user.getRoleIds()); + } + + // 2) 兜底:没有当前身份,或当前身份号未配置 + if (user.getType() != null && user.getType() == 1) { + roles.add(AgentRole.ADMIN); + } + List roleIds = user.getRoleIds(); + if (roleIds != null) { + for (Integer rid : roleIds) { + if (rid != null) { + roles.addAll(match(rid, cfg)); + } + } + } + applyAdminAlsoTeacher(roles, cfg); + + if (roles.isEmpty()) { + roles.add(AgentRole.GUEST); + } + logResolved(user, current, roles, + current == null ? "无当前身份,按账号全部身份" : "当前身份未配置,按账号全部身份"); + return roles; + } + + /** 角色集合的可读描述,如「教师」/「管理员+教师」 */ + public String describe(Set roles) { + if (roles == null || roles.isEmpty()) { + return "访客"; + } + StringBuilder sb = new StringBuilder(); + for (AgentRole r : roles) { + if (sb.length() > 0) { + sb.append("+"); + } + sb.append(r.getLabel()); + } + return sb.toString(); + } + + /** 单个角色号 → 智能体角色(一个号理论上可命中多个分组,全部保留) */ + private Set match(Integer roleId, AgentSettings.Roles cfg) { + Set hit = new LinkedHashSet<>(); + if (roleId == null) { + return hit; + } + if (contains(cfg.getAdmin(), roleId)) { + hit.add(AgentRole.ADMIN); + } + if (contains(cfg.getTeacher(), roleId)) { + hit.add(AgentRole.TEACHER); + } + if (contains(cfg.getStudent(), roleId)) { + hit.add(AgentRole.STUDENT); + } + return hit; + } + + private void applyAdminAlsoTeacher(Set roles, AgentSettings.Roles cfg) { + if (roles.contains(AgentRole.ADMIN) && cfg.isAdminAlsoTeacher()) { + roles.add(AgentRole.TEACHER); + } + } + + /** 身份只在「变化时」记一次 INFO,避免每个请求刷屏,同时便于核对角色号配置 */ + private void logResolved(BoaUser user, BoaRoleInfo current, Set roles, String source) { + String desc = describe(roles); + String line = source + " → " + desc + + (current == null ? "" : "(roleId=" + current.getRoleId() + + ", roleName=" + current.getRoleName() + ", schId=" + current.getSchId() + ")"); + Integer uid = user.getId(); + if (uid == null) { + log.info("[身份] {}", line); + return; + } + String prev = lastLogged.put(uid, desc); + if (!desc.equals(prev)) { + log.info("[身份] userId={} {}", uid, line); + } + } + + private boolean contains(List list, int v) { + return list != null && list.contains(v); + } +} diff --git a/src/main/java/com/yunhai/agent/common/listener/AgentAuditListener.java b/src/main/java/com/yunhai/agent/common/listener/AgentAuditListener.java new file mode 100644 index 0000000..c45640d --- /dev/null +++ b/src/main/java/com/yunhai/agent/common/listener/AgentAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.agent.common.listener; + +import com.yunhai.agent.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 + * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}(SSE 异步线程里可能为空,此时只填时间)。 + */ +@Component +public class AgentAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + String account = currentAccount(); + if (account != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(account); + } + entity.setUpdateBy(account); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + String account = currentAccount(); + if (account != null) { + entity.setUpdateBy(account); + } + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/agent/config/AgentSettings.java b/src/main/java/com/yunhai/agent/config/AgentSettings.java new file mode 100644 index 0000000..f642b2f --- /dev/null +++ b/src/main/java/com/yunhai/agent/config/AgentSettings.java @@ -0,0 +1,171 @@ +package com.yunhai.agent.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 智能体配置:大模型 + 各业务模块基地址 + 角色权限映射。 + * + *

全部落在 {@code application.yml} 的 {@code agent.*} 下,便于运维按环境改配置而不动代码。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "agent") +public class AgentSettings { + + /** 大模型配置 */ + private Llm llm = new Llm(); + + /** 编排参数 */ + private Loop loop = new Loop(); + + /** 角色权限映射(学生 / 教师 / 管理员 的角色号) */ + private Roles roles = new Roles(); + + /** + * 业务模块基地址表:key = 模块标识(工具定义里引用),value = 该模块的根地址(不含 /api)。 + *

例:{@code homeleave: http://base.yunhaixingtong.com/home-leave} + *

本地联调把 value 改成 {@code http://localhost:8106} 之类即可。 + */ + private Map modules = new LinkedHashMap<>(); + + /** + * 各业务模块的「默认落地页」——即该模块前端的一个路由(如 {@code /my})。 + * + *

★ 深链兜底(2026-10 新增):业务模块的用户态接口 {@code /<模块>/api/*} 只认 + * 浏览器 CAS 会话,服务端调用拿不到,必然回 {@code code=302}(详见 README 第 11 节)。 + * 此时智能体不再只丢一句报错,而是把模块页面地址随轨迹回传前端, + * 让用户用自己的浏览器登录态一键打开真实页面看数据 —— 这比"死等打通"务实得多。 + * + *

取值已按各模块前端 {@code src/router/routes.ts} 的真实路由核对(2026-10-10): + *

+     * homeleave        → /my             我的请假
+     * campusnotice     → /              我的公告(收件箱)
+     * dormnotice       → /par/notices   宿舍通知箱
+     * familyactivity   → /par/activities 活动广场
+     * coursehomework   → /stu/today     今日作业
+     * resourcelibrary  → /resource      资源管理
+     * profession       → /home          发展首页
+     * 
+ * 运维可在 {@code application.yml} 用 {@code agent.module-landing.<模块>=<路由>} 覆盖单个模块; + * 工具自身声明的 {@code landingPath}({@code write(...)} / {@code readAt(...)})优先级更高。 + */ + private Map moduleLanding = defaultModuleLanding(); + + /** 模块默认落地页的出厂值(见 {@link #moduleLanding}) */ + private static Map defaultModuleLanding() { + Map m = new LinkedHashMap<>(); + m.put("homeleave", "/my"); + m.put("campusnotice", "/"); + m.put("dormnotice", "/par/notices"); + m.put("familyactivity", "/par/activities"); + m.put("coursehomework", "/stu/today"); + m.put("resourcelibrary", "/resource"); + m.put("profession", "/home"); + return m; + } + + /** + * 是否按「用户已授权模块」过滤工具(modId 维度)。 + *

默认 false:不向模型隐藏工具,越权与否由各业务模块按透传身份自行裁决。 + *

置 true 时:用平台 GetUserMod 的 modId 与 {@code ToolDef.moduleId} 比对,未授权模块的工具不暴露。 + */ + private boolean filterToolsByAuthorizedModule = false; + + /** + * 是否开启「角色强校验」。 + *

默认 true:模型若调用了当前角色无权使用的工具,服务端直接拒绝执行(不发真实请求), + * 并在轨迹里留下 FORBIDDEN 记录 —— 这是「分学生、教师权限」的硬约束。 + *

置 false 可临时放开(排障用),此时仅按角色过滤列表,不做执行拦截。 + */ + private boolean enforceRolePermission = true; + + /** + * 浏览器代执行(方案 A,2026-10-12 新增)。 + * + *

业务模块的用户态接口只认浏览器 CAS 会话,服务端调用必然 {@code code=302} + * (README 第 11 节的四条实测证据)。开启后,模块类工具不再由服务端发请求,而是: + *

    + *
  1. 模型决定调用 → 服务端挂起,把「接口描述(方法/路径/参数)」下发前端;
  2. + *
  3. 前端用用户自己的登录态调用模块接口(同站部署 ⇒ 同源,Cookie 自动携带);
  4. + *
  5. 结果回传服务端 → 喂回模型 → 生成最终回答。
  6. + *
+ * 本地 dev(localhost:8081)因跨域取不到 base 域会话,代执行会失败并自动落回 + * 「去系统里查看」的深链兜底;同站部署后即真实生效。 + */ + private BrowserExec browserExec = new BrowserExec(); + + @Data + public static class BrowserExec { + /** 是否启用浏览器代执行(false = 维持服务端直调的旧行为) */ + private boolean enabled = true; + } + + @Data + public static class Llm { + /** OpenAI 兼容的 chat/completions 地址(DeepSeek 官方:https://api.deepseek.com/chat/completions) */ + private String host = "https://api.deepseek.com/chat/completions"; + + /** 多模态/推理兼用的兜底地址(可选,默认同上) */ + private String fallbackHost; + + private String apiKey; + + /** 默认模型:deepseek-flash(DeepSeek-V4.1-Flash,支持 function calling)。 + * 以官方实时清单为准:GET https://api.deepseek.com/models(2026-10-10:deepseek-flash / deepseek-v4-pro) */ + private String model = "deepseek-flash"; + + private int maxTokens = 2048; + + private double temperature = 0.3; + + /** 单次 HTTP 超时(毫秒)。⚠️ 实际出站超时已由平台统一客户端接管,见 {@code yh.http.read-timeout-ms};本字段保留仅作文档参考 */ + private int timeout = 60000; + + /** 是否开启深度思考(旧的 deepseek-reasoner 独立模型已下线;V4 系列改为在请求里带 effort 档位,本开关暂未接线) */ + private boolean thinking = false; + } + + @Data + public static class Loop { + /** 单轮对话内最多允许的「模型思考 → 调工具」循环次数,防死循环 */ + private int maxRounds = 6; + + /** 每个工具调用结果塞回模型时的最大字符数(防撑爆上下文) */ + private int toolResultMaxChars = 6000; + + /** 会话上下文最多保留的历史消息条数 */ + private int historyMaxMessages = 20; + + /** 会话空闲过期时间(分钟) */ + private int sessionTtlMinutes = 120; + } + + /** + * 角色映射:UserBase 的角色号(roleId)→ 智能体角色。 + *

★ 上线前必须按本校 UserBase 的角色体系校准: + * 平台 {@code BoaUser.getType() == 1} 恒为超管/校管;其余按 {@code roleIds} 命中下面的集合判定。 + * 一个用户可同时命中多个(如既是教师又是管理员),能力取并集。 + */ + @Data + public static class Roles { + /** 学生角色号(默认 1;请按 UserBase 校准) */ + private List student = new ArrayList<>(Arrays.asList(1)); + + /** 教师角色号(默认 2=教师、3=班主任;请按 UserBase 校准) */ + private List teacher = new ArrayList<>(Arrays.asList(2, 3)); + + /** 管理员角色号(默认 4=校管、5=分管领导、6=教务、7=校长;请按 UserBase 校准) */ + private List admin = new ArrayList<>(Arrays.asList(4, 5, 6, 7)); + + /** 管理员是否天然拥有教师能力(默认 true:校管/领导可用教师侧工具) */ + private boolean adminAlsoTeacher = true; + } +} diff --git a/src/main/java/com/yunhai/agent/config/Config.java b/src/main/java/com/yunhai/agent/config/Config.java new file mode 100644 index 0000000..247b0eb --- /dev/null +++ b/src/main/java/com/yunhai/agent/config/Config.java @@ -0,0 +1,21 @@ +package com.yunhai.agent.config; + +/** + * 全局路径常量。与 UserBase / Notice 一致:API 统一挂在 {@code /{platform}/} 之下 + * (platform = api / manage / mobiapi),前端固定走 {@code /api/...} 同源代理。 + */ +public enum Config { + Inst; + + /** 平台统一前缀:manage / api / mobiapi,前面有 / 后面无 /,拼接子路径时用 / 分隔 */ + public static final String API_PATH = "/{platform}"; + + /** 智能体域 */ + public static final String AGENT = "/agent"; + + /** 平台壳层域(GetUserInfo / GetUserMod / Logout,与各业务模块同形) */ + public static final String BASE = "/base"; + + /** 应用间开放域(签名鉴权,留给后续对第三方开放用) */ + public static final String OPEN_API_PATH = "/oapi"; +} diff --git a/src/main/java/com/yunhai/agent/controller/AgentController.java b/src/main/java/com/yunhai/agent/controller/AgentController.java new file mode 100644 index 0000000..21b9d88 --- /dev/null +++ b/src/main/java/com/yunhai/agent/controller/AgentController.java @@ -0,0 +1,214 @@ +package com.yunhai.agent.controller; + +import com.alibaba.fastjson.JSON; +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.agent.auth.RoleResolver; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.config.Config; +import com.yunhai.agent.model.ChatRequest; +import com.yunhai.agent.model.ChatResponse; +import com.yunhai.agent.service.AgentEvent; +import com.yunhai.agent.service.AgentOrchestrator; +import com.yunhai.agent.service.SessionStore; +import com.yunhai.agent.tool.CallerContext; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolRegistry; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; + +/** + * 智能体对外接口(路径前缀 {@code /{platform}/agent/},前端固定走 {@code /api/agent/...})。 + * + *

    + *
  • POST {@code /api/agent/chat} —— 一问一答(阻塞返回完整结果)
  • + *
  • POST {@code /api/agent/chat/stream} —— SSE,实时推送「正在思考 / 正在调用 XX / 最终回复」
  • + *
  • GET {@code /api/agent/tools} —— 能力目录(按当前角色过滤后返回)
  • + *
  • POST {@code /api/agent/session/clear} —— 清空当前会话上下文
  • + *
+ * + *

返回裸对象,由平台 {@code ApiResponseAdvice} 自动包装为 {@code {code, msg, data}}(与 NoticeService 一致)。 + * 当前登录用户由平台 CAS 会话注入 {@link BoaUser},角色由 {@link RoleResolver} 解析。 + */ +@Slf4j +@RestController +@RequestMapping(Config.API_PATH + Config.AGENT + "/") +public class AgentController { + + @Autowired + private AgentOrchestrator orchestrator; + @Autowired + private ToolRegistry registry; + @Autowired + private SessionStore sessionStore; + @Autowired + private AgentSettings settings; + @Autowired + private RoleResolver roleResolver; + + /** 一问一答 */ + @PostMapping("chat") + public ChatResponse chat(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) { + CallerContext caller = buildCaller(authUser, request); + return orchestrator.chat(req, describeUser(authUser, caller), caller, + authorizedModIds(authUser), null); + } + + /** SSE 流式:阶段事件实时推送 */ + @PostMapping(value = "chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter chatStream(@RequestBody ChatRequest req, BoaUser authUser, HttpServletRequest request) { + SseEmitter emitter = new SseEmitter(5 * 60 * 1000L); + CallerContext caller = buildCaller(authUser, request); + String userDesc = describeUser(authUser, caller); + Set mods = authorizedModIds(authUser); + + CompletableFuture.runAsync(() -> { + try { + orchestrator.chat(req, userDesc, caller, mods, event -> { + try { + emitter.send(SseEmitter.event().name(event.getType()) + .data(JSON.toJSONString(event), MediaType.APPLICATION_JSON)); + } catch (IOException | IllegalStateException e) { + // 客户端断开:后续事件丢弃即可 + } + }); + emitter.complete(); + } catch (Exception e) { + log.error("SSE 对话异常", e); + try { + AgentEvent err = AgentEvent.of("error", e.getMessage() == null ? "服务异常" : e.getMessage()); + emitter.send(SseEmitter.event().name("error") + .data(JSON.toJSONString(err), MediaType.APPLICATION_JSON)); + } catch (Exception ignore) { + // 忽略 + } + emitter.completeWithError(e); + } + }); + + // 客户端断开时释放 + emitter.onTimeout(emitter::complete); + emitter.onError(e -> log.debug("SSE 连接错误:{}", e.toString())); + return emitter; + } + + /** 能力目录:按当前角色 + 授权模块过滤后,按模块分组,前端用来渲染快捷指令与"能做什么" */ + @GetMapping("tools") + public Map tools(BoaUser authUser, HttpServletRequest request) { + CallerContext caller = buildCaller(authUser, request); + Set mods = authorizedModIds(authUser); + Set roles = caller.getRoles(); + + Map result = new LinkedHashMap<>(); + Map> grouped = registry.groupByModule(mods, roles); + Map modules = new LinkedHashMap<>(); + for (Map.Entry> e : grouped.entrySet()) { + List> items = new ArrayList<>(); + for (ToolDef d : e.getValue()) { + Map item = new LinkedHashMap<>(); + item.put("name", d.getName()); + item.put("title", d.getTitle()); + item.put("description", d.getDescription()); + item.put("write", !d.isReadOnly()); + item.put("landingPath", d.getLandingPath()); + item.put("roles", d.roleLabel()); + items.add(item); + } + modules.put(e.getKey(), items); + } + result.put("modules", modules); + result.put("total", registry.visibleTools(mods, roles).size()); + result.put("roles", roleResolver.describe(roles)); + + // 当前身份明细:前端展示「我现在是谁」,也方便现场核对角色号配置 + Map identity = new LinkedHashMap<>(); + identity.put("userId", caller.getUserId()); + identity.put("userName", caller.getUserName()); + identity.put("schoolId", caller.getSchoolId()); + identity.put("roleId", caller.getRoleId()); + identity.put("roleName", caller.getCurrentRoleName()); + identity.put("roles", roleResolver.describe(roles)); + result.put("identity", identity); + return result; + } + + /** 清空会话 */ + @PostMapping("session/clear") + public Map clearSession(@RequestBody(required = false) ChatRequest req) { + Map ok = new LinkedHashMap<>(); + if (req != null) { + sessionStore.clear(req.getSessionId()); + } + ok.put("cleared", true); + return ok; + } + + /* ==================== 内部:身份与权限 ==================== */ + + /** 组装调用者身份:请求头 + 平台 BoaUser(后者权威),并解析角色 */ + private CallerContext buildCaller(BoaUser authUser, HttpServletRequest request) { + CallerContext caller = CallerContext.from(request); + caller.fillFrom(authUser, roleResolver.resolve(authUser)); + return caller; + } + + /** 用户身份描述,写进 system prompt */ + private String describeUser(BoaUser user, CallerContext caller) { + StringBuilder sb = new StringBuilder(); + String name = user != null ? user.getName() : caller.getUserName(); + if (StringUtils.isNotBlank(name)) { + sb.append("姓名 ").append(name); + } else { + sb.append("用户ID ").append(caller.getUserId()); + } + sb.append(",身份 ").append(roleResolver.describe(caller.getRoles())); + if (caller.schoolIdAsInt() != null) { + sb.append(",学校ID ").append(caller.getSchoolId()); + } + return sb.toString(); + } + + /** 已授权 modId 集合;未开启过滤或无身份时返回 null(=不按 modId 过滤) */ + private Set authorizedModIds(BoaUser user) { + if (!settings.isFilterToolsByAuthorizedModule() || user == null || user.getId() == null) { + return null; + } + try { + Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null; + List mods = YschBaseOpenApi.getUserModule(user.getId(), schId); + if (mods == null) { + return new HashSet<>(); + } + Set ids = new HashSet<>(); + for (BoaModule m : mods) { + if (m.getId() != null) { + ids.add(m.getId()); + } + } + return ids; + } catch (Exception e) { + log.warn("获取用户授权模块失败,本轮不按 modId 过滤工具:{}", e.toString()); + return null; + } + } +} diff --git a/src/main/java/com/yunhai/agent/controller/BaseApiController.java b/src/main/java/com/yunhai/agent/controller/BaseApiController.java new file mode 100644 index 0000000..8172fff --- /dev/null +++ b/src/main/java/com/yunhai/agent/controller/BaseApiController.java @@ -0,0 +1,83 @@ +package com.yunhai.agent.controller; + +import com.yunhai.agent.config.Config; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaModule; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.util.Collections; +import java.util.List; + +/** + * 平台壳层接口(路径前缀 {@code /{platform}/base/},与各业务模块同名同义,前端外壳 layout 复用同一套调用)。 + * + *

{@code BoaUser} 由平台 starter 的参数解析器从 CAS 会话注入(与 NoticeService 完全一致)。 + */ +@Slf4j +@RestController +@RequestMapping(Config.API_PATH + Config.BASE + "/") +public class BaseApiController { + + /** 当前登录用户信息 */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + /** 当前用户已授权的模块列表(左侧菜单 / 权限过滤数据源) */ + @GetMapping("GetUserMod") + public List getUserMod(BoaUser user) { + if (user == null || user.getId() == null) { + return Collections.emptyList(); + } + Integer schId = user.getCurrentRole() != null ? user.getCurrentRole().getSchId() : null; + return YschBaseOpenApi.getUserModule(user.getId(), schId); + } + + /** + * 退出登录:销毁本应用的 CAS 会话。 + * + *

★ 必须真的把会话销毁掉,不能只留痕。本应用的登录态就是服务端 session 里的 + * {@code _const_cas_assertion_}(平台 {@code CasSsoApiRedirectFilter} 就是拿它判断 + * 「登录了没」,{@code CasSessionAuthStrategy} 再经 {@code getRemoteUser()} 解析出身份)。 + * 前端跳到 CAS 的 {@code /logout} 只能销毁 SSO 侧的票据,本应用的 JSESSIONID 依旧有效, + * 返回页面时仍是登录态 —— 用户看到的现象就是「点了退出没用 / 退不出去」。 + * + *

为什么不能指望 CAS 的 SLO 回推:平台虽然注册了 {@code SingleSignOutFilter}, + * 但没注册配套的会话监听,且回推要求本服务地址在 CAS 侧登记过(本地联调是 + * {@code http://localhost:8093},必然登记不了)。平台自带的业务模块 + * (如 HomeSchLeave 的 {@code LoginUserController#logout})同样是自己 + * {@code session.invalidate()},这里与其保持同形。 + * + *

同时保留 GET / POST 两种方法:前端外壳走 POST,平台老壳层沿用 GET。 + */ + @RequestMapping(value = "Logout", method = {RequestMethod.GET, RequestMethod.POST}) + public void logout(HttpServletRequest request) { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + invalidateSession(request); + } + + /** 销毁当前会话;会话本来就没有/已失效时不报错(登出接口必须幂等) */ + private void invalidateSession(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return; + } + try { + session.invalidate(); + log.info("[logout] 本地会话已销毁"); + } catch (IllegalStateException e) { + log.debug("[logout] 会话已失效,无需重复销毁"); + } + } +} diff --git a/src/main/java/com/yunhai/agent/entity/AgentMessage.java b/src/main/java/com/yunhai/agent/entity/AgentMessage.java new file mode 100644 index 0000000..bcf66a7 --- /dev/null +++ b/src/main/java/com/yunhai/agent/entity/AgentMessage.java @@ -0,0 +1,51 @@ +package com.yunhai.agent.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.Table; + +/** + * 会话里的一条消息(system / user / assistant / tool),按 seq_no 保序。 + * + *

assistant 消息可能携带工具调用请求,序列化进 {@link #toolCalls}; + * tool 消息用于回填工具执行结果,关联 {@link #toolCallId}。 + */ +@Entity +@Table(name = "agent_message", indexes = { + @Index(name = "idx_agent_msg_sid", columnList = "session_id,seq_no") +}) +@Getter +@Setter +public class AgentMessage extends BaseEntity { + + /** 所属会话 id */ + @Column(name = "session_id", nullable = false, length = 64) + private String sessionId; + + /** 会话内顺序(从 0 递增) */ + @Column(name = "seq_no") + private Integer seqNo; + + /** 角色:system / user / assistant / tool */ + @Column(name = "role", length = 16) + private String role; + + /** 正文(可能为空:assistant 只想调工具时) */ + @Lob + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + /** assistant 的 tool_calls(JSON 数组字符串) */ + @Lob + @Column(name = "tool_calls", columnDefinition = "TEXT") + private String toolCalls; + + /** role=tool 时关联的调用 id */ + @Column(name = "tool_call_id", length = 64) + private String toolCallId; +} diff --git a/src/main/java/com/yunhai/agent/entity/AgentSession.java b/src/main/java/com/yunhai/agent/entity/AgentSession.java new file mode 100644 index 0000000..7f23742 --- /dev/null +++ b/src/main/java/com/yunhai/agent/entity/AgentSession.java @@ -0,0 +1,50 @@ +package com.yunhai.agent.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 智能体会话:一个用户在「一段连续对话」里的上下文台账。 + * + *

会话与 userId 绑定(跨用户复用同一 sessionId 会被拒绝并重建),承载多轮对话的归属与审计信息; + * 具体消息见 {@link AgentMessage}。 + */ +@Entity +@Table(name = "agent_session", indexes = { + @Index(name = "idx_agent_session_sid", columnList = "session_id", unique = true), + @Index(name = "idx_agent_session_user", columnList = "user_id") +}) +@Getter +@Setter +public class AgentSession extends BaseEntity { + + /** 会话 id(前端持有并复用) */ + @Column(name = "session_id", nullable = false, length = 64) + private String sessionId; + + /** 归属用户 id */ + @Column(name = "user_id") + private Integer userId; + + /** 学校 id */ + @Column(name = "sch_id") + private Integer schId; + + /** 会话标题(取首条用户输入,便于列表展示) */ + @Column(name = "title", length = 200) + private String title; + + /** 角色快照(学生 / 教师 / 管理员),审计留痕用 */ + @Column(name = "role_snapshot", length = 64) + private String roleSnapshot; + + /** 最后访问时间(空闲过期依据) */ + @Column(name = "last_access") + private LocalDateTime lastAccess; +} diff --git a/src/main/java/com/yunhai/agent/entity/AgentToolCallLog.java b/src/main/java/com/yunhai/agent/entity/AgentToolCallLog.java new file mode 100644 index 0000000..259f8b0 --- /dev/null +++ b/src/main/java/com/yunhai/agent/entity/AgentToolCallLog.java @@ -0,0 +1,81 @@ +package com.yunhai.agent.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.Table; + +/** + * 工具调用审计:每次对业务模块的真实调用留一条痕(含越权被拒的调用)。 + * + *

用途:可追溯「谁、什么身份、在哪个会话、调了哪个模块的什么接口、传了什么、成没成、耗时多少」, + * 既是运维排障依据,也是「分学生、教师权限」的合规凭证。 + */ +@Entity +@Table(name = "agent_tool_call_log", indexes = { + @Index(name = "idx_agent_log_sid", columnList = "session_id"), + @Index(name = "idx_agent_log_tool", columnList = "tool_name"), + @Index(name = "idx_agent_log_user", columnList = "user_id") +}) +@Getter +@Setter +public class AgentToolCallLog extends BaseEntity { + + /** 所属会话 id */ + @Column(name = "session_id", length = 64) + private String sessionId; + + /** 调用者用户 id */ + @Column(name = "user_id") + private Integer userId; + + /** 学校 id */ + @Column(name = "sch_id") + private Integer schId; + + /** 调用者角色(学生 / 教师 / 管理员) */ + @Column(name = "role", length = 64) + private String role; + + /** 业务模块标识 */ + @Column(name = "module", length = 64) + private String module; + + /** 工具名,如 leave.submit */ + @Column(name = "tool_name", length = 128) + private String toolName; + + /** 工具中文名 */ + @Column(name = "tool_title", length = 128) + private String toolTitle; + + /** 是否写操作 */ + @Column(name = "is_write") + private Boolean write; + + /** 调用参数(JSON) */ + @Lob + @Column(name = "arguments", columnDefinition = "TEXT") + private String arguments; + + /** 是否成功(越权拦截为 false) */ + @Column(name = "success") + private Boolean success; + + /** 失败原因 */ + @Lob + @Column(name = "error", columnDefinition = "TEXT") + private String error; + + /** 耗时(毫秒) */ + @Column(name = "cost_ms") + private Long costMs; + + /** 建议跳转路由 */ + @Column(name = "landing_path", length = 128) + private String landingPath; +} diff --git a/src/main/java/com/yunhai/agent/entity/BaseEntity.java b/src/main/java/com/yunhai/agent/entity/BaseEntity.java new file mode 100644 index 0000000..bf16887 --- /dev/null +++ b/src/main/java/com/yunhai/agent/entity/BaseEntity.java @@ -0,0 +1,46 @@ +package com.yunhai.agent.entity; + +import com.yunhai.agent.common.listener.AgentAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.MappedSuperclass; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link AgentAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(AgentAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** 创建人登录账号 */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** 创建时间 */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** 更新人登录账号 */ + @Column(name = "update_by") + private String updateBy; + + /** 更新时间 */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java b/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java new file mode 100644 index 0000000..0122ba6 --- /dev/null +++ b/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java @@ -0,0 +1,256 @@ +package com.yunhai.agent.llm; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.llm.dto.ChatMessage; +import com.yunhai.agent.llm.dto.ChatResult; +import com.yunhai.agent.llm.dto.ToolCall; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.List; + +/** + * 大模型客户端(OpenAI 兼容协议):默认对接 DeepSeek 官方 + * {@code https://api.deepseek.com/chat/completions},模型 {@code deepseek-flash}。 + *

模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。 + * + *

协议要点: + *

    + *
  • 请求体 {@code {model, messages, tools, tool_choice, max_tokens, temperature}}
  • + *
  • 响应 {@code choices[0].message.content} 为正文, + * {@code choices[0].message.tool_calls[]} 为工具调用请求
  • + *
  • 模型要求调用工具时 {@code finish_reason == "tool_calls"},此时 content 通常为空
  • + *
+ * + *

换供应商只要改 {@code agent.llm.host}(豆包方舟 / 千问兼容模式 / 自有网关都遵循该协议)。 + */ +@Slf4j +@Component +public class DeepSeekClient implements LlmClient { + + private static final String TOOL_CHOICE_AUTO = "auto"; + + private final RestTemplate restTemplate; + private final AgentSettings settings; + + public DeepSeekClient(@Qualifier("yhRestTemplate") RestTemplate restTemplate, AgentSettings settings) { + this.restTemplate = restTemplate; + this.settings = settings; + } + + @Override + public ChatResult chat(List messages, JSONArray tools) { + AgentSettings.Llm llm = settings.getLlm(); + if (StringUtils.isBlank(llm.getApiKey())) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, + "未配置大模型 api-key(agent.llm.api-key)"); + } + + JSONObject body = new JSONObject(); + body.put("model", llm.getModel()); + body.put("max_tokens", llm.getMaxTokens()); + + JSONArray msgArr = new JSONArray(); + for (ChatMessage m : messages) { + msgArr.add(m.toJson()); + } + body.put("messages", msgArr); + + // 现役 V4 系列(deepseek-flash / deepseek-v4-pro)temperature 与 tools 都支持。 + // 保留 isReasoner 判断只为兼容历史上"reasoner 不支持二者"的模型名,正常情况下恒为 false。 + if (!isReasoner(llm.getModel())) { + body.put("temperature", llm.getTemperature()); + if (tools != null && !tools.isEmpty()) { + body.put("tools", tools); + body.put("tool_choice", TOOL_CHOICE_AUTO); + } + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + llm.getApiKey()); + headers.set("Accept", "application/json"); + + String url = StringUtils.isNotBlank(llm.getHost()) ? llm.getHost() : llm.getFallbackHost(); + String requestBody = JSON.toJSONString(body); + + long start = System.currentTimeMillis(); + ResponseEntity resp; + try { + resp = restTemplate.postForEntity(url, new HttpEntity<>(requestBody, headers), String.class); + } catch (Exception e) { + log.error("调用大模型失败 url={} model={} err={}", url, llm.getModel(), e.toString()); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanize(e, url)); + } + long cost = System.currentTimeMillis() - start; + + String raw = resp.getBody(); + if (StringUtils.isBlank(raw)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回为空"); + } + + JSONObject json; + try { + json = JSON.parseObject(raw); + } catch (Exception e) { + log.error("大模型返回非 JSON:{}", StringUtils.abbreviate(raw, 500)); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回格式异常"); + } + + if (json.containsKey("error")) { + JSONObject err = json.getJSONObject("error"); + String msg = err == null ? null : err.getString("message"); + String code = err == null ? null : err.getString("code"); + log.error("大模型返回错误:code={} msg={}", code, msg); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanizeApiError(msg)); + } + + ChatResult result = new ChatResult(); + result.setRaw(raw); + + JSONArray choices = json.getJSONArray("choices"); + if (choices == null || choices.isEmpty()) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型未返回任何选择"); + } + JSONObject message = choices.getJSONObject(0).getJSONObject("message"); + if (message != null) { + // 部分模型把 content 放在嵌套结构里(兼容历史网关),这里做一次宽容取值 + Object contentObj = message.get("content"); + if (contentObj instanceof String) { + result.setContent((String) contentObj); + } else if (contentObj != null) { + result.setContent(JSON.toJSONString(contentObj)); + } + JSONArray toolCalls = message.getJSONArray("tool_calls"); + if (toolCalls != null) { + List list = new ArrayList<>(); + for (int i = 0; i < toolCalls.size(); i++) { + JSONObject tc = toolCalls.getJSONObject(i); + ToolCall call = new ToolCall(); + call.setId(tc.getString("id")); + call.setType(tc.getString("type")); + JSONObject fn = tc.getJSONObject("function"); + if (fn != null) { + ToolCall.Function f = new ToolCall.Function(); + f.setName(fn.getString("name")); + f.setArguments(fn.getString("arguments")); + call.setFunction(f); + } + list.add(call); + } + result.setToolCalls(list); + } + } + + JSONObject usage = json.getJSONObject("usage"); + if (usage != null) { + result.setPromptTokens(usage.getInteger("prompt_tokens")); + result.setCompletionTokens(usage.getInteger("completion_tokens")); + } + + log.info("大模型调用完成 cost={}ms tools={} tokens={}/{}", + cost, result.getToolCalls().size(), + result.getPromptTokens(), result.getCompletionTokens()); + return result; + } + + /** + * 把底层 HTTP 异常翻译成使用者能看懂的话。 + * + *

典型场景:密钥无效时服务端回 401,而请求体是流式发送的,JDK 的 {@code HttpURLConnection} + * 会抛出 {@code HttpRetryException: cannot retry due to server authentication, in streaming mode} + * —— 这句话直接抛给前端毫无信息量,需要还原成「密钥无效」。 + */ + private static String humanize(Throwable e, String url) { + Integer status = findStatus(e); + if (status != null && (status == 401 || status == 403)) { + return "大模型鉴权失败(HTTP " + status + "):api-key 无效、已过期或无权限," + + "请检查 agent.llm.api-key(或环境变量 DEEPSEEK_API_KEY)"; + } + if (status != null && status == 402) { + return "大模型账户余额不足(HTTP 402):请前往大模型平台充值后重试"; + } + if (status != null && status == 429) { + return "大模型请求过于频繁或账户余额不足(HTTP 429),请稍后重试或检查账户额度"; + } + if (status != null && status >= 500) { + return "大模型服务端异常(HTTP " + status + "),请稍后重试"; + } + + String m = e.getMessage() == null ? e.toString() : e.getMessage(); + if (m.contains("cannot retry due to server authentication")) { + return "大模型鉴权失败:api-key 无效或未配置,请检查 agent.llm.api-key"; + } + if (m.contains("UnknownHost")) { + return "无法解析大模型地址 " + url + ",请检查网络或 agent.llm.host 配置"; + } + if (m.contains("onnection refused") || m.contains("connect timed out")) { + return "无法连接大模型地址 " + url + ",请检查网络与代理设置"; + } + if (m.contains("imed out") || m.contains("imeout")) { + return "调用大模型超时,请稍后重试(可调大 yh.http.read-timeout-ms)"; + } + return "大模型服务不可用:" + m; + } + + /** + * 把大模型返回体里的 {@code error.message} 翻成人话。 + * + *

该分支拿不到 HTTP 状态码(平台 RestTemplate 对 4xx 不回抛),只能按文案特征识别。 + * 典型:余额为 0 时 DeepSeek 回 {@code Insufficient Balance}(对应 HTTP 402), + * 原样抛给使用者等于没说清「要充值」。 + */ + private static String humanizeApiError(String msg) { + String m = msg == null ? "" : msg; + String lower = m.toLowerCase(); + if (lower.contains("insufficient balance") || lower.contains("insufficient_quota") + || lower.contains("exceeded your current quota") || lower.contains("payment required")) { + return "大模型账户余额不足(" + m + "):请前往大模型平台充值后再试"; + } + if (lower.contains("authentication fails") || lower.contains("invalid api key") + || lower.contains("invalid_api_key") || lower.contains("unauthorized")) { + return "大模型鉴权失败(" + m + "):api-key 无效或已过期,请检查 agent.llm.api-key"; + } + if (lower.contains("rate limit") || lower.contains("too many requests")) { + return "大模型请求过于频繁(" + m + "),请稍后重试"; + } + if (lower.contains("model not exist") || lower.contains("model_not_found") + || lower.contains("invalid model") || lower.contains("does not exist")) { + return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models " + + "核对 agent.llm.model(当前应为 deepseek-flash 或 deepseek-v4-pro)"; + } + return "大模型错误:" + m; + } + + /** 从异常链里挖出 HTTP 状态码(HttpRetryException / HttpStatusCodeException 两种常见载体) */ + private static Integer findStatus(Throwable e) { + for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) { + if (t instanceof java.net.HttpRetryException) { + int code = ((java.net.HttpRetryException) t).responseCode(); + if (code > 0) { + return code; + } + } else if (t instanceof org.springframework.web.client.HttpStatusCodeException) { + return ((org.springframework.web.client.HttpStatusCodeException) t).getRawStatusCode(); + } + } + return null; + } + + private boolean isReasoner(String model) { + return model != null && model.toLowerCase().contains("reasoner"); + } +} diff --git a/src/main/java/com/yunhai/agent/llm/LlmClient.java b/src/main/java/com/yunhai/agent/llm/LlmClient.java new file mode 100644 index 0000000..0f1231b --- /dev/null +++ b/src/main/java/com/yunhai/agent/llm/LlmClient.java @@ -0,0 +1,22 @@ +package com.yunhai.agent.llm; + +import com.alibaba.fastjson.JSONArray; +import com.yunhai.agent.llm.dto.ChatMessage; +import com.yunhai.agent.llm.dto.ChatResult; + +import java.util.List; + +/** + * 大模型客户端抽象:便于在 DeepSeek / 豆包 / 自有网关之间切换(配置驱动)。 + */ +public interface LlmClient { + + /** + * 发起一次对话补全。 + * + * @param messages 对话上下文(含 system) + * @param tools 可调用的工具 schema(OpenAI tools 格式);为空表示本轮不开放工具 + * @return 模型结果(正文 + 工具调用请求) + */ + ChatResult chat(List messages, JSONArray tools); +} diff --git a/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java b/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java new file mode 100644 index 0000000..26b9d5f --- /dev/null +++ b/src/main/java/com/yunhai/agent/llm/dto/ChatMessage.java @@ -0,0 +1,83 @@ +package com.yunhai.agent.llm.dto; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; + +/** + * 一条对话消息(OpenAI / DeepSeek chat 格式)。 + * + *

四种角色: + *

    + *
  • {@code system} — 系统提示词
  • + *
  • {@code user} — 用户输入
  • + *
  • {@code assistant} — 模型输出(可能带 {@link #toolCalls})
  • + *
  • {@code tool} — 工具执行结果回填(必须带 {@link #toolCallId})
  • + *
+ */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessage { + + public static final String ROLE_SYSTEM = "system"; + public static final String ROLE_USER = "user"; + public static final String ROLE_ASSISTANT = "assistant"; + public static final String ROLE_TOOL = "tool"; + + private String role; + private String content; + + /** assistant 消息里的工具调用请求 */ + private List toolCalls; + + /** role=tool 时必填:对应哪一次工具调用 */ + private String toolCallId; + + public ChatMessage(String role, String content) { + this.role = role; + this.content = content; + } + + public static ChatMessage system(String content) { + return new ChatMessage(ROLE_SYSTEM, content); + } + + public static ChatMessage user(String content) { + return new ChatMessage(ROLE_USER, content); + } + + public static ChatMessage tool(String toolCallId, String content) { + ChatMessage m = new ChatMessage(ROLE_TOOL, content); + m.setToolCallId(toolCallId); + return m; + } + + /** 序列化为 DeepSeek 要求的消息结构(null 字段不输出) */ + public JSONObject toJson() { + JSONObject o = new JSONObject(); + o.put("role", role); + if (content != null) { + o.put("content", content); + } else if (ROLE_ASSISTANT.equals(role)) { + // 带工具调用的 assistant 消息 content 允许为 null,但部分网关要求显式空串 + o.put("content", ""); + } + if (toolCalls != null && !toolCalls.isEmpty()) { + JSONArray arr = new JSONArray(); + for (ToolCall tc : toolCalls) { + arr.add(tc.toJson()); + } + o.put("tool_calls", arr); + } + if (StringUtils.isNotBlank(toolCallId)) { + o.put("tool_call_id", toolCallId); + } + return o; + } +} diff --git a/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java b/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java new file mode 100644 index 0000000..a1c349f --- /dev/null +++ b/src/main/java/com/yunhai/agent/llm/dto/ChatResult.java @@ -0,0 +1,30 @@ +package com.yunhai.agent.llm.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 一次模型调用的结果:要么是自然语言回复,要么是若干工具调用请求(也可能两者都有)。 + */ +@Data +public class ChatResult { + + /** 模型回答正文(可能为空:当它只想调工具时) */ + private String content; + + /** 模型要调用的工具 */ + private List toolCalls = new ArrayList<>(); + + /** 原始响应(排障用) */ + private String raw; + + /** token 用量,便于成本观测 */ + private Integer promptTokens; + private Integer completionTokens; + + public boolean hasToolCalls() { + return toolCalls != null && !toolCalls.isEmpty(); + } +} diff --git a/src/main/java/com/yunhai/agent/llm/dto/ToolCall.java b/src/main/java/com/yunhai/agent/llm/dto/ToolCall.java new file mode 100644 index 0000000..bd68e4d --- /dev/null +++ b/src/main/java/com/yunhai/agent/llm/dto/ToolCall.java @@ -0,0 +1,38 @@ +package com.yunhai.agent.llm.dto; + +import com.alibaba.fastjson.JSONObject; +import lombok.Data; + +/** + * 模型请求调用某个工具。 + */ +@Data +public class ToolCall { + + /** 调用 id,回填结果时原样带回 */ + private String id; + + /** 固定为 function */ + private String type = "function"; + + private Function function; + + @Data + public static class Function { + /** 工具名(对应 ToolRegistry 里注册的 name) */ + private String name; + /** 参数 JSON 字符串(由模型生成,不一定合法,执行前要容错解析) */ + private String arguments; + } + + public JSONObject toJson() { + JSONObject o = new JSONObject(); + o.put("id", id); + o.put("type", type == null ? "function" : type); + JSONObject f = new JSONObject(); + f.put("name", function == null ? null : function.getName()); + f.put("arguments", function == null ? "{}" : function.getArguments()); + o.put("function", f); + return o; + } +} diff --git a/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java b/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java new file mode 100644 index 0000000..56f13b7 --- /dev/null +++ b/src/main/java/com/yunhai/agent/model/BrowserToolRequest.java @@ -0,0 +1,56 @@ +package com.yunhai.agent.model; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 「浏览器代执行」挂起载荷(方案 A)。 + * + *

业务模块的用户态接口只认浏览器 CAS 会话,服务端代调必然 302。开启代执行后, + * 模型决定调用模块工具时,服务端不执行,而是把接口描述放进本载荷下发前端; + * 前端用用户自己的登录态调用模块接口,再把结果通过 + * {@link ChatRequest.ToolResultItem} 回传,服务端续跑模型生成最终回答。 + * + *

★ 安全边界:本载荷里的 {@code pathAndQuery} 只供前端调用使用; + * 回传续跑时服务端只信任工具名(重新查注册表拿方法/路径并做角色校验), + * 前端回传的任何路径信息都不会被采信,防止被改写成任意地址。 + */ +@Data +public class BrowserToolRequest { + + /** 本批代执行的工具调用(同一轮模型可能连调多个,如先查列表再查详情) */ + private List calls = new ArrayList<>(); + + /** 给用户看的一句话说明(前端在等待期展示) */ + private String notice; + + /** + * 一次代执行调用的完整描述。 + *

{@code pathAndQuery} 以 {@code /} 开头({@code /<模块ctx>/api/...?query}), + * 前端拼上当前页面的 {@code location.origin} 即为最终地址 —— + * 同站部署时天然同源;本地 dev 则会打到 vite 代理(404)→ 自动落回深链兜底。 + */ + @Data + public static class BrowserCall { + /** 工具内部名(点分),回传结果时原样带回 */ + private String name; + /** 中文简称(进度展示) */ + private String title; + /** 模型给的参数(回显给用户看「在查什么」;回传时原样带回) */ + private Map arguments; + /** 是否写操作 */ + private boolean write; + + /** GET / POST_JSON / POST_FORM */ + private String method; + /** 相对当前站点根的路径与查询串,如 /home-leave/api/activity/List?keyword=运动会 */ + private String pathAndQuery; + /** POST 时的请求体(GET 为 null) */ + private String body; + /** POST 时的 Content-Type */ + private String contentType; + } +} diff --git a/src/main/java/com/yunhai/agent/model/ChatRequest.java b/src/main/java/com/yunhai/agent/model/ChatRequest.java new file mode 100644 index 0000000..045f289 --- /dev/null +++ b/src/main/java/com/yunhai/agent/model/ChatRequest.java @@ -0,0 +1,61 @@ +package com.yunhai.agent.model; + +import lombok.Data; + +import java.util.Map; + +/** + * 前端发起的一次对话请求。 + */ +@Data +public class ChatRequest { + + /** 会话 id;首次为空,服务端生成并在响应里带回,前端后续复用 */ + private String sessionId; + + /** 用户输入的自然语言 */ + private String message; + + /** 是否允许调用工具(默认允许);调试/纯问答可置 false */ + private Boolean enableTools = true; + + /** 用户对上一次写操作的确认结果(预留:二次确认场景) */ + private Boolean confirmed; + + /** + * 表单提交:用户在补全表单里填完直接提交。 + *

非空时服务端直接执行指定工具(不再让模型猜该不该调、参数是什么), + * 保证「填了就一定按填的提交」。 + */ + private FormSubmit formSubmit; + + /** + * 浏览器代执行的结果回传(方案 A):与 {@link #formSubmit} 同级的「续跑」入口。 + *

前端用用户登录态调用模块接口后,把每个调用的原始响应带回; + * 服务端重新查注册表解析结果、喂回模型生成最终回答。 + */ + private java.util.List toolResult; + + /** 一次代执行调用的回传项(与挂起载荷里的 BrowserCall 按 name 对应) */ + @Data + public static class ToolResultItem { + /** 工具内部名(点分),与挂起时一致 */ + private String name; + /** 模型参数原样带回(审计与轨迹展示用) */ + private Map arguments; + /** HTTP 状态码;网络层失败时为 null */ + private Integer status; + /** 响应体原文 */ + private String body; + /** 网络层错误(fetch 失败/超时/跨域),有值时 status 无意义 */ + private String error; + } + + @Data + public static class FormSubmit { + /** 目标工具名,如 leave.submit */ + private String tool; + /** 用户填写的参数(键与业务接口参数名一致) */ + private Map args; + } +} diff --git a/src/main/java/com/yunhai/agent/model/ChatResponse.java b/src/main/java/com/yunhai/agent/model/ChatResponse.java new file mode 100644 index 0000000..559c164 --- /dev/null +++ b/src/main/java/com/yunhai/agent/model/ChatResponse.java @@ -0,0 +1,48 @@ +package com.yunhai.agent.model; + +import com.yunhai.agent.tool.FormRequest; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 一次对话的完整结果。 + */ +@Data +public class ChatResponse { + + private String sessionId; + + /** 智能体最终回复(自然语言) */ + private String reply; + + /** 本轮执行过的工具调用(按顺序) */ + private List toolCalls = new ArrayList<>(); + + /** 是否发生了写操作(前端据此提示「已为你提交」) */ + private boolean wroteSomething; + + /** 建议跳转的路由(写操作后给「去查看」按钮用) */ + private String landingPath; + + /** 循环轮数 / token 用量,便于观测 */ + private int rounds; + private Integer promptTokens; + private Integer completionTokens; + + /** 会话被重置或异常时的提示 */ + private String warning; + + /** + * 信息不全时下发的「补全表单」:前端渲染成可填卡片,用户填完直接提交。 + *

本轮正常执行完毕(或纯问答)时为 null。 + */ + private FormRequest form; + + /** + * 「浏览器代执行」挂起载荷(方案 A):非空表示本轮工具调用需要 + * 前端用用户登录态调用模块接口并回传结果({@code toolResult})续跑。 + */ + private BrowserToolRequest browserTool; +} diff --git a/src/main/java/com/yunhai/agent/model/ToolCallTrace.java b/src/main/java/com/yunhai/agent/model/ToolCallTrace.java new file mode 100644 index 0000000..0d7fb90 --- /dev/null +++ b/src/main/java/com/yunhai/agent/model/ToolCallTrace.java @@ -0,0 +1,32 @@ +package com.yunhai.agent.model; + +import lombok.Data; + +import java.util.Map; + +/** + * 一次工具调用的执行轨迹(回传前端渲染「智能体做了什么」)。 + */ +@Data +public class ToolCallTrace { + + private String name; + private String title; + + /** 业务模块标识(前端据此拼出该模块页面地址做「去处理」跳转) */ + private String module; + + /** 模型给出的参数(已脱敏:不含身份信息) */ + private Map arguments; + + private boolean success = true; + private String error; + + /** 是否产生副作用的写操作 */ + private boolean write; + + /** 执行后建议跳转的前端路由 */ + private String landingPath; + + private long costMs; +} diff --git a/src/main/java/com/yunhai/agent/repository/AgentMessageRepository.java b/src/main/java/com/yunhai/agent/repository/AgentMessageRepository.java new file mode 100644 index 0000000..032962e --- /dev/null +++ b/src/main/java/com/yunhai/agent/repository/AgentMessageRepository.java @@ -0,0 +1,23 @@ +package com.yunhai.agent.repository; + +import com.yunhai.agent.entity.AgentMessage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 会话消息仓储。 + */ +public interface AgentMessageRepository extends JpaRepository { + + List findBySessionIdOrderBySeqNoAsc(String sessionId); + + @Transactional + @Modifying + @Query("delete from AgentMessage m where m.sessionId = :sessionId") + int deleteBySessionId(@Param("sessionId") String sessionId); +} diff --git a/src/main/java/com/yunhai/agent/repository/AgentSessionRepository.java b/src/main/java/com/yunhai/agent/repository/AgentSessionRepository.java new file mode 100644 index 0000000..0be7378 --- /dev/null +++ b/src/main/java/com/yunhai/agent/repository/AgentSessionRepository.java @@ -0,0 +1,34 @@ +package com.yunhai.agent.repository; + +import com.yunhai.agent.entity.AgentSession; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * 智能体会话仓储。 + */ +public interface AgentSessionRepository extends JpaRepository { + + Optional findBySessionId(String sessionId); + + /** 空闲过期扫描:最后访问时间早于给定时刻的会话 */ + List findByLastAccessBefore(LocalDateTime time); + + /** 窄列更新最后访问时间 */ + @Transactional + @Modifying + @Query("update AgentSession s set s.lastAccess = :now where s.sessionId = :sessionId") + int touch(@Param("sessionId") String sessionId, @Param("now") LocalDateTime now); + + @Transactional + @Modifying + @Query("delete from AgentSession s where s.sessionId = :sessionId") + int deleteBySessionId(@Param("sessionId") String sessionId); +} diff --git a/src/main/java/com/yunhai/agent/repository/AgentToolCallLogRepository.java b/src/main/java/com/yunhai/agent/repository/AgentToolCallLogRepository.java new file mode 100644 index 0000000..39adfb0 --- /dev/null +++ b/src/main/java/com/yunhai/agent/repository/AgentToolCallLogRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.agent.repository; + +import com.yunhai.agent.entity.AgentToolCallLog; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 工具调用审计仓储。 + */ +public interface AgentToolCallLogRepository extends JpaRepository { + + List findBySessionIdOrderByIdAsc(String sessionId); + + long countByToolName(String toolName); + + long countBySuccess(Boolean success); +} diff --git a/src/main/java/com/yunhai/agent/service/AgentAuditService.java b/src/main/java/com/yunhai/agent/service/AgentAuditService.java new file mode 100644 index 0000000..9730f13 --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/AgentAuditService.java @@ -0,0 +1,69 @@ +package com.yunhai.agent.service; + +import com.alibaba.fastjson.JSON; +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.agent.entity.AgentToolCallLog; +import com.yunhai.agent.repository.AgentToolCallLogRepository; +import com.yunhai.agent.tool.CallerContext; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; + +/** + * 工具调用审计服务:把每一次工具执行(含被角色拦截的)写入 {@code agent_tool_call_log}。 + * + *

用独立事务({@code REQUIRES_NEW})落库,避免审计写入失败影响主对话链路。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentAuditService { + + private final AgentToolCallLogRepository logRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void record(String sessionId, CallerContext caller, ToolDef def, + Map args, ToolResult result) { + try { + AgentToolCallLog e = new AgentToolCallLog(); + e.setSessionId(sessionId); + e.setUserId(caller == null ? null : caller.userIdAsInt()); + e.setSchId(caller == null ? null : caller.schoolIdAsInt()); + e.setRole(roleText(caller == null ? null : caller.getRoles())); + e.setModule(def.getModule()); + e.setToolName(def.getName()); + e.setToolTitle(def.getTitle()); + e.setWrite(!def.isReadOnly()); + e.setArguments(args == null ? null : JSON.toJSONString(args)); + e.setSuccess(result.isSuccess()); + e.setError(result.getError()); + e.setCostMs(result.getCostMs()); + e.setLandingPath(def.getLandingPath()); + logRepository.save(e); + } catch (Exception ex) { + // 审计失败不阻断主流程 + log.warn("工具调用审计落库失败:{}", ex.toString()); + } + } + + private String roleText(Set roles) { + if (roles == null || roles.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (AgentRole r : roles) { + if (sb.length() > 0) { + sb.append(","); + } + sb.append(r.name()); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/yunhai/agent/service/AgentEvent.java b/src/main/java/com/yunhai/agent/service/AgentEvent.java new file mode 100644 index 0000000..f1584ce --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/AgentEvent.java @@ -0,0 +1,34 @@ +package com.yunhai.agent.service; + +import lombok.Data; + +/** + * 编排过程中的一个阶段事件(用于 SSE 推送给前端做「正在思考 / 正在调用 XX」的实时反馈)。 + */ +@Data +public class AgentEvent { + + /** thinking / tool_call / tool_result / message / done / error */ + private String type; + + /** 事件文本(展示用) */ + private String text; + + /** 结构化负载(工具名、参数、结果等) */ + private Object payload; + + public static AgentEvent of(String type, String text) { + AgentEvent e = new AgentEvent(); + e.type = type; + e.text = text; + return e; + } + + public static AgentEvent of(String type, String text, Object payload) { + AgentEvent e = new AgentEvent(); + e.type = type; + e.text = text; + e.payload = payload; + return e; + } +} diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java new file mode 100644 index 0000000..3452ee9 --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -0,0 +1,930 @@ +package com.yunhai.agent.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.llm.LlmClient; +import com.yunhai.agent.llm.dto.ChatMessage; +import com.yunhai.agent.llm.dto.ChatResult; +import com.yunhai.agent.llm.dto.ToolCall; +import com.yunhai.agent.model.BrowserToolRequest; +import com.yunhai.agent.model.ChatRequest; +import com.yunhai.agent.model.ChatResponse; +import com.yunhai.agent.model.ToolCallTrace; +import com.yunhai.agent.tool.CallerContext; +import com.yunhai.agent.tool.FormField; +import com.yunhai.agent.tool.FormRequest; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolExecutor; +import com.yunhai.agent.tool.ToolParam; +import com.yunhai.agent.tool.ToolRegistry; +import com.yunhai.agent.tool.ToolResult; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 智能体编排核心(ReAct 风格的工具调用循环): + * + *

+ *  用户输入
+ *    └─► 组装 system + 历史 + 本轮输入
+ *         └─► 调大模型(带上「按角色过滤后」的可用工具 schema)
+ *              ├─ 模型返回 tool_calls ─┬─ 写操作缺必填参数 ─► 【回补全表单】给用户填(不再口头追问)
+ *              │                        └─ 参数齐全 ─► 逐个执行(转成对该模块的真实 HTTP 调用)
+ *              │                                        ├─ 角色越权 ─► 服务端直接拒绝(留痕)
+ *              │                                        └─► 结果回填为 role=tool 消息 ─► 再次调模型
+ *              └─ 模型返回普通文本 ─► 这就是最终回复,结束
+ * 
+ * + *

表单提交({@link ChatRequest#getFormSubmit()})走另一条短路:直接执行指定工具, + * 不再让模型二次决策,保证「用户填了什么就提交什么」。 + */ +@Slf4j +@Service +public class AgentOrchestrator { + + private final AgentSettings settings; + private final LlmClient llmClient; + private final ToolRegistry registry; + private final ToolExecutor executor; + private final SessionStore sessionStore; + private final AgentAuditService auditService; + + @Autowired + public AgentOrchestrator(AgentSettings settings, LlmClient llmClient, ToolRegistry registry, + ToolExecutor executor, SessionStore sessionStore, + AgentAuditService auditService) { + this.settings = settings; + this.llmClient = llmClient; + this.registry = registry; + this.executor = executor; + this.sessionStore = sessionStore; + this.auditService = auditService; + } + + /** + * 执行一轮对话。 + * + * @param req 请求(含用户输入、会话 id,或表单提交) + * @param userDesc 用户身份描述(姓名/角色/学校),用于写进 system prompt + * @param caller 调用者身份(透传给业务模块 + 角色过滤/校验) + * @param authorizedModIds 已授权模块号集合(可空 = 不过滤) + * @param sink 阶段事件出口(同步传 NOOP,SSE 传真实推送) + */ + public ChatResponse chat(ChatRequest req, String userDesc, CallerContext caller, + Set authorizedModIds, EventSink sink) { + EventSink out = sink == null ? EventSink.NOOP : sink; + + // 浏览器代执行结果回传:续跑(方案 A) + if (req != null && req.getToolResult() != null && !req.getToolResult().isEmpty()) { + return resumeBrowser(req, caller, authorizedModIds, out); + } + + // 表单提交:短路直达执行 + if (req != null && req.getFormSubmit() != null + && StringUtils.isNotBlank(req.getFormSubmit().getTool())) { + return submitForm(req, userDesc, caller, authorizedModIds, out); + } + + if (req == null || StringUtils.isBlank(req.getMessage())) { + throw new IllegalArgumentException("请输入内容"); + } + + Set roles = caller.getRoles(); + SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(), + caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles)); + session.setTitle(abbreviate(req.getMessage())); + + ChatResponse resp = new ChatResponse(); + resp.setSessionId(session.getSessionId()); + + List messages = session.getMessages(); + if (messages.isEmpty()) { + messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles))); + } + messages.add(ChatMessage.user(req.getMessage())); + + boolean enableTools = req.getEnableTools() == null || req.getEnableTools(); + return runLoop(session, messages, resp, caller, authorizedModIds, roles, enableTools, false, out); + } + + /** + * 模型主循环(ReAct):思考 → 需要工具时「挂起等浏览器」或「服务端执行」→ 再思考,直到给出自然语言回复。 + * + *

★ 浏览器代执行(方案 A):业务模块的用户态接口只认浏览器 CAS 会话, + * 服务端直调必然 {@code code=302}(README 第 11 节四条证据)。开启 {@code agent.browser-exec.enabled} + * 且本轮工具都能准备成功时,整批挂起下发给前端,用用户自己的登录态执行后回传续跑({@link #resumeBrowser}); + * 准备失败(未知工具 / 模块未配置地址 / 缺参 / 越权)则回退服务端直调老路径(行为与历史版本一致)。 + */ + private ChatResponse runLoop(SessionStore.SessionState session, List messages, + ChatResponse resp, CallerContext caller, Set authorizedModIds, + Set roles, boolean enableTools, boolean resuming, EventSink out) { + JSONArray tools = enableTools ? registry.toOpenAiTools(authorizedModIds, roles) : null; + + int maxRounds = settings.getLoop().getMaxRounds(); + int totalPrompt = 0; + int totalCompletion = 0; + String lastContent = null; + + for (int round = 1; round <= maxRounds; round++) { + resp.setRounds(round); + String thinking = round == 1 + ? (resuming ? "正在根据查询结果整理回答…" : "正在理解你的需求…") + : "正在根据结果继续处理…"; + out.emit(AgentEvent.of("thinking", thinking)); + + ChatResult result; + try { + result = llmClient.chat(messages, tools); + } catch (Exception e) { + String msg = e.getMessage() == null ? "大模型调用失败" : e.getMessage(); + out.emit(AgentEvent.of("error", msg)); + resp.setReply("抱歉,我这边连接智能服务失败了:" + msg); + resp.setWarning(msg); + return resp; + } + + totalPrompt += nz(result.getPromptTokens()); + totalCompletion += nz(result.getCompletionTokens()); + lastContent = StringUtils.isNotBlank(result.getContent()) ? result.getContent() : lastContent; + + if (!result.hasToolCalls()) { + // 没有工具调用 → 这一条就是最终回复 + String reply = StringUtils.isNotBlank(result.getContent()) + ? result.getContent() + : "(模型没有返回内容,请换个说法再试一次)"; + messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply)); + resp.setReply(reply); + finish(resp, session, totalPrompt, totalCompletion, out); + return resp; + } + + // 记录 assistant 的 tool_calls(后续必须以 role=tool 逐条应答,否则上下文非法) + ChatMessage assistantMsg = new ChatMessage(ChatMessage.ROLE_ASSISTANT, result.getContent()); + assistantMsg.setToolCalls(result.getToolCalls()); + messages.add(assistantMsg); + + // ★ 预检:只要有一个「写操作」缺必填参数,就整体中断并改出表单。 + // 放在执行任何工具之前,避免消息历史里出现「有 tool_call 却没有对应 tool 结果」的非法上下文。 + ChatResponse pending = interceptMissing(result.getToolCalls(), messages, caller, + session.getSessionId(), out); + if (pending != null) { + pending.setSessionId(session.getSessionId()); + pending.setRounds(round); + finish(pending, session, totalPrompt, totalCompletion, out); + return pending; + } + + // ★ 浏览器代执行:整批可准备成功 → 挂起(撤销本轮 assistant(tool_calls),等浏览器回传续跑) + if (settings.getBrowserExec().isEnabled()) { + List batch = + tryPrepareBrowserBatch(result.getToolCalls(), caller); + if (batch != null) { + messages.remove(messages.size() - 1); + + BrowserToolRequest bt = new BrowserToolRequest(); + bt.setCalls(batch); + String titles = batch.get(0).getTitle() + + (batch.size() > 1 ? " 等 " + batch.size() + " 项查询" : ""); + bt.setNotice("「" + titles + "」需要用你的登录态获取,正在通过你的浏览器执行…"); + resp.setBrowserTool(bt); + resp.setReply(bt.getNotice()); + out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt)); + log.info("浏览器代执行挂起:session={} 工具={}", session.getSessionId(), + batch.stream().map(BrowserToolRequest.BrowserCall::getName) + .collect(java.util.stream.Collectors.joining(","))); + finish(resp, session, totalPrompt, totalCompletion, out); + return resp; + } + } + + for (ToolCall call : result.getToolCalls()) { + String toolName = call.getFunction() == null ? null : call.getFunction().getName(); + ToolDef def = toolName == null ? null : registry.get(toolName); + Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments()); + + if (def == null) { + // 模型幻觉出了不存在的工具:如实告知,让它改正 + String err = "不存在的工具:" + toolName; + out.emit(AgentEvent.of("tool_result", "工具不可用:" + toolName)); + messages.add(ChatMessage.tool(call.getId(), "{\"success\":false,\"error\":\"" + err + "\"}")); + ToolCallTrace trace = new ToolCallTrace(); + trace.setName(String.valueOf(toolName)); + trace.setTitle("未知工具"); + trace.setSuccess(false); + trace.setError(err); + resp.getToolCalls().add(trace); + continue; + } + + out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), + payloadOf(def, args))); + + ToolResult tr = executor.execute(def, args, caller); + auditService.record(session.getSessionId(), caller, def, args, tr); + + messages.add(ChatMessage.tool(call.getId(), tr.getContent())); + + ToolCallTrace trace = buildTrace(def, args, tr); + resp.getToolCalls().add(trace); + + if (!def.isReadOnly()) { + resp.setWroteSomething(true); + if (StringUtils.isNotBlank(def.getLandingPath())) { + resp.setLandingPath(def.getLandingPath()); + } + } + + out.emit(AgentEvent.of("tool_result", + def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()), + payloadOf(def, args))); + + log.info("工具 {} 执行 {} 耗时 {}ms", def.getName(), + tr.isSuccess() ? "成功" : "失败(" + tr.getError() + ")", tr.getCostMs()); + } + // 继续下一轮,让模型基于工具结果生成自然语言回复 + } + + // 超过最大轮数:把最后一次内容兜底返回,避免"空转" + String reply = StringUtils.isNotBlank(lastContent) + ? lastContent + : "这个需求需要多步操作,我已经执行到这里,建议你到对应页面确认一下结果。"; + messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply)); + resp.setReply(reply); + resp.setWarning("已达到单轮最大处理步数"); + finish(resp, session, totalPrompt, totalCompletion, out); + return resp; + } + + /** + * 尝试把本轮所有工具调用准备成一个「浏览器代执行」批次。 + * + * @return 批次;null = 无法整批代执行(存在未知工具 / 模块未配置地址 / 参数不合法 / 越权), + * 调用方回退服务端直调老路径 —— 保证任何情况下消息历史的 tool_call/tool 结果配对完整。 + */ + private List tryPrepareBrowserBatch(List calls, + CallerContext caller) { + List batch = new ArrayList<>(); + for (ToolCall call : calls) { + String toolName = call.getFunction() == null ? null : call.getFunction().getName(); + ToolDef def = registry.get(toolName); + if (def == null || !settings.getModules().containsKey(def.getModule())) { + return null; + } + // 越权的调用不进批次(浏览器白跑一趟没有意义),交回老路径立即拒绝 + if (executor.checkAccess(def, caller) != null) { + return null; + } + Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments()); + try { + batch.add(executor.prepareBrowserCall(def, args)); + } catch (Exception e) { + log.info("代执行批次准备失败({}),回退服务端执行:{}", def.getName(), e.getMessage()); + return null; + } + } + return batch; + } + + /** + * 浏览器代执行的续跑:前端已用用户自己的登录态调完模块接口并带回原始响应。 + * + *

服务端重新查注册表解析结果(角色校验、{@code code=302} 识别等与服务端执行完全同源, + * 不信任前端回传的任何路径信息),构造合法的 {@code assistant(tool_calls) + tool} 消息 + * 喂回模型继续 —— 模型既可以整理出最终回答,也可以发起下一批工具调用(链式查询)。 + */ + private ChatResponse resumeBrowser(ChatRequest req, CallerContext caller, + Set authorizedModIds, EventSink out) { + Set roles = caller.getRoles(); + SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(), + caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles)); + + ChatResponse resp = new ChatResponse(); + resp.setSessionId(session.getSessionId()); + + List messages = session.getMessages(); + if (messages.isEmpty()) { + resp.setReply("会话已过期,请重新说一次你的需求。"); + resp.setWarning("browser-resume:会话没有历史消息(服务重启或已过期)"); + finish(resp, session, 0, 0, out); + return resp; + } + + String idPrefix = "br_" + System.currentTimeMillis() + "_"; + ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null); + List calls = new ArrayList<>(); + List contents = new ArrayList<>(); + int idx = 0; + + for (ChatRequest.ToolResultItem item : req.getToolResult()) { + Map args = item.getArguments() == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(item.getArguments()); + ToolDef def = registry.get(item.getName()); + + ToolResult tr; + if (def == null) { + tr = unknownToolResult(item.getName()); + } else { + ToolResult denied = executor.checkAccess(def, caller); + if (denied != null) { + tr = denied; + } else if (StringUtils.isNotBlank(item.getError())) { + tr = ToolResult.fail(def, "浏览器调用未完成:" + item.getError(), 0); + } else { + tr = executor.parseBrowserResponse(def, item.getStatus(), item.getBody(), 0); + } + auditService.record(session.getSessionId(), caller, def, args, tr); + resp.getToolCalls().add(buildTrace(def, args, tr)); + if (!def.isReadOnly()) { + resp.setWroteSomething(true); + if (StringUtils.isNotBlank(def.getLandingPath())) { + resp.setLandingPath(def.getLandingPath()); + } + } + } + + out.emit(AgentEvent.of("tool_result", + (def == null ? item.getName() : def.getTitle()) + + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()))); + log.info("浏览器代执行回传 {} {} ", item.getName(), tr.isSuccess() ? "成功" : "失败"); + + ToolCall tc = new ToolCall(); + tc.setId(idPrefix + idx); + ToolCall.Function fn = new ToolCall.Function(); + fn.setName(def != null ? def.getName() : item.getName()); + fn.setArguments(JSON.toJSONString(args)); + tc.setFunction(fn); + calls.add(tc); + contents.add(tr.getContent()); + idx++; + } + + assistant.setToolCalls(calls); + messages.add(assistant); + for (int i = 0; i < contents.size(); i++) { + messages.add(ChatMessage.tool(idPrefix + i, contents.get(i))); + } + + return runLoop(session, messages, resp, caller, authorizedModIds, roles, true, true, out); + } + + /** 前端回传了未注册的工具名(正常不会发生):构造统一的失败结果回填给模型 */ + private ToolResult unknownToolResult(String name) { + ToolResult tr = new ToolResult(); + tr.setToolName(name); + tr.setTitle("未知工具"); + tr.setSuccess(false); + tr.setError("不存在的工具:" + name); + tr.setContent("{\"success\":false,\"error\":\"不存在的工具:" + name + "\"}"); + return tr; + } + + /* ==================== 表单:缺参拦截 与 直提交 ==================== */ + + /** + * 预检本轮的写操作工具:若有必填参数缺失,则撤销刚加入的 assistant 消息并返回一张补全表单。 + * + * @return 需要用户补全信息时返回响应(含 form);一切正常返回 null + */ + private ChatResponse interceptMissing(List calls, List messages, + CallerContext caller, String sessionId, EventSink out) { + for (ToolCall call : calls) { + String toolName = call.getFunction() == null ? null : call.getFunction().getName(); + ToolDef def = toolName == null ? null : registry.get(toolName); + if (def == null || def.isReadOnly()) { + continue; + } + // 越权的写操作不走表单(表单填了也执行不了),交由执行期统一拒绝并告知 + if (settings.isEnforceRolePermission() && !def.allowsAny(caller.getRoles())) { + continue; + } + Map args = parseArgs(call.getFunction() == null ? null : call.getFunction().getArguments()); + List missing = missingRequired(def, args); + if (missing.isEmpty()) { + continue; + } + + // 撤销本轮的 assistant(tool_calls),保持历史自洽(用户可能不填表、转而说别的) + if (!messages.isEmpty()) { + messages.remove(messages.size() - 1); + } + + FormRequest form = buildForm(def, args, missing, caller); + ChatResponse resp = new ChatResponse(); + resp.setForm(form); + resp.setReply(askText(def, missing)); + + out.emit(AgentEvent.of("form", "需要补充信息:" + String.join("、", form.getMissing()), + payloadOf(def, args))); + return resp; + } + return null; + } + + /** + * 表单直提交:用户已填好参数 → 直接执行工具 → 再由模型把结果说成自然语言。 + */ + private ChatResponse submitForm(ChatRequest req, String userDesc, CallerContext caller, + Set authorizedModIds, EventSink out) { + ChatRequest.FormSubmit fs = req.getFormSubmit(); + Set roles = caller.getRoles(); + SessionStore.SessionState session = sessionStore.getOrCreate(req.getSessionId(), + caller.userIdAsInt(), caller.schoolIdAsInt(), rolesText(roles)); + + ChatResponse resp = new ChatResponse(); + resp.setSessionId(session.getSessionId()); + + ToolDef def = registry.get(fs.getTool()); + if (def == null) { + resp.setReply("抱歉,这个操作暂时不可用。"); + resp.setWarning("未注册的工具:" + fs.getTool()); + return resp; + } + // 角色门禁:提交时再校验一次(模型侧过滤可被绕过,这里是硬约束) + if (!def.allowsAny(roles)) { + resp.setReply("抱歉,当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。"); + resp.setWarning("越权操作已被拒绝:" + def.getName()); + return resp; + } + + Map args = fs.getArgs() == null + ? new LinkedHashMap<>() : new LinkedHashMap<>(fs.getArgs()); + + // 兜底:表单若有漏填(前端校验被绕过),仍然回到表单而不是带着残缺参数去打接口 + List missing = missingRequired(def, args); + if (!missing.isEmpty()) { + resp.setForm(buildForm(def, args, missing, caller)); + resp.setReply(askText(def, missing)); + return resp; + } + + List messages = session.getMessages(); + if (messages.isEmpty()) { + messages.add(ChatMessage.system(buildSystemPrompt(userDesc, authorizedModIds, roles))); + } + // 把"用户填表提交了什么"写入历史,让后续对话有上下文 + messages.add(ChatMessage.user(renderSubmission(def, args))); + + out.emit(AgentEvent.of("tool_call", "正在执行:" + def.getTitle(), payloadOf(def, args))); + + // ★ 浏览器代执行(方案 A):模块接口只认浏览器会话,服务端提交必然 302。 + // 表单提交同样挂起给前端用用户登录态执行,结果回传后由 resumeBrowser 续跑; + // 准备失败(缺参/模块未配置)时回退服务端直调老路径。 + if (settings.getBrowserExec().isEnabled() + && settings.getModules().containsKey(def.getModule())) { + try { + BrowserToolRequest.BrowserCall bc = executor.prepareBrowserCall(def, args); + BrowserToolRequest bt = new BrowserToolRequest(); + bt.setCalls(new ArrayList<>(Collections.singletonList(bc))); + bt.setNotice("「" + def.getTitle() + "」需要用你的登录态提交,正在通过你的浏览器执行…"); + resp.setBrowserTool(bt); + resp.setReply(bt.getNotice()); + out.emit(AgentEvent.of("browser_tool", bt.getNotice(), bt)); + log.info("表单提交转浏览器代执行:session={} 工具={}", session.getSessionId(), def.getName()); + finish(resp, session, 0, 0, out); + return resp; + } catch (Exception e) { + log.warn("表单代执行准备失败,回退服务端执行:{} - {}", def.getName(), e.toString()); + } + } + + ToolResult tr = executor.execute(def, args, caller); + auditService.record(session.getSessionId(), caller, def, args, tr); + + // 构造合法的 assistant(tool_calls) + tool 结果,模型据此组织语言 + String callId = "form_" + System.currentTimeMillis(); + ToolCall tc = new ToolCall(); + tc.setId(callId); + ToolCall.Function fn = new ToolCall.Function(); + fn.setName(def.getName()); + fn.setArguments(JSON.toJSONString(args)); + tc.setFunction(fn); + ChatMessage assistant = new ChatMessage(ChatMessage.ROLE_ASSISTANT, null); + assistant.setToolCalls(Collections.singletonList(tc)); + messages.add(assistant); + messages.add(ChatMessage.tool(callId, tr.getContent())); + + resp.getToolCalls().add(buildTrace(def, args, tr)); + if (!def.isReadOnly()) { + resp.setWroteSomething(true); + if (StringUtils.isNotBlank(def.getLandingPath())) { + resp.setLandingPath(def.getLandingPath()); + } + } + out.emit(AgentEvent.of("tool_result", + def.getTitle() + (tr.isSuccess() ? " 完成" : " 失败:" + tr.getError()), + payloadOf(def, args))); + log.info("表单提交执行 {} {} 耗时 {}ms", def.getName(), tr.isSuccess() ? "成功" : "失败", tr.getCostMs()); + + String reply; + int promptTokens = 0; + int completionTokens = 0; + try { + ChatResult r = llmClient.chat(messages, null); + promptTokens = nz(r.getPromptTokens()); + completionTokens = nz(r.getCompletionTokens()); + reply = StringUtils.isNotBlank(r.getContent()) ? r.getContent() : fallbackReply(def, tr); + } catch (Exception e) { + log.warn("表单提交后生成回复失败,改用兜底话术:{}", e.toString()); + reply = fallbackReply(def, tr); + } + messages.add(new ChatMessage(ChatMessage.ROLE_ASSISTANT, reply)); + resp.setReply(reply); + finish(resp, session, promptTokens, completionTokens, out); + return resp; + } + + /** 找出缺失的必填参数(fixed 固定参数视为已提供) */ + private List missingRequired(ToolDef def, Map args) { + Map effective = new LinkedHashMap<>(); + if (def.getFixed() != null) { + effective.putAll(def.getFixed()); + } + if (args != null) { + effective.putAll(args); + } + + List missing = new ArrayList<>(); + for (ToolParam p : def.getParams()) { + if (!p.isRequired() || p.isHidden()) { + continue; + } + Object v = effective.get(p.getName()); + if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) { + missing.add(p); + } + } + return missing; + } + + /** 由工具定义生成补全表单 */ + private FormRequest buildForm(ToolDef def, Map args, List missing, + CallerContext caller) { + FormRequest form = new FormRequest(); + form.setTool(def.getName()); + form.setTitle(def.getTitle()); + form.setModule(def.getModule()); + form.setWrite(!def.isReadOnly()); + form.setLandingPath(def.getLandingPath()); + + // 本次构建内的字典缓存:同一字典只请求一次(字段下拉与已识别回显共用) + Map>> dictCache = new LinkedHashMap<>(); + + List fields = new ArrayList<>(); + List missingLabels = new ArrayList<>(); + for (ToolParam p : missing) { + FormField f = p.toFormField(); + // 字典类字段(如请假类型):先跑一次它的来源工具,把可选项直接摆到下拉框里 + // 判据是「声明了来源」而不是「当前没有选项」—— 参数可能已带内置兜底, + // 实时字典取到就覆盖兜底,取不到则保留兜底(下拉框不会退化成数字框)。 + if (StringUtils.isNotBlank(p.getOptionsFrom())) { + List> opts = dictOptions(p.getOptionsFrom(), caller, dictCache); + if (!opts.isEmpty()) { + f.setOptions(opts); + f.setWidget("select"); + f.setOptionsFallback(false); + } + } + fields.add(f); + missingLabels.add(p.labelOf()); + } + form.setFields(fields); + form.setMissing(missingLabels); + + // 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对) + Map preset = new LinkedHashMap<>(); + List> recognized = new ArrayList<>(); + if (args != null) { + for (Map.Entry e : args.entrySet()) { + Object v = e.getValue(); + if (v == null || (v instanceof String && StringUtils.isBlank((String) v))) { + continue; + } + preset.put(e.getKey(), v); + + ToolParam p = findParam(def, e.getKey()); + Map row = new LinkedHashMap<>(); + row.put("name", e.getKey()); + row.put("label", p != null ? p.labelOf() : e.getKey()); + row.put("text", humanize(p, v, caller, dictCache)); + recognized.add(row); + } + } + form.setPreset(preset); + form.setRecognized(recognized); + + form.setDescription("补齐下面的信息,我就直接帮你执行「" + def.getTitle() + "」。"); + form.setHint("带 * 为必填;时间类字段请选到具体时刻,我会按你选的时间提交。"); + return form; + } + + private ToolParam findParam(ToolDef def, String name) { + for (ToolParam p : def.getParams()) { + if (name.equals(p.getName())) { + return p; + } + } + return null; + } + + /** 参数值 → 可读文本:有静态枚举或动态字典时翻译成中文 */ + private String humanize(ToolParam p, Object value, CallerContext caller, + Map>> cache) { + String text = String.valueOf(value); + if (p == null) { + return text; + } + if (p.getEnumValues() != null && !p.getEnumValues().isEmpty()) { + int idx = p.getEnumValues().indexOf(text); + if (idx >= 0 && p.getEnumLabels() != null && idx < p.getEnumLabels().size()) { + return p.getEnumLabels().get(idx); + } + return text; + } + if (StringUtils.isNotBlank(p.getOptionsFrom())) { + for (Map o : dictOptions(p.getOptionsFrom(), caller, cache)) { + if (text.equals(o.get("value"))) { + return o.get("label"); + } + } + } + return text; + } + + /** 取字典选项(带缓存;取不到返回空列表,调用方据此退化为普通输入框) */ + private List> dictOptions(String toolName, CallerContext caller, + Map>> cache) { + if (cache.containsKey(toolName)) { + return cache.get(toolName); + } + List> opts = fetchDictOptions(toolName, caller); + cache.put(toolName, opts == null ? new ArrayList<>() : opts); + return cache.get(toolName); + } + + /** + * 执行字典工具并尽力解析成 [{value,label}]。 + *

解析失败(结构不认识、接口不通)一律返回 null,前端退化成普通输入框,不影响主流程。 + */ + private List> fetchDictOptions(String toolName, CallerContext caller) { + try { + ToolDef dict = registry.get(toolName); + if (dict == null || !dict.isReadOnly()) { + return null; + } + // 字典工具也受角色约束:越权则取不到选项,退化为普通输入框 + if (settings.isEnforceRolePermission() && !dict.allowsAny(caller.getRoles())) { + return null; + } + ToolResult tr = executor.execute(dict, new LinkedHashMap<>(), caller); + if (!tr.isSuccess()) { + return null; + } + JSONArray arr = findArray(JSON.toJSON(tr.getData()), 0); + if (arr == null || arr.isEmpty()) { + return null; + } + List> options = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (!(item instanceof JSONObject)) { + continue; + } + JSONObject o = (JSONObject) item; + String value = firstOf(o, "id", "value", "code", "dictId", "typeId", "key"); + if (StringUtils.isBlank(value)) { + continue; + } + String label = firstOf(o, "name", "label", "text", "title", "dictName", "value"); + Map one = new LinkedHashMap<>(); + one.put("value", value); + one.put("label", StringUtils.isBlank(label) ? value : label); + options.add(one); + } + return options.isEmpty() ? null : options; + } catch (Exception e) { + log.warn("拉取字典选项失败 {}:{}", toolName, e.toString()); + return null; + } + } + + private String firstOf(JSONObject o, String... keys) { + for (String k : keys) { + Object v = o.get(k); + if (v != null && StringUtils.isNotBlank(String.valueOf(v))) { + return String.valueOf(v); + } + } + return null; + } + + /** 递归找第一个数组(限深,避免异常结构下深度遍历) */ + private JSONArray findArray(Object node, int depth) { + if (node == null || depth > 3) { + return null; + } + if (node instanceof JSONArray) { + return (JSONArray) node; + } + if (node instanceof JSONObject) { + JSONObject o = (JSONObject) node; + for (String k : o.keySet()) { + JSONArray a = findArray(o.get(k), depth + 1); + if (a != null) { + return a; + } + } + } + return null; + } + + /** 缺参时的引导话术(表单之外的文字说明) */ + private String askText(ToolDef def, List missing) { + List parts = new ArrayList<>(); + for (ToolParam p : missing) { + String l = p.labelOf(); + if (StringUtils.isNotBlank(p.getExample())) { + l = l + "(格式如 " + p.getExample() + ")"; + } + parts.add(l); + } + return "要完成「" + def.getTitle() + "」,还差这几项:" + String.join("、", parts) + + "。\n可以直接在下面的表单里填,填好点「提交」就行。"; + } + + /** 把表单提交渲染成一条用户消息,写入会话历史 */ + private String renderSubmission(ToolDef def, Map args) { + StringBuilder sb = new StringBuilder(); + sb.append("我已在表单里填好并提交「").append(def.getTitle()).append("」,参数为:"); + for (Map.Entry e : args.entrySet()) { + sb.append("\n- ").append(e.getKey()).append(" = ").append(String.valueOf(e.getValue())); + } + return sb.toString(); + } + + private ToolCallTrace buildTrace(ToolDef def, Map args, ToolResult tr) { + ToolCallTrace trace = new ToolCallTrace(); + trace.setName(def.getName()); + trace.setTitle(def.getTitle()); + trace.setModule(def.getModule()); + trace.setArguments(args); + trace.setSuccess(tr.isSuccess()); + trace.setError(tr.getError()); + trace.setWrite(!def.isReadOnly()); + trace.setLandingPath(def.getLandingPath()); + trace.setCostMs(tr.getCostMs()); + return trace; + } + + /** 大模型不可用时的兜底话术(表单提交场景,结果已经确定) */ + private String fallbackReply(ToolDef def, ToolResult tr) { + if (tr.isSuccess()) { + return "「" + def.getTitle() + "」已执行完成。"; + } + return "「" + def.getTitle() + "」执行失败:" + (tr.getError() == null ? "未知原因" : tr.getError()); + } + + private void finish(ChatResponse resp, SessionStore.SessionState session, + int promptTokens, int completionTokens, EventSink out) { + resp.setPromptTokens(promptTokens); + resp.setCompletionTokens(completionTokens); + sessionStore.trim(session); + sessionStore.persist(session); + out.emit(AgentEvent.of("message", resp.getReply())); + out.emit(AgentEvent.of("done", "ok")); + } + + private int nz(Integer v) { + return v == null ? 0 : v; + } + + /** 容错解析模型给的参数 JSON(模型经常返回非法 JSON 或空串) */ + private Map parseArgs(String arguments) { + if (StringUtils.isBlank(arguments)) { + return new LinkedHashMap<>(); + } + try { + JSONObject obj = JSON.parseObject(arguments); + if (obj == null) { + return new LinkedHashMap<>(); + } + Map map = new LinkedHashMap<>(); + for (String k : obj.keySet()) { + map.put(k, obj.get(k)); + } + return map; + } catch (Exception e) { + log.warn("工具参数不是合法 JSON,已忽略:{}", arguments); + return new LinkedHashMap<>(); + } + } + + private Object payloadOf(ToolDef def, Map args) { + Map p = new LinkedHashMap<>(); + p.put("name", def.getName()); + p.put("title", def.getTitle()); + p.put("module", def.getModule()); + p.put("arguments", args); + p.put("write", !def.isReadOnly()); + return p; + } + + /** 构造系统提示词:身份 + 角色 + 能力清单 + 行为准则 */ + private String buildSystemPrompt(String userDesc, Set authorizedModIds, Set roles) { + String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); + StringBuilder sb = new StringBuilder(); + + sb.append("你是「云海星通校园平台」的智能助手,帮助用户在系统里直接完成操作,而不是只做问答。\n\n"); + + sb.append("【当前用户】").append(StringUtils.isBlank(userDesc) ? "未知用户" : userDesc).append("\n"); + sb.append("【当前时间】").append(now).append("(星期").append(weekday()).append(")\n\n"); + + sb.append("【可用能力】以下是你当前身份可以调用的业务能力。调用后系统会把真实结果给你,") + .append("你必须依据结果回答,绝不能编造成功或数据:\n"); + + Map> grouped = registry.groupByModule(authorizedModIds, roles); + for (Map.Entry> e : grouped.entrySet()) { + sb.append("- ").append(moduleLabel(e.getKey())).append(":"); + List names = new ArrayList<>(); + for (ToolDef d : e.getValue()) { + names.add(d.getTitle()); + } + sb.append(String.join("、", names)).append("\n"); + } + + sb.append("\n【行为准则】\n"); + sb.append("1. 用户表达一个操作意图(如“我要请假”“帮我看看作业”)时,直接调用对应工具完成,不要只给操作指引。\n"); + + sb.append("2. ★ 写操作(提交/审批/发布/撤销)缺参数时,**不要用文字反复追问**:\n") + .append(" 请照常调用该工具,把能从用户话里确定的参数填上,**不确定的字段直接省略**。\n") + .append(" 系统会自动弹出带中文标签、格式示例和下拉选项的表单让用户补齐,比口头追问准确得多。\n"); + + sb.append("3. 时间类参数统一用 yyyy-MM-dd HH:mm:ss;用户说“明天”“下周一”这类相对时间,") + .append("请基于上面的当前时间换算成具体日期时间后再填,并在回答里说明你换算成的日期。\n"); + + sb.append("4. 一次可以调用多个工具(例如先查列表再取详情),但不要重复调用同一个工具做同样的事。\n"); + sb.append("5. 工具返回 success=false 时,把失败原因用通俗的话告诉用户,并给出下一步建议;不要假装成功。\n"); + sb.append("6. 回答用简体中文,简洁直白,先给结论再给必要细节;不要输出 JSON 原文、不要暴露内部工具名与技术细节。\n"); + sb.append("7. 涉及审批、删除、批量操作等有副作用的动作,执行后要明确告知“已完成什么、影响范围”。\n"); + + sb.append("8. ★ 权限边界:你**只能**调用上面列出的能力。如果用户请求需要其它身份(例如学生要求“审批请假”“发布公告”,") + .append("或教师要求“提交学生的请假申请”),要如实说明“该操作需要 XX 身份”,不要试图越权,也不要承诺你会去办。\n"); + + return sb.toString(); + } + + private String weekday() { + String[] w = {"日", "一", "二", "三", "四", "五", "六"}; + java.util.Calendar c = java.util.Calendar.getInstance(); + return w[c.get(java.util.Calendar.DAY_OF_WEEK) - 1]; + } + + /** 角色集合 → 可读描述(落库快照用) */ + private String rolesText(Set roles) { + if (roles == null || roles.isEmpty()) { + return "访客"; + } + StringBuilder sb = new StringBuilder(); + for (AgentRole r : roles) { + if (sb.length() > 0) { + sb.append("+"); + } + sb.append(r.getLabel()); + } + return sb.toString(); + } + + private String abbreviate(String s) { + if (s == null) { + return null; + } + String t = s.trim().replaceAll("\\s+", " "); + return t.length() <= 60 ? t : t.substring(0, 60); + } + + private String moduleLabel(String module) { + switch (module) { + case "homeleave": return "学生请假"; + case "campusnotice": return "校园公告"; + case "dormnotice": return "宿舍通知"; + case "familyactivity": return "家校活动"; + case "coursehomework": return "课程作业"; + case "resourcelibrary": return "资源库"; + case "profession": return "教师专业发展"; + default: return module; + } + } +} diff --git a/src/main/java/com/yunhai/agent/service/EventSink.java b/src/main/java/com/yunhai/agent/service/EventSink.java new file mode 100644 index 0000000..648e966 --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/EventSink.java @@ -0,0 +1,14 @@ +package com.yunhai.agent.service; + +/** + * 阶段事件出口:同步接口走空实现,SSE 接口把事件写给浏览器。 + */ +@FunctionalInterface +public interface EventSink { + + void emit(AgentEvent event); + + /** 什么都不做的实现 */ + EventSink NOOP = event -> { + }; +} diff --git a/src/main/java/com/yunhai/agent/service/SessionStore.java b/src/main/java/com/yunhai/agent/service/SessionStore.java new file mode 100644 index 0000000..240478c --- /dev/null +++ b/src/main/java/com/yunhai/agent/service/SessionStore.java @@ -0,0 +1,199 @@ +package com.yunhai.agent.service; + +import com.alibaba.fastjson.JSON; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.entity.AgentMessage; +import com.yunhai.agent.entity.AgentSession; +import com.yunhai.agent.llm.dto.ChatMessage; +import com.yunhai.agent.llm.dto.ToolCall; +import com.yunhai.agent.repository.AgentMessageRepository; +import com.yunhai.agent.repository.AgentSessionRepository; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 会话上下文存储(JPA 落库版)。 + * + *

会话本体({@link AgentSession})与消息({@link AgentMessage})持久化到 MySQL, + * 服务重启后可续接;内存里只做「当前请求内的加载态」。 + * + *

安全:会话与 {@code userId} 绑定,跨用户读同一 sessionId 会被拒绝并重建。 + */ +@Slf4j +@Component +public class SessionStore { + + private final AgentSettings settings; + private final AgentSessionRepository sessionRepo; + private final AgentMessageRepository messageRepo; + + /** 会话级轻量互斥,避免同一会话并发写导致消息全量重写互相覆盖 */ + private final Map locks = new ConcurrentHashMap<>(); + + public SessionStore(AgentSettings settings, + AgentSessionRepository sessionRepo, + AgentMessageRepository messageRepo) { + this.settings = settings; + this.sessionRepo = sessionRepo; + this.messageRepo = messageRepo; + } + + /** 取会话;不存在或不属于该用户则新建(并落库会话行) */ + @Transactional + public SessionState getOrCreate(String sessionId, Integer userId, Integer schId, String roleSnapshot) { + if (StringUtils.isNotBlank(sessionId)) { + Optional exist = sessionRepo.findBySessionId(sessionId); + if (exist.isPresent() && sameUser(exist.get(), userId)) { + sessionRepo.touch(sessionId, LocalDateTime.now()); + return load(exist.get()); + } + if (exist.isPresent()) { + log.warn("会话 {} 归属不匹配(owner={} 请求者={}),已重建", + sessionId, exist.get().getUserId(), userId); + } + } + AgentSession s = new AgentSession(); + s.setSessionId(UUID.randomUUID().toString().replace("-", "")); + s.setUserId(userId); + s.setSchId(schId); + s.setRoleSnapshot(roleSnapshot); + s.setLastAccess(LocalDateTime.now()); + sessionRepo.save(s); + + SessionState st = new SessionState(); + st.setSessionId(s.getSessionId()); + st.setUserId(userId); + st.setSchId(schId); + st.setMessages(new ArrayList<>()); + return st; + } + + private SessionState load(AgentSession s) { + SessionState st = new SessionState(); + st.setSessionId(s.getSessionId()); + st.setUserId(s.getUserId()); + st.setSchId(s.getSchId()); + List msgs = new ArrayList<>(); + for (AgentMessage m : messageRepo.findBySessionIdOrderBySeqNoAsc(s.getSessionId())) { + ChatMessage cm = new ChatMessage(m.getRole(), m.getContent()); + if (StringUtils.isNotBlank(m.getToolCalls())) { + cm.setToolCalls(JSON.parseArray(m.getToolCalls(), ToolCall.class)); + } + cm.setToolCallId(m.getToolCallId()); + msgs.add(cm); + } + st.setMessages(msgs); + return st; + } + + /** + * 持久化会话消息(全量重写该会话的消息表 + 刷新访问时间与标题)。 + *

消息量小(≤ historyMaxMessages),全量重写比增量对账更简单可靠, + * 也天然处理了「拦截表单时撤销一条 assistant 消息」这类回退。 + */ + @Transactional + public void persist(SessionState state) { + if (state == null || StringUtils.isBlank(state.getSessionId())) { + return; + } + String sid = state.getSessionId(); + synchronized (lockOf(sid)) { + messageRepo.deleteBySessionId(sid); + int seq = 0; + for (ChatMessage m : state.getMessages()) { + AgentMessage em = new AgentMessage(); + em.setSessionId(sid); + em.setSeqNo(seq++); + em.setRole(m.getRole()); + em.setContent(m.getContent()); + em.setToolCallId(m.getToolCallId()); + if (m.getToolCalls() != null && !m.getToolCalls().isEmpty()) { + em.setToolCalls(JSON.toJSONString(m.getToolCalls())); + } + messageRepo.save(em); + } + sessionRepo.touch(sid, LocalDateTime.now()); + // 标题兜底:取首条用户输入 + if (StringUtils.isNotBlank(state.getTitle())) { + sessionRepo.findBySessionId(sid).ifPresent(s -> { + if (StringUtils.isBlank(s.getTitle())) { + s.setTitle(state.getTitle()); + sessionRepo.save(s); + } + }); + } + } + } + + @Transactional + public void clear(String sessionId) { + if (StringUtils.isBlank(sessionId)) { + return; + } + messageRepo.deleteBySessionId(sessionId); + sessionRepo.deleteBySessionId(sessionId); + locks.remove(sessionId); + } + + /** 按保留条数裁剪历史(保留最近 N 条,避免上下文无界增长) */ + public void trim(SessionState state) { + int max = settings.getLoop().getHistoryMaxMessages(); + List msgs = state.getMessages(); + if (msgs.size() > max) { + // 从头部丢弃最老的消息,但要保证不以 role=tool 开头(否则非法) + int drop = msgs.size() - max; + while (drop < msgs.size() && ChatMessage.ROLE_TOOL.equals(msgs.get(drop).getRole())) { + drop++; + } + state.setMessages(new ArrayList<>(msgs.subList(drop, msgs.size()))); + } + } + + /** 过期清理:每 10 分钟扫一次,删除空闲超时的会话及其消息 */ + @Scheduled(fixedDelay = 600_000, initialDelay = 600_000) + @Transactional + public void evictExpired() { + long ttl = settings.getLoop().getSessionTtlMinutes(); + LocalDateTime deadline = LocalDateTime.now().minusMinutes(ttl); + List expired = sessionRepo.findByLastAccessBefore(deadline); + if (expired.isEmpty()) { + return; + } + for (AgentSession s : expired) { + messageRepo.deleteBySessionId(s.getSessionId()); + sessionRepo.delete(s); + locks.remove(s.getSessionId()); + } + log.info("会话过期清理:删除 {} 个空闲会话(TTL {} 分钟)", expired.size(), ttl); + } + + private boolean sameUser(AgentSession s, Integer userId) { + return s.getUserId() == null ? userId == null : s.getUserId().equals(userId); + } + + private Object lockOf(String sid) { + return locks.computeIfAbsent(sid, k -> new Object()); + } + + /** 内存态会话(含本次请求加载的消息列表) */ + @Data + public static class SessionState { + private String sessionId; + private Integer userId; + private Integer schId; + private String title; + private List messages; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/CallerContext.java b/src/main/java/com/yunhai/agent/tool/CallerContext.java new file mode 100644 index 0000000..7477885 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/CallerContext.java @@ -0,0 +1,140 @@ +package com.yunhai.agent.tool; + +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 「谁在对话」——把当前请求的身份原样带着,转发到下游业务模块。 + * + *

为什么必须转发:各业务模块的用户/学校/角色都由平台 SSO 会话(Cookie)与 + * {@code currentRole} 请求头解析。AgentService 只有把这些透传过去, + * 下游才会以「当前登录人」的身份执行,做到权限收敛、数据范围正确。 + * + *

身份有两个来源:① 平台自动装配的参数解析器注入的 {@link BoaUser}(权威); + * ② 请求头 {@code currentRole/userId/roleId/schoolId}(兼容旧网关)。前者优先。 + */ +@Data +public class CallerContext { + + private String currentRole; + private String userId; + private String roleId; + private String schoolId; + private String cookie; + private String referer; + private String userAgent; + + /** 姓名(来自 BoaUser) */ + private String userName; + + /** 登录账号(来自 BoaUser) */ + private String account; + + /** 当前身份的角色名(平台 currentRole.roleName,如「教师」),用于展示「我现在是谁」 */ + private String currentRoleName; + + /** 解析后的智能体角色(学生 / 教师 / 管理员 / 访客),由 RoleResolver 填充 */ + private Set roles = new LinkedHashSet<>(); + + /** 是否拿到了可用身份(拿不到就只允许调用公共只读工具) */ + public boolean hasIdentity() { + return StringUtils.isNotBlank(userId) || StringUtils.isNotBlank(currentRole); + } + + public Integer userIdAsInt() { + return toInt(userId); + } + + public Integer roleIdAsInt() { + return toInt(roleId); + } + + public Integer schoolIdAsInt() { + return toInt(schoolId); + } + + public boolean hasRole(AgentRole role) { + return roles != null && roles.contains(role); + } + + /** + * 用平台解析出的 {@link BoaUser} 补齐权威身份(比请求头更可信),并落定角色集合。 + */ + public void fillFrom(BoaUser user, Set resolvedRoles) { + if (resolvedRoles != null && !resolvedRoles.isEmpty()) { + this.roles = resolvedRoles; + } + if (user == null) { + return; + } + if (StringUtils.isNotBlank(user.getName())) { + this.userName = user.getName(); + } + if (StringUtils.isNotBlank(user.getAccount())) { + this.account = user.getAccount(); + } + if (user.getId() != null) { + this.userId = String.valueOf(user.getId()); + } + if (user.getCurrentRole() != null && user.getCurrentRole().getSchId() != null) { + this.schoolId = String.valueOf(user.getCurrentRole().getSchId()); + } + // roleId 兜底:优先用「当前身份」的角色号(下游老模块读该头时才是对的), + // 没有当前身份时才退回 roleIds 里的第一个 + if (StringUtils.isBlank(this.roleId) && user.getCurrentRole() != null + && user.getCurrentRole().getRoleId() != null) { + this.roleId = String.valueOf(user.getCurrentRole().getRoleId()); + } + if (StringUtils.isBlank(this.roleId) && user.getRoleIds() != null) { + for (Integer rid : user.getRoleIds()) { + if (rid != null) { + this.roleId = String.valueOf(rid); + break; + } + } + } + // 当前身份名(角色名),便于前端展示「我现在是谁」 + if (user.getCurrentRole() != null && StringUtils.isNotBlank(user.getCurrentRole().getRoleName())) { + this.currentRoleName = user.getCurrentRole().getRoleName(); + } + } + + public static CallerContext from(HttpServletRequest request) { + CallerContext c = new CallerContext(); + c.currentRole = request.getHeader("currentRole"); + c.userId = request.getHeader("userId"); + c.roleId = request.getHeader("roleId"); + c.schoolId = request.getHeader("schoolId"); + c.referer = request.getHeader("yschreferer"); + c.userAgent = request.getHeader("User-Agent"); + + // Cookie 整体透传(平台 SSO 的登录态在 Cookie 里;SSO 亦可接受网关注入的会话) + Cookie[] cookies = request.getCookies(); + if (cookies != null && cookies.length > 0) { + StringBuilder sb = new StringBuilder(); + for (Cookie ck : cookies) { + if (sb.length() > 0) { + sb.append("; "); + } + sb.append(ck.getName()).append("=").append(ck.getValue()); + } + c.cookie = sb.toString(); + } + return c; + } + + private static Integer toInt(String s) { + try { + return StringUtils.isBlank(s) ? null : Integer.valueOf(s.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java new file mode 100644 index 0000000..eb9f2c4 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -0,0 +1,52 @@ +package com.yunhai.agent.tool; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 表单里的一个字段:由 {@link ToolParam} 生成,前端据此选择控件(下拉 / 日期时间 / 多行文本…)。 + */ +@Data +public class FormField { + + /** 提交时的参数名(与业务接口一致) */ + private String name; + + /** 中文标签,如「开始时间」 */ + private String label; + + /** 控件类型:text / textarea / number / date / datetime / select */ + private String widget; + + /** 数据类型:string / integer / number / boolean */ + private String type; + + private boolean required; + + /** 说明(来自工具定义,写清格式与含义) */ + private String description; + + /** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */ + private String example; + + private String defaultValue; + + /** + * 下拉选项的动态来源(字典工具名,如 leave.types)。 + *

有值但 {@link #options} 为空 ⇒ 字典接口没取到(多半是下游 302/超时), + * 前端据此提示用户「可先手填编号」,而不是干瞪眼。 + */ + private String optionsFrom; + + /** + * 当前 {@link #options} 是否来自内置兜底(而非学校实时字典)。 + *

为 true 时前端提示「用的是标准类型,自定义类型暂时没取到」,而不是静默当成权威值。 + */ + private boolean optionsFallback; + + /** 下拉选项 [{value,label}] */ + private List> options = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/agent/tool/FormRequest.java b/src/main/java/com/yunhai/agent/tool/FormRequest.java new file mode 100644 index 0000000..96b3e5e --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/FormRequest.java @@ -0,0 +1,54 @@ +package com.yunhai.agent.tool; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 「信息不全」时下发给前端的补全表单。 + * + *

设计意图:用户说「我要请假」时,与其让大模型自由发挥去追问(格式、必填项都不稳定), + * 不如由后端从工具定义里**确定性地**推导出还缺哪些字段,连同中文名、格式示例、下拉选项 + * 一起交给前端渲染成表单 —— 用户填完即提交,链路最短、最准确。 + */ +@Data +public class FormRequest { + + /** 目标工具名,如 leave.submit */ + private String tool; + + /** 表单标题,如「提交请假申请」 */ + private String title; + + /** 引导语,如「这些信息填好我就直接帮你提交」 */ + private String description; + + private String module; + + /** 是否写操作(前端据此做「提交」的视觉强调与二次确认) */ + private boolean write; + + /** 提交成功后建议跳转的路由 */ + private String landingPath; + + /** 待补全的字段(只含缺失的必填项;已由用户说出的参数放在 preset 里回显) */ + private List fields = new ArrayList<>(); + + /** 已经从对话里确定的参数(表单里以只读方式回显,用户可确认后提交;提交时原样带上) */ + private Map preset = new LinkedHashMap<>(); + + /** + * 上述 preset 的「可读版」:[{name,label,text}],其中 text 已按字典翻译成中文 + * (如 leaveType=1 → 病假)。前端直接展示,不用自己维护中文映射。 + */ + private List> recognized = new ArrayList<>(); + + /** 缺失字段的中文名,用于生成「还差:开始时间、事由」这类话术 */ + private List missing = new ArrayList<>(); + + /** 补充提示(可选),如「时间格式为 2026-10-11 08:00:00」 */ + private String hint; +} diff --git a/src/main/java/com/yunhai/agent/tool/ToolDef.java b/src/main/java/com/yunhai/agent/tool/ToolDef.java new file mode 100644 index 0000000..6d3b465 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/ToolDef.java @@ -0,0 +1,211 @@ +package com.yunhai.agent.tool; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.agent.auth.AgentRole; +import lombok.Data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 一个「工具」= 一个可被大模型调用的业务动作(对应某个业务模块的一个 REST 接口)。 + * + *

刻意做成**声明式**:新增模块只需要在 {@code defs} 包里声明工具,不用写新代码, + * 执行统一走 {@link ToolExecutor}。这样"全量接入"才不会退化成复制粘贴。 + * + *

2026-10 增补:{@link #roles} —— 声明该工具面向哪些身份(学生 / 教师 / 管理员)。 + * 未声明 = 所有角色可用;声明后既用于「模型侧过滤」,也用于「服务端越权拦截」。 + */ +@Data +public class ToolDef { + + /** + * 工具唯一名:{@code 模块.动作},如 leave.save / notice.list(小写点分,内部标识)。 + *

⚠️ 点号只是「内部」分隔符,**不能直接下发给大模型**,下发前必须过 {@link #wireName()}。 + */ + private String name; + + /** 业务模块标识,对应 {agent.modules} 的 key,用于拼目标地址 */ + private String module; + + /** 中文简称(前端展示"正在调用 XX"用) */ + private String title; + + /** 给模型的能力说明:做什么、什么场景用、必填项的含义 */ + private String description; + + /** 请求方式:GET / POST_JSON / POST_FORM */ + private String httpMethod = "GET"; + + /** 相对模块根地址的接口路径,如 /api/leave/Save */ + private String path; + + /** 参数定义 */ + private List params = new ArrayList<>(); + + /** 固定参数(接口要求但对话里无需用户提供,如学生申请固定 type=2) */ + private Map fixed = new LinkedHashMap<>(); + + /** + * 平台菜单模块号(与 GetUserMod 的 modId 对齐)。 + * 非空时:该用户未被授权此模块,则不向模型暴露该工具(权限收敛,避免"越权调用"的错觉)。 + */ + private Integer moduleId; + + /** + * 允许使用该工具的角色集合。空 = 所有角色可用。 + *

用于「分学生、教师权限」:学生侧工具(请假申请/交作业/报名)与 + * 教职工侧工具(审批/发布/登记)互不越界。 + */ + private Set roles = new LinkedHashSet<>(); + + /** 是否只读(查询类)。false = 会产生副作用(提交/审批/删除),前端会做二次确认提示 */ + private boolean readOnly = true; + + /** + * 建议前端跳转的模块内路由(模块前端的 hash 路由,如 {@code /my})。 + * + *

两种来源: + *

    + *
  • 工具显式声明:写操作用 {@link #write(String)}(同时标记 {@code readOnly=false}), + * 只读查询用 {@link #readAt(String)}(只设落地页、不改只读语义);
  • + *
  • 模块默认兜底:两者都没声明时,由 {@code ToolRegistry} 在注册期用 + * {@code agent.module-landing.<模块>} 回填(见 {@code AgentSettings#moduleLanding})。
  • + *
+ * 兜底的意义:服务端调不通模块用户态接口(下游 {@code code=302})时,前端仍能给出 + * 「去系统里查看」入口,让用户带自己的登录态看到真实数据。 + */ + private String landingPath; + + public ToolDef() { + } + + public static ToolDef of(String name, String module, String title, String description, + String httpMethod, String path) { + ToolDef d = new ToolDef(); + d.name = name; + d.module = module; + d.title = title; + d.description = description; + d.httpMethod = httpMethod; + d.path = path; + return d; + } + + public ToolDef p(ToolParam... ps) { + this.params.addAll(Arrays.asList(ps)); + return this; + } + + public ToolDef fix(String key, String value) { + this.fixed.put(key, value); + return this; + } + + public ToolDef modId(Integer moduleId) { + this.moduleId = moduleId; + return this; + } + + /** 声明该工具面向的角色(可多个)。不调用 = 所有角色可用。 */ + public ToolDef allow(AgentRole... rs) { + this.roles.addAll(Arrays.asList(rs)); + return this; + } + + public ToolDef write(String landingPath) { + this.readOnly = false; + this.landingPath = landingPath; + return this; + } + + /** + * 声明「只读工具」的落地页 —— 不改变 {@code readOnly} 语义(仍标记为查询,前端不做二次确认)。 + * + *

用途:查询失败(如下游 {@code code=302} 需要浏览器登录态)时,前端能给出 + * 「去系统里查看」入口。典型场景是教职工侧查询(如班级名单、请假统计)与学生侧 + * 看到默认页不是同一个页面,需要逐工具指明。 + */ + public ToolDef readAt(String landingPath) { + this.landingPath = landingPath; + return this; + } + + /** 当前调用者(角色集合)是否可用该工具。未声明角色 = 人人可用。 */ + public boolean allowsAny(Set callerRoles) { + if (roles == null || roles.isEmpty()) { + return true; + } + if (callerRoles == null || callerRoles.isEmpty()) { + return false; + } + for (AgentRole r : callerRoles) { + if (roles.contains(r)) { + return true; + } + } + return false; + } + + /** 角色受限的可读描述,如「教师/管理员」 */ + public String roleLabel() { + if (roles == null || roles.isEmpty()) { + return "全部角色"; + } + StringBuilder sb = new StringBuilder(); + for (AgentRole r : roles) { + if (sb.length() > 0) { + sb.append("/"); + } + sb.append(r.getLabel()); + } + return sb.toString(); + } + + /** + * 下发给大模型时使用的工具名(wire name)。 + * + *

★ 大模型对 {@code function.name} 有字符集硬约束:只允许 {@code ^[a-zA-Z0-9_-]+$}。 + * 点号是非法字符 —— 直接下发 {@code leave.myClasses} 会被服务端拒绝: + *

Invalid 'tools[0].function.name': string does not match pattern
+     * . Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.
+ * 故对外统一把 {@code .} 替换为 {@code _}({@code leave.myClasses} → {@code leave_myClasses}), + * 内部仍保留点分名(前端目录、审计日志、表单回传都不受影响)。 + * 回传的名字由 {@code ToolRegistry#get} 负责还原。 + */ + public String wireName() { + return name == null ? null : name.replace('.', '_'); + } + + /** 转成 OpenAI / DeepSeek 的 tools 元素:{type:function, function:{name,description,parameters}} */ + public JSONObject toOpenAiTool() { + JSONObject fn = new JSONObject(); + fn.put("name", wireName()); + fn.put("description", description); + + JSONObject props = new JSONObject(); + JSONArray required = new JSONArray(); + for (ToolParam p : params) { + props.put(p.getName(), p.toSchema()); + if (p.isRequired()) { + required.add(p.getName()); + } + } + JSONObject parameters = new JSONObject(); + parameters.put("type", "object"); + parameters.put("properties", props); + parameters.put("required", required); + fn.put("parameters", parameters); + + JSONObject tool = new JSONObject(); + tool.put("type", "function"); + tool.put("function", fn); + return tool; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java new file mode 100644 index 0000000..2cf9624 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -0,0 +1,471 @@ +package com.yunhai.agent.tool; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.agent.config.AgentSettings; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 工具执行器:把「模型给的参数」变成「对业务模块的一次真实 HTTP 调用」,并把结果规范化。 + * + *

统一约定(与前端各工程 alova 封装完全一致,保证行为可预期): + *

    + *
  • 平台响应 {@code {code,msg,data}}:{@code code==0} 视为成功,取 {@code data};否则取 {@code msg} 作为错误
  • + *
  • {@code POST_JSON} ↔ 后端 {@code @RequestBody}
  • + *
  • {@code POST_FORM} ↔ 后端 {@code @RequestParam}(表单)
  • + *
+ * + *

★ 2026-10 增补:执行前做角色强校验——越权的工具调用直接拒绝、不发真实请求, + * 这是「分学生、教师权限」的服务端硬约束(模型侧过滤只是第一道,这里才是兜底)。 + */ +@Slf4j +@Component +public class ToolExecutor { + + private final AgentSettings settings; + private final RestTemplate restTemplate; + + public ToolExecutor(AgentSettings settings, @Qualifier("yhRestTemplate") RestTemplate restTemplate) { + this.settings = settings; + this.restTemplate = restTemplate; + } + + /** + * 角色门禁:当前身份无权使用该工具时返回拒绝结果(不发真实请求),有权返回 {@code null}。 + *

服务端执行({@link #execute})与浏览器代执行的续跑({@code parseBrowserResponse} 之前) + * 共用这一道硬约束。 + */ + public ToolResult checkAccess(ToolDef def, CallerContext caller) { + if (settings.isEnforceRolePermission() && !def.allowsAny(caller == null ? null : caller.getRoles())) { + String who = (caller == null || caller.getRoles() == null || caller.getRoles().isEmpty()) + ? "访客" : caller.getRoles().toString(); + log.warn("[越权拦截] userId={} roles={} 尝试调用受限工具 {}(仅限 {})", + caller == null ? null : caller.getUserId(), who, def.getName(), def.roleLabel()); + return ToolResult.fail(def, + "当前身份无权执行「" + def.getTitle() + "」(该操作仅限:" + def.roleLabel() + ")。" + + "如需办理,请使用对应身份的账号,或到相应页面处理。", 0); + } + return null; + } + + /** + * 执行一个工具。 + * + * @param def 工具定义 + * @param args 模型给出的参数(已容错解析成 Map) + * @param caller 调用者身份(用于透传与角色校验) + */ + public ToolResult execute(ToolDef def, Map args, CallerContext caller) { + long start = System.currentTimeMillis(); + + // ★ 角色强校验:当前身份无权使用该工具 → 拒绝执行,不发真实请求 + ToolResult denied = checkAccess(def, caller); + if (denied != null) { + return denied; + } + + String base = settings.getModules().get(def.getModule()); + if (StringUtils.isBlank(base)) { + return ToolResult.fail(def, "模块「" + def.getModule() + "」未配置服务地址(agent.modules)", + System.currentTimeMillis() - start); + } + + // 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定) + Map merged = new LinkedHashMap<>(); + if (args != null) { + merged.putAll(args); + } + if (def.getFixed() != null) { + merged.putAll(def.getFixed()); + } + + // 缺参校验(必填项) + for (ToolParam p : def.getParams()) { + if (p.isRequired() && isBlank(merged.get(p.getName()))) { + if (p.getDefaultValue() != null) { + merged.put(p.getName(), p.getDefaultValue()); + } else { + return ToolResult.fail(def, "缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")", + System.currentTimeMillis() - start); + } + } + } + + // 路径占位替换:如 /api/student/homeworks/{id}/submissions → 取 id 填进路径,并从参数表中移除 + HttpHeaders headers = buildHeaders(caller); + String url = trimEnd(base) + def.getPath(); + + try { + String resolvedPath = resolvePath(def.getPath(), merged); + url = trimEnd(base) + resolvedPath; + + ResponseEntity resp; + String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase(); + + switch (method) { + case "GET": { + String fullUrl = appendQuery(url, merged, def); + resp = restTemplate.exchange(fullUrl, HttpMethod.GET, new HttpEntity<>(headers), String.class); + break; + } + case "POST_JSON": { + headers.setContentType(MediaType.APPLICATION_JSON); + String body = JSON.toJSONString(normalizeJsonArgs(merged, def)); + resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + break; + } + case "POST_FORM": { + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String body = toFormBody(merged); + resp = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class); + break; + } + default: + return ToolResult.fail(def, "不支持的请求方式:" + method, System.currentTimeMillis() - start); + } + + long cost = System.currentTimeMillis() - start; + return parseResponse(def, resp, cost); + + } catch (Exception e) { + long cost = System.currentTimeMillis() - start; + log.error("工具 {} 调用失败 url={} err={}", def.getName(), url, e.toString()); + String msg = e.getMessage() == null ? e.toString() : e.getMessage(); + if (msg.contains("Connection refused") || msg.contains("connect timed out")) { + msg = "无法连接业务模块「" + def.getModule() + "」(" + base + "),请确认该服务已启动"; + } + return ToolResult.fail(def, msg, cost); + } + } + + /* ==================== 浏览器代执行(方案 A) ==================== */ + + /** + * 把「模型参数」准备成一次可供前端浏览器执行的调用描述。 + * + *

与服务端 {@link #execute} 完全同源的逻辑:合并固定参数、必填校验、路径占位替换、 + * query/请求体构造 —— 差别只在「谁来发这个 HTTP 请求」。参数不合法时抛 + * {@link IllegalArgumentException} / {@link IllegalStateException},调用方据此回退服务端执行。 + * + * @return 描述里的 {@code pathAndQuery} 以 {@code /} 开头(已去掉协议与域名), + * 前端拼 {@code location.origin} 即为最终地址(同站部署 ⇒ 同源) + */ + public com.yunhai.agent.model.BrowserToolRequest.BrowserCall prepareBrowserCall( + ToolDef def, Map args) { + // 合并参数:模型参数 + 固定参数(固定参数优先级更高,避免模型改坏接口约定) + Map merged = new LinkedHashMap<>(); + if (args != null) { + merged.putAll(args); + } + if (def.getFixed() != null) { + merged.putAll(def.getFixed()); + } + + // 缺参校验(必填项) + for (ToolParam p : def.getParams()) { + if (p.isRequired() && isBlank(merged.get(p.getName()))) { + if (p.getDefaultValue() != null) { + merged.put(p.getName(), p.getDefaultValue()); + } else { + throw new IllegalArgumentException( + "缺少必填参数「" + p.getName() + "」(" + p.getDescription() + ")"); + } + } + } + + String base = trimEnd(settings.getModules().get(def.getModule())); + String resolvedPath = resolvePath(def.getPath(), merged); + String method = def.getHttpMethod() == null ? "GET" : def.getHttpMethod().toUpperCase(); + + com.yunhai.agent.model.BrowserToolRequest.BrowserCall call = + new com.yunhai.agent.model.BrowserToolRequest.BrowserCall(); + call.setName(def.getName()); + call.setTitle(def.getTitle()); + call.setWrite(!def.isReadOnly()); + call.setArguments(args == null ? new LinkedHashMap<>() : args); + + switch (method) { + case "GET": { + // 先按完整 URL 拼查询串,再剥掉协议与域名(前端只认相对路径) + String full = appendQuery(base + resolvedPath, merged, def); + call.setPathAndQuery(stripOrigin(full)); + break; + } + case "POST_JSON": { + call.setPathAndQuery(stripOrigin(base + resolvedPath)); + call.setBody(JSON.toJSONString(normalizeJsonArgs(merged, def))); + call.setContentType("application/json;charset=UTF-8"); + break; + } + case "POST_FORM": { + call.setPathAndQuery(stripOrigin(base + resolvedPath)); + call.setBody(toFormBody(merged)); + call.setContentType("application/x-www-form-urlencoded;charset=UTF-8"); + break; + } + default: + throw new IllegalArgumentException("不支持的请求方式:" + method); + } + call.setMethod(method); + return call; + } + + /** + * 解析浏览器回传的模块响应 —— 解析规则与服务端直调 {@link #execute} 完全一致 + * ({@code {code,msg,data}} 包装、{@code code=302} 识别、非包装响应原样返回)。 + * + * @param status 浏览器 fetch 拿到的 HTTP 状态码(网络层失败时为 null) + * @param body 响应体原文 + */ + public ToolResult parseBrowserResponse(ToolDef def, Integer status, String body, long costMs) { + if (status == null) { + return ToolResult.fail(def, "浏览器调用没有完成(未取得模块响应),请稍后重试", costMs); + } + if (status < 200 || status >= 300) { + return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs); + } + // ★ 浏览器通道专属守卫:拿回网页而不是数据,说明请求根本没落到模块接口上 + // (未同站部署 / 落到了 SPA 首页或登录页)。此时绝不能当"成功"喂给模型 —— + // 服务端直调不会出现这种形态(它直接打模块 API),故只在本方法里拦。 + if (body != null && body.trim().startsWith("<")) { + return ToolResult.fail(def, + "通过浏览器调用「" + def.getTitle() + "」拿回的是网页而不是数据," + + "多半是请求没落到模块接口上(本应用与模块不同源,或落到了登录页/单页应用首页)。" + + "请点下方「去系统里自己查看」,用你自己的登录态直接打开该模块页面。", costMs); + } + org.springframework.http.HttpStatus hs = org.springframework.http.HttpStatus.resolve(status); + if (hs == null) { + return ToolResult.fail(def, "模块接口返回 HTTP " + status, costMs); + } + return parseResponse(def, new ResponseEntity<>(body == null ? "" : body, hs), costMs); + } + + /** 去掉 URL 里的协议与域名,只留 path+query(本服务的模块地址都由配置拼出,格式可控) */ + private String stripOrigin(String fullUrl) { + int idx = fullUrl.indexOf("://"); + if (idx < 0) { + return fullUrl; + } + int slash = fullUrl.indexOf('/', idx + 3); + return slash < 0 ? "/" : fullUrl.substring(slash); + } + + /* ==================== 内部实现 ==================== */ + + /** + * 把路径里的 {@code {name}} 占位符替换成参数值,并把已消费的参数从参数表移除。 + *

示例:{@code /api/student/homeworks/{id}/submissions} + {@code {id:12,text:"..."}} + * → 路径 {@code /api/student/homeworks/12/submissions},剩余参数 {@code {text:"..."}}。 + */ + private String resolvePath(String path, Map args) { + if (path == null || path.indexOf('{') < 0) { + return path; + } + String result = path; + List consumed = new java.util.ArrayList<>(); + for (Map.Entry e : args.entrySet()) { + String token = "{" + e.getKey() + "}"; + if (result.contains(token)) { + Object v = e.getValue(); + result = result.replace(token, v == null ? "" : urlEncodePath(stringify(v))); + consumed.add(e.getKey()); + } + } + for (String k : consumed) { + args.remove(k); + } + if (result.contains("{")) { + // 还有未替换的占位 → 视为参数缺失,交给上层报错更清晰 + throw new IllegalStateException("接口路径存在未填充的占位符:" + result); + } + return result; + } + + /** 路径段编码:保留常见安全字符,中文等必须编码 */ + private String urlEncodePath(String s) { + try { + return URLEncoder.encode(s, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + return s; + } + } + + private HttpHeaders buildHeaders(CallerContext caller) { + HttpHeaders headers = new HttpHeaders(); + headers.set("yschapirequest", "1"); + if (caller != null) { + if (StringUtils.isNotBlank(caller.getCurrentRole())) { + headers.set("currentRole", caller.getCurrentRole()); + } + if (StringUtils.isNotBlank(caller.getUserId())) { + headers.set("userId", caller.getUserId()); + } + if (StringUtils.isNotBlank(caller.getRoleId())) { + headers.set("roleId", caller.getRoleId()); + } + if (StringUtils.isNotBlank(caller.getSchoolId())) { + headers.set("schoolId", caller.getSchoolId()); + } + if (StringUtils.isNotBlank(caller.getCookie())) { + headers.set("Cookie", caller.getCookie()); + } + if (StringUtils.isNotBlank(caller.getReferer())) { + headers.set("yschreferer", caller.getReferer()); + } + } + return headers; + } + + /** 解析平台统一响应 {code,msg,data} */ + private ToolResult parseResponse(ToolDef def, ResponseEntity resp, long cost) { + String raw = resp.getBody(); + if (StringUtils.isBlank(raw)) { + return ToolResult.ok(def, "{\"success\":true,\"data\":null}", null, cost); + } + + JSONObject json; + try { + json = JSON.parseObject(raw); + } catch (Exception e) { + // 非包装响应(如导出 CSV 文本)直接原样返回 + return ToolResult.ok(def, truncate(raw), raw, cost); + } + if (json == null || !json.containsKey("code")) { + return ToolResult.ok(def, truncate(raw), json, cost); + } + + int code = json.getIntValue("code"); + // 302 = 平台 CAS 过滤器要求「浏览器登录态」。服务端调用没有用户的业务系统会话, + // 应用间签名也**不能**替代会话(签名只对 /oapi/* 生效)——把话说清楚,别让使用者以为是网络问题。 + if (code == 302) { + return ToolResult.fail(def, "读取「" + def.getTitle() + "」需要你在业务系统里的登录态(下游返回 code=302)。" + + "服务端无法代表你登录业务模块 —— 点下方「去系统里查看」,用你自己的登录态直接打开该模块页面即可。", cost); + } + if (code != 0) { + String msg = json.getString("msg"); + return ToolResult.fail(def, StringUtils.isBlank(msg) ? ("业务错误 code=" + code) : msg, cost); + } + + Object data = json.get("data"); + String content = data == null ? "{\"success\":true,\"data\":null}" + : truncate(JSON.toJSONString(data)); + // 写操作没有返回体时,补一句明确结果,避免模型以为失败 + if (data == null && !def.isReadOnly()) { + content = "{\"success\":true,\"message\":\"" + def.getTitle() + " 已执行成功\"}"; + } + return ToolResult.ok(def, content, data, cost); + } + + /** GET:把参数拼到 query(跳过空值与固定参数里已给的重复项由 Map 天然覆盖) */ + private String appendQuery(String url, Map args, ToolDef def) { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); + for (Map.Entry e : args.entrySet()) { + Object v = e.getValue(); + if (v == null || StringUtils.isBlank(String.valueOf(v))) { + continue; + } + builder.queryParam(e.getKey(), stringify(v)); + } + return builder.build(false).toUriString(); + } + + /** POST_JSON:只保留非空参数 */ + private Map normalizeJsonArgs(Map args, ToolDef def) { + Map out = new LinkedHashMap<>(); + for (Map.Entry e : args.entrySet()) { + Object v = e.getValue(); + if (v == null || StringUtils.isBlank(String.valueOf(v))) { + continue; + } + out.put(e.getKey(), v); + } + return out; + } + + private String toFormBody(Map args) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : args.entrySet()) { + Object v = e.getValue(); + if (v == null || StringUtils.isBlank(String.valueOf(v))) { + continue; + } + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(urlEncode(e.getKey())).append("=").append(urlEncode(stringify(v))); + } + return sb.toString(); + } + + /** 数组 → 逗号串;其余直接 toString */ + private String stringify(Object v) { + if (v instanceof List) { + List list = (List) v; + StringBuilder sb = new StringBuilder(); + for (Object o : list) { + if (o == null) { + continue; + } + if (sb.length() > 0) { + sb.append(","); + } + sb.append(o); + } + return sb.toString(); + } + if (v instanceof JSONArray) { + return String.join(",", ((JSONArray) v).toJavaList(String.class)); + } + return String.valueOf(v); + } + + private boolean isBlank(Object v) { + return v == null || StringUtils.isBlank(String.valueOf(v)); + } + + private String urlEncode(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return s; + } + } + + private String truncate(String s) { + int max = settings.getLoop().getToolResultMaxChars(); + if (s == null) { + return null; + } + if (s.length() <= max) { + return s; + } + return s.substring(0, max) + "...[已截断,共 " + s.length() + " 字符]"; + } + + private String trimEnd(String s) { + if (s == null) { + return ""; + } + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java new file mode 100644 index 0000000..28c5fcd --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -0,0 +1,251 @@ +package com.yunhai.agent.tool; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 工具参数定义。一份声明,三处复用: + * + *

    + *
  1. 生成给大模型的 JSON Schema({@link #toSchema()})
  2. + *
  3. 入参必填校验
  4. + *
  5. 生成让用户直接填写的表单({@link #toFormField()})—— 信息不全时不再靠模型口头追问, + * 而是把字段的中文名、格式示例、可选值直接摆到界面上
  6. + *
+ */ +@Data +public class ToolParam { + + /** 参数名(与后端接口参数名严格一致,大小写敏感) */ + private String name; + + /** JSON Schema 类型:string / integer / number / boolean / array */ + private String type = "string"; + + /** 给模型看的说明(越具体越好,写明格式与取值范围) */ + private String description; + + private boolean required; + + /** 枚举取值(非空时会在描述与 schema 里同时体现,帮助模型少犯错) */ + private List enumValues; + + /** 枚举的中文名,与 enumValues 一一对应(表单下拉展示用;缺省时直接显示值) */ + private List enumLabels; + + /** + * 内置兜底枚举:只在「学校可自定义的字典」上使用,是 {@link #optionsFrom} 取不到实时值时的顶替品。 + *

有了它,下游字典接口不通时下拉框仍然是下拉框,而不是退化成让用户填「请假类型编号」的数字框。 + * 与 {@link #enumValues} 的区别:enumValues 是权威定义(如审批结论),本字段是「大概是这样,以实时字典为准」。 + */ + private List fallbackValues; + + /** 内置兜底枚举的中文名,与 fallbackValues 一一对应 */ + private List fallbackLabels; + + /** 默认值(模型没给时兜底) */ + private String defaultValue; + + /* ==================== 表单渲染元数据 ==================== */ + + /** 字段中文标签(表单里显示,如「开始时间」);缺省回退到 name */ + private String label; + + /** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */ + private String example; + + /** 控件类型:text / textarea / number / date / datetime / select;缺省按参数名与类型自动推断 */ + private String widget; + + /** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */ + private boolean hidden; + + /** + * 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。 + *

服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 —— + * 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。 + */ + private String optionsFrom; + + public ToolParam() { + } + + public ToolParam(String name, String type, String description, boolean required) { + this.name = name; + this.type = type; + this.description = description; + this.required = required; + } + + public static ToolParam required(String name, String type, String description) { + return new ToolParam(name, type, description, true); + } + + public static ToolParam optional(String name, String type, String description) { + return new ToolParam(name, type, description, false); + } + + public ToolParam enums(String... values) { + this.enumValues = Arrays.asList(values); + return this; + } + + /** 枚举值 + 中文名(一一对应),表单会渲染成带中文选项的下拉框 */ + public ToolParam options(String[] values, String[] labels) { + this.enumValues = Arrays.asList(values); + this.enumLabels = Arrays.asList(labels); + return this; + } + + /** + * 内置兜底选项:配合 {@link #optionsFrom} 使用。 + *

实时字典取到 → 用实时值;取不到 → 用这份兜底,下拉框不至于消失。 + */ + public ToolParam fallbackOptions(String[] values, String[] labels) { + this.fallbackValues = Arrays.asList(values); + this.fallbackLabels = Arrays.asList(labels); + return this; + } + + public ToolParam dft(String value) { + this.defaultValue = value; + return this; + } + + /** 字段中文标签 */ + public ToolParam label(String value) { + this.label = value; + return this; + } + + /** 示例值(帮助用户按格式填写) */ + public ToolParam eg(String value) { + this.example = value; + return this; + } + + /** 指定控件类型 */ + public ToolParam as(String value) { + this.widget = value; + return this; + } + + public ToolParam hide() { + this.hidden = true; + return this; + } + + /** 下拉选项来自某个只读字典工具 */ + public ToolParam optionsFrom(String tool) { + this.optionsFrom = tool; + return this; + } + + /** 表单里显示的名字 */ + public String labelOf() { + return StringUtils.isNotBlank(label) ? label : name; + } + + /** 当前生效的枚举值:权威枚举优先,其次内置兜底 */ + private List effectiveValues() { + if (enumValues != null && !enumValues.isEmpty()) return enumValues; + return fallbackValues; + } + + /** 当前生效的枚举中文名,与 {@link #effectiveValues()} 对应 */ + private List effectiveLabels() { + if (enumValues != null && !enumValues.isEmpty()) return enumLabels; + return fallbackLabels; + } + + /** 当前选项是否来自内置兜底(即实时字典没取到,用的是「大概是这样」的值) */ + public boolean isUsingFallbackOptions() { + return (enumValues == null || enumValues.isEmpty()) + && fallbackValues != null && !fallbackValues.isEmpty(); + } + + /** + * 推断控件类型:显式指定优先,其次枚举→下拉,然后按类型与参数名语义判断。 + *

参数名里的 date/time 是强信号(startDate / endDate / leaveTime), + * 文本类词根(remarks / reason / content)则渲染成多行输入。 + */ + public String resolveWidget() { + if (StringUtils.isNotBlank(widget)) return widget; + List vals = effectiveValues(); + if (vals != null && !vals.isEmpty()) return "select"; + + String t = StringUtils.trimToEmpty(type).toLowerCase(); + if ("integer".equals(t) || "number".equals(t)) return "number"; + if ("boolean".equals(t)) return "select"; + + String n = StringUtils.trimToEmpty(name).toLowerCase(); + if (n.contains("date") || n.contains("time")) return "datetime"; + if (n.contains("remark") || n.contains("reason") || n.contains("desc") || n.contains("memo") + || n.contains("content") || n.contains("address") || n.contains("text")) { + return "textarea"; + } + return "text"; + } + + /** 枚举 → 前端下拉项 [{value,label}](权威枚举优先,其次内置兜底) */ + public List> optionList() { + List> list = new ArrayList<>(); + List vals = effectiveValues(); + List labs = effectiveLabels(); + if (vals == null) return list; + for (int i = 0; i < vals.size(); i++) { + Map o = new LinkedHashMap<>(); + o.put("value", vals.get(i)); + String lab = (labs != null && i < labs.size()) ? labs.get(i) : null; + o.put("label", StringUtils.isNotBlank(lab) ? lab : vals.get(i)); + list.add(o); + } + return list; + } + + /** 转成前端渲染用的表单字段 */ + public FormField toFormField() { + FormField f = new FormField(); + f.setName(name); + f.setLabel(labelOf()); + f.setWidget(resolveWidget()); + f.setType(type); + f.setRequired(required); + f.setDescription(description); + f.setExample(example); + f.setDefaultValue(defaultValue); + f.setOptions(optionList()); + // 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」 + f.setOptionsFrom(optionsFrom); + // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 + f.setOptionsFallback(isUsingFallbackOptions()); + return f; + } + + /** 转成 JSON Schema 的属性节点(给模型看) */ + public JSONObject toSchema() { + JSONObject o = new JSONObject(); + o.put("type", type); + String desc = description == null ? "" : description; + List vals = effectiveValues(); + if (vals != null && !vals.isEmpty()) { + desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "可选值:" + String.join(" / ", vals); + o.put("enum", new JSONArray() {{ + addAll(vals); + }}); + } + if (StringUtils.isNotBlank(example)) { + desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "示例:" + example; + } + o.put("description", desc); + return o; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/ToolRegistry.java b/src/main/java/com/yunhai/agent/tool/ToolRegistry.java new file mode 100644 index 0000000..eb523a7 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/ToolRegistry.java @@ -0,0 +1,174 @@ +package com.yunhai.agent.tool; + +import com.alibaba.fastjson.JSONArray; +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.agent.auth.RolePolicy; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.tool.defs.ActivityTools; +import com.yunhai.agent.tool.defs.CampusNoticeTools; +import com.yunhai.agent.tool.defs.DormNoticeTools; +import com.yunhai.agent.tool.defs.HomeworkTools; +import com.yunhai.agent.tool.defs.LeaveTools; +import com.yunhai.agent.tool.defs.ProfessionTools; +import com.yunhai.agent.tool.defs.ResourceTools; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 工具注册表:所有可被大模型调用的业务动作都在这里汇总。 + * + *

新增一个模块的接入方式:写一个 {@code XxxTools#defs()} 返回 {@code List}, + * 在下面的 {@link #build()} 里加一行。执行、鉴权、schema 生成全部复用通用实现。 + * + *

可见性由两个维度共同决定: + *

    + *
  1. {@code modId} —— 平台菜单授权({@code agent.filter-tools-by-authorized-module=true} 时生效);
  2. + *
  3. {@code roles} —— 学生 / 教师 / 管理员角色(「分学生、教师权限」的落点)。
  4. + *
+ */ +@Slf4j +@Component +public class ToolRegistry { + + private final AgentSettings settings; + + /** name → ToolDef(保持插入顺序,便于目录展示) */ + private final Map tools = new LinkedHashMap<>(); + + /** + * 大模型侧工具名(点换下划线)→ 内部点分名。 + * + *

大模型对 {@code function.name} 只接受 {@code ^[a-zA-Z0-9_-]+$},点号非法, + * 所以下发时用 {@link ToolDef#wireName()},模型回传的也是这个名字,这里负责还原。 + */ + private final Map wireAliases = new LinkedHashMap<>(); + + public ToolRegistry(AgentSettings settings) { + this.settings = settings; + } + + @PostConstruct + public void init() { + build(); + log.info("智能体工具注册完成,共 {} 个:{}", tools.size(), String.join(", ", tools.keySet())); + // 校验:工具引用的模块必须在 agent.modules 里配了地址,否则运行期必然失败 + for (ToolDef d : tools.values()) { + if (!settings.getModules().containsKey(d.getModule())) { + log.warn("工具 {} 引用了未配置地址的模块「{}」,请检查 agent.modules", d.getName(), d.getModule()); + } + } + } + + private void build() { + register(LeaveTools.defs()); + register(CampusNoticeTools.defs()); + register(DormNoticeTools.defs()); + register(ActivityTools.defs()); + register(HomeworkTools.defs()); + register(ResourceTools.defs()); + register(ProfessionTools.defs()); + // 套用「学生 / 教师」角色能力策略(未显式声明角色的工具在此落定) + RolePolicy.apply(tools.values()); + } + + private void register(List defs) { + for (ToolDef d : defs) { + if (tools.containsKey(d.getName())) { + throw new IllegalStateException("工具名重复:" + d.getName()); + } + String wire = d.wireName(); + // wire 名撞车会导致模型回传无法还原(点→下划线是单向压缩),注册期就拦下来 + if (tools.containsKey(wire)) { + throw new IllegalStateException("工具名冲突:" + d.getName() + " 的模型侧名字「" + wire + + "」与已有工具同名,请改名"); + } + if (wire != null && !wire.equals(d.getName())) { + String prev = wireAliases.put(wire, d.getName()); + if (prev != null) { + throw new IllegalStateException("模型侧工具名冲突:" + d.getName() + " 与 " + prev + + " 换成模型可用名后都是「" + wire + "」,请改名"); + } + } + // ★ 深链兜底:工具没声明落地页时,取该模块的默认页面(agent.module-landing)。 + // 这样"查询失败 / 越权拦截"也能带着一个真实可打开的模块地址回到前端。 + if (StringUtils.isBlank(d.getLandingPath())) { + d.setLandingPath(settings.getModuleLanding().get(d.getModule())); + } + tools.put(d.getName(), d); + } + } + + /** + * 按名字取工具:**同时兼容内部点分名与大模型侧下划线名**。 + * + *

模型回传的 {@code function.name} 是 {@link ToolDef#wireName()}(点已换成下划线), + * 而前端表单回传、字典工具引用用的是内部点分名,两种都要能命中。 + */ + public ToolDef get(String name) { + if (name == null) { + return null; + } + ToolDef d = tools.get(name); + if (d != null) { + return d; + } + String real = wireAliases.get(name); + return real == null ? null : tools.get(real); + } + + public Collection all() { + return tools.values(); + } + + /** + * 按「已授权模块 + 角色」过滤后,转成给模型看的 tools schema。 + * + * @param authorizedModIds 当前用户已授权的 modId 集合;为 null 表示不按 modId 过滤 + * @param roles 当前用户角色集合;为 null 表示不按角色过滤(排障用) + */ + public JSONArray toOpenAiTools(Set authorizedModIds, Set roles) { + JSONArray arr = new JSONArray(); + for (ToolDef d : tools.values()) { + if (!visible(d, authorizedModIds, roles)) { + continue; + } + arr.add(d.toOpenAiTool()); + } + return arr; + } + + /** 供前端「我能用这个智能体做什么」的目录展示 */ + public List visibleTools(Set authorizedModIds, Set roles) { + return tools.values().stream() + .filter(d -> visible(d, authorizedModIds, roles)) + .collect(Collectors.toList()); + } + + private boolean visible(ToolDef d, Set authorizedModIds, Set roles) { + // 1) modId 维度:仅在开启「按授权模块过滤」时才过滤(modId 为 null 的工具不参与该维度) + if (d.getModuleId() != null && authorizedModIds != null && !authorizedModIds.contains(d.getModuleId())) { + return false; + } + // 2) 角色维度:未声明角色的工具人人可用 + return d.allowsAny(roles); + } + + /** 按模块分组统计(目录接口用) */ + public Map> groupByModule(Set authorizedModIds, Set roles) { + Map> map = new LinkedHashMap<>(); + for (ToolDef d : visibleTools(authorizedModIds, roles)) { + map.computeIfAbsent(d.getModule(), k -> new ArrayList<>()).add(d); + } + return map; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/ToolResult.java b/src/main/java/com/yunhai/agent/tool/ToolResult.java new file mode 100644 index 0000000..b51dc26 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/ToolResult.java @@ -0,0 +1,54 @@ +package com.yunhai.agent.tool; + +import lombok.Data; + +/** + * 一次工具执行的结果。既要回填给模型(content),也要回传给前端(展示"做了什么")。 + */ +@Data +public class ToolResult { + + private String toolName; + private String title; + private boolean success = true; + private String error; + + /** 回填给模型的内容(已截断、已结构化) */ + private String content; + + /** 回传给前端的原始 data(供渲染卡片 / 跳转) */ + private Object data; + + private String landingPath; + private long costMs; + + public static ToolResult ok(ToolDef def, String content, Object data, long costMs) { + ToolResult r = new ToolResult(); + r.toolName = def.getName(); + r.title = def.getTitle(); + r.content = content; + r.data = data; + r.landingPath = def.getLandingPath(); + r.costMs = costMs; + return r; + } + + public static ToolResult fail(ToolDef def, String error, long costMs) { + ToolResult r = new ToolResult(); + r.toolName = def.getName(); + r.title = def.getTitle(); + r.success = false; + r.error = error; + // ★ 失败也要带落地页:下游 302(需浏览器登录态)、越权拦截、缺参等情况, + // 前端据此给出「去系统里查看」,让用户用自己的登录态把事办完。 + r.landingPath = def.getLandingPath(); + // 回填给模型的内容里必须明确写"失败+原因",否则模型会编造成功 + r.content = "{\"success\":false,\"error\":\"" + escape(error) + "\"}"; + r.costMs = costMs; + return r; + } + + private static String escape(String s) { + return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java new file mode 100644 index 0000000..27f1a8f --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java @@ -0,0 +1,82 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 家校活动模块工具 —— 后端 FamilyActivity,前缀 /api/activity、/api/base。 + * + *

核心闭环:活动广场 → 家长报名 → 学校确认。 + */ +public final class ActivityTools { + + public static final String MODULE = "familyactivity"; + + private ActivityTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("activity.plaza", MODULE, "活动广场", + "当前家长/学生视角的活动广场(含我的报名状态与分桶计数)。用户问“最近有什么活动”“我能报什么”时用它。", + "GET", "/api/activity/MyActivities") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "活动名称关键词")) + .readAt("/par/activities")); + + list.add(ToolDef.of("activity.list", MODULE, "已发布活动列表", + "已发布活动列表(家长端收件箱口径)。", + "GET", "/api/activity/List") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "关键词")) + .readAt("/list")); + + list.add(ToolDef.of("activity.detail", MODULE, "活动详情", + "查看活动详情(时间、地点、说明、附件)。", + "GET", "/api/activity/Detail") + .p(ToolParam.required("serid", "integer", "活动 id"), + ToolParam.optional("type", "integer", "1 查看并标读(默认) / 0 仅查看").dft("1")) + .readAt("/par/activities")); + + list.add(ToolDef.of("activity.mySignup", MODULE, "我的报名详情", + "查看我对某个活动的报名信息与状态。", + "GET", "/api/activity/MySignup") + .p(ToolParam.required("serid", "integer", "活动 id")) + .readAt("/par/mine")); + + list.add(ToolDef.of("activity.signup", MODULE, "报名活动", + "家长为学生报名参加活动。调用前必须确认活动 serid;参加人数 joinCount 与联系人信息可选但建议询问。", + "POST_JSON", "/api/activity/Signup") + .p(ToolParam.required("serid", "integer", "活动 id") + .label("活动"), + ToolParam.optional("joinCount", "integer", "参加人数(含学生本人)") + .label("参加人数").dft("1"), + ToolParam.optional("guardianName", "string", "监护人姓名") + .label("监护人姓名"), + ToolParam.optional("guardianPhone", "string", "监护人电话") + .label("监护人电话").eg("13800000000"), + ToolParam.optional("remark", "string", "备注") + .label("备注").as("textarea")) + .write("/par/mine")); + + list.add(ToolDef.of("activity.cancelSignup", MODULE, "取消报名", + "取消我对某个活动的报名。", + "POST_FORM", "/api/activity/CancelSignup") + .p(ToolParam.required("serid", "integer", "活动 id") + .label("活动")) + .write("/par/mine")); + + list.add(ToolDef.of("activity.classTree", MODULE, "年级班级树", + "年级 → 班级 树(发布活动时选接收班级用)。", + "GET", "/api/base/GetClassTree") + .readAt("/list/new")); + + return list; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java new file mode 100644 index 0000000..be9724b --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java @@ -0,0 +1,120 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 校园公告(校园通知)模块工具 —— 后端 CampusAnnouncement / NoticeFront,前缀 /api/notice。 + */ +public final class CampusNoticeTools { + + public static final String MODULE = "campusnotice"; + + private CampusNoticeTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("notice.received", MODULE, "我收到的公告", + "分页查询当前用户收到的校园公告。用户问“最近有什么通知”“有没有未读公告”时用它。", + "GET", "/api/notice/List") + .p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"), + ToolParam.optional("keyword", "string", "标题关键词"), + ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要"), + ToolParam.optional("readType", "integer", "阅读状态:0 全部 / 1 未读 / 2 已读")) + .readAt("/") + .modId(null)); + + list.add(ToolDef.of("notice.readTabCount", MODULE, "公告未读统计", + "查询当前用户公告的已收总数 / 未读数 / 已读数(回答“我还有几条没看”)。", + "GET", "/api/notice/ReadTabCount") + .readAt("/")); + + list.add(ToolDef.of("notice.sent", MODULE, "我发出的公告", + "分页查询我发布(或有权限查看)的公告,可按状态筛选(草稿/已发布/待审批/已驳回)。", + "GET", "/api/notice/SendList") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("scope", "string", "传 user 只查自己发布的"), + ToolParam.optional("status", "integer", "0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回"), + ToolParam.optional("keyword", "string", "标题关键词")) + .readAt("/list") + .modId(null)); + + list.add(ToolDef.of("notice.detail", MODULE, "公告详情", + "查看一条公告的正文、附件与已读情况。", + "GET", "/api/notice/Detail") + .p(ToolParam.required("serid", "integer", "公告 id"), + ToolParam.optional("type", "integer", "1 查看并标记已读(默认) / 0 仅查看不标读").dft("1")) + .readAt("/")); + + list.add(ToolDef.of("notice.deptDics", MODULE, "发文部门字典", + "查询可选的发文部门(发布公告时填 deptId 用)。", + "GET", "/api/dict/List") + .fix("type", "1") + .readAt("/list/new")); + + list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告", + "发布一条校园公告。调用前必须确认:标题 title、正文 content、发文部门 deptId;" + + "缺失就问用户,不要编造部门。", + "POST_JSON", "/api/notice/Create") + .p(ToolParam.required("title", "string", "公告标题") + .label("公告标题").eg("关于国庆假期安排的通知"), + ToolParam.required("content", "string", "公告正文(纯文本)") + .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"), + ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics") + .label("发文部门").optionsFrom("notice.deptDics"), + ToolParam.optional("deptName", "string", "发文部门名称") + .label("发文部门名称"), + ToolParam.optional("type", "integer", "重要程度") + .label("重要程度").dft("1") + .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}), + ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执") + .label("需要阅读回执").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("smsFlag", "integer", "是否同时发短信") + .label("同时发短信").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("publishType", "string", "发布方式") + .label("发布方式").dft("direct") + .options(new String[]{"direct", "draft", "approve"}, + new String[]{"直接发布", "存草稿", "提交审批"})) + .write("/list/new") + .modId(null)); + + list.add(ToolDef.of("notice.approvalList", MODULE, "待我审批的公告", + "查询待我审批的公告列表(公告审批中心)。", + "GET", "/api/notice/ApprovalList") + .readAt("/approval") + .p(ToolParam.optional("status", "string", "pending 待审批(默认) / approved 已通过 / rejected 已驳回 / all 全部").dft("pending"), + ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"))); + + list.add(ToolDef.of("notice.approve", MODULE, "通过公告审批", + "审批通过一条公告(末步通过即真实发布)。", + "POST_FORM", "/api/notice/Approve") + .p(ToolParam.required("serid", "integer", "公告 id") + .label("公告"), + ToolParam.optional("opinion", "string", "审批意见") + .label("审批意见").as("textarea").eg("内容符合要求,同意发布")) + .write("/approval") + .modId(null)); + + list.add(ToolDef.of("notice.reject", MODULE, "驳回公告审批", + "驳回一条公告审批(意见必填,会退回发布人)。", + "POST_FORM", "/api/notice/Reject") + .p(ToolParam.required("serid", "integer", "公告 id") + .label("公告"), + ToolParam.required("remark", "string", "驳回意见") + .label("驳回意见").as("textarea").eg("标题表述不清,请修改后重新提交")) + .write("/approval") + .modId(null)); + + return list; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java new file mode 100644 index 0000000..b5fb715 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java @@ -0,0 +1,95 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 宿舍通知模块工具 —— 后端 DormNotice,前缀 /api/notice、/api/dorm、/api/base。 + * + *

家校闭环:宿管发通知 → 家长查看 → 家长回执/填报。 + */ +public final class DormNoticeTools { + + public static final String MODULE = "dormnotice"; + + private DormNoticeTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("dorm.myNotices", MODULE, "我的宿舍通知箱", + "当前家长/学生视角的宿舍通知箱(含我的回执状态与各分桶计数)。用户问“宿舍有什么通知”“我要回执”时用它。", + "GET", "/api/notice/MyNotices") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "关键词")) + .readAt("/par/notices")); + + list.add(ToolDef.of("dorm.myNotice", MODULE, "宿舍通知详情(含我的回执)", + "查看某条宿舍通知的详情,以及我当前的填报表单与回执状态。", + "GET", "/api/notice/MyNotice") + .p(ToolParam.required("serid", "integer", "通知 id")) + .readAt("/par/notices")); + + list.add(ToolDef.of("dorm.sendList", MODULE, "宿舍通知发布列表", + "查询已发布的宿舍通知(家长端收件箱 / 管理端全部)。", + "GET", "/api/notice/List") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "标题关键词")) + .readAt("/list")); + + list.add(ToolDef.of("dorm.submitReceipt", MODULE, "提交宿舍通知回执", + "家长提交某条宿舍通知的回执 / 填报。调用前须确认 serid;如该通知需填报,还需填 fillData 内容。", + "POST_JSON", "/api/notice/SubmitReceipt") + .p(ToolParam.required("serid", "integer", "通知 id") + .label("宿舍通知"), + ToolParam.optional("guardianName", "string", "监护人姓名") + .label("监护人姓名"), + ToolParam.optional("guardianPhone", "string", "监护人电话") + .label("监护人电话").eg("13800000000"), + ToolParam.optional("relation", "string", "与学生关系") + .label("与学生关系").eg("父亲"), + ToolParam.optional("remark", "string", "备注") + .label("备注").as("textarea"), + ToolParam.optional("fillData", "object", "填报内容,键值对(需填报类通知必传)") + .label("填报内容")) + .write("/par/mine")); + + list.add(ToolDef.of("dorm.cancelReceipt", MODULE, "取消宿舍通知回执", + "撤回我已提交的宿舍通知回执。", + "POST_FORM", "/api/notice/CancelReceipt") + .p(ToolParam.required("serid", "integer", "通知 id") + .label("宿舍通知")) + .write("/par/mine")); + + list.add(ToolDef.of("dorm.buildings", MODULE, "宿舍楼栋列表", + "查询宿舍楼栋列表(含房间数/床位数/入住数)。", + "GET", "/api/dorm/BuildingList") + .readAt("/setting")); + + list.add(ToolDef.of("dorm.rooms", MODULE, "宿舍房间列表", + "查询某楼栋下的房间,或按关键词搜索房间。", + "GET", "/api/dorm/RoomList") + .p(ToolParam.optional("buildingId", "integer", "楼栋 id"), + ToolParam.optional("keyword", "string", "房间号关键词")) + .readAt("/setting")); + + list.add(ToolDef.of("dorm.roomStudents", MODULE, "房间住宿生名单", + "查询某个宿舍房间内的住宿生名单(含宿舍长标识)。", + "GET", "/api/dorm/RoomStudents") + .p(ToolParam.required("roomId", "integer", "房间 id")) + .readAt("/setting")); + + list.add(ToolDef.of("dorm.dormTree", MODULE, "宿舍楼栋房间树", + "楼栋 → 房间 的完整树(发布宿舍通知时选接收房间用)。", + "GET", "/api/base/GetDormTree") + .readAt("/list/new")); + + return list; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java new file mode 100644 index 0000000..b89b0dc --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java @@ -0,0 +1,57 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 课程作业模块工具(学生端 V2)—— 后端 CourseHomework,前缀 /api/student/homeworks。 + * + *

学生端接口路径带资源 id({@code /{id}/...}),执行器支持 {@code {name}} 路径占位。 + */ +public final class HomeworkTools { + + public static final String MODULE = "coursehomework"; + + private HomeworkTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("homework.myList", MODULE, "我的作业列表", + "查询我的作业:待做 / 已完成 / 已截止 / 全部。用户问“今天有什么作业”“还有几项没交”时用它。", + "GET", "/api/student/homeworks") + .p(ToolParam.optional("tab", "string", "all 全部(默认) / todo 待做 / done 已完成 / closed 已截止").dft("all"), + ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "作业标题关键词")) + .readAt("/stu/today")); + + list.add(ToolDef.of("homework.detail", MODULE, "作业详情", + "查看一项作业的说明、附件要求与我当前的提交内容。", + "GET", "/api/student/homeworks/{id}") + .p(ToolParam.required("id", "integer", "作业 id")) + .readAt("/stu/today")); + + list.add(ToolDef.of("homework.result", MODULE, "作业批阅结果", + "查看我的作业批阅结果与教师评语。", + "GET", "/api/student/homeworks/{id}/result") + .p(ToolParam.required("id", "integer", "作业 id")) + .readAt("/stu/redo")); + + list.add(ToolDef.of("homework.submit", MODULE, "提交作业", + "提交一项作业的作答文本。调用前必须确认作业 id 与作答内容;" + + "若该作业要求上传图片/附件,告知用户需到作业页面上传,智能体只处理文本作答。", + "POST_JSON", "/api/student/homeworks/{id}/submissions") + .p(ToolParam.required("id", "integer", "作业 id") + .label("作业"), + ToolParam.optional("text", "string", "作答文本内容") + .label("作答内容").as("textarea").eg("我的解题思路是……")) + .write("/stu/today")); + + return list; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java new file mode 100644 index 0000000..6e0f848 --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java @@ -0,0 +1,164 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * 学生请假模块工具(后端 HomeSchLeave,前缀 /api/leave、/api/dict、/api/leavestats)。 + * + *

平台菜单 modId(HomeLeave routes.ts 契约):2 请假申请 / 3 我的请假 / 4 请假审批 / 5 请假登记 / 6 请假统计 / 7 应用设置 + */ +public final class LeaveTools { + + public static final String MODULE = "homeleave"; + + /** 日期时间格式:后端统一 yyyy-MM-dd HH:mm:ss */ + private static final String DT_HINT = "格式 yyyy-MM-dd HH:mm:ss(如 2026-10-12 08:00:00)"; + + /** + * 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。 + *

权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义), + * 但该接口需登录会话、Agent 侧常取不到;没有兜底时表单会退化成让用户填「类型编号」的数字框。 + */ + private static final String[] LEAVE_TYPE_VALUES = {"1", "2"}; + + private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"}; + + private LeaveTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + // ---------- 只读:辅助信息 ---------- + list.add(ToolDef.of("leave.types", MODULE, "请假类型", + "查询本校本模块可选的请假类型(病假/事假等)及其编号。用户说“请假”但没给类型时,先调它拿到 leaveType 编号。", + "GET", "/api/dict/LeaveTypeOptions") + .readAt("/apply") + .modId(2)); + + list.add(ToolDef.of("leave.myClasses", MODULE, "我的班级", + "查询当前用户关联的班级(学生=所在班级;教师=任教班级;校管理员=全校班级)。登记请假、筛选前先调它拿 classId。", + "GET", "/api/leave/MyClasses") + .readAt("/apply") + .modId(2)); + + list.add(ToolDef.of("leave.classStudents", MODULE, "班级学生名单", + "查询某班级的学生名单(教师给学生登记请假时选人用)。", + "GET", "/api/leave/ClassStudents") + .p(ToolParam.required("classId", "integer", "班级 id,来自 leave.myClasses")) + .readAt("/register") + .modId(5)); + + // ---------- 只读:查询 ---------- + list.add(ToolDef.of("leave.myLeaves", MODULE, "查询请假记录", + "分页查询请假记录。学生查本人、教师查任教班级、管理员查全校(数据范围由后端按身份收敛)。" + + "用户问“我请了几天假”“上次请假通过没”时用它。", + "GET", "/api/leave/List") + .p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"), + ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"), + ToolParam.optional("leaveType", "integer", "按请假类型筛选:1 病假、2 事假及其他") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS), + ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"), + ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"), + ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)")) + .modId(3)); + + list.add(ToolDef.of("leave.detail", MODULE, "请假详情", + "查询一条请假的完整详情(时间段、销假时间、附件、可执行按钮)。", + "GET", "/api/leave/Details") + .p(ToolParam.required("id", "integer", "请假记录 id,来自 leave.myLeaves")) + .modId(3)); + + list.add(ToolDef.of("leave.stats", MODULE, "请假统计", + "按日期/班级/类型统计请假情况。", + "GET", "/api/leavestats/Stats") + .p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"), + ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"), + ToolParam.optional("classId", "integer", "班级 id"), + ToolParam.optional("leaveType", "integer", "请假类型:1 病假、2 事假及其他") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)) + .readAt("/stats") + .modId(6)); + + // ---------- 写:提交 / 审批 / 销假 ---------- + list.add(ToolDef.of("leave.submit", MODULE, "提交请假申请", + "学生提交一条请假申请(提交后状态为待审核,等班主任审批)。" + + "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;" + + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", + "POST_JSON", "/api/leave/Save") + .p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") + .label("请假类型") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) + .optionsFrom("leave.types"), + ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) + .label("开始时间").eg("2026-10-12 08:00:00"), + ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) + .label("结束时间").eg("2026-10-12 17:00:00"), + ToolParam.required("remarks", "string", "请假事由") + .label("请假事由").as("textarea").eg("发烧就医,需在家休息"), + ToolParam.optional("classId", "integer", "班级 id;用户只有唯一班级时可不传") + .label("班级"), + ToolParam.optional("stuId", "integer", "学生 id(教师代学生登记时用)") + .label("学生")) + .fix("type", "2") + .fix("continuousLeave", "0") + .write("/my") + .modId(2)); + + list.add(ToolDef.of("leave.withdraw", MODULE, "撤销请假", + "撤销一条请假申请。待审核可直接撤销;已通过则转为待撤销(需教师审批)。", + "POST_FORM", "/api/leave/Withdraw") + .p(ToolParam.required("id", "integer", "请假记录 id") + .label("请假记录")) + .write("/my") + .modId(3)); + + list.add(ToolDef.of("leave.reportBack", MODULE, "申请销假", + "学生请完假返校后申请销假(状态转待销假,教师确认后回写实际时间)。", + "POST_JSON", "/api/leave/ReportBack") + .p(ToolParam.required("id", "integer", "请假记录 id") + .label("请假记录"), + ToolParam.optional("startDate", "string", "实际返校时间," + DT_HINT) + .label("实际返校时间").eg("2026-10-12 08:00:00"), + ToolParam.optional("endDate", "string", "实际结束时间," + DT_HINT) + .label("实际结束时间").eg("2026-10-12 17:00:00")) + .fix("continuousLeave", "0") + .write("/my") + .modId(3)); + + list.add(ToolDef.of("leave.audit", MODULE, "审批请假", + "教师/管理员审批一条请假。", + "POST_JSON", "/api/leave/Audit") + .p(ToolParam.required("id", "integer", "请假记录 id") + .label("请假记录"), + ToolParam.required("status", "integer", "审批结论") + .label("审批结论") + .options(new String[]{"1", "8", "3", "4", "6", "7"}, + new String[]{"通过", "拒绝", "撤销通过", "撤销拒绝", "销假通过", "销假拒绝"})) + .write("/audit") + .modId(4)); + + list.add(ToolDef.of("leave.batchPass", MODULE, "批量通过请假", + "一键通过某班级全部待审核请假,或按 id 列表批量通过。", + "POST_JSON", "/api/leave/BatchPass") + .p(ToolParam.optional("classId", "integer", "班级 id:通过该班全部待审核") + .label("班级"), + ToolParam.optional("ids", "array", "请假 id 数组:仅通过勾选的这些") + .label("请假记录").eg("101,102")) + .write("/audit") + .modId(4)); + + return list; + } + + /** 供文档/目录使用 */ + public static List moduleAlias() { + return Arrays.asList("请假", "学生请假", "homeleave"); + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java new file mode 100644 index 0000000..6f1ab2b --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java @@ -0,0 +1,90 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 教师专业发展模块工具 —— 后端 Profession,前缀 /api/profession、/api/moudule、/api/points、/api/stats。 + */ +public final class ProfessionTools { + + public static final String MODULE = "profession"; + + private ProfessionTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("profession.homeRecords", MODULE, "栏目最新记录", + "各栏目最新发布的记录块(专业发展首页内容)。", + "GET", "/api/profession/HomeModuleRecords") + .p(ToolParam.optional("size", "integer", "每栏取几条").dft("6")) + .readAt("/home")); + + list.add(ToolDef.of("profession.list", MODULE, "专业发展记录列表", + "后台口径的专业发展记录分页查询,可按栏目/教师/时间筛选。", + "GET", "/api/profession/ProfessionList") + .p(ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10"), + ToolParam.optional("keyword", "string", "标题关键词"), + ToolParam.optional("moduleId", "integer", "栏目 id"), + ToolParam.optional("starttime", "string", "开始时间 yyyy-MM-dd"), + ToolParam.optional("endtime", "string", "结束时间 yyyy-MM-dd")) + .readAt("/module/list")); + + list.add(ToolDef.of("profession.moduleRecords", MODULE, "栏目文章列表", + "某个栏目下的文章分页(附同校其他栏目)。", + "GET", "/api/profession/ModuleRecordList") + .p(ToolParam.required("moduleId", "integer", "栏目 id"), + ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10")) + .readAt("/module/list")); + + list.add(ToolDef.of("profession.myRecords", MODULE, "我的栏目文章", + "查询我在某栏目下发表的文章。", + "GET", "/api/profession/MyModuleRecordList") + .p(ToolParam.required("moduleId", "integer", "栏目 id"), + ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10")) + .readAt("/timeline")); + + list.add(ToolDef.of("profession.detail", MODULE, "记录详情", + "查看一条专业发展记录的详情。", + "GET", "/api/profession/ProfessionDetail") + .p(ToolParam.required("SerID", "integer", "记录 id")) + .readAt("/module/list")); + + list.add(ToolDef.of("profession.moduleTree", MODULE, "栏目树", + "专业发展的栏目树(含我的栏目),发布记录时选栏目用。", + "GET", "/api/moudule/ModuleTree") + .readAt("/record/new")); + + list.add(ToolDef.of("profession.myPoints", MODULE, "我的积分", + "查询我的专业发展积分与来源明细。", + "GET", "/api/profession/MyPoints") + .readAt("/timeline")); + + list.add(ToolDef.of("profession.pointsRank", MODULE, "积分排行", + "积分排行榜。", + "GET", "/api/profession/PointsRank") + .p(ToolParam.optional("top", "integer", "取前几名").dft("8")) + .readAt("/home")); + + list.add(ToolDef.of("profession.publish", MODULE, "发布专业发展记录", + "发布一条专业发展记录(教案/学习资料/反思等)。调用前必须确认栏目 moduleId、标题与内容。", + "POST_FORM", "/api/profession/Create") + .p(ToolParam.required("moduleId", "integer", "栏目 id,来自 profession.moduleTree") + .label("所属栏目").optionsFrom("profession.moduleTree"), + ToolParam.required("title", "string", "记录标题") + .label("记录标题").eg("2026年秋季公开课教学设计"), + ToolParam.optional("content", "string", "记录正文") + .label("记录正文").as("textarea")) + .write("/record/new")); + + return list; + } +} diff --git a/src/main/java/com/yunhai/agent/tool/defs/ResourceTools.java b/src/main/java/com/yunhai/agent/tool/defs/ResourceTools.java new file mode 100644 index 0000000..faf5c7c --- /dev/null +++ b/src/main/java/com/yunhai/agent/tool/defs/ResourceTools.java @@ -0,0 +1,59 @@ +package com.yunhai.agent.tool.defs; + +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; + +import java.util.ArrayList; +import java.util.List; + +/** + * 资源库模块工具 —— 后端 ResourceLibrary,前缀 /api/resource、/api/category、/api/textbook。 + */ +public final class ResourceTools { + + public static final String MODULE = "resourcelibrary"; + + private ResourceTools() { + } + + public static List defs() { + List list = new ArrayList<>(); + + list.add(ToolDef.of("resource.search", MODULE, "检索资源", + "按关键词/分类/学科检索教学资源(课件、教案、试题等)。用户问“找一份 XX 的课件”“有没有 XX 资料”时用它。", + "GET", "/api/resource/List") + .p(ToolParam.optional("keyword", "string", "关键词:资源名称 / 编码 / 上传人"), + ToolParam.optional("categoryId", "integer", "分类节点 id(含子节点)"), + ToolParam.optional("subject", "string", "学科"), + ToolParam.optional("type", "string", "资源类型"), + ToolParam.optional("orderBy", "string", "排序:time_desc 最新 / download_desc 下载最多 / view_desc 浏览最多"), + ToolParam.optional("pageIndex", "integer", "页码").dft("1"), + ToolParam.optional("pageSize", "integer", "每页条数").dft("10")) + .readAt("/resource")); + + list.add(ToolDef.of("resource.detail", MODULE, "资源详情", + "查看一份资源的详细信息(分类、学科、格式、下载量等)。", + "GET", "/api/resource/Detail") + .p(ToolParam.required("id", "integer", "资源 id,来自 resource.search")) + .readAt("/resource")); + + list.add(ToolDef.of("resource.categoryTree", MODULE, "资源分类树", + "资源分类树(学段 → 学科 → 年级册次 → 章节),用于按分类筛选资源。", + "GET", "/api/category/Tree") + .readAt("/resource")); + + list.add(ToolDef.of("resource.collect", MODULE, "收藏资源", + "收藏或取消收藏一份资源。", + "POST_FORM", "/api/resource/Collect") + .p(ToolParam.required("id", "integer", "资源 id") + .label("资源")) + .write("/resource/collect")); + + list.add(ToolDef.of("resource.textbookOptions", MODULE, "教材选项", + "查询可选的教材版本 / 学科 / 出版社。", + "GET", "/api/textbook/Options") + .readAt("/textbook")); + + return list; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0b67808 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,147 @@ +server: + port: 8093 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + datasource: + # 智能体自身落库:会话(agent_session)+ 消息(agent_message)+ 工具调用审计(agent_tool_call_log) + # 首次部署前执行:CREATE DATABASE bsch_agent DEFAULT CHARSET utf8mb4; + url: jdbc:mysql://localhost:3306/bsch_agent?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai + username: root + password: 123456 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: false + hibernate: + # 开发期使用 update 自动建表;生产环境设置成 none + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + # 平台统一 HTTP 客户端(yhRestTemplate,由 yh-http starter 提供)超时: + # 本服务的出站调用(大模型 / 各业务模块)都走它,读超时给足以免大模型慢响应被切断 + http: + connect-timeout-ms: 5000 + read-timeout-ms: 60000 + write-timeout-ms: 60000 + sso: + # 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),票据校验=根路径 /serviceValidate + server-url-prefix: http://sso.yunhaixingtong.com/ + client-host-url: http://localhost:8093 + allow-headers: all + allow-origins: all + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用)。本地联调用 100009(160 UserBase/中央密钥链在册身份) + app-key: 100009 + app-secret: dcc36c98299a4d36bb0ec39d42008e8c + server: + enforce-patterns: [/oapi/*] + trust-registry: true + + # ★ 开放接口服务目录(用户中心 / YschBaseOpenApi)—— 必需,缺了登录必报「内部服务错误」 + # 平台 yh-openapi starter 的 HasOpenApiEndpointCondition 会检查 + # yh.open.base-host 或 yh.open.services.userbase: + # 有 → 装配 OpenApiUserDetailsProvider(CAS 登录成功后按登录名反查 BoaUser) + # 无 → 退化为 NoOpUserDetailsProvider,其 loadByLoginName 直接抛 + # UnsupportedOperationException → 全局异常处理器返回 code=6「内部服务错误」 + # (前端表现:登录成功后「载入用户信息失败,请检查智能体服务是否已启动」) + # 说明:userbase/userclass/usermsg/flow-center 四域走枚举兜底 = base-host + 默认 ctx, + # 故 base-host 一项即可;如需单独指向某域再加 yh.open.services.<域>。 + open: + base-host: http://base.yunhaixingtong.com + +# ============================================================ +# 智能体配置:大模型 + 各业务模块地址 + 角色权限(★ 上线前按环境改这里) +# ============================================================ +agent: + # --- 大模型:DeepSeek 官方(OpenAI 兼容协议)--- + llm: + host: https://api.deepseek.com/chat/completions + # 豆包方舟示例:https://ark.cn-beijing.volces.com/api/v3/chat/completions + # 千问兼容模式:https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions + api-key: ${DEEPSEEK_API_KEY:sk-请替换为你的DeepSeek密钥} + # ★ 模型标识必须以官方实时清单为准:GET https://api.deepseek.com/models + # (2026-10-10 实测只有下面两个;旧的 deepseek-chat / deepseek-reasoner 已不在清单里) + model: deepseek-flash # DeepSeek-V4.1-Flash:性价比最高,支持 function calling(默认) + # 可选:deepseek-v4-pro # DeepSeek-V4-Pro:更强推理,单价更高 + max-tokens: 2048 + temperature: 0.3 + timeout: 60000 + + # --- 编排参数 --- + loop: + max-rounds: 6 # 单轮最多「思考→调工具」次数,防死循环 + tool-result-max-chars: 6000 # 工具结果回填模型前的截断长度 + history-max-messages: 20 # 会话保留的历史消息条数 + session-ttl-minutes: 120 # 会话空闲过期 + + # 是否按「用户已授权模块」过滤工具(默认 false:不隐藏,越权由各业务模块按透传身份裁决) + filter-tools-by-authorized-module: false + + # ★ 角色强校验:模型若调用了当前角色无权使用的工具,服务端直接拒绝执行(分学生、教师权限的硬约束) + enforce-role-permission: true + + # ★ 浏览器代执行(方案 A,2026-10-12):模块类工具不由服务端直调(必然 302), + # 而是挂起下发给前端,用用户自己的浏览器登录态调用模块接口后回传结果续跑。 + # 需要与模块同站部署(同源 Cookie 才能带上);本地 dev 跨域会失败并自动落回深链兜底。 + browser-exec: + enabled: true + + # ★ 角色映射:按本校 UserBase 角色体系校准(BoaUser.type=1 恒为管理员) + roles: + student: [1] # 学生 + teacher: [2, 3] # 教师 / 班主任 + admin: [4, 5, 6, 7] # 校管 / 分管领导 / 教务 / 校长 + admin-also-teacher: true # 管理员同时拥有教师侧能力 + + # --- 业务模块基地址(key 必须与工具定义里的 MODULE 常量一致)--- + # 本地联调:把 value 换成对应服务本机地址(如 http://localhost:8106) + modules: + 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 + resourcelibrary: http://base.yunhaixingtong.com/resourcelibrary + profession: http://base.yunhaixingtong.com/profession + + # --- 模块默认落地页(深链兜底)--- + # 服务端调不通模块用户态接口时(下游 code=302,只认浏览器 CAS 会话), + # 智能体会把模块页面地址回传真前端,让用户用自己的登录态直接去看真实数据。 + # 下面是「出厂默认值」的显式回写(代码 AgentSettings#moduleLanding 已内置同值兜底), + # 改这里即可覆盖单个模块;工具自身声明的 landingPath 优先级更高。 + # 路由取值已按各模块前端 src/router/routes.ts 核对(2026-10-10)。 + module-landing: + homeleave: /my # 我的请假 + campusnotice: / # 我的公告(收件箱) + dormnotice: /par/notices # 宿舍通知箱 + familyactivity: /par/activities # 活动广场 + coursehomework: /stu/today # 今日作业 + resourcelibrary: /resource # 资源管理 + profession: /home # 发展首页 + +# 密钥链验签依赖 Redis(sign:app:*)。关掉 Redis 健康指标:Redis 抖动不应把 /actuator/health +# 拉成 DOWN(503),否则部署健康检查与探活会误判整机不可用。 +management: + health: + redis: + enabled: false + +logging: + level: + com.yunhai.agent: info diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..7356ea4 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: agent + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + cloud: + nacos: + config: + enabled: ${NACOS_CONFIG_ENABLED:false} + server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} + namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} + # 应用自身配置所在分组 + group: OA_APP + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 agent.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 + fail-fast: false + # 只读账号 + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + # 共享公共配置 + shared-configs: + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml + group: DEFAULT_GROUP + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间 + refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/test/java/com/yunhai/agent/BrowserExecProtocolTest.java b/src/test/java/com/yunhai/agent/BrowserExecProtocolTest.java new file mode 100644 index 0000000..cc70f32 --- /dev/null +++ b/src/test/java/com/yunhai/agent/BrowserExecProtocolTest.java @@ -0,0 +1,146 @@ +package com.yunhai.agent; + +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.model.BrowserToolRequest; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolExecutor; +import com.yunhai.agent.tool.ToolParam; +import com.yunhai.agent.tool.ToolResult; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 浏览器代执行协议(方案 A)的回归防线: + * 准备(服务端同源逻辑构造调用描述)与回传解析(302 识别等与服务端直调一致)。 + */ +class BrowserExecProtocolTest { + + private ToolExecutor executor() { + AgentSettings s = new AgentSettings(); + s.getModules().put("familyactivity", "http://base.yunhaixingtong.com/family-activity"); + return new ToolExecutor(s, null); + } + + private ToolDef getDef() { + return ToolDef.of("activity.plaza", "familyactivity", "活动广场", "按关键词查询活动", + "GET", "/api/activity/List") + .p(ToolParam.optional("keyword", "string", "关键词")) + .fix("type", "2"); + } + + /** ① 默认开启代执行(同站部署后零配置生效) */ + @Test + void browserExecEnabledByDefault() { + assertTrue(new AgentSettings().getBrowserExec().isEnabled()); + } + + /** ② GET:固定参数合并进 query、剥掉协议与域名、模型参数原样回显 */ + @Test + void prepareGetMergesFixedAndStripsOrigin() { + Map args = new LinkedHashMap<>(); + args.put("keyword", "亲子运动会"); + + BrowserToolRequest.BrowserCall c = executor().prepareBrowserCall(getDef(), args); + + assertEquals("GET", c.getMethod()); + assertTrue(c.getPathAndQuery().startsWith("/family-activity/api/activity/List"), + "应为模块 ctx 开头的相对路径:" + c.getPathAndQuery()); + assertFalse(c.getPathAndQuery().contains("http"), "不能把域名下发给前端"); + assertTrue(c.getPathAndQuery().contains("keyword=亲子运动会")); + assertTrue(c.getPathAndQuery().contains("type=2"), "固定参数必须并入 query"); + assertEquals("activity.plaza", c.getName()); + assertEquals("亲子运动会", String.valueOf(c.getArguments().get("keyword"))); + assertFalse(c.isWrite()); + } + + /** ③ POST_JSON:路径占位替换 + 请求体构造 + Content-Type */ + @Test + void preparePostJsonResolvesPathAndBuildsBody() { + ToolDef d = ToolDef.of("activity.signup", "familyactivity", "报名活动", "按活动编号报名", + "POST_JSON", "/api/activity/{id}/Signup") + .p(ToolParam.required("id", "integer", "活动编号")) + .p(ToolParam.optional("persons", "integer", "参加人数")) + .fix("source", "agent") + // 默认为只读,写操作须显式声明(否则 isWrite 恒 false) + .write("/par/mine"); + + Map args = new LinkedHashMap<>(); + args.put("id", 12); + args.put("persons", 2); + + BrowserToolRequest.BrowserCall c = executor().prepareBrowserCall(d, args); + + assertEquals("POST_JSON", c.getMethod()); + assertEquals("/family-activity/api/activity/12/Signup", c.getPathAndQuery(), + "路径占位符必须被填充且不再出现在 query/body"); + assertTrue(c.getBody().contains("\"persons\":2"), "body 应含业务参数:" + c.getBody()); + assertTrue(c.getBody().contains("\"source\":\"agent\""), "固定参数必须并入 body"); + assertTrue(c.isWrite()); + assertEquals("application/json;charset=UTF-8", c.getContentType()); + } + + /** ④ 缺必填参数:准备阶段就拦下(调用方回退服务端老路径,缺参错误同样回给模型) */ + @Test + void prepareFailsOnMissingRequired() { + ToolDef d = ToolDef.of("activity.signup", "familyactivity", "报名活动", "按活动编号报名", + "POST_JSON", "/api/activity/{id}/Signup") + .p(ToolParam.required("id", "integer", "活动编号")); + + assertThrows(IllegalArgumentException.class, + () -> executor().prepareBrowserCall(d, new LinkedHashMap<>())); + } + + /** ⑤ 回传解析:code=302 必须识别为失败(浏览器会话过期等场景) */ + @Test + void parseBrowserResponseDetects302() { + ToolResult tr = executor().parseBrowserResponse(getDef(), 200, + "{\"code\":302,\"urlToRedirectTo\":\"http://sso/login\"}", 5); + assertFalse(tr.isSuccess()); + assertTrue(tr.getError().contains("302")); + } + + /** ⑥ 回传解析:正常 {code:0,data} → 成功且取 data;非 2xx / 无状态 → 明确失败 */ + @Test + void parseBrowserResponseNormalAndFailures() { + ToolResult ok = executor().parseBrowserResponse(getDef(), 200, + "{\"code\":0,\"msg\":\"ok\",\"data\":{\"total\":3}}", 5); + assertTrue(ok.isSuccess()); + assertTrue(ok.getContent().contains("\"total\":3")); + + ToolResult http404 = executor().parseBrowserResponse(getDef(), 404, "not found", 5); + assertFalse(http404.isSuccess()); + assertTrue(http404.getError().contains("404")); + + ToolResult noStatus = executor().parseBrowserResponse(getDef(), null, null, 0); + assertFalse(noStatus.isSuccess()); + } + + /** ⑦ 回传解析:非包装响应(导出文本等)原样成功返回 */ + @Test + void parseBrowserResponseUnwrappedBody() { + ToolResult tr = executor().parseBrowserResponse(getDef(), 200, "姓名,班级\n张三,一班", 5); + assertTrue(tr.isSuccess()); + assertTrue(tr.getContent().contains("张三")); + } + + /** + * ⑧ 回传解析:拿回网页(SPA 首页 / 登录页)必须判失败。 + *

未同站部署时,模块路径很可能被前端路由兜成 index.html(HTTP 200 + HTML), + * 若当成"成功",模型会把网页源码当数据读 → 必须拦下并引导走深链兜底。 + */ + @Test + void parseBrowserResponseRejectsHtmlPage() { + ToolResult tr = executor().parseBrowserResponse(getDef(), 200, + "校园助手", 5); + assertFalse(tr.isSuccess()); + assertTrue(tr.getError().contains("网页"), "错误应说明拿回的是网页:" + tr.getError()); + } +} diff --git a/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java new file mode 100644 index 0000000..0d30a36 --- /dev/null +++ b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java @@ -0,0 +1,119 @@ +package com.yunhai.agent; + +import com.yunhai.agent.tool.FormField; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolParam; +import com.yunhai.agent.tool.defs.LeaveTools; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 表单字段「控件形态」的回归断言(纯单元,不依赖 Spring)。 + * + *

背景(2026-10-10 截图实测):请假表单里「请假类型」渲染成了普通输入框, + * 占位文字是「请假类型编号,来自 leave.types」。根因有两层: + *

    + *
  1. 它在工具定义里是 {@code integer},本应落到数字控件,但前端没有 {@code number} 分支;
  2. + *
  3. 它的可选项来自字典工具 {@code leave.types},字典没取到时不该静默退化 —— + * 必须把 {@link FormField#getOptionsFrom()} 一并下发,前端才能提示「可先手填编号」。
  4. + *
+ */ +class FormFieldWidgetTest { + + /** ① 整数参数 → number 控件;字典来源必须一并下发(前端据此在字典失败时给提示) */ + @Test + void integerParamWithDictSourceRendersNumberAndCarriesOptionsFrom() { + ToolParam p = ToolParam.required("leaveType", "integer", "请假类型编号,来自 leave.types") + .label("请假类型").optionsFrom("leave.types"); + + FormField f = p.toFormField(); + + assertEquals("number", f.getWidget(), "integer 参数应渲染成数字控件"); + assertEquals("leave.types", f.getOptionsFrom(), "动态选项来源必须一并下发"); + assertTrue(f.getOptions().isEmpty(), "未取到字典时 options 应为空(前端据此提示)"); + } + + /** ② 静态枚举 → select,且不应带 optionsFrom(否则前端会误报「字典没取到」) */ + @Test + void enumParamRendersSelectWithoutOptionsFrom() { + ToolParam p = ToolParam.required("level", "string", "级别") + .label("级别").options(new String[]{"1", "2"}, new String[]{"普通", "重要"}); + + FormField f = p.toFormField(); + + assertEquals("select", f.getWidget()); + assertNull(f.getOptionsFrom(), "静态枚举没有动态来源,optionsFrom 应为 null"); + assertEquals(2, f.getOptions().size()); + } + + /** ③ 日期与长文本仍按参数名推断(防止改动上面两条时把推断逻辑带歪) */ + @Test + void nameBasedWidgetInferenceStillWorks() { + assertEquals("datetime", + ToolParam.required("startDate", "string", "开始时间").toFormField().getWidget()); + assertEquals("textarea", + ToolParam.required("remarks", "string", "请假事由").toFormField().getWidget()); + } + + /** + * ④ 内置兜底:声明了字典来源、而实时字典取不到时,仍应是下拉框,而不是让人填编号的数字框。 + *

这正是 2026-10-10 反馈「请假类型怎么变成数字框了,还是 1 这种」的修复点 —— + * 光有选项来源不够,还得有可用的默认选项,控件才立得住。 + */ + @Test + void fallbackOptionsKeepSelectWidgetWhenDictUnavailable() { + ToolParam p = ToolParam.required("leaveType", "integer", "请假类型") + .label("请假类型") + .fallbackOptions(new String[]{"1", "2"}, new String[]{"病假", "事假及其他"}) + .optionsFrom("leave.types"); + + FormField f = p.toFormField(); + + assertEquals("select", f.getWidget(), "有兜底选项时必须渲染成下拉框"); + assertEquals(2, f.getOptions().size()); + assertEquals("1", f.getOptions().get(0).get("value")); + assertEquals("病假", f.getOptions().get(0).get("label")); + assertEquals("事假及其他", f.getOptions().get(1).get("label")); + assertTrue(f.isOptionsFallback(), "必须标记为兜底,前端才能提示「自定义项没取到」"); + assertEquals("leave.types", f.getOptionsFrom(), "实时字典来源要保留,取到后覆盖兜底"); + } + + /** ⑤ 权威枚举优先于兜底:两者都配时兜底不生效,也不应被误标为兜底 */ + @Test + void explicitEnumWinsOverFallback() { + ToolParam p = ToolParam.required("status", "integer", "审批结论") + .options(new String[]{"1"}, new String[]{"通过"}) + .fallbackOptions(new String[]{"9"}, new String[]{"兜底项"}); + + FormField f = p.toFormField(); + + assertEquals(1, f.getOptions().size()); + assertEquals("通过", f.getOptions().get(0).get("label")); + assertFalse(f.isOptionsFallback()); + } + + /** ⑥ 真实工具定义回归:请假类型必须是带中文选项的下拉框,不能退回数字框 */ + @Test + void leaveSubmitTypeIsASelectInRealToolDefinition() { + ToolDef def = LeaveTools.defs().stream() + .filter(d -> "leave.submit".equals(d.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("leave.submit 工具定义丢失")); + + ToolParam leaveType = def.getParams().stream() + .filter(p -> "leaveType".equals(p.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("leaveType 参数丢失")); + + FormField f = leaveType.toFormField(); + + assertEquals("select", f.getWidget()); + assertEquals(2, f.getOptions().size()); + assertEquals("病假", f.getOptions().get(0).get("label")); + assertEquals("事假及其他", f.getOptions().get(1).get("label")); + } +} diff --git a/src/test/java/com/yunhai/agent/RolePermissionTest.java b/src/test/java/com/yunhai/agent/RolePermissionTest.java new file mode 100644 index 0000000..7bbb0c7 --- /dev/null +++ b/src/test/java/com/yunhai/agent/RolePermissionTest.java @@ -0,0 +1,218 @@ +package com.yunhai.agent; + +import com.yunhai.agent.auth.AgentRole; +import com.yunhai.agent.auth.RoleResolver; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.tool.CallerContext; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolExecutor; +import com.yunhai.agent.tool.ToolRegistry; +import com.yunhai.agent.tool.ToolResult; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 「分学生、教师权限」的核心断言(纯单元,不依赖 Spring / SSO / 数据库)。 + * + *

验证两件事:① 模型侧过滤(哪些工具可见);② 服务端强校验(越权直接拒绝执行)。 + */ +class RolePermissionTest { + + private AgentSettings settings; + private ToolRegistry registry; + private RoleResolver resolver; + + @BeforeEach + void setUp() { + settings = new AgentSettings(); + registry = new ToolRegistry(settings); + registry.init(); + resolver = new RoleResolver(settings); + } + + /** 伪造一个指定角色号的平台用户(type=null 表示非超管) */ + private BoaUser user(Integer id, Integer... roleIds) { + BoaUser u = new BoaUser(); + u.setId(id); + u.setName("测试用户" + id); + u.setAccount("test" + id); + u.setRoleIds(Arrays.asList(roleIds)); + return u; + } + + /** 伪造一个带「当前身份」的平台用户 */ + private BoaUser userWithCurrent(Integer id, Integer currentRoleId, String currentRoleName, Integer... allRoleIds) { + BoaUser u = user(id, allRoleIds); + if (currentRoleId != null) { + BoaRoleInfo cur = new BoaRoleInfo(); + cur.setRoleId(currentRoleId); + cur.setRoleName(currentRoleName); + cur.setSchId(3); + cur.setSchName("测试学校"); + u.setCurrentRole(cur); + } + return u; + } + + private boolean visible(List tools, String name) { + return tools.stream().anyMatch(t -> t.getName().equals(name)); + } + + @Test + void multiIdentityAccountUsesCurrentRoleOnly() { + // 实测账号形态:10008 同时挂着 学生(1) + 教师(2) + 管理员(4),当前身份 = 学生 + BoaUser u = userWithCurrent(10008, 1, "学生", 1, 2, 4); + Set roles = resolver.resolve(u); + + assertTrue(roles.contains(AgentRole.STUDENT), "当前身份是学生,应识别为学生"); + assertFalse(roles.contains(AgentRole.TEACHER), "不应因账号另挂教师身份而获得教师能力"); + assertFalse(roles.contains(AgentRole.ADMIN), "不应因账号另挂管理员身份而获得管理员能力"); + + List visible = registry.visibleTools(null, roles); + assertTrue(visible(visible, "homework.submit"), "学生当前身份应可用 homework.submit"); + assertFalse(visible(visible, "notice.publish"), "学生当前身份不应看到 notice.publish"); + assertFalse(visible(visible, "profession.myPoints"), "学生当前身份不应看到 profession.*"); + } + + @Test + void switchingCurrentRoleSwitchesCapabilities() { + BoaUser u = userWithCurrent(10008, 1, "学生", 1, 2, 4); + assertTrue(resolver.resolve(u).contains(AgentRole.STUDENT)); + + // 门户把当前身份切成教师 → 同一账号能力随之变化 + u.getCurrentRole().setRoleId(2); + u.getCurrentRole().setRoleName("教师"); + Set asTeacher = resolver.resolve(u); + assertTrue(asTeacher.contains(AgentRole.TEACHER)); + assertFalse(asTeacher.contains(AgentRole.STUDENT)); + List teacherTools = registry.visibleTools(null, asTeacher); + assertTrue(visible(teacherTools, "notice.publish"), "教师当前身份应可用 notice.publish"); + assertFalse(visible(teacherTools, "homework.submit"), "教师当前身份不应看到 homework.submit"); + + // 切成管理员 → 管理员 + 教师(admin-also-teacher 默认 true) + u.getCurrentRole().setRoleId(4); + u.getCurrentRole().setRoleName("管理员"); + Set asAdmin = resolver.resolve(u); + assertTrue(asAdmin.contains(AgentRole.ADMIN)); + assertTrue(asAdmin.contains(AgentRole.TEACHER)); + assertFalse(asAdmin.contains(AgentRole.STUDENT), "管理员当前身份不应含学生专属能力"); + } + + @Test + void unknownCurrentRoleFallsBackToAccountRoles() { + // 当前身份号未在 agent.roles.* 里声明 → 回退按账号全部身份判定(避免误判成访客) + BoaUser u = userWithCurrent(200, 999, "自定义角色", 1, 2); + Set roles = resolver.resolve(u); + assertTrue(roles.contains(AgentRole.STUDENT), "回退后应保留账号的学生身份"); + assertTrue(roles.contains(AgentRole.TEACHER), "回退后应保留账号的教师身份"); + } + + @Test + void accountWithoutCurrentRoleStillResolvesByRoleIds() { + // 老口径:平台没下发 currentRole 时,仍按 roleIds 判定(不能变成访客) + Set roles = resolver.resolve(user(300, 2)); + assertTrue(roles.contains(AgentRole.TEACHER)); + } + + @Test + void studentSeesOnlyStudentSideTools() { + Set roles = resolver.resolve(user(101, 1)); + assertTrue(roles.contains(AgentRole.STUDENT), "roleId=1 应识别为学生"); + + List visible = registry.visibleTools(null, roles); + // 学生能做:交作业、报名活动、请假申请 + assertTrue(visible(visible, "homework.submit"), "学生应可用 homework.submit"); + assertTrue(visible(visible, "activity.signup"), "学生应可用 activity.signup"); + assertTrue(visible(visible, "leave.submit"), "学生应可用 leave.submit"); + // 学生不能做:审批、发布、专业发展、宿管管理 + assertFalse(visible(visible, "leave.audit"), "学生不应看到 leave.audit"); + assertFalse(visible(visible, "leave.batchPass"), "学生不应看到 leave.batchPass"); + assertFalse(visible(visible, "notice.publish"), "学生不应看到 notice.publish"); + assertFalse(visible(visible, "profession.myPoints"), "学生不应看到 profession.*"); + assertFalse(visible(visible, "dorm.buildings"), "学生不应看到宿舍管理侧"); + } + + @Test + void teacherSeesStaffToolsButNotStudentHomework() { + Set roles = resolver.resolve(user(102, 2)); + assertTrue(roles.contains(AgentRole.TEACHER), "roleId=2 应识别为教师"); + + List visible = registry.visibleTools(null, roles); + // 教师能做:审批、发布、专业发展 + assertTrue(visible(visible, "leave.audit"), "教师应可用 leave.audit"); + assertTrue(visible(visible, "notice.publish"), "教师应可用 notice.publish"); + assertTrue(visible(visible, "profession.myPoints"), "教师应可用 profession.*"); + assertTrue(visible(visible, "leave.stats"), "教师应可用 leave.stats"); + // 教师不做:学生交作业、报名活动 + assertFalse(visible(visible, "homework.submit"), "教师不应看到 homework.submit"); + assertFalse(visible(visible, "activity.signup"), "教师不应看到 activity.signup"); + } + + @Test + void adminByTypeGetsStaffCapabilities() { + BoaUser u = user(1); + u.setType(1); // 平台超管口径 + Set roles = resolver.resolve(u); + assertTrue(roles.contains(AgentRole.ADMIN), "type=1 应识别为管理员"); + assertTrue(roles.contains(AgentRole.TEACHER), "管理员默认兼有教师能力"); + + List visible = registry.visibleTools(null, roles); + assertTrue(visible(visible, "leave.audit")); + assertTrue(visible(visible, "notice.publish")); + } + + @Test + void guestSeesOnlyUnrestrictedTools() { + Set roles = resolver.resolve(null); + assertTrue(roles.contains(AgentRole.GUEST)); + + List visible = registry.visibleTools(null, roles); + assertTrue(visible(visible, "notice.received"), "公共只读能力应对访客可见"); + assertFalse(visible(visible, "leave.submit"), "访客不应看到请假申请"); + assertFalse(visible(visible, "leave.audit"), "访客不应看到审批"); + } + + @Test + void executorRejectsUnauthorizedToolAtServerSide() { + // 即便模型绕过过滤硬调用,服务端也必须拒绝 + ToolExecutor executor = new ToolExecutor(settings, new RestTemplate()); + ToolDef audit = registry.get("leave.audit"); + assertTrue(audit != null && !audit.allowsAny(Collections.singleton(AgentRole.STUDENT)), + "leave.audit 应限制学生使用"); + + CallerContext student = new CallerContext(); + student.setRoles(new LinkedHashSet<>(Collections.singletonList(AgentRole.STUDENT))); + + ToolResult tr = executor.execute(audit, new LinkedHashMap<>(), student); + assertFalse(tr.isSuccess(), "学生调用审批工具必须失败"); + assertTrue(tr.getError() != null && tr.getError().contains("无权"), + "拒绝理由应说明无权,实际:" + tr.getError()); + } + + @Test + void executorAllowsAuthorizedTool() { + // 教师调用审批工具不会被角色闸门拦截(因未配置模块地址,会在网关层报"未配置地址",不是"无权") + ToolExecutor executor = new ToolExecutor(settings, new RestTemplate()); + ToolDef audit = registry.get("leave.audit"); + + CallerContext teacher = new CallerContext(); + teacher.setRoles(new LinkedHashSet<>(Collections.singletonList(AgentRole.TEACHER))); + + ToolResult tr = executor.execute(audit, new LinkedHashMap<>(), teacher); + assertTrue(tr.getError() == null || !tr.getError().contains("无权"), + "教师调用不应被角色闸门拒绝,实际:" + tr.getError()); + } +} diff --git a/src/test/java/com/yunhai/agent/SessionLogoutTest.java b/src/test/java/com/yunhai/agent/SessionLogoutTest.java new file mode 100644 index 0000000..cc11171 --- /dev/null +++ b/src/test/java/com/yunhai/agent/SessionLogoutTest.java @@ -0,0 +1,90 @@ +package com.yunhai.agent; + +import com.yunhai.agent.controller.BaseApiController; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 「退出登录」的回归断言(纯单元,不依赖 Spring 容器)。 + * + *

背景(2026-10-10 用户反馈「退出登录没用」):本应用的登录态不是别的东西, + * 就是服务端 session 里的 {@code _const_cas_assertion_} —— 平台 + * {@code CasSsoApiRedirectFilter} 正是拿它判断是否已登录 + * ({@code CasSessionAuthStrategy} 再经 {@code getRemoteUser()} 解析出身份)。 + * + *

老实现只把 {@code Logout} 当留痕(打一行日志就走),前端又只跳了 CAS 的 + * {@code /logout},于是 SSO 的票据没了、本应用的 JSESSIONID 还活着: + * 一回到页面仍是登录态,从用户视角看就是「点了退出没用」。 + * + *

这组断言锁死「Logout 必须销毁本地会话」,防止以后再退化成只打日志。 + */ +class SessionLogoutTest { + + /** 平台 CAS 过滤器判定「已登录」用的 session 属性名 */ + private static final String CAS_ASSERTION = "_const_cas_assertion_"; + + /** ① 登录态下调用 Logout,会话必须真的被销毁(这是「退出登录」能生效的前提) */ + @Test + void logoutInvalidatesLoggedInSession() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = (MockHttpSession) request.getSession(true); + session.setAttribute(CAS_ASSERTION, "assertion-stub"); + + new BaseApiController().logout(request); + + assertTrue(session.isInvalid(), "Logout 必须销毁本地会话,否则前端跳完 CAS 回来还是登录态"); + } + + /** ② 无会话时调用不报错(登出接口必须幂等,且不能因此 500) */ + @Test + void logoutIsSafeWithoutSession() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + new BaseApiController().logout(request); + + assertNotNull(request, "无会话时调用不应抛异常"); + } + + /** ③ 会话已被销毁后再调一次也不报错(浏览器重放 / 重复点击) */ + @Test + void logoutIsIdempotentOnAlreadyInvalidatedSession() { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpSession session = (MockHttpSession) request.getSession(true); + session.invalidate(); + + new BaseApiController().logout(request); + + assertTrue(session.isInvalid()); + } + + /** + * ④ 方法必须同时接受 GET 与 POST。 + * 前端外壳走 POST,平台老壳层(以及其它业务模块的惯例)走 GET, + * 少一个就会出现「某种入口点了没反应」。 + */ + @Test + void logoutAcceptsBothGetAndPost() throws NoSuchMethodException { + Method m = BaseApiController.class.getMethod("logout", javax.servlet.http.HttpServletRequest.class); + + RequestMapping mapping = m.getAnnotation(RequestMapping.class); + + assertNotNull(mapping, "Logout 上必须有 @RequestMapping"); + List methods = Arrays.asList(mapping.method()); + assertTrue(methods.contains(RequestMethod.GET), "必须接受 GET(平台老壳层惯例)"); + assertTrue(methods.contains(RequestMethod.POST), "必须接受 POST(前端外壳调用方式)"); + assertFalse(m.getParameterTypes().length == 0, "必须能拿到 HttpServletRequest 才能销毁会话"); + assertEquals("Logout", mapping.value()[0]); + } +} diff --git a/src/test/java/com/yunhai/agent/ToolLandingPathTest.java b/src/test/java/com/yunhai/agent/ToolLandingPathTest.java new file mode 100644 index 0000000..b1b11c3 --- /dev/null +++ b/src/test/java/com/yunhai/agent/ToolLandingPathTest.java @@ -0,0 +1,113 @@ +package com.yunhai.agent; + +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolRegistry; +import com.yunhai.agent.tool.ToolResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 工具「落地页(深链兜底)」的回归断言(纯单元,不依赖 Spring)。 + * + *

背景(2026-10-10):模块的用户态接口 {@code /<模块>/api/*} 只认浏览器 CAS 会话, + * 服务端调用一律被下游回 {@code code=302}。于是改成:执行失败时不再只丢一句报错, + * 而是把模块页面地址回传前端,让用户带自己的登录态去看真实数据。 + * + *

该能力依赖两件事,缺一前端按钮就点不动: + *

    + *
  1. 每个工具都必须有 landingPath(自己声明的,或注册期回填的模块默认页);
  2. + *
  3. 失败的 {@link ToolResult} 也必须带上 landingPath。
  4. + *
+ */ +class ToolLandingPathTest { + + private ToolRegistry registry; + + @BeforeEach + void setUp() { + registry = new ToolRegistry(new AgentSettings()); + registry.init(); + } + + /** ① 每个工具都必须有落地页 —— 少了它,"查不到时去系统里看"就是空按钮 */ + @Test + void everyToolHasLandingPath() { + int checked = 0; + for (ToolDef d : registry.all()) { + assertNotNull(d.getLandingPath(), "工具 " + d.getName() + " 缺少落地页(landingPath)"); + assertFalse(d.getLandingPath().trim().isEmpty(), + "工具 " + d.getName() + " 的落地页为空串"); + checked++; + } + assertTrue(checked > 0, "工具注册表不应为空"); + } + + /** ② 落地页必须是「模块内相对路由」,不能是完整 URL(拼地址是前端 moduleUrl 的职责) */ + @Test + void landingPathIsModuleRelativeRoute() { + for (ToolDef d : registry.all()) { + String p = d.getLandingPath(); + assertTrue(p.startsWith("/"), "工具 " + d.getName() + " 的落地页应以 / 开头,实际:" + p); + assertFalse(p.contains("://"), "工具 " + d.getName() + " 的落地页不应是完整 URL:" + p); + assertFalse(p.startsWith("//"), "工具 " + d.getName() + " 的落地页不应以 // 开头:" + p); + } + } + + /** ③ 工具没声明落地页时,注册期必须回填「该模块的默认页」 */ + @Test + void blankLandingFallsBackToModuleDefault() { + AgentSettings settings = new AgentSettings(); + settings.getModuleLanding().put("homeleave", "/__custom__"); + ToolRegistry custom = new ToolRegistry(settings); + custom.init(); + + // leave.myLeaves 未显式声明落地页 → 应取模块默认值 + assertEquals("/__custom__", custom.get("leave.myLeaves").getLandingPath()); + // leave.types 显式 readAt("/apply") → 工具声明优先,不被模块默认覆盖 + assertEquals("/apply", custom.get("leave.types").getLandingPath()); + } + + /** ④ 只有注册表中出现的模块才需要默认页;配了没用的模块应能被发现(防止键名拼错) */ + @Test + void moduleLandingKeysCoverEveryModuleUsedByTools() { + Set used = new HashSet<>(); + registry.all().forEach(d -> used.add(d.getModule())); + AgentSettings settings = new AgentSettings(); + for (String m : used) { + assertTrue(settings.getModuleLanding().containsKey(m), + "模块「" + m + "」被工具引用,但 agent.module-landing 里没有默认页"); + } + } + + /** ⑤ 只读工具的落地页不改变只读语义(否则前端会误弹二次确认) */ + @Test + void readAtKeepsReadOnlySemantics() { + ToolDef myLeaves = registry.get("leave.myLeaves"); + assertTrue(myLeaves.isReadOnly(), "leave.myLeaves 是查询,应保持 readOnly"); + assertNotNull(myLeaves.getLandingPath(), "查询工具也应有落地页(用于 302 兜底)"); + + ToolDef submit = registry.get("leave.submit"); + assertFalse(submit.isReadOnly(), "leave.submit 是写操作,readOnly 应为 false"); + } + + /** ⑥ ★ 失败结果必须携带落地页 —— 这是"前端失败时也能给按钮"的前提 */ + @Test + void failResultCarriesLandingPath() { + ToolDef def = registry.get("notice.received"); + ToolResult r = ToolResult.fail(def, "读取「我收到的公告」需要你在业务系统里的登录态(下游返回 code=302)。", 12); + + assertFalse(r.isSuccess()); + assertEquals(def.getLandingPath(), r.getLandingPath(), + "失败结果必须带上落地页,否则前端无法给出「去系统里查看」"); + assertEquals("/", r.getLandingPath()); + } +} diff --git a/src/test/java/com/yunhai/agent/ToolNameWireFormatTest.java b/src/test/java/com/yunhai/agent/ToolNameWireFormatTest.java new file mode 100644 index 0000000..daa57a1 --- /dev/null +++ b/src/test/java/com/yunhai/agent/ToolNameWireFormatTest.java @@ -0,0 +1,100 @@ +package com.yunhai.agent; + +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.agent.config.AgentSettings; +import com.yunhai.agent.tool.ToolDef; +import com.yunhai.agent.tool.ToolRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 工具名「下发形态」的回归断言(纯单元,不依赖 Spring)。 + * + *

背景(2026-10-10 线上真实报错): + *

大模型错误:Invalid 'tools[0].function.name': string does not match pattern.
+ * Expected a string that matches the pattern '^[a-zA-Z0-9_-]+$'.
+ * 内部工具名是点分结构({@code leave.myClasses}),点号不在允许字符集里, + * 直接下发会被大模型服务端 400 拒绝。该测试保证下发给模型的每个名字都合法, + * 且模型回传后能被还原成内部名。 + */ +class ToolNameWireFormatTest { + + /** 大模型对 function.name 的字符集约束 */ + private static final String PATTERN = "^[a-zA-Z0-9_-]+$"; + /** OpenAI / DeepSeek 的长度上限 */ + private static final int MAX_LEN = 64; + + private ToolRegistry registry; + + @BeforeEach + void setUp() { + registry = new ToolRegistry(new AgentSettings()); + registry.init(); + } + + /** ① 每个工具下发给模型的名字都必须合法(这一条就是当初能提前抓出 bug 的断言) */ + @Test + void wireNamesAreModelSafe() { + int checked = 0; + for (ToolDef d : registry.all()) { + String wire = d.wireName(); + assertNotNull(wire, "工具 " + d.getName() + " 的 wireName 不应为 null"); + assertTrue(wire.matches(PATTERN), + "工具名「" + wire + "」(来自 " + d.getName() + ")不符合模型命名规范 " + PATTERN); + assertTrue(wire.length() <= MAX_LEN, + "工具名「" + wire + "」超过 " + MAX_LEN + " 字符上限"); + checked++; + } + assertTrue(checked > 0, "工具注册表不应为空"); + } + + /** ② 实际生成的 tools schema 里,名字同样合法且无重复 */ + @Test + void toolsSchemaNamesAreModelSafeAndUnique() { + JSONArray arr = registry.toOpenAiTools(null, null); + assertTrue(arr.size() > 0, "tools schema 不应为空"); + + Set seen = new HashSet<>(); + for (int i = 0; i < arr.size(); i++) { + JSONObject fn = arr.getJSONObject(i).getJSONObject("function"); + String name = fn.getString("name"); + assertTrue(name.matches(PATTERN), + "tools[" + i + "].function.name = 「" + name + "」不符合规范 " + PATTERN); + assertTrue(seen.add(name), "tools[" + i + "].function.name 重复:「" + name + "」"); + } + } + + /** ③ 模型回传 wire name 时,必须能还原成与内部点分名同一个 ToolDef */ + @Test + void wireNameResolvesBackToSameTool() { + for (ToolDef d : registry.all()) { + ToolDef byReal = registry.get(d.getName()); + ToolDef byWire = registry.get(d.wireName()); + assertSame(byReal, byWire, + "「" + d.getName() + "」与「" + d.wireName() + "」应解析到同一个工具"); + } + } + + /** ④ 点分名确实含点、wire 名确实不含点(防止有人把 wireName 写成恒等函数) */ + @Test + void dottedNamesAreActuallyTranslated() { + long dotted = registry.all().stream().filter(d -> d.getName().contains(".")).count(); + assertTrue(dotted > 0, "应存在点分结构的工具名(如 leave.myClasses)"); + for (ToolDef d : registry.all()) { + assertEquals(d.getName().replace('.', '_'), d.wireName()); + if (d.getName().contains(".")) { + assertTrue(d.wireName().indexOf('.') < 0, + "wire 名不应含点号:" + d.wireName()); + } + } + } +}