diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f..74f96dde 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -30,6 +30,7 @@ import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; /** * Compatibility-focused application service that keeps ClawHub transport logic @@ -131,9 +132,7 @@ public class ClawHubCompatAppService { public String downloadLocationByPath(String canonicalSlug, String version) { SkillCoordinate coord = mapper.fromCanonical(canonicalSlug); - return "latest".equals(version) - ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" - : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return buildDownloadLocation(coord, version); } public String downloadLocationByQuery(String slug, @@ -141,9 +140,28 @@ public class ClawHubCompatAppService { String userId, Map userNsRoles) { SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); - return "latest".equals(version) - ? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download" - : "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download"; + return buildDownloadLocation(coord, version); + } + + /** + * Builds the redirect Location for a download. + * + *

+ * Each path segment is percent-encoded, because a non-ASCII slug (for example a + * Chinese skill name) cannot be written into the HTTP {@code Location} header as-is: + * Tomcat encodes header values as ISO-8859-1 and drops the header when a character + * falls outside 0-255, which breaks the ClawHub CLI download. Using + * {@code pathSegment(...)} keeps the '/' separators literal while encoding the + * segment contents. + */ + private String buildDownloadLocation(SkillCoordinate coord, String version) { + UriComponentsBuilder builder = UriComponentsBuilder.fromPath("/api/v1/skills") + .pathSegment(coord.namespace(), coord.slug()); + if (!"latest".equals(version)) { + builder.pathSegment("versions", version); + } + builder.pathSegment("download"); + return builder.encode().toUriString(); } private SkillCoordinate resolveQueryCoordinate(String slug, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index c5921f59..2670031c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -77,4 +77,45 @@ class ClawHubCompatAppServiceTest { assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download"); } + + @Test + void downloadLocationByQuery_percentEncodesNonAsciiSlug() { + // A non-ASCII slug (e.g. a Chinese skill name) must be percent-encoded, otherwise + // Tomcat drops the Location header (ISO-8859-1 only) and the ClawHub CLI download fails. + Namespace namespace = new Namespace("global", "Global", "owner-1"); + Skill cjkSkill = new Skill(1L, "需求", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + cjkSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("需求")).thenReturn(context); + when(compatSkillLookupService.canAccess(cjkSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("需求", "20260707.025847", null, null); + + assertThat(location) + .isEqualTo("/api/v1/skills/global/%E9%9C%80%E6%B1%82/versions/20260707.025847/download"); + // The header value must be writable as ISO-8859-1 (all bytes in 0-255). + assertThat(java.nio.charset.StandardCharsets.ISO_8859_1.newEncoder().canEncode(location)).isTrue(); + } + + @Test + void downloadLocationByQuery_includesVersionSegmentForAsciiSlug() { + Namespace namespace = new Namespace("team-a", "Team A", "owner-1"); + Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC); + CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext( + namespace, + publicSkill, + Optional.empty() + ); + + when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context); + when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true); + + String location = service.downloadLocationByQuery("my-skill", "20260707.025847", null, null); + + assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/versions/20260707.025847/download"); + } }