Files
yunhai-agent-service/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java
T
lijing 42a7a8030d
build / build-push (push) Successful in 52s
fix(upload): 附件上传地址统一由编排层拼模块前缀,修专业发展上传 404
用户在自己线上环境传专业发展附件时接口 404。根因:`ToolParam.upload()` 的覆盖值被当成
**完整路径**原样下发,于是 `profession.publish` 声明 `/api/file/Upload` 就打到站点根
`http://base.yunhaixingtong.com/api/file/Upload` —— 那里没有应用。
(请假那次是「前缀拿模块 key 拼错」,这次是「压根没拼」,同一个坑的两种形态。)

- `AgentOrchestrator` 新增 `resolveUploadPath(module, baseUrl, override)`:
  约定与 `ToolDef.path` 完全一致 —— **参数只写模块内相对路径**,模块前缀一律在这里拼;
  对「已经带了前缀」的写法保持幂等,调用方不必猜该写哪一种。顺带删掉只剩一处调用的
  `modulePathPrefix`,把注释合并到 `pathPrefix` 上。
- `HomeworkTools.UPLOAD_PATH` 改为模块内相对路径 `/api/file/Upload`(下发值不变:
  `/coursehomework/api/file/Upload`),与 `ProfessionTools` 统一写法。
- `ToolParam.upload()` 的文档写清约定,避免下一个人再写完整路径。

测试(`ModuleFormParityTest`,替换掉原先「测试自己重算一遍拼接逻辑」的弱断言):
- `everyPublishToolUploadPathMatchesItsModuleRealEndpoint`:逐条钉住 6 个模块的线上真实地址
  (对照各模块发布页源码:NoticeUpload.vue / AttachmentUpload.vue / RecordEdit.vue),
  其中 profession 那条正是这次线上 404 的回归用例;
- `noUploadPathIsEverEmittedWithoutItsModulePrefix`:**通用不变量** —— 遍历整个注册表,
  任何附件字段的最终地址都必须以所属模块前缀开头、不许出现裸 `/api/…` 与双斜杠,
  且 `.upload()` 里不许自带前缀。以后新增模块/附件字段会自动被覆盖。

全量 121/121 BUILD SUCCESS。
2026-10-14 09:56:21 +08:00

195 lines
11 KiB
Java
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.
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 教师专业发展模块工具 —— 后端 Profession,前缀 /api/profession、/api/moudule、/api/points、/api/stats。
*
* <p>★ 发布表单的字段以模块前端源码为**权威契约**:
* {@code ProfessionView/src/views/profession/RecordEdit.vue}(发布/编辑记录页)。
* 提交体(模块页 {@code save()} 逐行核对):
* <pre>
* POST /api/profession/Create (application/x-www-form-urlencoded)
* { moduleId, title, grade, subject, ifOpen, remark,
* contents: JSON.stringify([{fieldId, value}]), // 栏目动态字段
* fileIds: "11,12" } // 逗号串
* </pre>
* 与助手此前实现的差异(本轮修正):早先只有 moduleId/title/content 三项,
* 既缺「年级段 / 学科 / 是否公开 / 正文(字段名应是 remark 而不是 content)」,
* 也缺栏目动态字段与附件 —— 发出去的记录与在系统里发的天然不同。
*/
public final class ProfessionTools {
public static final String MODULE = "profession";
private ProfessionTools() {
}
public static List<ToolDef> defs() {
List<ToolDef> 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"));
/* ==================== 发布表单的字典(都必须由前端带登录态代取) ====================
* 模块的用户态接口只认浏览器 CAS 会话,服务端直取必然 code=302,
* 因此这里只声明「去哪儿取」,由 AgentOrchestrator 下发 optionCall / extraFields.call。
*/
// 年级段:模块页 RecordEdit.vue 的 grades(<select>,可空)
list.add(ToolDef.of("profession.grades", MODULE, "年级段字典",
"发布记录时的「年级段」下拉选项。属发布表单的组成部分,一般不单独调用。",
"GET", "/api/profession/GetGradeList")
.readAt("/record/new"));
// 学科:{subId, subName},subId 对齐班级管理的 SUB01 编码
list.add(ToolDef.of("profession.subjects", MODULE, "学科字典",
"发布记录时的「学科」下拉选项。属发布表单的组成部分,一般不单独调用。",
"GET", "/api/profession/GetSubList")
.readAt("/record/new"));
// 栏目的动态字段定义:{name, fields:[{id,fieldName,fieldType,ifRequired,...}]}
list.add(ToolDef.of("profession.moduleFields", MODULE, "栏目动态字段定义",
"某个栏目挂了哪些自定义字段(字段名/类型/是否必填/选项)。"
+ "发布表单据它渲染「栏目动态字段」区块,一般不单独调用。",
"GET", "/api/moudule/Getdatelist")
.p(ToolParam.required("SerID", "integer", "栏目 id(moduleId)"))
.readAt("/record/new"));
// 行政区划树:级联字段(province_city)用,level=2 省市 / 3 省市区
list.add(ToolDef.of("profession.areaTree", MODULE, "行政区划树",
"省市区三级行政区划树,供发布表单的「省市区」级联字段使用。",
"GET", "/api/profession/GetAreaTree")
.p(ToolParam.optional("level", "integer", "展开层级:2 省市 / 3 省市区").dft("3"))
.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(professionPublishParams())
.extraFields("profession.moduleFields", "contents", moduleIdAs("SerID"))
.extraFieldsBefore("remark")
.extraFieldsArea("profession.areaTree")
.fullForm()
.write("/record/new"));
return list;
}
/**
* (Java 8 没有 {@code Map.of})栏目动态字段的依赖映射:查询参数名 → 表单字段名。
* <p>模块侧的栏目字段接口查询参数叫 {@code SerID},而表单字段叫 {@code moduleId} —— 两边名字不同。
*/
private static Map<String, String> moduleIdAs(String queryParam) {
Map<String, String> m = new LinkedHashMap<>();
m.put(queryParam, "moduleId");
return m;
}
/**
* 「发布 / 编辑记录」页的完整字段。
*
* <p>★ 与 {@code ProfessionView/src/views/profession/RecordEdit.vue} 的表单逐项对齐:
* <pre>
* 标题(必填,≤200) → 年级段 / 学科(两个可空下拉) → 是否公开(单选 公开/不公开)
* → 栏目动态字段(按栏目渲染 → contents) → 正文内容(富文本 → remark) → 附件(fileIds)
* </pre>
* 唯一多出来的是「所属栏目」——模块页是从栏目列表点「发布」进来的(栏目由入口决定),
* 助手没有这个上下文,所以必须让用户选一次。
*/
private static ToolParam[] professionPublishParams() {
List<ToolParam> ps = new ArrayList<>();
ps.add(ToolParam.required("moduleId", "integer", "栏目 id,来自 profession.moduleTree")
.label("所属栏目")
.optionsFrom("profession.moduleTree"));
ps.add(ToolParam.required("title", "string", "记录标题(200 字以内)")
.label("记录标题").placeholder("请输入记录标题").maxLength(200)
.eg("2026年秋季公开课教学设计"));
ps.add(ToolParam.optional("grade", "string", "年级段,来自 profession.grades")
.label("年级段")
// 年级段字典是 {value,label}(不是平台通用的 {k,v} / 业务 VO 的 {id,name}),必须指认键名
.optionsFrom("profession.grades").optionsKeys("value", "label"));
ps.add(ToolParam.optional("subject", "string", "学科,取值来自 profession.subjects 的 subId")
.label("学科")
.optionsFrom("profession.subjects").optionsKeys("subId", "subName"));
ps.add(ToolParam.optional("ifOpen", "integer", "是否公开:1 公开 / 0 不公开")
.label("是否公开").as("radio").dft("1")
.options(new String[]{"1", "0"}, new String[]{"公开", "不公开"}));
ps.add(ToolParam.optional("remark", "string", "正文内容(对应模块页的富文本编辑器)")
.label("正文内容").as("textarea").placeholder("请输入正文内容…"));
// 栏目动态字段的打包结果:由表单取回字段定义、渲染后自行生成,用户不直接填
ps.add(ToolParam.optional("contents", "string", "栏目动态字段的取值(JSON 数组,由表单自动生成)")
.label("栏目动态字段").hide());
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
.label("添加附件").as("file").multiple(true).join(",")
// ★ 专业发展的附件上传接口与其余模块不同:走 file 域(不是 base/FileUpload)。
// 只写模块内相对路径,前缀由 AgentOrchestrator.resolveUploadPath 拼 → /profession/api/file/Upload
// (ProfessionView/src/views/profession/RecordEdit.vue 的 uploadAction = fileUploadUrl)
.upload("/api/file/Upload"));
return ps.toArray(new ToolParam[0]);
}
}