fix(upload): 附件上传地址统一由编排层拼模块前缀,修专业发展上传 404
build / build-push (push) Successful in 52s

用户在自己线上环境传专业发展附件时接口 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:
lijing
2026-10-14 09:56:21 +08:00
parent 8cfbb949b6
commit 42a7a8030d
5 changed files with 149 additions and 29 deletions
@@ -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));
}
/** 四个走默认上传接口的模块都要能拼出「以 / 开头且无双斜杠」的地址 */
/**
* 六个模块的附件上传地址:逐条钉住**线上真实地址**(对照各模块发布页源码核出来的)。
*
* <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
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<String, String[]> cases = new LinkedHashMap<>();
cases.put("notice.publish", new String[]{"/campusnotice/api/base/FileUpload", "NoticeUpload.vue"});
cases.put("dorm.publish", new String[]{"/dormnotice/api/base/FileUpload", "AttachmentUpload.vue"});
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
@@ -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 上是逗号串");