Files
yunhai-agent-service/README.md
T
lijing 67e55f1875
build / build-push (push) Successful in 52s
feat(auth): 角色映射按平台字典(1-7)校准,并移除资源库模块对接
- 角色字典实测校准(采样 200+ 账号 currentRole/roles):
  1 老师 / 2 校领导 / 3 学生 / 4 家长 / 5 局领导 / 6 工作号 / 7 管理员
  → agent.roles: student=[3] teacher=[1,6] parent=[4] admin=[2,5,7]
  (旧默认 student=[1]/teacher=[2,3]/admin=[4..7] 系臆测,导致老师账号被显示为学生)
- 新增家长身份 AgentRole.PARENT;活动报名/取消改由 STUDENT_OR_PARENT 放行
- 当前身份识别增加 roleName 关键词兜底,避免识别不出就降级为访客
- 移除资源库对接:删除 ResourceTools 及注册/编排/agent.modules/module-landing
- 测试 48/48 通过
2026-10-12 16:10:39 +08:00

536 lines
32 KiB
Markdown
Raw Blame History

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