Merge branch 'main' into feat-0315

This commit is contained in:
xiose 2026-03-19 10:08:06 +08:00
commit e7b452f428
17 changed files with 169 additions and 54 deletions

View file

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

View file

@ -67,6 +67,7 @@ public class SkillController extends BaseApiController {
detail.id(),
detail.slug(),
detail.displayName(),
detail.ownerDisplayName(),
detail.summary(),
detail.visibility(),
detail.status(),

View file

@ -6,6 +6,7 @@ public record SkillDetailResponse(
Long id,
String slug,
String displayName,
String ownerDisplayName,
String summary,
String visibility,
String status,

View file

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

View file

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

View file

@ -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(),

View file

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

View file

@ -157,6 +157,7 @@ export interface SkillDetail {
id: number
slug: string
displayName: string
ownerDisplayName?: string
summary?: string
visibility: string
status: string

View 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')
})
})

View file

@ -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} />
),
}}
>

View file

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

View file

@ -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",

View file

@ -588,6 +588,7 @@
"documentationSource": "来源:{{path}}",
"documentationUnavailableTitle": "文档暂时不可用",
"documentationUnavailable": "当前无法读取这个技能版本的文档文件。你仍然可以在文件列表里查看包内容。",
"authorLabel": "作者 {{name}}",
"expandOverview": "展开全文",
"collapseOverview": "收起内容",
"noDocumentationTitle": "这个版本没有概览文档",

View file

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