From 0b576e3f59d73c0b75441db5c6158c5b400d8031 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:16:25 +0800 Subject: [PATCH 01/22] fix(search): wrap long empty-state descriptions --- web/src/shared/components/empty-state.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/shared/components/empty-state.tsx b/web/src/shared/components/empty-state.tsx index 796811e7..58f03bf3 100644 --- a/web/src/shared/components/empty-state.tsx +++ b/web/src/shared/components/empty-state.tsx @@ -26,7 +26,9 @@ export function EmptyState({ title, description, action }: EmptyStateProps) {

{title}

{description && ( -

{description}

+

+ {description} +

)} {action &&
{action}
} From 73550c0b0632227eac1c3e1b054210df5a2452fb Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:18:39 +0800 Subject: [PATCH 02/22] feat(login): add password visibility toggle --- web/src/i18n/locales/en.json | 2 ++ web/src/i18n/locales/zh.json | 2 ++ web/src/pages/login.tsx | 30 ++++++++++++++++++++++-------- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8ac9a317..3d91497d 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -111,6 +111,8 @@ "password": "Password", "usernamePlaceholder": "Enter username", "passwordPlaceholder": "Enter password", + "showPassword": "Show password", + "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", "noAccount": "Don't have an account?", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 031e3d41..c2d7e844 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -111,6 +111,8 @@ "password": "密码", "usernamePlaceholder": "输入用户名", "passwordPlaceholder": "输入密码", + "showPassword": "显示密码", + "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", "noAccount": "还没有账号?", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 8ddd4ba4..74f0278a 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useSearch } from '@tanstack/react-router' import { useState } from 'react' import { useTranslation } from 'react-i18next' +import { Eye, EyeOff } from 'lucide-react' import { getDirectAuthRuntimeConfig } from '@/api/client' import { LoginButton } from '@/features/auth/login-button' import { SessionBootstrapEntry } from '@/features/auth/session-bootstrap-entry' @@ -18,6 +19,7 @@ export function LoginPage() { const directAuthConfig = getDirectAuthRuntimeConfig() const [username, setUsername] = useState('') const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' const { data: authMethods } = useAuthMethods(search.returnTo) @@ -85,14 +87,26 @@ export function LoginPage() {
- setPassword(event.target.value)} - placeholder={t('login.passwordPlaceholder')} - /> +
+ setPassword(event.target.value)} + placeholder={t('login.passwordPlaceholder')} + className="pr-12" + /> + +
{loginMutation.error ? (

{loginMutation.error.message}

From 7eb88356cd4777dd43cabed89f41ab056ca06bf6 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:20:53 +0800 Subject: [PATCH 03/22] fix(search): align skill card metadata rows --- web/src/features/skill/skill-card.tsx | 6 +++--- web/src/pages/search.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index e688abc5..c1b6a9d8 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -10,13 +10,13 @@ interface SkillCardProps { export function SkillCard({ skill, onClick }: SkillCardProps) { return ( {/* Hover gradient border effect */}
-
+

{skill.displayName} @@ -30,7 +30,7 @@ export function SkillCard({ skill, onClick }: SkillCardProps) {

)} -
+
{skill.latestVersion && ( v{skill.latestVersion} diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index f2fda1ed..8a1778d1 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -92,7 +92,7 @@ export function SearchPage() { <>
{data.items.map((skill, idx) => ( -
+
handleSkillClick(skill.namespace, skill.slug)} From 33abb1133ee5b78b5863ad2c42624d836fe3927c Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:22:23 +0800 Subject: [PATCH 04/22] feat(skill): add back button on detail page --- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/zh.json | 1 + web/src/pages/skill-detail.tsx | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3d91497d..3c93dac9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -324,6 +324,7 @@ "emptyDescription": "No skills have been published in this namespace yet" }, "skillDetail": { + "back": "Back", "notFound": "Skill not found", "notFoundDesc": "This skill may have been deleted or never existed", "loginRequired": "Login Required", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index c2d7e844..7a0b8fb8 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -324,6 +324,7 @@ "emptyDescription": "该命名空间下还没有发布任何技能" }, "skillDetail": { + "back": "返回上一页", "notFound": "技能不存在", "notFoundDesc": "该技能可能已被删除或从未存在", "loginRequired": "需要登录", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 67223007..99f5c5a3 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { InstallCommand } from '@/features/skill/install-command' @@ -77,6 +78,14 @@ export function SkillDetailPage() { }) } + const handleBack = () => { + if (window.history.length > 1) { + window.history.back() + return + } + navigate({ to: '/search', search: { q: '', sort: 'relevance', page: 0 } }) + } + if (isLoadingSkill) { return (
@@ -122,6 +131,15 @@ export function SkillDetailPage() { {/* Main Content */}
+
From 553a72eeb1ed76d739a9921a0c4dc5ef45d32188 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:24:29 +0800 Subject: [PATCH 05/22] fix(token): center copy feedback toast --- web/src/features/token/create-token-dialog.tsx | 16 ++++++++++++++-- web/src/shared/lib/toast.ts | 18 +++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 9d93b543..7b2c22e9 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -54,10 +54,22 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) { try { await navigator.clipboard.writeText(createdToken.token) - toast.success(t('createToken.copySuccess')) + toast.success(t('createToken.copySuccess'), undefined, { + position: 'top-center', + classNames: { + title: 'text-center font-semibold', + description: 'text-center', + }, + }) } catch (error) { console.error('Failed to copy token:', error) - toast.error(t('createToken.copyFailed')) + toast.error(t('createToken.copyFailed'), undefined, { + position: 'top-center', + classNames: { + title: 'text-center font-semibold', + description: 'text-center', + }, + }) } } diff --git a/web/src/shared/lib/toast.ts b/web/src/shared/lib/toast.ts index a5b9607b..a4c9f7f1 100644 --- a/web/src/shared/lib/toast.ts +++ b/web/src/shared/lib/toast.ts @@ -1,17 +1,17 @@ -import { toast as sonnerToast } from 'sonner' +import { toast as sonnerToast, type ExternalToast } from 'sonner' export const toast = { - success: (message: string, description?: string) => { - sonnerToast.success(message, { description }) + success: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.success(message, { description, ...options }) }, - error: (message: string, description?: string) => { - sonnerToast.error(message, { description }) + error: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.error(message, { description, ...options }) }, - warning: (message: string, description?: string) => { - sonnerToast.warning(message, { description }) + warning: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.warning(message, { description, ...options }) }, - info: (message: string, description?: string) => { - sonnerToast.info(message, { description }) + info: (message: string, description?: string, options?: ExternalToast) => { + sonnerToast.info(message, { description, ...options }) }, promise: ( promise: Promise, From 72f0b9f8f3f5c227121d8d4402f99f729b416cfc Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:26:37 +0800 Subject: [PATCH 06/22] fix(login): validate required fields before submit --- web/src/i18n/locales/en.json | 2 ++ web/src/i18n/locales/zh.json | 2 ++ web/src/pages/login.tsx | 40 +++++++++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 3c93dac9..d7dd06d6 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -111,6 +111,8 @@ "password": "Password", "usernamePlaceholder": "Enter username", "passwordPlaceholder": "Enter password", + "usernameRequired": "Username is required", + "passwordRequired": "Password is required", "showPassword": "Show password", "hidePassword": "Hide password", "submitting": "Logging in...", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 7a0b8fb8..0747b9c0 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -111,6 +111,8 @@ "password": "密码", "usernamePlaceholder": "输入用户名", "passwordPlaceholder": "输入密码", + "usernameRequired": "请输入用户名", + "passwordRequired": "请输入密码", "showPassword": "显示密码", "hidePassword": "隐藏密码", "submitting": "登录中...", diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index 74f0278a..5a394212 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -20,6 +20,7 @@ export function LoginPage() { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [showPassword, setShowPassword] = useState(false) + const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({}) const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh' const { data: authMethods } = useAuthMethods(search.returnTo) @@ -32,8 +33,23 @@ export function LoginPage() { async function handleSubmit(event: React.FormEvent) { event.preventDefault() + const trimmedUsername = username.trim() + const nextFieldErrors: { username?: string, password?: string } = {} + + if (!trimmedUsername) { + nextFieldErrors.username = t('login.usernameRequired') + } + if (!password) { + nextFieldErrors.password = t('login.passwordRequired') + } + if (nextFieldErrors.username || nextFieldErrors.password) { + setFieldErrors(nextFieldErrors) + return + } + + setFieldErrors({}) try { - await loginMutation.mutateAsync({ username, password }) + await loginMutation.mutateAsync({ username: trimmedUsername, password }) await navigate({ to: returnTo }) } catch { // mutation state drives the error UI @@ -81,9 +97,18 @@ export function LoginPage() { id="username" autoComplete="username" value={username} - onChange={(event) => setUsername(event.target.value)} + onChange={(event) => { + setUsername(event.target.value) + if (fieldErrors.username) { + setFieldErrors((current) => ({ ...current, username: undefined })) + } + }} placeholder={t('login.usernamePlaceholder')} + aria-invalid={fieldErrors.username ? 'true' : 'false'} /> + {fieldErrors.username ? ( +

{fieldErrors.username}

+ ) : null}
@@ -93,9 +118,15 @@ export function LoginPage() { type={showPassword ? 'text' : 'password'} autoComplete="current-password" value={password} - onChange={(event) => setPassword(event.target.value)} + onChange={(event) => { + setPassword(event.target.value) + if (fieldErrors.password) { + setFieldErrors((current) => ({ ...current, password: undefined })) + } + }} placeholder={t('login.passwordPlaceholder')} className="pr-12" + aria-invalid={fieldErrors.password ? 'true' : 'false'} />
+ {fieldErrors.password ? ( +

{fieldErrors.password}

+ ) : null}
{loginMutation.error ? (

{loginMutation.error.message}

From 725b71d323d17a4bb664e98ecf2fae451740119d Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:27:50 +0800 Subject: [PATCH 07/22] fix(skill): contain readme content overflow --- web/src/features/skill/markdown-renderer.tsx | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 6b164983..5e01e119 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -9,13 +9,48 @@ interface MarkdownRendererProps { } export function MarkdownRenderer({ content, className }: MarkdownRendererProps) { - const containerClassName = [className, 'prose prose-sm max-w-none dark:prose-invert'] + const containerClassName = [ + className, + 'prose prose-sm max-w-none break-words [overflow-wrap:anywhere] dark:prose-invert', + ] .filter(Boolean) .join(' ') return (
- + ( +
+
{children}
+
+ ), + code: ({ className: codeClassName, children, ...props }) => { + const isInline = !codeClassName?.includes('language-') + + if (isInline) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) + }, + table: ({ children }) => ( +
+ {children}
+
+ ), + }} + > {content}
From df850d1f63523900e6918e1ac4e8dcfcbcf55c84 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:30:15 +0800 Subject: [PATCH 08/22] fix(search): support short skill queries --- server/skillhub-search/pom.xml | 5 + .../PostgresFullTextQueryService.java | 42 +++++-- .../PostgresFullTextQueryServiceTest.java | 110 ++++++++++++++++++ 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java diff --git a/server/skillhub-search/pom.xml b/server/skillhub-search/pom.xml index 573a4869..222d2980 100644 --- a/server/skillhub-search/pom.xml +++ b/server/skillhub-search/pom.xml @@ -27,5 +27,10 @@ org.springframework spring-context + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java index 12727810..b23d5f14 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryService.java @@ -12,6 +12,7 @@ import java.util.Set; @Service public class PostgresFullTextQueryService implements SearchQueryService { + private static final int SHORT_KEYWORD_LENGTH = 2; private final EntityManager entityManager; @@ -21,6 +22,9 @@ public class PostgresFullTextQueryService implements SearchQueryService { @Override public SearchResult search(SearchQuery query) { + String normalizedKeyword = normalizeKeyword(query.keyword()); + boolean hasKeyword = normalizedKeyword != null; + boolean useShortKeywordFallback = hasKeyword && normalizedKeyword.length() <= SHORT_KEYWORD_LENGTH; Set memberNamespaceIds = query.visibilityScope().memberNamespaceIds().isEmpty() ? Set.of(-1L) : query.visibilityScope().memberNamespaceIds(); @@ -48,8 +52,17 @@ public class PostgresFullTextQueryService implements SearchQueryService { } // Full-text search - if (query.keyword() != null && !query.keyword().isBlank()) { - sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + if (hasKeyword) { + if (useShortKeywordFallback) { + sql.append("AND ("); + sql.append("LOWER(title) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(summary) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(keywords) LIKE LOWER(:keywordLike) "); + sql.append("OR LOWER(search_text) LIKE LOWER(:keywordLike)"); + sql.append(") "); + } else { + sql.append("AND search_vector @@ plainto_tsquery('simple', :keyword) "); + } } // Sorting @@ -59,7 +72,7 @@ public class PostgresFullTextQueryService implements SearchQueryService { sql.append("ORDER BY (SELECT rating_avg FROM skill WHERE id = skill_id) DESC "); } else if ("newest".equals(query.sortBy())) { sql.append("ORDER BY (SELECT updated_at FROM skill WHERE id = skill_id) DESC "); - } else if ("relevance".equals(query.sortBy()) && query.keyword() != null && !query.keyword().isBlank()) { + } else if ("relevance".equals(query.sortBy()) && hasKeyword && !useShortKeywordFallback) { sql.append("ORDER BY ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC "); } else { sql.append("ORDER BY updated_at DESC "); @@ -80,8 +93,12 @@ public class PostgresFullTextQueryService implements SearchQueryService { nativeQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - nativeQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + nativeQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + nativeQuery.setParameter("keyword", normalizedKeyword); + } } nativeQuery.setParameter("limit", query.size()); @@ -115,12 +132,23 @@ public class PostgresFullTextQueryService implements SearchQueryService { countQuery.setParameter("namespaceId", query.namespaceId()); } - if (query.keyword() != null && !query.keyword().isBlank()) { - countQuery.setParameter("keyword", query.keyword()); + if (hasKeyword) { + if (useShortKeywordFallback) { + countQuery.setParameter("keywordLike", "%" + normalizedKeyword + "%"); + } else { + countQuery.setParameter("keyword", normalizedKeyword); + } } long total = ((Number) countQuery.getSingleResult()).longValue(); return new SearchResult(skillIds, total, query.page(), query.size()); } + + private String normalizeKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return keyword.trim(); + } } diff --git a/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java new file mode 100644 index 00000000..e91954d9 --- /dev/null +++ b/server/skillhub-search/src/test/java/com/iflytek/skillhub/search/postgres/PostgresFullTextQueryServiceTest.java @@ -0,0 +1,110 @@ +package com.iflytek.skillhub.search.postgres; + +import com.iflytek.skillhub.search.SearchQuery; +import com.iflytek.skillhub.search.SearchVisibilityScope; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PostgresFullTextQueryServiceTest { + + @Test + void shortKeywordsShouldUseLikeFallback() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "ai", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keywordLike", "%ai%"); + verify(countQuery).setParameter("keywordLike", "%ai%"); + verify(nativeQuery, never()).setParameter("keyword", "ai"); + verify(countQuery, never()).setParameter("keyword", "ai"); + } + + @Test + void longerKeywordsShouldKeepFullTextSearch() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of(1L)); + when(countQuery.getSingleResult()).thenReturn(1L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "agent", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + verify(nativeQuery).setParameter("keyword", "agent"); + verify(countQuery).setParameter("keyword", "agent"); + verify(nativeQuery, never()).setParameter("keywordLike", "%agent%"); + verify(countQuery, never()).setParameter("keywordLike", "%agent%"); + } + + @Test + void shortKeywordSqlShouldAvoidTsRankOrdering() { + EntityManager entityManager = mock(EntityManager.class); + Query nativeQuery = mock(Query.class); + Query countQuery = mock(Query.class); + when(entityManager.createNativeQuery(anyString())) + .thenReturn(nativeQuery) + .thenReturn(countQuery); + when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery); + when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + when(countQuery.getSingleResult()).thenReturn(0L); + + PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager); + + service.search(new SearchQuery( + "go", + null, + new SearchVisibilityScope(null, Set.of(), Set.of()), + "relevance", + 0, + 20 + )); + + var sqlCaptor = org.mockito.ArgumentCaptor.forClass(String.class); + verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getAllValues().getFirst()).contains("LOWER(title) LIKE LOWER(:keywordLike)"); + assertThat(sqlCaptor.getAllValues().getFirst()).doesNotContain("ts_rank"); + } +} From ccc23289bc59f02537819609d1d1ebba3ae482a5 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:31:16 +0800 Subject: [PATCH 09/22] fix(dialog): portal token modal to body --- web/src/shared/ui/dialog.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index 913984d7..9b000382 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { createPortal } from 'react-dom' import { cn } from '@/shared/lib/utils' interface DialogContextValue { @@ -65,7 +66,10 @@ DialogTrigger.displayName = 'DialogTrigger' const DialogPortal = ({ children }: { children: React.ReactNode }) => { const { open } = useDialog() if (!open) return null - return <>{children} + if (typeof document === 'undefined') { + return null + } + return createPortal(children, document.body) } const DialogOverlay = React.forwardRef>( From fc9bdffc7d3e370f7e61d7c1556bd912421d6052 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:38:18 +0800 Subject: [PATCH 10/22] fix(token): validate name length and uniqueness --- .../skillhub/dto/TokenCreateRequest.java | 2 + .../migration/V8__token_name_constraints.sql | 6 ++ .../src/main/resources/messages.properties | 2 + .../src/main/resources/messages_zh.properties | 2 + .../controller/TokenControllerTest.java | 27 ++++++++ .../skillhub/auth/entity/ApiToken.java | 2 +- .../auth/repository/ApiTokenRepository.java | 1 + .../skillhub/auth/token/ApiTokenService.java | 33 +++++++++- .../auth/token/ApiTokenServiceTest.java | 65 +++++++++++++++++++ .../features/token/create-token-dialog.tsx | 52 +++++++++++++-- web/src/features/token/token-list.tsx | 2 +- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + 13 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java index 63eadf18..7b821bf4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TokenCreateRequest.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.util.List; public record TokenCreateRequest( @NotBlank(message = "{validation.token.name.notBlank}") + @Size(max = 64, message = "{validation.token.name.size}") String name, List scopes ) {} diff --git a/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql new file mode 100644 index 00000000..2038bc4c --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V8__token_name_constraints.sql @@ -0,0 +1,6 @@ +ALTER TABLE api_token + ALTER COLUMN name TYPE VARCHAR(64); + +CREATE UNIQUE INDEX uk_api_token_user_active_name + ON api_token (user_id, LOWER(name)) + WHERE revoked_at IS NULL; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 329db1c0..b6950d12 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -15,6 +15,8 @@ validation.namespace.description.size=Description must not exceed 512 characters validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required validation.token.name.notBlank=Token name cannot be blank +validation.token.name.size=Token name must be at most 64 characters +error.token.name.duplicate=You already have a token with this name error.auth.required=Authentication required error.auth.local.username.exists=Username already exists diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6d839aea..b72d88e0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -15,6 +15,8 @@ validation.namespace.description.size=描述长度不能超过 512 个字符 validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 validation.token.name.notBlank=Token 名称不能为空 +validation.token.name.size=Token 名称最多 64 个字符 +error.token.name.duplicate=你已经有同名 Token error.auth.required=需要先登录 error.auth.local.username.exists=用户名已存在 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 52b98554..ebf5a512 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -19,10 +20,14 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,4 +66,26 @@ class TokenControllerTest { verify(apiTokenService).revokeToken(7L, "user-42"); } + + @Test + void create_rejectsNamesLongerThan64Characters() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + given(apiTokenService.createToken(anyString(), anyString(), anyString())) + .willThrow(new DomainBadRequestException("validation.token.name.size")); + + mockMvc.perform(post("/api/v1/tokens") + .with(authentication(auth)) + .with(csrf()) + .contentType("application/json") + .content(""" + {"name":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index 8fb654d2..ae1213d3 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -21,7 +21,7 @@ public class ApiToken { @Column(name = "user_id", nullable = false) private String userId; - @Column(nullable = false, length = 128) + @Column(nullable = false, length = 64) private String name; @Column(name = "token_prefix", nullable = false, length = 16) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index cc77c92a..368b0dd6 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -11,4 +11,5 @@ public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index f51b60a0..f546e148 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,6 +22,7 @@ public class ApiTokenService { private static final String TOKEN_PREFIX = "sk_"; private static final int TOKEN_BYTES = 32; + private static final int MAX_NAME_LENGTH = 64; private final SecureRandom secureRandom = new SecureRandom(); private final ApiTokenRepository tokenRepo; @@ -31,14 +34,21 @@ public class ApiTokenService { @Transactional public TokenCreateResult createToken(String userId, String name, String scopeJson) { + String normalizedName = normalizeName(name); + validateTokenName(userId, normalizedName); + byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); String rawToken = TOKEN_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); String tokenHash = sha256(rawToken); String prefix = rawToken.substring(0, Math.min(rawToken.length(), 8)); - ApiToken token = new ApiToken(userId, name, prefix, tokenHash, scopeJson); - token = tokenRepo.save(token); + ApiToken token = new ApiToken(userId, normalizedName, prefix, tokenHash, scopeJson); + try { + token = tokenRepo.save(token); + } catch (DataIntegrityViolationException ex) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } return new TokenCreateResult(rawToken, token); } @@ -76,4 +86,23 @@ public class ApiTokenService { throw new RuntimeException("SHA-256 not available", e); } } + + private String normalizeName(String name) { + if (name == null) { + return ""; + } + return name.trim(); + } + + private void validateTokenName(String userId, String name) { + if (name.isBlank()) { + throw new DomainBadRequestException("validation.token.name.notBlank"); + } + if (name.length() > MAX_NAME_LENGTH) { + throw new DomainBadRequestException("validation.token.name.size"); + } + if (tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(userId, name)) { + throw new DomainBadRequestException("error.token.name.duplicate"); + } + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java new file mode 100644 index 00000000..d23e4c9c --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.auth.token; + +import com.iflytek.skillhub.auth.repository.ApiTokenRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ApiTokenServiceTest { + + @Mock + private ApiTokenRepository tokenRepo; + + private ApiTokenService service; + + @BeforeEach + void setUp() { + service = new ApiTokenService(tokenRepo); + } + + @Test + void createToken_rejectsNamesLongerThan64Characters() { + String longName = "a".repeat(65); + + assertThatThrownBy(() -> service.createToken("user-1", longName, "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("validation.token.name.size"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_rejectsDuplicateActiveNamesIgnoringCase() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class) + .hasMessageContaining("error.token.name.duplicate"); + + verify(tokenRepo, never()).save(any()); + } + + @Test + void createToken_trimsNameBeforeCheckingDuplicates() { + when(tokenRepo.existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token")) + .thenReturn(true); + + assertThatThrownBy(() -> service.createToken("user-1", " My Token ", "[]")) + .isInstanceOf(DomainBadRequestException.class); + + verify(tokenRepo).existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase("user-1", "My Token"); + verify(tokenRepo, never()).save(any()); + } +} diff --git a/web/src/features/token/create-token-dialog.tsx b/web/src/features/token/create-token-dialog.tsx index 7b2c22e9..d23e9959 100644 --- a/web/src/features/token/create-token-dialog.tsx +++ b/web/src/features/token/create-token-dialog.tsx @@ -19,33 +19,57 @@ import type { CreateTokenRequest, CreateTokenResponse } from '@/api/types' interface CreateTokenDialogProps { children: React.ReactNode + existingNames?: string[] } -export function CreateTokenDialog({ children }: CreateTokenDialogProps) { +const MAX_TOKEN_NAME_LENGTH = 64 + +export function CreateTokenDialog({ children, existingNames = [] }: CreateTokenDialogProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [name, setName] = useState('') const [createdToken, setCreatedToken] = useState(null) + const [nameError, setNameError] = useState(null) const queryClient = useQueryClient() + const normalizedName = name.trim() + const hasDuplicateName = existingNames.some( + (existingName) => existingName.trim().toLocaleLowerCase() === normalizedName.toLocaleLowerCase() + ) + const createMutation = useMutation({ mutationFn: (request: CreateTokenRequest) => tokenApi.createToken(request), onSuccess: (data) => { setCreatedToken(data) setName('') + setNameError(null) queryClient.invalidateQueries({ queryKey: ['tokens'] }) }, }) const handleCreate = () => { - if (!name.trim()) return - createMutation.mutate({ name: name.trim() }) + if (!normalizedName) { + setNameError(t('createToken.nameRequired')) + return + } + if (normalizedName.length > MAX_TOKEN_NAME_LENGTH) { + setNameError(t('createToken.nameTooLong', { max: MAX_TOKEN_NAME_LENGTH })) + return + } + if (hasDuplicateName) { + setNameError(t('createToken.nameDuplicate')) + return + } + + setNameError(null) + createMutation.mutate({ name: normalizedName }) } const handleClose = () => { setOpen(false) setCreatedToken(null) setName('') + setNameError(null) createMutation.reset() } @@ -92,22 +116,40 @@ export function CreateTokenDialog({ children }: CreateTokenDialogProps) { id="token-name" placeholder={t('createToken.namePlaceholder')} value={name} - onChange={(e) => setName(e.target.value)} + maxLength={MAX_TOKEN_NAME_LENGTH} + onChange={(e) => { + setName(e.target.value) + if (nameError) { + setNameError(null) + } + }} onKeyDown={(e) => { if (e.key === 'Enter') { handleCreate() } }} + aria-invalid={nameError || hasDuplicateName ? 'true' : 'false'} /> +
+ + {nameError ?? (hasDuplicateName && normalizedName ? t('createToken.nameDuplicate') : '')} + + + {normalizedName.length}/{MAX_TOKEN_NAME_LENGTH} + +
+ {createMutation.error ? ( +

{createMutation.error.message}

+ ) : null} diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 9e9a53fb..55daa5c2 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -62,7 +62,7 @@ export function TokenList() {

{t('token.title')}

- + token.name)}>
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index d7dd06d6..87c94597 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -398,6 +398,9 @@ "description": "Create a new API Token for CLI or API access", "nameLabel": "Token Name", "namePlaceholder": "e.g.: my-cli-token", + "nameRequired": "Token name is required", + "nameTooLong": "Token name must be at most {{max}} characters", + "nameDuplicate": "You already have a token with this name", "creating": "Creating...", "create": "Create", "successTitle": "Token Created", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0747b9c0..9a4aad3b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -398,6 +398,9 @@ "description": "创建一个新的 API Token 用于 CLI 或 API 访问", "nameLabel": "Token 名称", "namePlaceholder": "例如: my-cli-token", + "nameRequired": "请输入 Token 名称", + "nameTooLong": "Token 名称最多 {{max}} 个字符", + "nameDuplicate": "你已经有同名 Token", "creating": "创建中...", "create": "创建", "successTitle": "Token 创建成功", From 32add06b5d0fd15e58ffa56189f6f3179bcc0391 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:40:17 +0800 Subject: [PATCH 11/22] fix(skill): format publish time in local system time --- web/src/pages/skill-detail.tsx | 3 ++- web/src/shared/lib/date-time.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 web/src/shared/lib/date-time.ts diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 99f5c5a3..cd539d1d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -9,6 +9,7 @@ import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' import { adminApi } from '@/api/client' +import { formatLocalDateTime } from '@/shared/lib/date-time' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs' import { Button } from '@/shared/ui/button' @@ -191,7 +192,7 @@ export function SkillDetailPage() { - {new Date(version.publishedAt).toLocaleDateString(i18n.language)} + {formatLocalDateTime(version.publishedAt, i18n.language)}
{version.changelog && ( diff --git a/web/src/shared/lib/date-time.ts b/web/src/shared/lib/date-time.ts new file mode 100644 index 00000000..ff229610 --- /dev/null +++ b/web/src/shared/lib/date-time.ts @@ -0,0 +1,21 @@ +function parseServerDateTime(value: string): Date { + if (/[zZ]$|[+-]\d{2}:\d{2}$/.test(value)) { + return new Date(value) + } + + const [datePart, timePart = '00:00:00'] = value.split('T') + const [year, month, day] = datePart.split('-').map(Number) + const [rawTime, fractional = ''] = timePart.split('.') + const [hours = 0, minutes = 0, seconds = 0] = rawTime.split(':').map(Number) + const milliseconds = Number((fractional + '000').slice(0, 3)) + + return new Date(year, (month || 1) - 1, day || 1, hours, minutes, seconds, milliseconds) +} + +export function formatLocalDateTime( + value: string, + locale: string, + options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }, +) { + return new Intl.DateTimeFormat(locale, options).format(parseServerDateTime(value)) +} From 495b05f63fa8f4199ae19f75194ad067830b29ac Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:41:33 +0800 Subject: [PATCH 12/22] fix(dialog): keep modals centered on long pages --- web/src/shared/ui/dialog.tsx | 74 +++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/web/src/shared/ui/dialog.tsx b/web/src/shared/ui/dialog.tsx index 9b000382..1512c46d 100644 --- a/web/src/shared/ui/dialog.tsx +++ b/web/src/shared/ui/dialog.tsx @@ -28,6 +28,20 @@ const Dialog = ({ open: controlledOpen, onOpenChange, children }: DialogProps) = const open = controlledOpen ?? uncontrolledOpen const handleOpenChange = onOpenChange ?? setUncontrolledOpen + React.useEffect(() => { + if (!open || typeof document === 'undefined') { + return undefined + } + + const { body } = document + const previousOverflow = body.style.overflow + body.style.overflow = 'hidden' + + return () => { + body.style.overflow = previousOverflow + } + }, [open]) + return ( {children} @@ -96,39 +110,37 @@ const DialogContent = React.forwardRef -
-
e.stopPropagation()} - {...props} +
e.stopPropagation()} + {...props} + > + {children} + -
+ + + +
) From bf319332c519b3dee07013fc6d2ec164be56d7de Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:44:11 +0800 Subject: [PATCH 13/22] feat(token): paginate token list --- .../skillhub/controller/TokenController.java | 14 ++++--- .../controller/TokenControllerTest.java | 40 +++++++++++++++++++ .../auth/repository/ApiTokenRepository.java | 3 ++ .../skillhub/auth/token/ApiTokenService.java | 8 ++++ web/src/api/client.ts | 31 +++++++++----- web/src/features/token/token-list.tsx | 26 ++++++++---- 6 files changed, 99 insertions(+), 23 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index cb2af098..f0978f65 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TokenCreateRequest; import com.iflytek.skillhub.dto.TokenCreateResponse; import com.iflytek.skillhub.dto.TokenSummaryResponse; @@ -45,17 +46,20 @@ public class TokenController extends BaseApiController { } @GetMapping - public ApiResponse> list(@AuthenticationPrincipal PlatformPrincipal principal) { - var tokens = apiTokenService.listActiveTokens(principal.userId()); - var result = tokens.stream().map(t -> new TokenSummaryResponse( + public ApiResponse> list( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size); + var result = tokens.map(t -> new TokenSummaryResponse( t.getId(), t.getName(), t.getTokenPrefix(), t.getCreatedAt().toString(), t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" - )).toList(); - return ok("response.success.read", result); + )); + return ok("response.success.read", PageResponse.from(result)); } @DeleteMapping("/{id}") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index ebf5a512..7b133405 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -19,6 +19,8 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @@ -27,6 +29,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -88,4 +91,41 @@ class TokenControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); } + + @Test + void list_returns_paginated_tokens() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var tokenPage = new PageImpl<>( + List.of( + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"), + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]") + ), + PageRequest.of(1, 10), + 12 + ); + var first = tokenPage.getContent().get(0); + var second = tokenPage.getContent().get(1); + org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L); + org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage); + + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].name").value("cli")) + .andExpect(jsonPath("$.data.items[1].name").value("deploy")) + .andExpect(jsonPath("$.data.total").value(12)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index 368b0dd6..ba7f919f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.ApiToken; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,5 +13,6 @@ public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + Page findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable); boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index f546e148..8d3cf4d0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,6 +73,12 @@ public class ApiTokenService { return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); } + public Page listActiveTokens(String userId, int page, int size) { + int resolvedPage = Math.max(page, 0); + int resolvedSize = Math.max(size, 1); + return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize)); + } + @Transactional public void touchLastUsed(ApiToken token) { token.setLastUsedAt(LocalDateTime.now()); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 465dfddc..605f7e2d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -372,19 +372,28 @@ export const accountApi = { } export const tokenApi = { - async getTokens(): Promise { - const tokens = await unwrap(client.GET('/api/v1/tokens', { + async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { + const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { + params: { + query: { + page: params?.page ?? 0, + size: params?.size ?? 10, + }, + }, headers: withRequestHeaders(), } as never) as never) - return tokens - .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) - .map((token) => ({ - ...token, - id: token.id!, - name: token.name!, - tokenPrefix: token.tokenPrefix!, - createdAt: token.createdAt!, - })) + return { + ...page, + items: page.items + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })), + } }, async createToken(request: CreateTokenRequest): Promise { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 55daa5c2..e266a23f 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -13,19 +13,24 @@ import { } from '@/shared/ui/table' import { CreateTokenDialog } from './create-token-dialog' import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { Pagination } from '@/shared/components/pagination' import { toast } from '@/shared/lib/toast' +import { formatLocalDateTime } from '@/shared/lib/date-time' import type { ApiToken } from '@/api/types' +const PAGE_SIZE = 10 + export function TokenList() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const queryClient = useQueryClient() + const [page, setPage] = useState(0) const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({ open: false, }) - const { data: tokens, isLoading } = useQuery({ - queryKey: ['tokens'], - queryFn: tokenApi.getTokens, + const { data: tokenPage, isLoading } = useQuery<{ items: ApiToken[]; total: number; page: number; size: number }>({ + queryKey: ['tokens', page, PAGE_SIZE], + queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }), }) const deleteMutation = useMutation({ @@ -51,9 +56,12 @@ export function TokenList() { const formatDate = (dateString?: string | null) => { if (!dateString) return '-' - return new Date(dateString).toLocaleString('zh-CN') + return formatLocalDateTime(dateString, i18n.language) } + const tokens = tokenPage?.items ?? [] + const totalPages = tokenPage ? Math.max(Math.ceil(tokenPage.total / tokenPage.size), 1) : 1 + if (isLoading) { return
{t('token.loading')}
} @@ -62,12 +70,12 @@ export function TokenList() {

{t('token.title')}

- token.name)}> + token.name)}>
- {!tokens || tokens.length === 0 ? ( + {!tokenPage || tokenPage.total === 0 ? (

{t('token.empty')}

{t('token.emptyHint')}

@@ -114,6 +122,10 @@ export function TokenList() {
)} + {tokenPage && tokenPage.total > PAGE_SIZE ? ( + + ) : null} + setDeleteDialog({ ...deleteDialog, open })} From 05c4842ac538eb4a77c450e13684932f19afe549 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:47:02 +0800 Subject: [PATCH 14/22] fix(auth): validate email format on register --- .../skillhub/dto/LocalRegisterRequest.java | 6 +++--- .../src/main/resources/messages.properties | 3 +++ .../src/main/resources/messages_zh.properties | 3 +++ .../controller/LocalAuthControllerTest.java | 17 +++++++++++++++++ .../skillhub/auth/local/LocalAuthService.java | 11 +++++++++++ .../auth/local/LocalAuthServiceTest.java | 9 +++++++++ 6 files changed, 46 insertions(+), 3 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java index 1f560ea8..5a4a2749 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -4,10 +4,10 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record LocalRegisterRequest( - @NotBlank(message = "用户名不能为空") + @NotBlank(message = "{validation.auth.local.username.notBlank}") String username, - @NotBlank(message = "密码不能为空") + @NotBlank(message = "{validation.auth.local.password.notBlank}") String password, - @Email(message = "邮箱格式不正确") + @Email(message = "{validation.auth.local.email.invalid}") String email ) {} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index b6950d12..ac4ab915 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -14,6 +14,9 @@ validation.namespace.displayName.size=Display name must not exceed 128 character validation.namespace.description.size=Description must not exceed 512 characters validation.member.userId.notNull=User ID is required validation.member.role.notNull=Role is required +validation.auth.local.username.notBlank=Username cannot be blank +validation.auth.local.password.notBlank=Password cannot be blank +validation.auth.local.email.invalid=Email format is invalid validation.token.name.notBlank=Token name cannot be blank validation.token.name.size=Token name must be at most 64 characters error.token.name.duplicate=You already have a token with this name diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index b72d88e0..defc9b8b 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -14,6 +14,9 @@ validation.namespace.displayName.size=显示名称长度不能超过 128 个字 validation.namespace.description.size=描述长度不能超过 512 个字符 validation.member.userId.notNull=用户 ID 不能为空 validation.member.role.notNull=角色不能为空 +validation.auth.local.username.notBlank=用户名不能为空 +validation.auth.local.password.notBlank=密码不能为空 +validation.auth.local.email.invalid=邮箱格式不正确 validation.token.name.notBlank=Token 名称不能为空 validation.token.name.size=Token 名称最多 64 个字符 error.token.name.duplicate=你已经有同名 Token diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index b0d84d8e..a443d4ba 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -95,6 +95,23 @@ class LocalAuthControllerTest { verify(skillHubMetrics).incrementUserRegister(); } + @Test + void register_rejectsInvalidEmailFormat() throws Exception { + given(localAuthService.register("bob", "Abcd123!", "not-an-email")) + .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid")); + + mockMvc.perform(post("/api/v1/auth/local/register") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"username":"bob","password":"Abcd123!","email":"not-an-email"} + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.msg").value("邮箱格式不正确")); + + verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); + } + @Test void login_failure_recordsFailureMetric() throws Exception { given(localAuthService.login("alice", "wrong")) diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 1addd5f7..574bc418 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -23,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional; public class LocalAuthService { private static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{3,64}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); private static final int MAX_FAILED_ATTEMPTS = 5; private static final Duration LOCK_DURATION = Duration.ofMinutes(15); @@ -57,6 +58,7 @@ public class LocalAuthService { } String normalizedEmail = normalizeEmail(email); + validateEmail(normalizedEmail); if (normalizedEmail != null && userAccountRepository.findByEmailIgnoreCase(normalizedEmail).isPresent()) { throw new AuthFlowException(HttpStatus.CONFLICT, "error.auth.local.email.exists"); } @@ -190,4 +192,13 @@ public class LocalAuthService { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.local.username.invalid"); } } + + private void validateEmail(String email) { + if (email == null) { + return; + } + if (!EMAIL_PATTERN.matcher(email).matches()) { + throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid"); + } + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 8fc505cc..6eaec2b2 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -131,4 +131,13 @@ class LocalAuthServiceTest { .isInstanceOf(AuthFlowException.class) .hasMessageContaining("error.auth.local.accountDisabled"); } + + @Test + void register_rejectsInvalidEmailFormat() { + given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false); + + assertThatThrownBy(() -> service.register("Alice", "Abcd123!", "not-an-email")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("validation.auth.local.email.invalid"); + } } From a1083412e1f91e5c7aebfab6512eb8686425b71f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:51:06 +0800 Subject: [PATCH 15/22] fix(stars): keep favorites data in sync --- .../skillhub/service/MySkillAppService.java | 25 +++++- .../service/MySkillAppServiceTest.java | 89 +++++++++++++++++++ web/src/features/social/use-star.ts | 1 + 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 6f2684f0..219a5727 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.social.SkillStarRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.data.domain.PageRequest; @@ -19,6 +20,7 @@ import java.util.stream.Collectors; @Service public class MySkillAppService { + private static final int STAR_PAGE_SIZE = 200; private final SkillRepository skillRepository; private final NamespaceRepository namespaceRepository; @@ -68,10 +70,7 @@ public class MySkillAppService { } public List listMyStars(String userId) { - List stars = skillStarRepository.findByUserId( - userId, - PageRequest.of(0, 200) - ).getContent(); + List stars = loadAllStars(userId); List skillIds = stars.stream() .map(com.iflytek.skillhub.domain.social.SkillStar::getSkillId) @@ -111,6 +110,24 @@ public class MySkillAppService { .toList(); } + private List loadAllStars(String userId) { + List stars = new java.util.ArrayList<>(); + int pageNumber = 0; + + while (true) { + Page page = skillStarRepository.findByUserId( + userId, + PageRequest.of(pageNumber, STAR_PAGE_SIZE) + ); + stars.addAll(page.getContent()); + + if (!page.hasNext()) { + return stars; + } + pageNumber++; + } + } + private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsById, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java new file mode 100644 index 00000000..dcaa01d5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -0,0 +1,89 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.social.SkillStar; +import com.iflytek.skillhub.domain.social.SkillStarRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +class MySkillAppServiceTest { + + @Mock + private SkillRepository skillRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private SkillStarRepository skillStarRepository; + + private MySkillAppService service; + + @BeforeEach + void setUp() { + service = new MySkillAppService(skillRepository, namespaceRepository, skillVersionRepository, skillStarRepository); + } + + @Test + void listMyStars_loadsAllPagesOfStars() { + SkillStar firstStar = new SkillStar(1L, "user-1"); + ReflectionTestUtils.setField(firstStar, "createdAt", LocalDateTime.of(2026, 3, 14, 10, 0)); + SkillStar secondStar = new SkillStar(2L, "user-1"); + ReflectionTestUtils.setField(secondStar, "createdAt", LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(skillStarRepository.findByUserId("user-1", PageRequest.of(0, 200))) + .willReturn(new PageImpl<>(List.of(firstStar), PageRequest.of(0, 200), 201)); + given(skillStarRepository.findByUserId("user-1", PageRequest.of(1, 200))) + .willReturn(new PageImpl<>(List.of(secondStar), PageRequest.of(1, 200), 201)); + + Skill firstSkill = new Skill(1L, "first-skill", "user-1", SkillVisibility.PUBLIC); + firstSkill.setDisplayName("First Skill"); + firstSkill.setSummary("first summary"); + ReflectionTestUtils.setField(firstSkill, "id", 1L); + ReflectionTestUtils.setField(firstSkill, "starCount", 1); + ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(firstSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 10, 0)); + + Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC); + secondSkill.setDisplayName("Second Skill"); + secondSkill.setSummary("second summary"); + ReflectionTestUtils.setField(secondSkill, "id", 2L); + ReflectionTestUtils.setField(secondSkill, "starCount", 2); + ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L); + ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(skillRepository.findByIdIn(List.of(1L, 2L))).willReturn(List.of(firstSkill, secondSkill)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(new Namespace("team-ai", "Team AI", "user-1"))); + + var stars = service.listMyStars("user-1"); + + assertThat(stars).hasSize(2); + assertThat(stars.get(0).slug()).isEqualTo("second-skill"); + assertThat(stars.get(1).slug()).isEqualTo("first-skill"); + } +} diff --git a/web/src/features/social/use-star.ts b/web/src/features/social/use-star.ts index c4106756..f5ab55e1 100644 --- a/web/src/features/social/use-star.ts +++ b/web/src/features/social/use-star.ts @@ -47,6 +47,7 @@ export function useToggleStar(skillId: number) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'star'] }) queryClient.invalidateQueries({ queryKey: ['skills'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'stars'] }) }, }) } From 60145d7dae8b4c10dda8277c6dd84c5aa099e83a Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:53:27 +0800 Subject: [PATCH 16/22] fix(publish): timeout stalled publish requests --- web/src/api/client.ts | 43 +++++++++++++++++++++-- web/src/i18n/locales/en.json | 2 ++ web/src/i18n/locales/zh.json | 2 ++ web/src/pages/dashboard/publish.tsx | 7 +++- web/src/shared/hooks/use-skill-queries.ts | 3 ++ 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 605f7e2d..fe3b4872 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -170,15 +170,54 @@ type ApiEnvelope = { requestId: string } -export async function fetchJson(input: RequestInfo | URL, init?: RequestInit): Promise { +type RequestWithTimeout = RequestInit & { + timeoutMs?: number +} + +function createRequestSignal(init?: RequestWithTimeout): { signal?: AbortSignal, cleanup: () => void } { + if (!init?.timeoutMs && !init?.signal) { + return { signal: init?.signal ?? undefined, cleanup: () => {} } + } + + const controller = new AbortController() + const timeoutId = init?.timeoutMs ? window.setTimeout(() => controller.abort('timeout'), init.timeoutMs) : undefined + const abortListener = () => controller.abort() + + if (init?.signal) { + if (init.signal.aborted) { + controller.abort() + } else { + init.signal.addEventListener('abort', abortListener, { once: true }) + } + } + + return { + signal: controller.signal, + cleanup: () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId) + } + init?.signal?.removeEventListener('abort', abortListener) + }, + } +} + +export async function fetchJson(input: RequestInfo | URL, init?: RequestWithTimeout): Promise { + const { signal, cleanup } = createRequestSignal(init) let response: Response try { response = await fetch(withBaseUrl(input), { ...init, + signal, headers: withRequestHeaders(init?.headers), }) - } catch { + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new ApiError('error.request.timeout', 408) + } throw new ApiError('Network error', 0) + } finally { + cleanup() } let json: ApiEnvelope | null = null diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 87c94597..61980879 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -470,6 +470,8 @@ "success": "Published Successfully", "successDescription": "{{skill}} has been submitted for review and will be available after admin approval", "error": "Publish Failed", + "timeoutTitle": "Publish timed out", + "timeoutDescription": "The publish request took too long. Please check the skill list later or try again.", "selectRequired": "Please select namespace and file" }, "toast": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 9a4aad3b..e5c9583c 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -470,6 +470,8 @@ "success": "发布成功", "successDescription": "{{skill}} 已提交审核,等待管理员批准后即可使用", "error": "发布失败", + "timeoutTitle": "发布请求超时", + "timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。", "selectRequired": "请选择命名空间和文件" }, "toast": { diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index faa3cf88..fde75241 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -8,6 +8,7 @@ import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queries' import { toast } from '@/shared/lib/toast' +import { ApiError } from '@/api/client' export function PublishPage() { const { t } = useTranslation() @@ -39,6 +40,10 @@ export function PublishPage() { ) navigate({ to: '/dashboard/skills' }) } catch (error) { + if (error instanceof ApiError && error.status === 408) { + toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription')) + return + } toast.error(t('publish.error'), error instanceof Error ? error.message : '') } } @@ -123,4 +128,4 @@ export function PublishPage() {
) -} \ No newline at end of file +} diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index e3b77692..38d7d2e4 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -2,6 +2,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import type { SkillSummary, SkillDetail, SkillVersion, SkillFile, SearchParams, PagedResponse, PublishResult, Namespace, NamespaceMember } from '@/api/types' import { fetchJson, fetchText, getCsrfHeaders, meApi } from '@/api/client' +const PUBLISH_REQUEST_TIMEOUT_MS = 60_000 + async function searchSkills(params: SearchParams): Promise> { const queryParams = new URLSearchParams() if (params.q) queryParams.append('q', params.q) @@ -75,6 +77,7 @@ async function publishSkill(params: { namespace: string; file: File; visibility: method: 'POST', headers: getCsrfHeaders(), body: formData, + timeoutMs: PUBLISH_REQUEST_TIMEOUT_MS, }) } From 55449902409ef65c7b5d40ba614555a13e8fccb9 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:54:45 +0800 Subject: [PATCH 17/22] fix(publish): keep publish button text color consistent --- web/src/pages/dashboard/publish.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index fde75241..d9578f31 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -118,7 +118,7 @@ export function PublishPage() {
+ +
+
+ {error instanceof Error ? error.message : t('apiError.unknown')} +
+
+ ) + } + return (
diff --git a/web/src/main.tsx b/web/src/main.tsx index b5d7863f..6ae2314f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,8 @@ -import React from 'react' import ReactDOM from 'react-dom/client' import { App } from './app/providers' import './i18n/config' import './index.css' ReactDOM.createRoot(document.getElementById('root')!).render( - - - , + , ) From f3cd59a6c091c38ac145322a22c780d1472b666e Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:58:17 +0800 Subject: [PATCH 19/22] feat(search): update results as users type --- web/src/features/search/search-bar.tsx | 27 +++++++++++++++---- web/src/pages/search.tsx | 36 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/web/src/features/search/search-bar.tsx b/web/src/features/search/search-bar.tsx index 60c045f1..4d25d86e 100644 --- a/web/src/features/search/search-bar.tsx +++ b/web/src/features/search/search-bar.tsx @@ -1,22 +1,39 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Input } from '@/shared/ui/input' import { Button } from '@/shared/ui/button' interface SearchBarProps { defaultValue?: string + value?: string placeholder?: string + onChange?: (query: string) => void onSearch?: (query: string) => void } -export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBarProps) { +export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) { const { t } = useTranslation() const [query, setQuery] = useState(defaultValue) + const isControlled = value !== undefined + const currentQuery = isControlled ? value : query + + useEffect(() => { + if (!isControlled) { + setQuery(defaultValue) + } + }, [defaultValue, isControlled]) + + const handleChange = (nextQuery: string) => { + if (!isControlled) { + setQuery(nextQuery) + } + onChange?.(nextQuery) + } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (onSearch) { - onSearch(query) + onSearch(currentQuery) } } @@ -38,8 +55,8 @@ export function SearchBar({ defaultValue = '', placeholder, onSearch }: SearchBa setQuery(e.target.value)} + value={currentQuery} + onChange={(e) => handleChange(e.target.value)} placeholder={placeholder || t('searchBar.placeholder')} className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12" /> diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 8a1778d1..334c4cb8 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -1,3 +1,4 @@ +import { startTransition, useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { SearchBar } from '@/features/search/search-bar' @@ -16,6 +17,11 @@ export function SearchPage() { const q = searchParams.q || '' const sort = searchParams.sort || 'relevance' const page = searchParams.page ?? 0 + const [queryInput, setQueryInput] = useState(q) + + useEffect(() => { + setQueryInput(q) + }, [q]) const { data, isLoading } = useSearchSkills({ q, @@ -24,8 +30,34 @@ export function SearchPage() { size: 12, }) + useEffect(() => { + const normalizedQuery = queryInput.trim() + if (normalizedQuery === q) { + return + } + + if (!normalizedQuery) { + startTransition(() => { + navigate({ to: '/search', search: { q: '', sort, page: 0 }, replace: page === 0 }) + }) + return + } + + const timeoutId = window.setTimeout(() => { + startTransition(() => { + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + }) + }, 250) + + return () => window.clearTimeout(timeoutId) + }, [navigate, page, q, queryInput, sort]) + const handleSearch = (query: string) => { - navigate({ to: '/search', search: { q: query, sort, page: 0 } }) + const normalizedQuery = query.trim() + setQueryInput(query) + startTransition(() => { + navigate({ to: '/search', search: { q: normalizedQuery, sort, page: 0 }, replace: true }) + }) } const handleSortChange = (newSort: string) => { @@ -46,7 +78,7 @@ export function SearchPage() {
{/* Search Bar */}
- +
{/* Sort Selector */} From 8c8ce1d8815700c19e30f320bac6180bc651d85f Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:59:26 +0800 Subject: [PATCH 20/22] fix(publish): clarify duplicate version upload errors --- web/src/i18n/locales/en.json | 2 ++ web/src/i18n/locales/zh.json | 2 ++ web/src/pages/dashboard/publish.tsx | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 61980879..1f10da9f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -472,6 +472,8 @@ "error": "Publish Failed", "timeoutTitle": "Publish timed out", "timeoutDescription": "The publish request took too long. Please check the skill list later or try again.", + "versionExistsTitle": "Version already exists", + "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "selectRequired": "Please select namespace and file" }, "toast": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e5c9583c..ee3fd5af 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -472,6 +472,8 @@ "error": "发布失败", "timeoutTitle": "发布请求超时", "timeoutDescription": "本次发布等待时间过长,请稍后到技能列表确认结果,或重新尝试发布。", + "versionExistsTitle": "版本号已存在", + "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "selectRequired": "请选择命名空间和文件" }, "toast": { diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index d9578f31..5230524c 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -10,6 +10,16 @@ import { useMyNamespaces, usePublishSkill } from '@/shared/hooks/use-skill-queri import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' +function isVersionExistsMessage(message?: string): boolean { + if (!message) { + return false + } + + return message.includes('error.skill.version.exists') + || message.includes('Version already exists') + || message.includes('版本已存在') +} + export function PublishPage() { const { t } = useTranslation() const navigate = useNavigate() @@ -44,6 +54,15 @@ export function PublishPage() { toast.error(t('publish.timeoutTitle'), t('publish.timeoutDescription')) return } + + if (error instanceof ApiError && isVersionExistsMessage(error.serverMessage || error.message)) { + toast.error( + t('publish.versionExistsTitle'), + t('publish.versionExistsDescription'), + ) + return + } + toast.error(t('publish.error'), error instanceof Error ? error.message : '') } } From 9040b8ef3ac0143f7e612fb6c6224172a4d00219 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:01:45 +0800 Subject: [PATCH 21/22] chore(api): add concise logs for failed core requests --- .../exception/GlobalExceptionHandler.java | 61 +++++++++++++++---- .../security/ApiAccessDeniedHandler.java | 11 ++++ .../security/ApiAuthenticationEntryPoint.java | 11 ++++ 3 files changed, 72 insertions(+), 11 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 822039e8..a2a4a028 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -1,16 +1,19 @@ package com.iflytek.skillhub.exception; import com.iflytek.skillhub.auth.exception.AuthFlowException; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -27,39 +30,44 @@ public class GlobalExceptionHandler { } @ExceptionHandler(LocalizedException.class) - public ResponseEntity> handleLocalizedError(LocalizedException ex) { + public ResponseEntity> handleLocalizedError(LocalizedException ex, HttpServletRequest request) { HttpStatus status = ex.status(); + logHandledException(status, ex.messageCode(), request); return ResponseEntity.status(status).body( apiResponseFactory.error(status.value(), ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(AuthFlowException.class) - public ResponseEntity> handleAuthFlowException(AuthFlowException ex) { + public ResponseEntity> handleAuthFlowException(AuthFlowException ex, HttpServletRequest request) { HttpStatus status = ex.getStatus(); + logHandledException(status, ex.getMessageCode(), request); return ResponseEntity.status(status).body( apiResponseFactory.error(status.value(), ex.getMessageCode(), ex.getMessageArgs())); } @ExceptionHandler(DomainBadRequestException.class) - public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex) { + public ResponseEntity> handleDomainBadRequest(DomainBadRequestException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, ex.messageCode(), request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainForbiddenException.class) - public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex) { + public ResponseEntity> handleDomainForbidden(DomainForbiddenException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(DomainNotFoundException.class) - public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex) { + public ResponseEntity> handleDomainNotFound(DomainNotFoundException ex, HttpServletRequest request) { + logHandledException(HttpStatus.NOT_FOUND, ex.messageCode(), request); return ResponseEntity.status(HttpStatus.NOT_FOUND).body( apiResponseFactory.error(404, ex.messageCode(), ex.messageArgs())); } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() .findFirst() .map(FieldError::getDefaultMessage) @@ -67,6 +75,7 @@ public class GlobalExceptionHandler { .findFirst() .map(error -> error.getDefaultMessage()) .orElse(null)); + logHandledException(HttpStatus.BAD_REQUEST, "validation.request.invalid", request); if (msg == null || msg.isBlank()) { return ResponseEntity.badRequest().body(apiResponseFactory.error(400, "error.badRequest")); } @@ -74,22 +83,52 @@ public class GlobalExceptionHandler { } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleBadRequest(IllegalArgumentException ex) { + public ResponseEntity> handleBadRequest(IllegalArgumentException ex, HttpServletRequest request) { + logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request); return ResponseEntity.badRequest().body( apiResponseFactory.error(400, "error.badRequest")); } @ExceptionHandler(SecurityException.class) - public ResponseEntity> handleForbidden(SecurityException ex) { + public ResponseEntity> handleForbidden(SecurityException ex, HttpServletRequest request) { + logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request); return ResponseEntity.status(HttpStatus.FORBIDDEN).body( apiResponseFactory.error(403, "error.forbidden")); } @ExceptionHandler(Exception.class) - public ResponseEntity> handleGlobalException(Exception ex) { - String requestId = MDC.get("requestId"); - logger.error("Unhandled exception [requestId={}]", requestId, ex); + public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) { + logger.error( + "Unhandled API exception [requestId={}, method={}, path={}, userId={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + resolveUserId(request), + ex + ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( apiResponseFactory.error(500, "error.internal")); } + + private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) { + logger.info( + "API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]", + MDC.get("requestId"), + status.value(), + request.getMethod(), + request.getRequestURI(), + resolveUserId(request), + messageCode + ); + } + + private String resolveUserId(HttpServletRequest request) { + if (!(request.getUserPrincipal() instanceof Authentication authentication)) { + return "anonymous"; + } + if (authentication.getPrincipal() instanceof PlatformPrincipal principal) { + return principal.userId(); + } + return authentication.getName(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index a5d36a73..4987bf9d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -15,6 +18,7 @@ import java.io.IOException; @Component public class ApiAccessDeniedHandler implements AccessDeniedHandler { + private static final Logger logger = LoggerFactory.getLogger(ApiAccessDeniedHandler.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; @@ -27,6 +31,13 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { + logger.info( + "Forbidden API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + accessDeniedException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(403, "error.forbidden"); response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index d9bfe089..bac1e010 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -15,6 +18,7 @@ import java.io.IOException; @Component public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + private static final Logger logger = LoggerFactory.getLogger(ApiAuthenticationEntryPoint.class); private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; @@ -27,6 +31,13 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + logger.info( + "Unauthorized API request [requestId={}, method={}, path={}, reason={}]", + MDC.get("requestId"), + request.getMethod(), + request.getRequestURI(), + authException.getClass().getSimpleName() + ); ApiResponse body = apiResponseFactory.error(401, "error.auth.required"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); From 176bfe9fb5c791ac2431cf7cfb97eb1b2fae6fc8 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 20:09:41 +0800 Subject: [PATCH 22/22] fix(token): handle successful deletes and refresh immediately --- web/src/api/client.ts | 13 ++++++++-- web/src/features/token/token-list.tsx | 37 ++++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index fe3b4872..54ee7908 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -456,14 +456,23 @@ export const tokenApi = { }, async deleteToken(tokenId: number): Promise { - await unwrap(client.DELETE('/api/v1/tokens/{id}', { + const { error, response } = await client.DELETE('/api/v1/tokens/{id}', { params: { path: { id: tokenId, }, }, headers: withCsrf(), - }) as never) + } as never) + + if (response.status === 204) { + return + } + + const envelope = (error && isApiEnvelope(error) ? error : null) as { msg?: string } | null + if (!response.ok || error) { + throw new ApiError(envelope?.msg || `HTTP ${response.status}`, response.status, envelope?.msg) + } }, } diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 6e21bd1e..0cef270f 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -19,6 +19,7 @@ import { formatLocalDateTime } from '@/shared/lib/date-time' import type { ApiToken } from '@/api/types' const PAGE_SIZE = 10 +type TokenPage = { items: ApiToken[]; total: number; page: number; size: number } export function TokenList() { const { t, i18n } = useTranslation() @@ -28,7 +29,7 @@ export function TokenList() { open: false, }) - const { data: tokenPage, isLoading, isError, error } = useQuery<{ items: ApiToken[]; total: number; page: number; size: number }>({ + const { data: tokenPage, isLoading, isError, error } = useQuery({ queryKey: ['tokens', page, PAGE_SIZE], queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }), meta: { @@ -38,11 +39,41 @@ export function TokenList() { const deleteMutation = useMutation({ mutationFn: (tokenId: number) => tokenApi.deleteToken(tokenId), + onMutate: async (tokenId) => { + await queryClient.cancelQueries({ queryKey: ['tokens'] }) + + const previousPages = queryClient.getQueriesData({ queryKey: ['tokens'] }) + queryClient.setQueriesData({ queryKey: ['tokens'] }, (current) => { + if (!current) { + return current + } + + const nextItems = current.items.filter((token) => token.id !== tokenId) + if (nextItems.length === current.items.length) { + return current + } + + return { + ...current, + items: nextItems, + total: Math.max(current.total - 1, 0), + } + }) + + return { previousPages } + }, onSuccess: () => { + if (tokenPage && tokenPage.items.length === 1 && page > 0) { + setPage(page - 1) + } + setDeleteDialog({ open: false }) queryClient.invalidateQueries({ queryKey: ['tokens'] }) toast.success(t('token.deleteSuccess')) }, - onError: () => { + onError: (_error, _tokenId, context) => { + context?.previousPages.forEach(([queryKey, previousPage]) => { + queryClient.setQueryData(queryKey, previousPage) + }) toast.error(t('token.deleteFailed')) }, }) @@ -53,7 +84,7 @@ export function TokenList() { const confirmDelete = async () => { if (deleteDialog.tokenId) { - deleteMutation.mutate(deleteDialog.tokenId) + await deleteMutation.mutateAsync(deleteDialog.tokenId) } }