mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-12 23:01:05 +00:00
Merge pull request #337 from iflytek/fix/search-page-400
fix(search): harden portal query parsing
This commit is contained in:
commit
62028e9f55
4 changed files with 266 additions and 6 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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+/)
|
||||
|
|
|
|||
179
web/src/api/generated/schema.d.ts
vendored
179
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue