diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 2cb8c21..fb5ab37 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -643,7 +643,7 @@ public class AgentOrchestrator { } /** - * 模块在站点根下的**路径前缀**(`homeleave` → `/home-leave`)。 + * 由模块的**完整地址**推出它在站点根下的路径前缀(`homeleave` + `…/home-leave` → `/home-leave`)。 * *
★ 不能用模块 key 直接拼:key 只是 `agent.modules` 的内部标识,真实路由前缀可能不同 ——
* 请假正是这种情况,key 是 `homeleave` 而前缀是 `/home-leave`(差一个连字符)。
@@ -651,16 +651,6 @@ public class AgentOrchestrator {
* POST 到 `/homeleave/api/base/FileUpload`,被网关当成不存在的应用直接 **404**
* (实测:`/home-leave/...` 回 302 走 CAS,`/homeleave/...` 回 caddy 404)。
*
- * @param module 模块标识(`ToolDef.module`)
- * @return 路径前缀,以 `/` 开头且不以 `/` 结尾;模块未配置时退回 `/ ★ 约定与 {@link ToolDef#getPath()} 完全一致:**参数只写模块内相对路径**,模块前缀一律在这里拼。
+ * 早先把 {@code uploadPathOverride} 当成「完整路径」直接用,于是
+ * {@code profession.publish} 声明 {@code /api/file/Upload} 就原样下发,
+ * 线上打到 {@code http://base.yunhaixingtong.com/api/file/Upload} —— 站点根没有这个应用,**404**
+ * (请假那次是「前缀用 key 拼错」,这次是「压根没拼」;同一个坑的两种形态)。
+ *
+ * 为了容错,对「已经带了前缀」的写法保持幂等({@code /profession/api/file/Upload} 不会再被
+ * 拼成 {@code /profession/profession/...}),调用方不必猜该写哪一种。
+ *
+ * @param module 模块标识
+ * @param moduleBaseUrl {@code agent.modules} 里配置的地址(用来推路径前缀)
+ * @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload}
+ */
+ public static String resolveUploadPath(String module, String moduleBaseUrl, String override) {
+ String prefix = pathPrefix(module, moduleBaseUrl);
+ String rel = StringUtils.isNotBlank(override) ? override.trim() : "/api/base/FileUpload";
+ if (!rel.startsWith("/")) {
+ rel = "/" + rel;
+ }
+ if (rel.equals(prefix) || rel.startsWith(prefix + "/")) {
+ return rel;
+ }
+ return prefix + rel;
+ }
+
/** 由工具定义生成补全表单 */
private FormRequest buildForm(ToolDef def, Map 不写时由编排层按「模块前缀 + {@code /api/base/FileUpload}」兜底;
+ * 写时前缀同样由 {@code AgentOrchestrator.resolveUploadPath} 拼 ——
+ * ★ 这里**不要**自己带模块前缀,否则会与约定不一致(曾经因此线上 404)。
+ */
private String uploadPathOverride;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
@@ -461,7 +467,10 @@ public class ToolParam {
return this;
}
- /** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
+ /**
+ * 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}),
+ * 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。
+ */
public ToolParam upload(String path) {
this.uploadPathOverride = path;
return this;
diff --git a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java
index 5c52ef3..473f0c3 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java
@@ -25,8 +25,14 @@ public final class HomeworkTools {
private static final String TEACHER = "/api/teacher/homeworks";
- /** 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同) */
- private static final String UPLOAD_PATH = "/coursehomework/api/file/Upload";
+ /**
+ * 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同)。
+ *
+ * ★ 只写**模块内相对路径**(与 {@code ToolDef.path} 同约定):模块前缀由
+ * {@code AgentOrchestrator.resolveUploadPath} 统一拼,最终下发 {@code /coursehomework/api/file/Upload}。
+ * 这里自己带上前缀曾经导致 profession 那种「线上 404」,别再写回去。
+ */
+ private static final String UPLOAD_PATH = "/api/file/Upload";
private HomeworkTools() {
}
diff --git a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java
index 52e495f..fa6cae0 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java
@@ -185,7 +185,9 @@ public final class ProfessionTools {
.label("栏目动态字段").hide());
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
.label("添加附件").as("file").multiple(true).join(",")
- // ★ 专业发展的附件上传接口与其余模块不同:/api/file/Upload(不是 /api/base/FileUpload)
+ // ★ 专业发展的附件上传接口与其余模块不同:走 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]);
}
diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
index 19341c0..8f80b1b 100644
--- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
+++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
@@ -214,7 +214,9 @@ class ModuleFormParityTest {
assertNotNull(att);
assertEquals("file", att.resolveWidget());
assertTrue(att.isFileObject(), "作业的 attachments 是 [{fileId,fileUrl,fileType,fileName,sort}] 对象数组");
- assertEquals("/coursehomework/api/file/Upload", att.getUploadPathOverride(),
+ assertEquals("/api/file/Upload", att.getUploadPathOverride(),
+ "声明只写模块内相对路径(前缀由编排层拼)");
+ assertEquals("/coursehomework/api/file/Upload", emittedUploadPath(registry.get("homework.publish"), att),
"作业模块的附件上传走 file 域,与其余模块的 base/FileUpload 不同");
}
@@ -259,16 +261,98 @@ class ModuleFormParityTest {
assertEquals("/y", AgentOrchestrator.pathPrefix("y", null));
}
- /** 四个走默认上传接口的模块都要能拼出「以 / 开头且无双斜杠」的地址 */
+ /**
+ * 六个模块的附件上传地址:逐条钉住**线上真实地址**(对照各模块发布页源码核出来的)。
+ *
+ * 这里是「用生产代码算、拿真实地址比」,而不是把拼接逻辑在测试里再抄一遍 ——
+ * 上一版测试就是自己拼 {@code pathPrefix(...) + "/api/base/FileUpload"},
+ * 于是 {@code profession.publish} 声明成 {@code /api/file/Upload} 却**从不经过前缀拼接**
+ * 这件事,测试根本看不见,线上直接 404。
+ *
+ * 各模块真实地址的出处:
+ * 不写死模块清单,以后新增模块/新增附件字段会自动被覆盖 ——
+ * 这正是「拼错前缀」和「压根没拼前缀」两类 404 的公共判据。
+ */
+ @Test
+ void noUploadPathIsEverEmittedWithoutItsModulePrefix() {
+ int checked = 0;
+ for (ToolDef def : registry.all()) {
+ ToolParam p = firstFileParam(def);
+ if (p == null) {
+ continue;
+ }
+ String prefix = AgentOrchestrator.pathPrefix(def.getModule(),
+ settings.getModules().get(def.getModule()));
+ String path = emittedUploadPath(def, p);
+
+ assertTrue(path.startsWith("/"), def.getName() + " 的上传地址要以 / 开头:" + path);
+ assertTrue(path.startsWith(prefix + "/"),
+ def.getName() + " 的上传地址必须带模块前缀 " + prefix + ",实际:" + path);
+ assertFalse(path.contains("//"),
+ def.getName() + " 的上传地址不能出现双斜杠:" + path);
+
+ // ★ .upload() 里只许写模块内相对路径:带上前缀会让「有没有拼」这件事重新变得依赖调用方
+ if (p.getUploadPathOverride() != null && !p.getUploadPathOverride().isEmpty()) {
+ assertFalse(p.getUploadPathOverride().startsWith(prefix),
+ def.getName() + " 的 .upload() 只写模块内相对路径(如 /api/file/Upload),"
+ + "前缀由编排层拼,实际写了:" + p.getUploadPathOverride());
+ }
+ checked++;
+ }
+ assertTrue(checked >= 6, "六个模块的附件字段都应被覆盖到,实际只检查了 " + checked + " 个");
+ }
+
+ /** 按生产代码的口径算出「最终下发给前端的上传地址」 */
+ private String emittedUploadPath(ToolDef def, ToolParam fileParam) {
+ return AgentOrchestrator.resolveUploadPath(def.getModule(),
+ settings.getModules().get(def.getModule()), fileParam.getUploadPathOverride());
+ }
+
+ /** 工具定义里第一个附件字段(widget=file) */
+ private ToolParam firstFileParam(ToolDef def) {
+ if (def == null || def.getParams() == null) {
+ return null;
+ }
+ for (ToolParam p : def.getParams()) {
+ if ("file".equals(p.getWidget())) {
+ return p;
+ }
+ }
+ return null;
+ }
+
/* ==================== ⑦ 附件控件渲染 ==================== */
@Test
@@ -346,7 +430,9 @@ class ModuleFormParityTest {
assertEquals("textarea", param(def, "remark").getWidget());
assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText());
- // 附件:上传接口与其余模块**不同**(/api/file/Upload,不是 /api/base/FileUpload)
+ // 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。
+ // 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由
+ // everyPublishToolUploadPathMatchesItsModuleRealEndpoint 兜住。
ToolParam fileIds = param(def, "fileIds");
assertEquals("file", fileIds.getWidget());
assertEquals(",", fileIds.getJoinSeparator(), "fileIds 在 wire 上是逗号串");
+ *
+ * 其中 {@code serviceHost} 由各模块 {@code host.config.ts} 推出 = {@code origin + 当前路径首段},
+ * 正是 {@code agent.modules} 里配置的那个路径前缀。
+ */
@Test
- void everyModuleResolvesAnAbsoluteUploadPath() {
- for (String m : Arrays.asList("campusnotice", "dormnotice", "familyactivity", "homeleave")) {
- String p = AgentOrchestrator.pathPrefix(m, settings.getModules().get(m)) + "/api/base/FileUpload";
- assertTrue(p.startsWith("/"), m + " 的上传地址要以 / 开头,实际:" + p);
- assertFalse(p.contains("//"), m + " 的上传地址不能出现双斜杠,实际:" + p);
+ void everyPublishToolUploadPathMatchesItsModuleRealEndpoint() {
+ Map