mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
Merge branch 'main' into feat-0315
This commit is contained in:
commit
e7b452f428
17 changed files with 169 additions and 54 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public class SkillController extends BaseApiController {
|
|||
detail.id(),
|
||||
detail.slug(),
|
||||
detail.displayName(),
|
||||
detail.ownerDisplayName(),
|
||||
detail.summary(),
|
||||
detail.visibility(),
|
||||
detail.status(),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ public record SkillDetailResponse(
|
|||
Long id,
|
||||
String slug,
|
||||
String displayName,
|
||||
String ownerDisplayName,
|
||||
String summary,
|
||||
String visibility,
|
||||
String status,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(?<version>\\d+)__(?<description>.+)\\.sql$");
|
||||
|
||||
@Test
|
||||
void versionedMigrations_mustUseUniqueVersions() throws IOException {
|
||||
Map<Integer, List<String>> 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<String> 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<Integer> 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<String> 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<String> invalidFiles = migrationFiles().stream()
|
||||
.map(path -> path.getFileName().toString())
|
||||
.filter(name -> !VERSIONED_MIGRATION_PATTERN.matcher(name).matches())
|
||||
.sorted()
|
||||
.toList();
|
||||
|
||||
assertThat(invalidFiles).isEmpty();
|
||||
}
|
||||
|
||||
private List<Path> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export interface SkillDetail {
|
|||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
ownerDisplayName?: string
|
||||
summary?: string
|
||||
visibility: string
|
||||
status: string
|
||||
|
|
|
|||
12
web/src/features/skill/markdown-renderer.test.tsx
Normal file
12
web/src/features/skill/markdown-renderer.test.tsx
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
|||
</td>
|
||||
),
|
||||
img: ({ className: imageClassName, alt, ...props }) => (
|
||||
<img className={cn('w-full', imageClassName)} alt={alt ?? ''} {...props} />
|
||||
<img className={cn(MARKDOWN_IMAGE_CLASS_NAME, imageClassName)} alt={alt ?? ''} {...props} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmUnstar = async () => {
|
||||
if (!starStatus?.starred) {
|
||||
return
|
||||
}
|
||||
await toggleStarMutation.mutateAsync(starStatus.starred)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
<Card
|
||||
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{ borderColor: 'hsl(var(--border-card))' }}
|
||||
onClick={onClick}
|
||||
|
|
@ -52,18 +33,6 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showStarredBadge ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-primary/25 bg-primary/12 px-2.5 py-1 text-[11px] font-semibold text-primary shadow-sm transition-colors hover:bg-primary/18"
|
||||
aria-label={t('skillCard.starred')}
|
||||
title={t('skillCard.starredAction')}
|
||||
onClick={handleStarredBadgeClick}
|
||||
>
|
||||
<Bookmark className="h-3.5 w-3.5 fill-current" />
|
||||
{t('skillCard.starred')}
|
||||
</button>
|
||||
) : null}
|
||||
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -87,10 +56,9 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
{formatCompactCount(skill.downloadCount)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}
|
||||
aria-label={showStarredBadge ? t('skillCard.starred') : undefined}
|
||||
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredBadge ? 'fill-current' : ''}`} />
|
||||
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
|
||||
{skill.starCount}
|
||||
</span>
|
||||
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
|
||||
|
|
@ -104,15 +72,5 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
title={t('skillCard.unstarTitle')}
|
||||
description={t('skillCard.unstarDescription', { name: skill.displayName })}
|
||||
confirmText={t('skillCard.unstarConfirm')}
|
||||
onConfirm={handleConfirmUnstar}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -588,6 +588,7 @@
|
|||
"documentationSource": "来源:{{path}}",
|
||||
"documentationUnavailableTitle": "文档暂时不可用",
|
||||
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
|
||||
"authorLabel": "作者 {{name}}",
|
||||
"expandOverview": "展开全文",
|
||||
"collapseOverview": "收起内容",
|
||||
"noDocumentationTitle": "这个版本没有概览文档",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
<h1 className="text-balance text-4xl font-bold font-heading text-foreground">{skill.displayName}</h1>
|
||||
{skill.ownerDisplayName && (
|
||||
<div className="flex min-w-0">
|
||||
<div className="inline-flex max-w-full items-center gap-2 rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-sm text-muted-foreground shadow-sm backdrop-blur-sm">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-[0.08em] text-primary">
|
||||
{getAuthorMonogram(skill.ownerDisplayName)}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{t('skillDetail.authorLabel', { name: skill.ownerDisplayName })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{skill.summary && (
|
||||
<p className="text-lg text-muted-foreground leading-relaxed">{skill.summary}</p>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue