Merge pull request #337 from iflytek/fix/search-page-400

fix(search): harden portal query parsing
This commit is contained in:
dongmucat 2026-04-22 13:53:46 +08:00 committed by GitHub
commit 62028e9f55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 266 additions and 6 deletions

View file

@ -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<String> 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<Long, NamespaceRole> 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;
}
}

View file

@ -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));
}
}

View file

@ -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+/)

View file

@ -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;