用户在自己线上环境传专业发展附件时接口 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。
This commit is contained in:
@@ -643,7 +643,7 @@ public class AgentOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 模块在站点根下的**路径前缀**(`homeleave` → `/home-leave`)。
|
* 由模块的**完整地址**推出它在站点根下的路径前缀(`homeleave` + `…/home-leave` → `/home-leave`)。
|
||||||
*
|
*
|
||||||
* <p>★ 不能用模块 key 直接拼:key 只是 `agent.modules` 的内部标识,真实路由前缀可能不同 ——
|
* <p>★ 不能用模块 key 直接拼:key 只是 `agent.modules` 的内部标识,真实路由前缀可能不同 ——
|
||||||
* 请假正是这种情况,key 是 `homeleave` 而前缀是 `/home-leave`(差一个连字符)。
|
* 请假正是这种情况,key 是 `homeleave` 而前缀是 `/home-leave`(差一个连字符)。
|
||||||
@@ -651,16 +651,6 @@ public class AgentOrchestrator {
|
|||||||
* POST 到 `/homeleave/api/base/FileUpload`,被网关当成不存在的应用直接 **404**
|
* POST 到 `/homeleave/api/base/FileUpload`,被网关当成不存在的应用直接 **404**
|
||||||
* (实测:`/home-leave/...` 回 302 走 CAS,`/homeleave/...` 回 caddy 404)。
|
* (实测:`/home-leave/...` 回 302 走 CAS,`/homeleave/...` 回 caddy 404)。
|
||||||
*
|
*
|
||||||
* @param module 模块标识(`ToolDef.module`)
|
|
||||||
* @return 路径前缀,以 `/` 开头且不以 `/` 结尾;模块未配置时退回 `/<module>`
|
|
||||||
*/
|
|
||||||
private String modulePathPrefix(String module) {
|
|
||||||
return pathPrefix(module, settings.getModules().get(module));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 由模块的**完整地址**推出它在站点根下的路径前缀(`homeleave` + `…/home-leave` → `/home-leave`)。
|
|
||||||
*
|
|
||||||
* @param module 模块标识(仅用于兜底)
|
* @param module 模块标识(仅用于兜底)
|
||||||
* @param moduleBaseUrl `agent.modules` 里配置的地址,如 `http://host/home-leave`
|
* @param moduleBaseUrl `agent.modules` 里配置的地址,如 `http://host/home-leave`
|
||||||
*/
|
*/
|
||||||
@@ -685,6 +675,34 @@ public class AgentOrchestrator {
|
|||||||
return path.isEmpty() ? "/" + module : path;
|
return path.isEmpty() ? "/" + module : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件上传地址(下发到前端的**相对站点根**路径)。
|
||||||
|
*
|
||||||
|
* <p>★ 约定与 {@link ToolDef#getPath()} 完全一致:**参数只写模块内相对路径**,模块前缀一律在这里拼。
|
||||||
|
* 早先把 {@code uploadPathOverride} 当成「完整路径」直接用,于是
|
||||||
|
* {@code profession.publish} 声明 {@code /api/file/Upload} 就原样下发,
|
||||||
|
* 线上打到 {@code http://base.yunhaixingtong.com/api/file/Upload} —— 站点根没有这个应用,**404**
|
||||||
|
* (请假那次是「前缀用 key 拼错」,这次是「压根没拼」;同一个坑的两种形态)。
|
||||||
|
*
|
||||||
|
* <p>为了容错,对「已经带了前缀」的写法保持幂等({@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<String, Object> args, List<ToolParam> missing,
|
private FormRequest buildForm(ToolDef def, Map<String, Object> args, List<ToolParam> missing,
|
||||||
CallerContext caller) {
|
CallerContext caller) {
|
||||||
@@ -716,12 +734,11 @@ public class AgentOrchestrator {
|
|||||||
for (ToolParam p : shown) {
|
for (ToolParam p : shown) {
|
||||||
FormField f = p.toFormField();
|
FormField f = p.toFormField();
|
||||||
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
|
// 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST)。
|
||||||
// 缺省按「模块真实前缀 + /api/base/FileUpload」,个别模块上传接口不同(作业是 /api/file/Upload)
|
// 缺省「模块前缀 + /api/base/FileUpload」;个别模块上传接口不同(作业/专业发展是 /api/file/Upload)
|
||||||
// 时由参数覆盖。★ 前缀必须取自 agent.modules 的配置,不能用模块 key 拼(见 modulePathPrefix)。
|
// 时由参数覆盖 —— **覆盖值也只写模块内相对路径**,前缀一律由 resolveUploadPath 拼。
|
||||||
if ("file".equals(f.getWidget())) {
|
if ("file".equals(f.getWidget())) {
|
||||||
f.setUploadPath(StringUtils.isNotBlank(p.getUploadPathOverride())
|
f.setUploadPath(resolveUploadPath(def.getModule(),
|
||||||
? p.getUploadPathOverride()
|
settings.getModules().get(def.getModule()), p.getUploadPathOverride()));
|
||||||
: modulePathPrefix(def.getModule()) + "/api/base/FileUpload");
|
|
||||||
}
|
}
|
||||||
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -120,7 +120,13 @@ public class ToolParam {
|
|||||||
/** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */
|
/** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */
|
||||||
private boolean fileObject;
|
private boolean fileObject;
|
||||||
|
|
||||||
/** 附件上传地址覆盖(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
/**
|
||||||
|
* 附件上传地址覆盖(**模块内相对路径**,如 {@code /api/file/Upload})。
|
||||||
|
*
|
||||||
|
* <p>不写时由编排层按「模块前缀 + {@code /api/base/FileUpload}」兜底;
|
||||||
|
* 写时前缀同样由 {@code AgentOrchestrator.resolveUploadPath} 拼 ——
|
||||||
|
* ★ 这里**不要**自己带模块前缀,否则会与约定不一致(曾经因此线上 404)。
|
||||||
|
*/
|
||||||
private String uploadPathOverride;
|
private String uploadPathOverride;
|
||||||
|
|
||||||
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
||||||
@@ -461,7 +467,10 @@ public class ToolParam {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
/**
|
||||||
|
* 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}),
|
||||||
|
* 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。
|
||||||
|
*/
|
||||||
public ToolParam upload(String path) {
|
public ToolParam upload(String path) {
|
||||||
this.uploadPathOverride = path;
|
this.uploadPathOverride = path;
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ public final class HomeworkTools {
|
|||||||
|
|
||||||
private static final String TEACHER = "/api/teacher/homeworks";
|
private static final String TEACHER = "/api/teacher/homeworks";
|
||||||
|
|
||||||
/** 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同) */
|
/**
|
||||||
private static final String UPLOAD_PATH = "/coursehomework/api/file/Upload";
|
* 附件上传地址(作业模块走 file 域,与其余模块的 base/FileUpload 不同)。
|
||||||
|
*
|
||||||
|
* <p>★ 只写**模块内相对路径**(与 {@code ToolDef.path} 同约定):模块前缀由
|
||||||
|
* {@code AgentOrchestrator.resolveUploadPath} 统一拼,最终下发 {@code /coursehomework/api/file/Upload}。
|
||||||
|
* 这里自己带上前缀曾经导致 profession 那种「线上 404」,别再写回去。
|
||||||
|
*/
|
||||||
|
private static final String UPLOAD_PATH = "/api/file/Upload";
|
||||||
|
|
||||||
private HomeworkTools() {
|
private HomeworkTools() {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ public final class ProfessionTools {
|
|||||||
.label("栏目动态字段").hide());
|
.label("栏目动态字段").hide());
|
||||||
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
||||||
.label("添加附件").as("file").multiple(true).join(",")
|
.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"));
|
.upload("/api/file/Upload"));
|
||||||
return ps.toArray(new ToolParam[0]);
|
return ps.toArray(new ToolParam[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,9 @@ class ModuleFormParityTest {
|
|||||||
assertNotNull(att);
|
assertNotNull(att);
|
||||||
assertEquals("file", att.resolveWidget());
|
assertEquals("file", att.resolveWidget());
|
||||||
assertTrue(att.isFileObject(), "作业的 attachments 是 [{fileId,fileUrl,fileType,fileName,sort}] 对象数组");
|
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 不同");
|
"作业模块的附件上传走 file 域,与其余模块的 base/FileUpload 不同");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,16 +261,98 @@ class ModuleFormParityTest {
|
|||||||
assertEquals("/y", AgentOrchestrator.pathPrefix("y", null));
|
assertEquals("/y", AgentOrchestrator.pathPrefix("y", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 四个走默认上传接口的模块都要能拼出「以 / 开头且无双斜杠」的地址 */
|
/**
|
||||||
|
* 六个模块的附件上传地址:逐条钉住**线上真实地址**(对照各模块发布页源码核出来的)。
|
||||||
|
*
|
||||||
|
* <p>这里是「用生产代码算、拿真实地址比」,而不是把拼接逻辑在测试里再抄一遍 ——
|
||||||
|
* 上一版测试就是自己拼 {@code pathPrefix(...) + "/api/base/FileUpload"},
|
||||||
|
* 于是 {@code profession.publish} 声明成 {@code /api/file/Upload} 却**从不经过前缀拼接**
|
||||||
|
* 这件事,测试根本看不见,线上直接 404。
|
||||||
|
*
|
||||||
|
* <p>各模块真实地址的出处:
|
||||||
|
* <ul>
|
||||||
|
* <li>公告 {@code NoticeUpload.vue}:{@code serviceHost + "/api/base/FileUpload"}</li>
|
||||||
|
* <li>宿舍/活动/请假 {@code AttachmentUpload.vue}:同上</li>
|
||||||
|
* <li>作业 {@code AttachmentUpload.vue}:{@code serviceHost + "/api/file/Upload"}(走 file 域)</li>
|
||||||
|
* <li>专业发展 {@code RecordEdit.vue} 的 {@code uploadAction = fileUploadUrl} = {@code serviceHost + "/api/file/Upload"}</li>
|
||||||
|
* </ul>
|
||||||
|
* 其中 {@code serviceHost} 由各模块 {@code host.config.ts} 推出 = {@code origin + 当前路径首段},
|
||||||
|
* 正是 {@code agent.modules} 里配置的那个路径前缀。
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
void everyModuleResolvesAnAbsoluteUploadPath() {
|
void everyPublishToolUploadPathMatchesItsModuleRealEndpoint() {
|
||||||
for (String m : Arrays.asList("campusnotice", "dormnotice", "familyactivity", "homeleave")) {
|
Map<String, String[]> cases = new LinkedHashMap<>();
|
||||||
String p = AgentOrchestrator.pathPrefix(m, settings.getModules().get(m)) + "/api/base/FileUpload";
|
cases.put("notice.publish", new String[]{"/campusnotice/api/base/FileUpload", "NoticeUpload.vue"});
|
||||||
assertTrue(p.startsWith("/"), m + " 的上传地址要以 / 开头,实际:" + p);
|
cases.put("dorm.publish", new String[]{"/dormnotice/api/base/FileUpload", "AttachmentUpload.vue"});
|
||||||
assertFalse(p.contains("//"), m + " 的上传地址不能出现双斜杠,实际:" + p);
|
cases.put("activity.publish", new String[]{"/familyactivity/api/base/FileUpload", "AttachmentUpload.vue"});
|
||||||
|
cases.put("leave.register", new String[]{"/home-leave/api/base/FileUpload", "AttachmentUpload.vue"});
|
||||||
|
cases.put("homework.publish", new String[]{"/coursehomework/api/file/Upload", "走 file 域,不是 base/FileUpload"});
|
||||||
|
cases.put("profession.publish", new String[]{"/profession/api/file/Upload", "RecordEdit.vue 的 fileUploadUrl"});
|
||||||
|
|
||||||
|
for (Map.Entry<String, String[]> e : cases.entrySet()) {
|
||||||
|
ToolDef def = registry.get(e.getKey());
|
||||||
|
assertNotNull(def, "工具不存在:" + e.getKey());
|
||||||
|
ToolParam fileParam = firstFileParam(def);
|
||||||
|
assertNotNull(fileParam, e.getKey() + " 应当有附件字段");
|
||||||
|
assertEquals(e.getValue()[0], emittedUploadPath(def, fileParam),
|
||||||
|
e.getKey() + " 的附件上传地址(" + e.getValue()[1] + ")");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用不变量:**任何一个**附件字段的最终上传地址都必须以它所属模块的路由前缀开头。
|
||||||
|
*
|
||||||
|
* <p>不写死模块清单,以后新增模块/新增附件字段会自动被覆盖 ——
|
||||||
|
* 这正是「拼错前缀」和「压根没拼前缀」两类 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
|
@Test
|
||||||
@@ -346,7 +430,9 @@ class ModuleFormParityTest {
|
|||||||
assertEquals("textarea", param(def, "remark").getWidget());
|
assertEquals("textarea", param(def, "remark").getWidget());
|
||||||
assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText());
|
assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText());
|
||||||
|
|
||||||
// 附件:上传接口与其余模块**不同**(/api/file/Upload,不是 /api/base/FileUpload)
|
// 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。
|
||||||
|
// 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由
|
||||||
|
// everyPublishToolUploadPathMatchesItsModuleRealEndpoint 兜住。
|
||||||
ToolParam fileIds = param(def, "fileIds");
|
ToolParam fileIds = param(def, "fileIds");
|
||||||
assertEquals("file", fileIds.getWidget());
|
assertEquals("file", fileIds.getWidget());
|
||||||
assertEquals(",", fileIds.getJoinSeparator(), "fileIds 在 wire 上是逗号串");
|
assertEquals(",", fileIds.getJoinSeparator(), "fileIds 在 wire 上是逗号串");
|
||||||
|
|||||||
Reference in New Issue
Block a user