diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java index e2b8469e..36227b31 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSearchController.java @@ -6,10 +6,13 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillSearchAppService; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.regex.Pattern; /** * Portal search endpoint that adapts HTTP query parameters to the search @@ -18,6 +21,10 @@ import java.util.Map; @RestController @RequestMapping({"/api/web/skills"}) public class SkillSearchController extends BaseApiController { + private static final Pattern NON_NEGATIVE_INTEGER = Pattern.compile("\\d+"); + private static final String DEFAULT_SORT = "newest"; + private static final int DEFAULT_PAGE = 0; + private static final int DEFAULT_SIZE = 20; private final SkillSearchAppService skillSearchAppService; @@ -33,18 +40,21 @@ public class SkillSearchController extends BaseApiController { @RequestParam(required = false) String q, @RequestParam(required = false) String namespace, @RequestParam(name = "label", required = false) java.util.List labels, - @RequestParam(defaultValue = "newest") String sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, + @Parameter(schema = @Schema(defaultValue = DEFAULT_SORT)) + @RequestParam(required = false) String sort, + @Parameter(schema = @Schema(type = "integer", defaultValue = "0", minimum = "0")) + @RequestParam(required = false) String page, + @Parameter(schema = @Schema(type = "integer", defaultValue = "20", minimum = "1")) + @RequestParam(required = false) String size, @RequestAttribute(value = "userId", required = false) String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { SkillSearchAppService.SearchResponse response = skillSearchAppService.search( q, namespace, - sort, - page, - size, + normalizeSort(sort), + parseNonNegativeInt(page, DEFAULT_PAGE), + parsePositiveInt(size, DEFAULT_SIZE), labels, userId, userNsRoles @@ -52,4 +62,31 @@ public class SkillSearchController extends BaseApiController { return ok("response.success.read", response); } + + private String normalizeSort(String sort) { + if (sort == null || sort.isBlank()) { + return DEFAULT_SORT; + } + return sort.trim(); + } + + private int parseNonNegativeInt(String rawValue, int defaultValue) { + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + String normalized = rawValue.trim(); + if (!NON_NEGATIVE_INTEGER.matcher(normalized).matches()) { + return defaultValue; + } + try { + return Integer.parseInt(normalized); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + private int parsePositiveInt(String rawValue, int defaultValue) { + int parsed = parseNonNegativeInt(rawValue, defaultValue); + return parsed > 0 ? parsed : defaultValue; + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java index 601c9176..bed761ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillSearchControllerTest.java @@ -100,4 +100,47 @@ class SkillSearchControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items").isArray()); } + + @Test + void searchShouldFallbackToDefaultsForBlankQueryParams() throws Exception { + when(skillSearchAppService.search( + eq(null), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(null), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("sort", " ") + .param("page", "") + .param("size", " ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)); + } + + @Test + void searchShouldFallbackToDefaultsForInvalidPagination() throws Exception { + when(skillSearchAppService.search( + eq(null), + eq(null), + eq("newest"), + eq(0), + eq(20), + eq(null), + any(), + any())) + .thenReturn(new SkillSearchAppService.SearchResponse(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/web/skills") + .param("page", "NaN") + .param("size", "-12")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)); + } } diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index be14db8f..09117161 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -228,6 +228,7 @@ test.describe('Search Results (Real API)', () => { await page.goto(searchUrl(basicSeed!.keyword)) await page.waitForLoadState('networkidle') const cards = getSearchCards(page) + await expect(cards.first()).toBeVisible({ timeout: 10_000 }) const visibleCount = await cards.count() const countText = await page.getByText(/\d+\s+skills found/i).textContent() const totalMatch = countText?.match(/\d+/) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index d8714025..a734922a 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -468,6 +468,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/skills/{namespace}/{slug}/submit-review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitForReview_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/reports": { parameters: { query?: never; @@ -500,6 +532,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{namespace}/{slug}/confirm-publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPublish_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{namespace}/{slug}/archive": { parameters: { query?: never; @@ -3239,6 +3303,10 @@ export interface components { targetVersion: string; confirmWarnings?: boolean; }; + SubmitReviewRequest: { + version: string; + targetVisibility: string; + }; SkillReportSubmitRequest: { reason?: string; details?: string; @@ -3257,6 +3325,9 @@ export interface components { reportId?: number; status?: string; }; + ConfirmPublishRequest: { + version: string; + }; AdminSkillActionRequest: { reason?: string; }; @@ -5569,6 +5640,60 @@ export interface operations { }; }; }; + submitForReview: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + submitForReview_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SubmitReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; submitReport: { parameters: { query?: never; @@ -5623,6 +5748,60 @@ export interface operations { }; }; }; + confirmPublish: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; + confirmPublish_1: { + parameters: { + query?: never; + header?: never; + path: { + namespace: string; + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmPublishRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; archiveSkill: { parameters: { query?: never;