diff --git a/docs/16-backend-time-inventory.md b/docs/16-backend-time-inventory.md index 0f46e93b..565b444f 100644 --- a/docs/16-backend-time-inventory.md +++ b/docs/16-backend-time-inventory.md @@ -99,11 +99,11 @@ - `V12__governance_notifications.sql` - `user_notification.created_at / read_at` -- `V13__api_token_timestamptz.sql` +- `V24__api_token_timestamptz.sql` - `api_token.expires_at / last_used_at / revoked_at / created_at` -- `V14__account_merge_request_timestamptz.sql` +- `V25__account_merge_request_timestamptz.sql` - `account_merge_request.token_expires_at / completed_at / created_at` -- `V15__skill_version_timestamptz.sql` +- `V26__skill_version_timestamptz.sql` - `skill_version.published_at / created_at / yanked_at` - `V16__skill_hidden_at_timestamptz.sql` - `skill.hidden_at` diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 78f5127a..217804f3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -67,6 +67,7 @@ public class SkillController extends BaseApiController { detail.id(), detail.slug(), detail.displayName(), + detail.ownerDisplayName(), detail.summary(), detail.visibility(), detail.status(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 70d8ae7e..1af0c753 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -6,6 +6,7 @@ public record SkillDetailResponse( Long id, String slug, String displayName, + String ownerDisplayName, String summary, String visibility, String status, diff --git a/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V24__api_token_timestamptz.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql rename to server/skillhub-app/src/main/resources/db/migration/V24__api_token_timestamptz.sql diff --git a/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V25__account_merge_request_timestamptz.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql rename to server/skillhub-app/src/main/resources/db/migration/V25__account_merge_request_timestamptz.sql diff --git a/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V26__skill_version_timestamptz.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql rename to server/skillhub-app/src/main/resources/db/migration/V26__skill_version_timestamptz.sql diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 6be4e061..90388da2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -149,6 +149,7 @@ class SkillControllerTest { 1L, "demo", "Demo", + "Alice", "Pending preview", "PUBLIC", "ACTIVE", @@ -173,6 +174,7 @@ class SkillControllerTest { mockMvc.perform(get("/api/web/skills/team/demo")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.ownerDisplayName").value("Alice")) .andExpect(jsonPath("$.data.canSubmitPromotion").value(false)) .andExpect(jsonPath("$.data.headlineVersion.version").value("1.1.0")) .andExpect(jsonPath("$.data.ownerPreviewVersion.id").value(11L)) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java new file mode 100644 index 00000000..ee23aee6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.db; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class FlywayMigrationGuardrailTest { + + private static final Pattern VERSIONED_MIGRATION_PATTERN = + Pattern.compile("^V(?\\d+)__(?.+)\\.sql$"); + + @Test + void versionedMigrations_mustUseUniqueVersions() throws IOException { + Map> versions = new LinkedHashMap<>(); + + for (Path file : migrationFiles()) { + Matcher matcher = VERSIONED_MIGRATION_PATTERN.matcher(file.getFileName().toString()); + if (!matcher.matches()) { + continue; + } + int version = Integer.parseInt(matcher.group("version")); + versions.computeIfAbsent(version, ignored -> new ArrayList<>()) + .add(relativeToRepo(file)); + } + + List duplicates = versions.entrySet().stream() + .filter(entry -> entry.getValue().size() > 1) + .map(entry -> "V" + entry.getKey() + " -> " + entry.getValue()) + .toList(); + + assertThat(duplicates).isEmpty(); + } + + @Test + void versionedMigrations_mustRemainContiguous() throws IOException { + List versions = migrationFiles().stream() + .map(path -> VERSIONED_MIGRATION_PATTERN.matcher(path.getFileName().toString())) + .filter(Matcher::matches) + .map(matcher -> Integer.parseInt(matcher.group("version"))) + .sorted() + .toList(); + + List gaps = new ArrayList<>(); + for (int expected = 1; expected <= versions.size(); expected++) { + int actual = versions.get(expected - 1); + if (actual != expected) { + gaps.add("expected V" + expected + " but found V" + actual); + } + } + + assertThat(gaps).isEmpty(); + } + + @Test + void migrationFiles_mustMatchFlywayVersionedNaming() throws IOException { + List invalidFiles = migrationFiles().stream() + .map(path -> path.getFileName().toString()) + .filter(name -> !VERSIONED_MIGRATION_PATTERN.matcher(name).matches()) + .sorted() + .toList(); + + assertThat(invalidFiles).isEmpty(); + } + + private List migrationFiles() throws IOException { + Path root = repoRoot() + .resolve("server") + .resolve("skillhub-app") + .resolve("src/main/resources/db/migration"); + try (var stream = Files.list(root)) { + return stream + .filter(path -> path.getFileName().toString().endsWith(".sql")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } + } + + private Path repoRoot() { + return Path.of("").toAbsolutePath().getParent().getParent(); + } + + private String relativeToRepo(Path file) { + return repoRoot().relativize(file).toString(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 74724362..55bf67c5 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -10,6 +10,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.storage.ObjectStorageService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -42,6 +44,7 @@ public class SkillQueryService { private final PromotionRequestRepository promotionRequestRepository; private final SkillSlugResolutionService skillSlugResolutionService; private final SkillLifecycleProjectionService skillLifecycleProjectionService; + private final UserAccountRepository userAccountRepository; public SkillQueryService( NamespaceRepository namespaceRepository, @@ -53,7 +56,8 @@ public class SkillQueryService { VisibilityChecker visibilityChecker, PromotionRequestRepository promotionRequestRepository, SkillSlugResolutionService skillSlugResolutionService, - SkillLifecycleProjectionService skillLifecycleProjectionService) { + SkillLifecycleProjectionService skillLifecycleProjectionService, + UserAccountRepository userAccountRepository) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -64,12 +68,14 @@ public class SkillQueryService { this.promotionRequestRepository = promotionRequestRepository; this.skillSlugResolutionService = skillSlugResolutionService; this.skillLifecycleProjectionService = skillLifecycleProjectionService; + this.userAccountRepository = userAccountRepository; } public record SkillDetailDTO( Long id, String slug, String displayName, + String ownerDisplayName, String summary, String visibility, String status, @@ -133,11 +139,16 @@ public class SkillQueryService { SkillLifecycleProjectionService.VersionProjection headlineVersion = projection.headlineVersion(); SkillLifecycleProjectionService.VersionProjection publishedVersion = projection.publishedVersion(); SkillLifecycleProjectionService.VersionProjection ownerPreviewVersion = projection.ownerPreviewVersion(); + String ownerDisplayName = userAccountRepository.findById(skill.getOwnerId()) + .map(UserAccount::getDisplayName) + .filter(name -> name != null && !name.isBlank()) + .orElse(null); return new SkillDetailDTO( skill.getId(), skill.getSlug(), skill.getDisplayName(), + ownerDisplayName, skill.getSummary(), skill.getVisibility().name(), skill.getStatus().name(), diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 65963a8b..a2c30fc5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -9,6 +9,8 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.storage.ObjectStorageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -50,6 +52,8 @@ class SkillQueryServiceTest { private VisibilityChecker visibilityChecker; @Mock private PromotionRequestRepository promotionRequestRepository; + @Mock + private UserAccountRepository userAccountRepository; private SkillQueryService service; private SkillSlugResolutionService skillSlugResolutionService; @@ -69,7 +73,8 @@ class SkillQueryServiceTest { visibilityChecker, promotionRequestRepository, skillSlugResolutionService, - skillLifecycleProjectionService + skillLifecycleProjectionService, + userAccountRepository ); } @@ -97,6 +102,7 @@ class SkillQueryServiceTest { when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version)); + when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null))); // Act SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); @@ -105,6 +111,7 @@ class SkillQueryServiceTest { assertNotNull(result); assertEquals(skillSlug, result.slug()); assertEquals("Test Skill", result.displayName()); + assertEquals("Alice", result.ownerDisplayName()); assertNotNull(result.headlineVersion()); assertEquals("1.0.0", result.headlineVersion().version()); assertFalse(result.canReport()); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 82860131..d3766849 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -157,6 +157,7 @@ export interface SkillDetail { id: number slug: string displayName: string + ownerDisplayName?: string summary?: string visibility: string status: string diff --git a/web/src/features/skill/markdown-renderer.test.tsx b/web/src/features/skill/markdown-renderer.test.tsx new file mode 100644 index 00000000..d7f39c4c --- /dev/null +++ b/web/src/features/skill/markdown-renderer.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { MARKDOWN_IMAGE_CLASS_NAME } from './markdown-renderer' + +describe('MARKDOWN_IMAGE_CLASS_NAME', () => { + it('keeps markdown images at their intrinsic width while remaining responsive', () => { + const classNames = MARKDOWN_IMAGE_CLASS_NAME.split(' ') + + expect(classNames).toContain('h-auto') + expect(classNames).toContain('max-w-full') + expect(classNames).not.toContain('w-full') + }) +}) diff --git a/web/src/features/skill/markdown-renderer.tsx b/web/src/features/skill/markdown-renderer.tsx index 0a9931a3..12c18cce 100644 --- a/web/src/features/skill/markdown-renderer.tsx +++ b/web/src/features/skill/markdown-renderer.tsx @@ -6,6 +6,8 @@ import { cn } from '@/shared/lib/utils' import { remarkInferCodeLanguage } from './code-language' import { stripMarkdownFrontmatter } from './markdown-frontmatter' +export const MARKDOWN_IMAGE_CLASS_NAME = 'h-auto max-w-full' + interface MarkdownRendererProps { content: string className?: string @@ -181,7 +183,7 @@ export function MarkdownRenderer({ content, className }: MarkdownRendererProps) ), img: ({ className: imageClassName, alt, ...props }) => ( - {alt + {alt ), }} > diff --git a/web/src/features/skill/skill-card.tsx b/web/src/features/skill/skill-card.tsx index d05e1bf2..648a4d3b 100644 --- a/web/src/features/skill/skill-card.tsx +++ b/web/src/features/skill/skill-card.tsx @@ -1,9 +1,6 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' import type { SkillSummary } from '@/api/types' import { useAuth } from '@/features/auth/use-auth' -import { useStar, useToggleStar } from '@/features/social/use-star' -import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { useStar } from '@/features/social/use-star' import { Card } from '@/shared/ui/card' import { NamespaceBadge } from '@/shared/components/namespace-badge' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' @@ -17,29 +14,13 @@ interface SkillCardProps { } export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCardProps) { - const { t } = useTranslation() const { isAuthenticated } = useAuth() - const [confirmOpen, setConfirmOpen] = useState(false) const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated) - const toggleStarMutation = useToggleStar(skill.id) - const showStarredBadge = highlightStarred && isAuthenticated && starStatus?.starred + const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred const headlineVersion = getHeadlineVersion(skill) - const handleStarredBadgeClick = (event: React.MouseEvent) => { - event.stopPropagation() - setConfirmOpen(true) - } - - const handleConfirmUnstar = async () => { - if (!starStatus?.starred) { - return - } - await toggleStarMutation.mutateAsync(starStatus.starred) - } - return ( - <> -
- {showStarredBadge ? ( - - ) : null}
@@ -87,10 +56,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard {formatCompactCount(skill.downloadCount)} - + {skill.starCount} {skill.ratingAvg !== undefined && skill.ratingCount > 0 && ( @@ -104,15 +72,5 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
- - - ) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c16423fc..78eda345 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -588,6 +588,7 @@ "documentationSource": "Source: {{path}}", "documentationUnavailableTitle": "Documentation is unavailable", "documentationUnavailable": "The documentation file could not be loaded. You can still inspect the package contents in the file list.", + "authorLabel": "By {{name}}", "expandOverview": "Expand full overview", "collapseOverview": "Collapse content", "noDocumentationTitle": "No package documentation", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 54641502..d12e9ff7 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -588,6 +588,7 @@ "documentationSource": "来源:{{path}}", "documentationUnavailableTitle": "文档暂时不可用", "documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。", + "authorLabel": "作者 {{name}}", "expandOverview": "展开全文", "collapseOverview": "收起内容", "noDocumentationTitle": "这个版本没有概览文档", diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index c51e9630..45cebb24 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -68,6 +68,19 @@ function parseMetadataJson(parsed?: string) { } } +function getAuthorMonogram(name?: string) { + if (!name) { + return '?' + } + + const trimmed = name.trim() + if (!trimmed) { + return '?' + } + + return trimmed[0]!.toUpperCase() +} + function getPromotionConflictKey(error: ApiError): 'promotion.duplicate_pending' | 'promotion.already_promoted' | null { if (error.serverMessageKey === 'promotion.duplicate_pending') { return 'promotion.duplicate_pending' @@ -547,7 +560,17 @@ export function SkillDetailPage() { )} -

{skill.displayName}

+

{skill.displayName}

+ {skill.ownerDisplayName && ( +
+
+ + {getAuthorMonogram(skill.ownerDisplayName)} + + {t('skillDetail.authorLabel', { name: skill.ownerDisplayName })} +
+
+ )} {skill.summary && (

{skill.summary}

)}