build / build-push (push) Successful in 51s
两个模块与「校园公告」同构(发布 → 阅读情况 → 回执闭环),原先只接了「读列表 + 我提交」 一层,价值最高的 发布/盯回执/催办/统计 全空。本轮补齐。 宿舍通知(9 → 35 个工具) - 发布闭环:dorm.publish(立即发布、默认发给全部宿舍)/ dorm.editNotice / dorm.sendList(改指 /api/notice/SendList,我发布的)/ dorm.readDetail / dorm.statistics; 字典:dorm.noticeTypeDic(type=noticeType)/ dorm.deptDic(type=dept)/ dorm.formTemplates。 - 回执闭环:dorm.receipts / dorm.receiptDetail / dorm.reviewReceipt / dorm.remind / dorm.expedite / dorm.remindStudent / dorm.markRead / dorm.pull / dorm.top; 家长侧补 dorm.initializeRed(一键已读)。 - 宿舍特有的床位台账(模块自带、此前完全没接):dorm.pendingStudents → dorm.assign → dorm.removeStudent / dorm.transfer / dorm.setMonitor,以及楼栋房间增改 (dorm.addBuilding / dorm.editBuilding / dorm.addRoom / dorm.editRoom)。 家校活动(7 → 22 个工具) - 发布闭环:activity.publish / activity.edit / activity.sent / activity.readDetail / activity.statistics;字典:activity.deptDic(type=1)/ activity.categoryDic(type=2)。 - 报名闭环:activity.signups / activity.signupDetail / activity.review / activity.remind / activity.expedite / activity.pull / activity.top; 家长侧补 activity.initializeRed。 裸意图直达(IntentShortcut) - 新增 DORM_PUBLISH(仅教职工)与 ACTIVITY_PUBLISH(仅教职工),学生落回模型如实说明。 - ★ 修跨模块歧义:「发通知 / 发布通知」不带限定时校园公告与宿舍通知都成立, 已从公告短语集移除,改由模型按上下文选;只认带「公告/校园」或「宿舍」「活动」限定的说法。 角色(RolePolicy) - 发布/编辑/阅读情况/回执处理/催办/统计/字典/床位台账 → STAFF_ONLY; 家长侧(通知箱、回执提交、活动广场、报名、一键已读)保持不限。 表单下拉复用请假的 optionCall + valueKeys + dependsOn 通道(模块 /api/* 只认浏览器会话, 服务端必然 302)。发布表单的接收对象固定为「全部」(receiveType/receiveList 用 fix 写死), 避免表单里出现难用的房间/班级 id 输入框。 测试:新增 DormActivityPublishIntentTest(10);IntentDirectFormTest +4(教师得表单且不过大模型、 学生落回模型);NoticeIntentFormTest 增加「发通知不再被公告劫持」; RolePermissionTest 补新工具的可见性断言。全量 86/86 通过。 README:4.2 能力划分更新 + 新增「宿舍通知/家校活动补全了哪些能力」;5.1.0 意图对照表补三行。
622 lines
40 KiB
Markdown
622 lines
40 KiB
Markdown
# AgentService · 校园智能助手
|
||
|
||
> 把「用户的一句话」变成「系统里一次真实的业务操作」的服务端大脑。
|
||
> 用户说「我要请假」,它会调用请假模块的接口真正提交申请;说「看看我有哪些作业没做」,它会去查作业接口。
|
||
>
|
||
> **2026-10 重构**:框架对齐 **NoticeService(通知公告)** —— Spring Boot 2.7.18 + `com.yunhai.platform` 基础平台
|
||
> starter(CAS 单点登录 + 统一响应 + 用户中心门面)+ JPA 落库 + Nacos 配置中心,
|
||
> 并新增 **学生 / 教师角色权限** 与 **会话、工具调用审计落库**。
|
||
|
||
## 1. 它是什么 / 不是什么
|
||
|
||
**是**:一个独立的 Spring Boot 微服务,负责
|
||
1. 对接大模型(DeepSeek,OpenAI 兼容协议),把自然语言解析成「调用哪个业务接口、带什么参数」;
|
||
2. 持有一张**工具注册表**(每个业务模块的 REST 接口 = 一个工具),按模型指令逐个执行;
|
||
3. **按「学生 / 教师 / 管理员」角色收敛能力**:模型侧只暴露该角色可用的工具,服务端执行前再强校验一次(越权直接拒绝);
|
||
4. 把执行结果回灌给模型,生成给用户看的自然语言回复;
|
||
5. ★ **信息不全时自动生成补全表单**:不靠模型口头追问(格式、必填项都不稳定),
|
||
而是从工具定义**确定性地**推导出还缺哪些字段,连同中文标签、格式示例、下拉选项
|
||
(可现取业务字典接口)一起下发给前端渲染成表单;用户填完直接提交执行 —— 填什么就提交什么。
|
||
|
||
**不是**:不承载任何业务数据、不复制业务逻辑、不做业务权限的最终裁决。
|
||
所有业务读写都转发到**原有各模块服务**,并**原样透传当前用户身份**(SSO 会话 Cookie + `currentRole` 等请求头),
|
||
因此各模块原有的数据范围收敛(学生只看本人、教师看任教班级…)继续生效 —— 业务权限权威永远在被调用的模块。
|
||
|
||
## 2. 技术栈(与通知公告项目一致)
|
||
|
||
| 维度 | 选型 |
|
||
|---|---|
|
||
| 框架 | Spring Boot **2.7.18**(parent),Java 8 |
|
||
| 平台能力 | `com.yunhai.platform:yh-spring-boot-starter`(CAS SSO + 统一响应 + 用户中心门面 + 应用间签名鉴权 + actuator) |
|
||
| 包名 | `com.yunhai.agent` |
|
||
| 持久化 | Spring Data **JPA** + MySQL 8(`bsch_agent` 库) |
|
||
| 配置中心 | Nacos(`bootstrap.yml`,不可达时回落本地 `application.yml`) |
|
||
| JSON | fastjson 1.2.80(主类开启安全模式) |
|
||
| 文档 | springdoc-openapi 1.7.0 |
|
||
| 部署 | Gitea CI(`.gitea/workflows/build.yml`)+ Docker(`docker/`),镜像 `agent-service` |
|
||
|
||
**单点登录**:CAS。`yh.sso.*` 配置接入平台 SSO,`/api/*` 走登录校验;
|
||
当前登录用户由平台参数解析器注入 `BoaUser`(姓名 / 账号 / 角色 / 学校),与 NoticeService 完全同款写法。
|
||
|
||
## 3. 目录结构
|
||
|
||
```
|
||
AgentService/
|
||
├── pom.xml Spring Boot 2.7.18 + yh-platform
|
||
├── docker/ 运行镜像 / 生产兜底配置 / 启动脚本
|
||
├── .gitea/workflows/build.yml CI:maven 构建 → 镜像 → projman 建档
|
||
└── src/main/
|
||
├── java/com/yunhai/agent/
|
||
│ ├── AgentApplication.java 启动类(fastjson 安全模式 + scanBasePackages)
|
||
│ ├── auth/ ★ 角色权限
|
||
│ │ ├── AgentRole.java 角色枚举(学生 / 教师 / 管理员 / 访客)
|
||
│ │ ├── RoleResolver.java BoaUser → 角色(★ 只认 currentRole 当前身份)
|
||
│ │ └── RolePolicy.java ★ 工具 → 角色 的策略表(集中可读、按校可调)
|
||
│ ├── config/
|
||
│ │ ├── Config.java 路径常量(/{platform}/agent)
|
||
│ │ ├── AgentSettings.java ★ 全部可配项(大模型 + 模块地址 + 角色映射)
|
||
│ │ └── RestTemplateConfig.java HTTP 客户端(大模型 / 业务模块)
|
||
│ ├── common/listener/ JPA 审计监听(自动填创建人/时间)
|
||
│ ├── entity/ ★ 落库实体
|
||
│ │ ├── BaseEntity.java 自增主键 + 创建/更新审计字段
|
||
│ │ ├── AgentSession.java 会话(agent_session)
|
||
│ │ ├── AgentMessage.java 会话消息(agent_message)
|
||
│ │ └── AgentToolCallLog.java 工具调用审计(agent_tool_call_log)
|
||
│ ├── repository/ 三个实体的 JPA 仓储
|
||
│ ├── llm/
|
||
│ │ ├── LlmClient.java 模型客户端抽象(可换供应商)
|
||
│ │ ├── DeepSeekClient.java DeepSeek / OpenAI 兼容实现
|
||
│ │ └── dto/ ChatMessage / ToolCall / ChatResult
|
||
│ ├── tool/
|
||
│ │ ├── ToolDef.java 工具定义(模块 + 路径 + 参数 + ★ 角色)
|
||
│ │ ├── ToolParam.java 参数定义 → 自动生成 JSON Schema 与表单字段
|
||
│ │ ├── ToolRegistry.java 工具注册表(★ 按角色 + 授权模块过滤)
|
||
│ │ ├── ToolExecutor.java 统一执行器(拼 URL、透传身份、★ 角色强校验、解包 {code,msg,data})
|
||
│ │ ├── CallerContext.java 当前调用者身份(透传用)
|
||
│ │ ├── ToolResult.java 执行结果
|
||
│ │ └── defs/ ★ 各模块工具定义(新增模块只改这里)
|
||
│ ├── service/
|
||
│ │ ├── AgentOrchestrator.java 编排核心(思考 → 调工具 → 再思考)
|
||
│ │ ├── SessionStore.java 会话上下文(JPA 落库 + 空闲过期)
|
||
│ │ ├── AgentAuditService.java 工具调用审计落库
|
||
│ │ └── AgentEvent.java / EventSink.java SSE 阶段事件
|
||
│ ├── model/ ChatRequest / ChatResponse / ToolCallTrace
|
||
│ └── controller/
|
||
│ ├── AgentController.java /api/agent/* 对外接口
|
||
│ └── BaseApiController.java /api/base/GetUserInfo|GetUserMod(平台壳层)
|
||
└── resources/
|
||
├── application.yml ★ 上线只改这个
|
||
└── bootstrap.yml Nacos 配置中心
|
||
```
|
||
|
||
## 4. 角色权限(学生 / 教师)
|
||
|
||
### 4.1 角色从哪来(★ 以平台「当前身份」为准)
|
||
|
||
平台里**一个账号可以同时挂多个身份**:
|
||
|
||
| 字段 | 含义 |
|
||
|---|---|
|
||
| `BoaUser.roleIds` | 该账号拥有的**全部**角色号(可能同时是学生 + 教师 + 管理员) |
|
||
| `BoaUser.currentRole` | 用户在门户里**当前激活**的那一个身份(`roleId` / `roleName` / `schId`),等价于 `YschBaseOpenApi.getCurrentIdentity(userId)` |
|
||
|
||
`RoleResolver` 的判定顺序:
|
||
|
||
1. **只认当前身份**:取 `currentRole.roleId`,命中 `agent.roles.student/teacher/admin` 即获得该角色,
|
||
**且只获得这一个身份** —— 与学校门户、其它业务模块口径一致;
|
||
2. 当前身份是管理员时,按 `agent.roles.admin-also-teacher` 追加教师能力;
|
||
3. 当前身份号**未在配置里声明**(或平台没下发 `currentRole`)→ 回退按 `roleIds` / `type` 的旧口径判定,
|
||
并打一条 WARN 提示补配置(避免误判成访客而"什么都做不了");
|
||
4. 什么都识别不出 → 访客(仅公共只读能力)。
|
||
|
||
> **为什么不做并集**:实测账号 `10008` 同时是「学生 + 管理员 + 教师」,若把全部身份并集,
|
||
> 权限只增不减、能力边界完全模糊(人人都是全量 54 项)。改成当前身份后,
|
||
> 同一账号在「学生」身份下只看到学生能力,在「教师」身份下只看到教师能力。
|
||
> 身份切换在**学校门户**完成(平台提供 `POST /oapi/identity/switch`),智能助手自动跟随,无需在应用内再做一个切换器。
|
||
|
||
角色号在 `application.yml` 的 `agent.roles.*` 配置,**上线前须按本校 UserBase 角色体系校准**(默认 学生=1、教师=2/3、管理员=4/5/6/7)。
|
||
解析结果只在**身份变化时**记一次 INFO 日志(`[身份] userId=… 当前身份 → 教师(roleId=2, schId=3)`),方便现场核对。
|
||
`GET /api/agent/tools` 也额外返回 `identity`(`userId` / `userName` / `schoolId` / `roleId` / `roleName` / `roles`)便于前端展示与排查。
|
||
|
||
### 4.2 能力划分(`auth/RolePolicy`)
|
||
|
||
| 名单 | 工具 | 说明 |
|
||
|---|---|---|
|
||
| 教职工(教师+管理员) | `leave.register` `leave.audit` `leave.batchPass` `leave.stats` `leave.classStudents`、`notice.publish` `notice.approve` `notice.reject` `notice.sent` `notice.deptDics` `notice.approvalList`、`dorm.*`(发布/编辑/我发布的/阅读情况/回执名单与处理/催办/催回执/代标记/撤回/置顶/统计/字典模板 **+ 楼栋房间床位台账**;家长侧 5 个除外)、`activity.*`(发布/编辑/我发布的/阅读情况/报名名单与处理/催办/上报/撤回/置顶/统计/字典 + 选班级;家长侧除外)、`profession.*`(全部) | 请假登记/审批、发布公告、**宿舍通知发布与回执管理 + 床位台账**、**活动发布与报名处理**、专业发展 |
|
||
| 学生 | `leave.submit` `leave.reportBack`、`homework.*`(全部)、`activity.signup` `activity.cancelSignup` | 请假申请、交作业、报名活动、销假 |
|
||
| 学生+教职工共用 | `leave.withdraw` | 撤销请假(学生=申请撤销,教师=直接作废) |
|
||
| 不限(所有已识别角色) | 各模块的只读查询(列表/详情/字典等) | 数据范围由下游模块按身份收敛 |
|
||
|
||
> ★ **请假为何分两个工具**:模块只有一个写接口 `POST /api/leave/Save`,靠 `type` 区分业务——
|
||
> `type=2` 学生**申请**(待班主任审核)、`type=1` 教师**登记**(免审直接生效,且必须指明班级+学生)。
|
||
> 合成一个工具时写死了 `type=2`,教师「帮学生请假」会被提交成待审核的学生申请;
|
||
> 而且「班级/学生」是可选参数、不满足"缺参才出表单"的条件,表单里根本没有这两项。
|
||
> 拆成 `leave.submit`(学生)与 `leave.register`(教职工)后,角色过滤天然把错的工具挡在模型视野外,
|
||
> 表单字段由各自必填参数推导,教师表单自动带出「班级 → 学生」联动下拉。
|
||
|
||
> ★ **宿舍通知 / 家校活动补全了哪些能力**:这两个模块与「校园公告」是**同一套骨架**
|
||
> —— *发布 → 阅读情况 → 回执闭环*,原先只接了「读列表 + 家长提交」这一层,
|
||
> 价值最高的 *发布 / 盯回执 / 催办 / 统计* 全空。本轮补齐:
|
||
> - **发布闭环**:`dorm.publish` / `activity.publish`(+ `edit` / 我发布的 / 阅读情况 / 统计)、
|
||
> 发布表单下拉所需的字典工具(`dorm.noticeTypeDic` `dorm.deptDic` `activity.deptDic` `activity.categoryDic`);
|
||
> - **回执闭环**:`dorm.receipts` / `dorm.reviewReceipt` / `dorm.remind` / `activity.signups` / `activity.review` / `activity.remind`
|
||
> —— 回答「还有谁没交 / 没回执」,并支持确认、取消、催办;
|
||
> - **宿舍特有(模块自带但一直没接)的床位台账**:`dorm.pendingStudents` → `dorm.assign`(安排入住)
|
||
> → `dorm.removeStudent`(退宿)/ `dorm.transfer`(调宿)/ `dorm.setMonitor`(设宿舍长),
|
||
> 以及楼栋房间的增改(`dorm.addBuilding` `dorm.editBuilding` `dorm.addRoom` `dorm.editRoom`)。
|
||
>
|
||
> 写操作**不需要新传输层**:统一走已跑通的浏览器代执行(前端带用户登录态调 `/api/*`),
|
||
> 表单下拉复用请假的 `optionCall + valueKeys + dependsOn` 通道。
|
||
> ⚠️ **删除类(`/api/notice/Delete`、`/api/activity/Delete`)暂未接入**:它们的请求体是
|
||
> **JSON 数组**(`@RequestBody List<Integer>`),而工具执行器只构造对象体;且删除破坏性强,留待需要时单列。
|
||
|
||
### 4.3 两道闸门
|
||
|
||
1. **模型侧过滤**(软):`ToolRegistry.toOpenAiTools(modIds, roles)` 只把该角色可用的工具放进大模型的 `tools`,
|
||
模型不会「看见」越权能力;
|
||
2. **服务端强校验**(硬):`ToolExecutor.execute` 在执行前再校验一次 `ToolDef.allowsAny(roles)`,
|
||
越权则**拒绝执行、不发真实请求**、写一条 FORBIDDEN 审计日志。
|
||
—— 由 `agent.enforce-role-permission: true` 控制(默认开)。
|
||
|
||
## 5. 对外接口
|
||
|
||
> ⚠️ 返回体由平台 `ApiResponseAdvice` **统一包装**为 `{code, msg, data}`
|
||
> (与 NoticeService / 各业务模块一致),真实数据在 `data` 里。SSE 接口除外。
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| POST | `/api/agent/chat` | 一问一答,`data` 为 `{sessionId, reply, toolCalls[], wroteSomething, landingPath, form?}` |
|
||
| POST | `/api/agent/chat/stream` | SSE,实时推送 `thinking / tool_call / tool_result / form / message / done / error`(不包装) |
|
||
| GET | `/api/agent/tools` | 能力目录(按当前角色过滤后按模块分组),前端首屏用 |
|
||
| POST | `/api/agent/session/clear` | 清空会话上下文 |
|
||
| GET | `/api/base/GetUserInfo` | 当前登录用户(与其它模块同形,前端外壳复用) |
|
||
| GET | `/api/base/GetUserMod` | 当前用户已授权模块 |
|
||
|
||
### 5.1 表单化补参(两个请求形态)
|
||
|
||
#### 5.1.0 裸意图直达(`service/IntentShortcut`)
|
||
|
||
「只有意图、没有细节」的一句话(典型:**「我要请假」**、**「我要发布校园公告」**)由后端**直接**按身份弹表单,
|
||
不经过大模型。原因:同一句话交给模型可能得到三种都不合预期的结果 —— ① 先去调只读字典(用户看到一串
|
||
类型列表、没有表单);② 调了写工具却自行**编造**参数直接提交;③ 用文字反问。用户的预期是立刻看到表单。
|
||
|
||
命中规则刻意做得**很窄**(宁可少直达、不可错直达):去掉空白与标点、剥掉「我要/帮我/麻烦…」前缀
|
||
与「吧/了/一下」后缀后,**正好等于**标准说法、且原句 ≤12 字。因此带信息的句子
|
||
(`我要请10月12日上午的病假,发烧就医`、`我请假那天作业怎么办`、`发一条关于社团招新的通知`)
|
||
**不会**被劫持,照常交给模型。
|
||
|
||
**加新意图只需三步**(不用动 `AgentOrchestrator`):① 一条 `Intent`(工具 + 引导语);
|
||
② 一个「标准说法」短语集;③ 往 `RULES` 里追加一条 `Rule`。
|
||
|
||
同一意图按身份落到不同工具:
|
||
|
||
| 意图 | 身份 | 工具 | 表单字段 | 提交后 |
|
||
|---|---|---|---|---|
|
||
| 请假 | 学生 | `leave.submit` | 请假类型 / 开始时间 / 结束时间 / 事由 | `type=2` 待班主任审核 |
|
||
| 请假 | 教职工 | `leave.register` | **班级 → 学生**(联动下拉)/ 请假类型 / 起止时间 / 事由 | `type=1` 免审直接生效 |
|
||
| 发布校园公告 | 教职工 | `notice.publish` | 公告标题 / 公告正文 / **发文部门**(下拉) | **立即发布、全站可见** |
|
||
| 发布校园公告 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
|
||
| 发布宿舍通知 | 教职工 | `dorm.publish` | 通知标题 / 通知正文 / **通知类型 + 发文部门**(下拉)/ 回执方式 | 发给**全部宿舍**的家长(房间级定向到模块页面勾选) |
|
||
| 发布宿舍通知 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员(宿管)身份 |
|
||
| 发布家校活动 | 教职工 | `activity.publish` | 活动标题 / 说明 / **活动分类 + 主办部门**(下拉)/ 地点 / 起止时间 / 报名方式 | 发给**全部班级**的家长 |
|
||
| 发布家校活动 | 学生 | —(置 null) | — | 落回模型,如实说明需要教师/管理员身份 |
|
||
|
||
⚠️ **发布类意图必须带模块限定**。2026-10 起宿舍通知也接上了发布工具,因此
|
||
`发通知`/`发布通知` 成了**跨模块歧义**(校园公告 / 宿舍通知都可能),已**移出**公告短语集;
|
||
现在只认 `发布公告`/`发个公告`/`我要发布校园公告`/`发布校园通知`(带「公告」或「校园」限定)
|
||
与 `发布宿舍通知`/`发宿舍通知`(带「宿舍」限定)。光说「公告」「通知」更像**查询**,本就不进短语表。
|
||
|
||
⚠️ **不要再给 `notice.publish` 加「发布方式」参数**:后端 `/api/notice/Create` 就是**直接发布**
|
||
(是否转审批由学校「应用设置」里的流程绑定决定,请求侧绕不过),并不存在 `publishType` 字段 ——
|
||
声明它会让用户以为选了「存草稿」却仍然直接发出去。需要草稿/送审请走公告模块页面
|
||
(`/api/notice/SaveDraft` 与 `/api/notice/Submit`,后者还需要先有草稿 id,属另一条链路)。
|
||
|
||
#### 5.1.1 缺参 → 下发补全表单
|
||
`POST /api/agent/chat {"message":"我要请假"}`(学生身份)
|
||
|
||
```jsonc
|
||
{
|
||
"code": 0, "msg": "",
|
||
"data": {
|
||
"sessionId": "...",
|
||
"reply": "好的,下面是**请假申请**表单:选好请假类型、起止时间和事由,点「确认提交」我就直接提交,提交后由班主任审核。",
|
||
"form": {
|
||
"tool": "leave.submit",
|
||
"title": "提交请假申请",
|
||
"write": true,
|
||
"landingPath": "/my",
|
||
"missing": ["请假类型", "开始时间", "结束时间", "请假事由"],
|
||
"fields": [
|
||
{ "name": "leaveType", "label": "请假类型", "widget": "select", "type": "integer", "required": true,
|
||
"optionsFrom": "leave.types", "valueKey": "k", "labelKey": "v",
|
||
"optionsFallback": true,
|
||
"options": [{ "value": "1", "label": "病假" }, { "value": "2", "label": "事假及其他" }],
|
||
"optionCall": { "name": "leave.types", "method": "GET",
|
||
"pathAndQuery": "/homeleave/api/dict/LeaveTypeOptions" } },
|
||
{ "name": "startDate", "label": "开始时间", "widget": "datetime", "required": true,
|
||
"description": "请假开始时间,格式 yyyy-MM-dd HH:mm:ss", "example": "2026-10-12 08:00:00" }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**教师身份**下同一句「我要请假」得到的是登记表单,`classId` / `stuId` 用占位符地址做联动下拉:
|
||
|
||
```jsonc
|
||
{ "name": "classId", "label": "班级", "widget": "select", "type": "integer", "required": true,
|
||
"optionsFrom": "leave.myClasses", "valueKey": "classId", "labelKey": "className",
|
||
"optionCall": { "name": "leave.myClasses", "method": "GET",
|
||
"pathAndQuery": "/homeleave/api/leave/MyClasses" } },
|
||
{ "name": "stuId", "label": "学生", "widget": "select", "type": "integer", "required": true,
|
||
"dependsOn": ["classId"], "optionsFrom": "leave.classStudents", "valueKey": "stuId", "labelKey": "stuName",
|
||
"optionCall": { "name": "leave.classStudents", "method": "GET",
|
||
"pathAndQuery": "/homeleave/api/leave/ClassStudents?classId={{classId}}" } }
|
||
```
|
||
|
||
> **★ 为什么下拉选项要由前端代取**:模块的字典接口(`MyClasses` / `ClassStudents` / `LeaveTypeOptions`)
|
||
> 只认浏览器 CAS 会话,服务端直调必然 `code=302`。以前"服务端取一次、失败就退化成数字输入框",
|
||
> 结果是教师登记请假得手填 `classId` / `stuId` 编号,根本没法用。现在后端只下发**去哪儿取**
|
||
> (`optionCall`,依赖字段留 `{{占位符}}`),前端用用户自己的登录态拉回原始字典,
|
||
> 按后端给出的 `valueKey`/`labelKey` **确定性**映射成下拉项;取不到时才退化成可手填编号的输入框。
|
||
>
|
||
> **字典工具的 `fixed` 参数会被一并拼上** —— 多个模块的字典接口靠它选字典种类,
|
||
> 例如公告的发文部门取自 `/api/dict/List?type=1`(`{k,v}` 形状,故 `deptId` 声明
|
||
> `.optionsKeys("k","v")`)。早先漏拼固定参数,前端取回的是「没指定类型」的结果,下拉是空的。
|
||
|
||
**② 填完提交 → 直接执行**:`POST /api/agent/chat`
|
||
|
||
```jsonc
|
||
{ "sessionId": "...",
|
||
"formSubmit": { "tool": "leave.submit",
|
||
"args": { "leaveType": 1, "startDate": "2026-10-12 08:00:00",
|
||
"endDate": "2026-10-12 17:00:00", "remarks": "发烧就医" } } }
|
||
```
|
||
|
||
服务端**不再让模型二次决策**,直接执行该工具(并再校验一次角色),再用真实结果生成自然语言回复。
|
||
|
||
**字段从哪来**:全部由 `ToolParam` 的表单元数据推导 ——
|
||
`label(...)` 中文标签、`eg(...)` 格式示例、`as("textarea"|"datetime"|...)` 指定控件、
|
||
`options(values, labels)` 静态枚举、`optionsFrom("leave.types")` 动态字典。
|
||
|
||
- 控件自动推断:有枚举 → 下拉;参数名含 `date/time` → 日期时间选择器;含 `remark/reason/content` → 多行文本。
|
||
- `optionsFrom` 会**先执行该只读字典工具**,把返回项填成下拉选项(解析失败自动退化为普通输入框)。
|
||
- **只在「写操作」缺必填参数时拦截**(查询类缺参不打扰用户),且拦截发生在执行任何工具之前。
|
||
|
||
## 6. 数据落库
|
||
|
||
| 表 | 用途 |
|
||
|---|---|
|
||
| `agent_session` | 会话:sessionId / userId / schId / 标题 / 角色快照 / 最后访问 |
|
||
| `agent_message` | 会话消息:按 seq_no 保序的 role/content/tool_calls/tool_call_id |
|
||
| `agent_tool_call_log` | **工具调用审计**:谁 / 什么角色 / 哪个会话 / 哪个模块的什么工具 / 参数 / 成功与否 / 耗时 / 越权被拒 |
|
||
|
||
- 会话与 `userId` 绑定,跨用户复用同一 `sessionId` 会被拒绝并重建;
|
||
- 消息空闲超时(默认 120 分钟)由定时任务清理;
|
||
- 审计写入用独立事务(失败不影响主对话链路)。
|
||
|
||
`spring.jpa.hibernate.ddl-auto=update` 会自动建表。
|
||
|
||
## 7. 部署
|
||
|
||
### 7.1 前置
|
||
```sql
|
||
CREATE DATABASE bsch_agent DEFAULT CHARSET utf8mb4;
|
||
```
|
||
依赖:MySQL 8、Nacos(可选,不可达时回落本地配置)、Redis(应用间验签用;健康检查已关闭)、内网 Maven 仓库(nexus.yunhaixingtong.com)。
|
||
|
||
### 7.2 配置(`application.yml`)
|
||
```yaml
|
||
agent:
|
||
llm:
|
||
host: https://api.deepseek.com/chat/completions
|
||
api-key: sk-你的DeepSeek密钥 # 或用环境变量 DEEPSEEK_API_KEY 注入;
|
||
# 推荐放 src/main/resources/application-local.yml(已被 .gitignore 忽略)
|
||
model: deepseek-flash # ★ 以官方实时清单为准:GET https://api.deepseek.com/models
|
||
# 2026-10-10 实测仅 deepseek-flash / deepseek-v4-pro
|
||
roles: # ★ 按本校 UserBase 角色体系校准
|
||
student: [1]
|
||
teacher: [2, 3]
|
||
admin: [4, 5, 6, 7]
|
||
enforce-role-permission: true # 角色强校验
|
||
modules: # ★ 每个模块的服务根地址(不含 /api)
|
||
homeleave: http://base.yunhaixingtong.com/home-leave
|
||
campusnotice: http://base.yunhaixingtong.com/campusnotice
|
||
dormnotice: http://base.yunhaixingtong.com/dormnotice
|
||
familyactivity: http://base.yunhaixingtong.com/familyactivity
|
||
coursehomework: http://base.yunhaixingtong.com/coursehomework
|
||
profession: http://base.yunhaixingtong.com/profession
|
||
```
|
||
|
||
### 7.3 打包运行
|
||
```bash
|
||
mvn -DskipTests clean package
|
||
java -jar target/agent-0.0.1.jar
|
||
# 默认端口 8093(本地);生产 context-path=/agent,端口以 Nacos / projman 注入为准
|
||
```
|
||
|
||
### 7.4 上线(Gitea CI + projman)
|
||
推送 `main` → CI 构建镜像 `agent-service` 推 Nexus → projman 建档 → 运维在「后端发布」手动部署。
|
||
|
||
## 8. 怎么新增一个模块的接入(本设计的重点)
|
||
|
||
「全量接入」不应该退化成复制粘贴。新增模块**只要写一个类**:
|
||
|
||
```java
|
||
// tool/defs/XxxTools.java
|
||
public final class XxxTools {
|
||
public static final String MODULE = "xxx"; // 与 application.yml 的 modules key 一致
|
||
public static List<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 / 无状态码明确失败;
|
||
⑦ 非包装响应(导出文本等)原样成功。
|