Merge codex/builtin-skills-content into codex/builtin-skills-release

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 18:27:25 +08:00
commit b20ad397ad
24 changed files with 7241 additions and 13 deletions

View file

@ -5,6 +5,8 @@ on:
branches: [main]
paths:
- 'docs/skillhub/**'
- 'weekly/**'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
permissions:
@ -34,8 +36,17 @@ jobs:
uses: actions/configure-pages@v4
- name: Install dependencies
run: cd docs/skillhub && npm ci
- name: Build and validate weekly reports
run: |
cd weekly
python3 scripts/build_site.py
python3 scripts/validate_site.py _site
- name: Build with VitePress
run: cd docs/skillhub && npm run build
- name: Add weekly reports to Pages artifact
run: |
mkdir -p docs/skillhub/.vitepress/dist/weekly
cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:

View file

@ -91,7 +91,9 @@ jobs:
filters: |
docs:
- 'docs/skillhub/**'
- 'weekly/**'
- '.github/workflows/pr-tests.yml'
- '.github/workflows/deploy-docs.yml'
- name: Set up Node.js
if: steps.changed.outputs.docs == 'true'
@ -108,3 +110,18 @@ jobs:
- name: Build VitePress site
if: steps.changed.outputs.docs == 'true'
run: cd docs/skillhub && npm run build
- name: Build and validate weekly reports
if: steps.changed.outputs.docs == 'true'
run: |
cd weekly
python3 scripts/build_site.py
python3 scripts/validate_site.py _site
- name: Assemble Pages artifact layout
if: steps.changed.outputs.docs == 'true'
run: |
mkdir -p docs/skillhub/.vitepress/dist/weekly
cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/
test -f docs/skillhub/.vitepress/dist/weekly/index.html
test -f docs/skillhub/.vitepress/dist/weekly/archive.html

4
.gitignore vendored
View file

@ -69,6 +69,7 @@ package-lock.json
.tmp/
tmp/
__pycache__/
weekly/_site/
# Git worktrees
.worktrees/
@ -84,6 +85,9 @@ docs/superpowers/
# Local workspace metadata
CLAUDE.md
# Local report-generation skill
.agents/skills/generate-skillhub-weekly-report/
# Helm chart dependencies
charts/skillhub/charts/*.tgz

View file

@ -24,6 +24,7 @@ export default defineConfig({
{ text: '首页', link: '/' },
{ text: '快速开始', link: '/quickstart' },
{ text: '功能指南', link: '/guide/skill-publish' },
{ text: '开源周报', link: 'https://iflytek.github.io/skillhub/weekly/' },
{ text: 'FAQ', link: '/faq' },
],
sidebar: [
@ -69,6 +70,7 @@ export default defineConfig({
{ text: 'Home', link: '/en/' },
{ text: 'Quick Start', link: '/en/quickstart' },
{ text: 'Guide', link: '/en/guide/skill-publish' },
{ text: 'Weekly Reports', link: 'https://iflytek.github.io/skillhub/weekly/' },
{ text: 'FAQ', link: '/en/faq' },
],
sidebar: [

View file

@ -98,7 +98,7 @@ public class SkillLifecycleAppService {
Map<Long, NamespaceRole> userNamespaceRoles,
AuditRequestContext auditContext) {
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = findVersion(skill.getId(), version);
SkillVersion skillVersion = findVersionForUpdate(skill.getId(), version);
skillGovernanceService.deleteVersion(
skill,
skillVersion,
@ -261,6 +261,13 @@ public class SkillLifecycleAppService {
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
}
private SkillVersion findVersionForUpdate(Long skillId, String version) {
return skillVersionRepository.findBySkillIdForUpdate(skillId).stream()
.filter(candidate -> candidate.getVersion().equals(version))
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", version));
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNamespaceRoles) {
return userNamespaceRoles != null ? userNamespaceRoles : Map.of();
}

View file

@ -144,7 +144,8 @@ class SkillLifecycleControllerTest {
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
.willReturn(skill);
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.0.0")).willReturn(java.util.Optional.of(version));
given(skillVersionRepository.findBySkillIdForUpdate(1L))
.willReturn(java.util.List.of(version));
mockMvc.perform(delete("/api/web/skills/global/demo-skill/versions/1.0.0")
.requestAttr("userId", "usr_1")

View file

@ -0,0 +1,144 @@
package com.iflytek.skillhub.controller.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.verify;
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.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class SkillVersionDeleteFlowIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private NamespaceRepository namespaceRepository;
@Autowired
private SkillRepository skillRepository;
@Autowired
private SkillVersionRepository skillVersionRepository;
@Autowired
private ReviewTaskRepository reviewTaskRepository;
@MockBean
private ObjectStorageService objectStorageService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@Test
void deleteRejectedVersion_removesOnlyItsReviewHistory() throws Exception {
String ownerId = "owner-1";
String suffix = UUID.randomUUID().toString().substring(0, 8);
Namespace namespace = namespaceRepository.save(
new Namespace("version-delete-" + suffix, "Version Delete " + suffix, ownerId)
);
Skill skill = new Skill(namespace.getId(), "demo-skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
skill.setCreatedBy(ownerId);
skill.setUpdatedBy(ownerId);
skill = skillRepository.save(skill);
SkillVersion rejectedVersion = new SkillVersion(skill.getId(), "1.0.0", ownerId);
rejectedVersion.setStatus(SkillVersionStatus.REJECTED);
rejectedVersion = skillVersionRepository.save(rejectedVersion);
SkillVersion retainedVersion = new SkillVersion(skill.getId(), "2.0.0", ownerId);
retainedVersion.setStatus(SkillVersionStatus.REJECTED);
retainedVersion = skillVersionRepository.save(retainedVersion);
ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
rejectedTask.setStatus(ReviewTaskStatus.REJECTED);
rejectedTask = reviewTaskRepository.save(rejectedTask);
ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId);
approvedTask.setStatus(ReviewTaskStatus.APPROVED);
approvedTask = reviewTaskRepository.save(approvedTask);
ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId);
retainedTask.setStatus(ReviewTaskStatus.REJECTED);
retainedTask = reviewTaskRepository.save(retainedTask);
Long skillId = skill.getId();
Long rejectedVersionId = rejectedVersion.getId();
mockMvc.perform(delete("/api/web/skills/{namespace}/{slug}/versions/{version}",
namespace.getSlug(), skill.getSlug(), rejectedVersion.getVersion())
.with(authentication(portalAuth(ownerId, "USER")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.skillId").value(skillId))
.andExpect(jsonPath("$.data.versionId").value(rejectedVersionId))
.andExpect(jsonPath("$.data.action").value("DELETE_VERSION"))
.andExpect(jsonPath("$.data.status").value("1.0.0"));
assertThat(skillVersionRepository.findById(rejectedVersion.getId())).isEmpty();
assertThat(skillVersionRepository.findById(retainedVersion.getId())).isPresent();
assertThat(reviewTaskRepository.findById(rejectedTask.getId())).isEmpty();
assertThat(reviewTaskRepository.findById(approvedTask.getId())).isEmpty();
assertThat(reviewTaskRepository.findById(retainedTask.getId())).isPresent();
verify(objectStorageService).deleteObjects(argThat(keys ->
keys.equals(List.of("packages/" + skillId + "/" + rejectedVersionId + "/bundle.zip"))
));
}
private UsernamePasswordAuthenticationToken portalAuth(String userId, String... roles) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,
userId,
userId + "@example.com",
"",
"session",
Set.of(roles)
);
List<SimpleGrantedAuthority> authorities = Arrays.stream(roles)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
return new UsernamePasswordAuthenticationToken(principal, null, authorities);
}
}

View file

@ -14,7 +14,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
@ -76,4 +78,50 @@ class SkillLifecycleAppServiceTest {
assertThat(response.status()).isEqualTo("ARCHIVED");
verify(skillGovernanceService).archiveSkill(11L, "owner-1", Map.of(7L, NamespaceRole.OWNER), "127.0.0.1", "JUnit", "cleanup");
}
@Test
void deleteVersion_locksAllSkillVersionsBeforeDelegatingLifecycleMutation() {
Namespace namespace = new Namespace("global", "Global", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 7L);
Skill skill = new Skill(7L, "demo-skill", "owner-1", SkillVisibility.PUBLIC);
ReflectionTestUtils.setField(skill, "id", 11L);
SkillVersion version = new SkillVersion(11L, "1.0.0", "owner-1");
ReflectionTestUtils.setField(version, "id", 13L);
version.setStatus(SkillVersionStatus.REJECTED);
SkillVersion retainedVersion = new SkillVersion(11L, "2.0.0", "owner-1");
ReflectionTestUtils.setField(retainedVersion, "id", 14L);
retainedVersion.setStatus(SkillVersionStatus.UPLOADED);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillSlugResolutionService.resolve(
7L,
"demo-skill",
"owner-1",
SkillSlugResolutionService.Preference.CURRENT_USER
)).thenReturn(skill);
when(skillVersionRepository.findBySkillIdForUpdate(11L))
.thenReturn(java.util.List.of(version, retainedVersion));
var response = service.deleteVersion(
"global",
"demo-skill",
"1.0.0",
"owner-1",
Map.of(7L, NamespaceRole.OWNER),
new AuditRequestContext("127.0.0.1", "JUnit")
);
assertThat(response.versionId()).isEqualTo(13L);
assertThat(response.action()).isEqualTo("DELETE_VERSION");
verify(skillVersionRepository).findBySkillIdForUpdate(11L);
verify(skillGovernanceService).deleteVersion(
skill,
version,
"owner-1",
Map.of(7L, NamespaceRole.OWNER),
"127.0.0.1",
"JUnit",
"global"
);
}
}

View file

@ -249,6 +249,11 @@ class ScanTaskConsumerLoggingTest {
throw new UnsupportedOperationException();
}
@Override
public List<SkillVersion> findBySkillIdForUpdate(Long skillId) {
throw new UnsupportedOperationException();
}
@Override
public List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) {
throw new UnsupportedOperationException();

View file

@ -415,6 +415,11 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public List<SkillVersion> findBySkillIdForUpdate(Long skillId) {
throw unsupported();
}
@Override
public List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) {
throw unsupported();

View file

@ -12,6 +12,7 @@ public interface SkillVersionRepository {
List<SkillVersion> findBySkillIdIn(List<Long> skillIds);
List<SkillVersion> findBySkillIdInAndStatus(List<Long> skillIds, SkillVersionStatus status);
List<SkillVersion> findBySkillId(Long skillId);
List<SkillVersion> findBySkillIdForUpdate(Long skillId);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status);
SkillVersion save(SkillVersion version);

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
@ -41,6 +42,7 @@ public class SkillGovernanceService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillFileRepository skillFileRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final ObjectStorageService objectStorageService;
private final AuditLogService auditLogService;
private final ApplicationEventPublisher eventPublisher;
@ -51,6 +53,7 @@ public class SkillGovernanceService {
public SkillGovernanceService(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
SkillFileRepository skillFileRepository,
ReviewTaskRepository reviewTaskRepository,
ObjectStorageService objectStorageService,
AuditLogService auditLogService,
ApplicationEventPublisher eventPublisher,
@ -60,6 +63,7 @@ public class SkillGovernanceService {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillFileRepository = skillFileRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.objectStorageService = objectStorageService;
this.auditLogService = auditLogService;
this.eventPublisher = eventPublisher;
@ -172,6 +176,8 @@ public class SkillGovernanceService {
throw new DomainBadRequestException("error.skill.version.delete.lastVersion", version.getVersion());
}
// Rejected versions retain terminal review history whose FK must not outlive the version.
reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId()));
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
List<String> storageKeys = new ArrayList<>();
files.stream()

View file

@ -5,40 +5,43 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.doThrow;
import static org.mockito.BDDMockito.given;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.event.SkillStatusChangedEvent;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.security.SecurityScanService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillFile;
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import java.util.Map;
import java.time.ZoneOffset;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@ExtendWith(MockitoExtension.class)
class SkillGovernanceServiceTest {
@ -52,6 +55,8 @@ class SkillGovernanceServiceTest {
@Mock
private SkillFileRepository skillFileRepository;
@Mock
private ReviewTaskRepository reviewTaskRepository;
@Mock
private ObjectStorageService objectStorageService;
@Mock
private AuditLogService auditLogService;
@ -70,6 +75,7 @@ class SkillGovernanceServiceTest {
skillRepository,
skillVersionRepository,
skillFileRepository,
reviewTaskRepository,
objectStorageService,
auditLogService,
eventPublisher,
@ -229,6 +235,35 @@ class SkillGovernanceServiceTest {
verify(auditLogService).record("owner", "DELETE_SKILL_VERSION", "SKILL_VERSION", 2L, null, "127.0.0.1", "JUnit", "{\"version\":\"1.0.0\"}");
}
@Test
void deleteVersion_removesReviewTasksBeforeRejectedVersion() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion rejectedVersion = new SkillVersion(1L, "1.0.0", "owner");
setField(rejectedVersion, "id", 2L);
rejectedVersion.setStatus(SkillVersionStatus.REJECTED);
SkillVersion otherVersion = new SkillVersion(1L, "2.0.0", "owner");
setField(otherVersion, "id", 3L);
otherVersion.setStatus(SkillVersionStatus.DRAFT);
given(skillVersionRepository.findBySkillId(1L))
.willReturn(java.util.List.of(rejectedVersion, otherVersion));
given(skillFileRepository.findByVersionId(2L)).willReturn(java.util.List.of());
service.deleteVersion(
skill,
rejectedVersion,
"owner",
Map.of(),
"127.0.0.1",
"JUnit",
"test-ns"
);
InOrder deletionOrder = inOrder(reviewTaskRepository, skillVersionRepository);
deletionOrder.verify(reviewTaskRepository).deleteBySkillVersionIdIn(java.util.List.of(2L));
deletionOrder.verify(skillVersionRepository).delete(rejectedVersion);
}
@Test
void deleteVersion_deletesStorageAfterCommitWhenSynchronizationIsActive() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
@ -309,10 +344,36 @@ class SkillGovernanceServiceTest {
assertThrows(DomainBadRequestException.class,
() -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"));
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList());
verify(skillVersionRepository, never()).delete(any());
verify(objectStorageService, never()).deleteObject(any());
}
@Test
void deleteVersion_rejectsUnauthorizedUserWithoutDeletingReviewTasks() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
setField(version, "id", 2L);
version.setStatus(SkillVersionStatus.REJECTED);
assertThrows(
DomainForbiddenException.class,
() -> service.deleteVersion(
skill,
version,
"member",
Map.of(1L, NamespaceRole.MEMBER),
"127.0.0.1",
"JUnit",
"test-ns"
)
);
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList());
verify(skillVersionRepository, never()).delete(any());
}
@Test
void deleteVersion_rejectsLastRemainingVersion() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
@ -326,6 +387,7 @@ class SkillGovernanceServiceTest {
() -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit", "test-ns"));
assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion");
verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(anyList());
verify(skillVersionRepository, never()).delete(any());
}

View file

@ -3,16 +3,22 @@ package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* JPA-backed repository for skill version history and status-oriented version queries.
*
* <p>The deletion lock uses explicit {@code FOR UPDATE} SQL because Hibernate's PostgreSQL dialect
* emits {@code FOR NO KEY UPDATE}, which H2's PostgreSQL compatibility mode cannot execute. It
* locks every version in stable ID order so concurrent deletions cannot both remove the last
* versions of one skill.
*/
@Repository
public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, Long>, SkillVersionRepository {
@ -22,6 +28,16 @@ public interface SkillVersionJpaRepository extends JpaRepository<SkillVersion, L
List<SkillVersion> findBySkillIdInAndStatusOrderByCreatedAtDesc(List<Long> skillIds, SkillVersionStatus status);
Optional<SkillVersion> findBySkillIdAndVersion(Long skillId, String version);
@Override
@Query(value = """
SELECT skill_version.*
FROM skill_version
WHERE skill_version.skill_id = :skillId
ORDER BY skill_version.id
FOR UPDATE
""", nativeQuery = true)
List<SkillVersion> findBySkillIdForUpdate(@Param("skillId") Long skillId);
@Override
default List<SkillVersion> findBySkillIdAndStatus(Long skillId, SkillVersionStatus status) {
return findBySkillIdAndStatusOrderByCreatedAtDesc(skillId, status);

26
weekly/README.md Normal file
View file

@ -0,0 +1,26 @@
# SkillHub Weekly Mirror
This directory is the reviewed mirror of the public
[`XiaoSeS/skillhub-weekly`](https://github.com/XiaoSeS/skillhub-weekly) site.
The standalone repository remains the authoritative content source.
The SkillHub documentation workflow builds this directory and places the result
under the existing VitePress Pages artifact:
- Latest report: `https://iflytek.github.io/skillhub/weekly/`
- Archive: `https://iflytek.github.io/skillhub/weekly/archive.html`
- Report: `https://iflytek.github.io/skillhub/weekly/reports/<week>/`
## Local validation
```bash
cd weekly
python3 scripts/sync_report_theme.py site/reports/*/index.html
python3 scripts/build_site.py
python3 scripts/validate_site.py _site
```
Do not edit `_site/`; it is ignored and rebuilt by CI. Update reports in the
standalone repository first, then copy `site/`, `assets/`, and `scripts/`
byte-for-byte into this directory so both published sites keep the same report
HTML, Notion-light theme, charts, and Tab behavior.

File diff suppressed because it is too large Load diff

193
weekly/scripts/build_site.py Executable file
View file

@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Build the SkillHub weekly report site from self-contained report files."""
from __future__ import annotations
import argparse
import json
import shutil
from html import escape
from pathlib import Path
def load_manifest(source: Path) -> tuple[str, list[dict[str, str]]]:
manifest_path = source / "reports.json"
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
latest = payload.get("latest")
reports = payload.get("reports")
if not isinstance(latest, str) or not latest:
raise ValueError("reports.json must define a non-empty latest week")
if not isinstance(reports, list) or not reports:
raise ValueError("reports.json must contain at least one report")
required = {"week", "title", "period", "snapshot", "path"}
normalized: list[dict[str, str]] = []
for index, report in enumerate(reports):
if not isinstance(report, dict) or not required.issubset(report):
missing = required - set(report) if isinstance(report, dict) else required
raise ValueError(f"report #{index + 1} is missing fields: {sorted(missing)}")
normalized.append({key: str(report[key]) for key in required})
weeks = {report["week"] for report in normalized}
if latest not in weeks:
raise ValueError(f"latest week {latest!r} is not present in reports")
return latest, sorted(normalized, key=lambda item: item["week"], reverse=True)
def render_archive(latest: str, reports: list[dict[str, str]]) -> str:
rows = "\n".join(
f""" <li>
<a class="report-link" href="./{escape(report['path'], quote=True)}">
<span class="report-copy">
<span class="report-kicker">
<span>{escape(report['week'])}</span>
{'<span class="latest">最新</span>' if report['week'] == latest else ''}
</span>
<strong>{escape(report['title'])}</strong>
<span class="report-meta">{escape(report['period'])} · 快照 {escape(report['snapshot'])}</span>
</span>
<span class="arrow" aria-hidden="true"></span>
</a>
</li>"""
for report in reports
)
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SkillHub 开源周报归档</title>
<style data-site-theme="notion-light">
:root {{
color-scheme: light;
--page:#fff;
--warm:#f6f5f4;
--ink:#0d0d0d;
--ink-soft:#31302e;
--muted:#615d59;
--faint:#76716c;
--line:#e5e3e1;
--blue:#0075de;
--blue-active:#005bab;
--green:#147a33;
--green-soft:#e9f7ec;
--focus:#097fe8;
}}
* {{ box-sizing: border-box; }}
body {{
margin:0;
background:var(--page);
color:var(--ink);
font:15px/1.65 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
-webkit-font-smoothing:antialiased;
}}
a {{ color:var(--blue); text-decoration:none; text-underline-offset:3px; }}
a:hover {{ color:var(--blue-active); }}
:focus-visible {{ outline:2px solid var(--focus); outline-offset:3px; }}
main {{ width:min(920px,100%); min-height:100vh; margin:0 auto; padding:44px 28px 56px; }}
.topline {{ display:flex; align-items:center; justify-content:space-between; gap:18px; margin-bottom:44px; }}
.brand {{ display:flex; align-items:center; gap:10px; color:var(--ink); }}
.brand-mark {{
display:inline-flex;
width:34px;
height:34px;
align-items:center;
justify-content:center;
border-radius:6px;
background:var(--ink-soft);
color:#fff;
font-size:14px;
font-weight:700;
letter-spacing:.04em;
}}
.brand-name {{ font-size:17px; font-weight:700; letter-spacing:-.01em; }}
.brand-tag {{ padding:4px 10px; border-radius:999px; background:#f1f0ef; color:var(--muted); font-size:12px; font-weight:600; }}
.utility {{ display:flex; flex-wrap:wrap; gap:16px; font-size:13px; }}
h1 {{ margin:0; font-size:clamp(32px,5vw,44px); line-height:1.15; letter-spacing:-.025em; }}
.intro {{ margin:9px 0 30px; color:var(--muted); }}
.archive-summary {{ margin-bottom:18px; padding:18px 20px; border-radius:8px; background:var(--warm); color:var(--ink-soft); }}
.archive-summary strong {{ color:var(--ink); }}
ul {{ margin:0; padding:0; overflow:hidden; border:1px solid var(--line); border-radius:12px; list-style:none; }}
li + li {{ border-top:1px solid var(--line); }}
.report-link {{ display:flex; align-items:center; justify-content:space-between; gap:24px; padding:20px 22px; color:var(--ink); }}
.report-link:hover {{ background:#faf9f8; text-decoration:none; }}
.report-copy {{ display:flex; min-width:0; flex-direction:column; gap:4px; }}
.report-kicker {{ display:flex; align-items:center; gap:8px; color:var(--faint); font-size:12px; font-weight:600; }}
.report-copy strong {{ color:var(--ink); font-size:17px; line-height:1.4; }}
.report-meta {{ color:var(--muted); font-size:12.5px; }}
.latest {{ padding:2px 8px; border-radius:999px; background:var(--green-soft); color:var(--green); font-size:11px; font-weight:700; }}
.arrow {{ flex:0 0 auto; color:var(--faint); font-size:20px; transition:transform .15s ease; }}
.report-link:hover .arrow {{ transform:translateX(3px); color:var(--ink); }}
.back {{ display:inline-block; margin-top:24px; font-size:13px; font-weight:600; }}
@media (max-width:600px) {{
main {{ padding:28px 18px 40px; }}
.topline {{ align-items:flex-start; flex-direction:column; gap:12px; margin-bottom:34px; }}
.report-link {{ align-items:flex-start; padding:18px; }}
.report-copy strong {{ font-size:15px; }}
}}
@media (prefers-reduced-motion:reduce) {{ .arrow {{ transition:none; }} }}
</style>
</head>
<body>
<main>
<div class="topline">
<div class="brand">
<span class="brand-mark" aria-hidden="true">SH</span>
<span class="brand-name">SkillHub</span>
<span class="brand-tag">开源周报</span>
</div>
<nav class="utility" aria-label="站点链接">
<a href="https://iflytek.github.io/skillhub/">项目文档</a>
<a href="https://github.com/iflytek/skillhub">GitHub 仓库</a>
</nav>
</div>
<h1>SkillHub 开源周报归档</h1>
<p class="intro">按统计周期倒序查看历期开源周报</p>
<div class="archive-summary">当前共收录 <strong>{len(reports)} </strong>最新一期为 <strong>{escape(latest)}</strong></div>
<ul>
{rows}
</ul>
<a class="back" href="./">返回最新周报</a>
</main>
</body>
</html>
"""
def build(source: Path, output: Path) -> None:
latest, reports = load_manifest(source)
latest_report = next(report for report in reports if report["week"] == latest)
latest_source = source / latest_report["path"] / "index.html"
if not latest_source.is_file():
raise FileNotFoundError(f"latest report not found: {latest_source}")
for report in reports:
report_file = source / report["path"] / "index.html"
if not report_file.is_file():
raise FileNotFoundError(f"report not found: {report_file}")
if output.exists():
shutil.rmtree(output)
shutil.copytree(source, output)
latest_html = latest_source.read_text(encoding="utf-8")
latest_html = latest_html.replace('href="../../archive.html"', 'href="./archive.html"')
(output / "index.html").write_text(latest_html, encoding="utf-8")
(output / "archive.html").write_text(
render_archive(latest, reports),
encoding="utf-8",
)
(output / ".nojekyll").write_text("", encoding="utf-8")
print(f"Built {len(reports)} report(s); latest={latest}; output={output}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=Path("site"))
parser.add_argument("--output", type=Path, default=Path("_site"))
args = parser.parse_args()
build(args.source.resolve(), args.output.resolve())
if __name__ == "__main__":
main()

View file

@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Inline the canonical Notion-light theme into self-contained weekly reports."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
THEME_PATTERN = re.compile(
r'(?P<open><style data-report-theme="notion-light">\n)'
r".*?"
r"(?P<close>\n[ \t]*</style>)",
flags=re.DOTALL,
)
def sync_theme(theme_path: Path, report_paths: list[Path]) -> None:
theme = theme_path.read_text(encoding="utf-8").rstrip()
for report_path in report_paths:
source = report_path.read_text(encoding="utf-8")
updated, replacements = THEME_PATTERN.subn(
lambda match: f"{match.group('open')}{theme}{match.group('close')}",
source,
)
if replacements != 1:
raise ValueError(
f"{report_path}: expected one notion-light theme block, "
f"found {replacements}"
)
report_path.write_text(updated, encoding="utf-8")
print(f"Synced theme: {report_path}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"reports",
type=Path,
nargs="+",
help="HTML report files containing a notion-light theme block",
)
parser.add_argument(
"--theme",
type=Path,
default=Path("assets/notion-light.css"),
help="canonical CSS file",
)
args = parser.parse_args()
sync_theme(args.theme.resolve(), [path.resolve() for path in args.reports])
if __name__ == "__main__":
main()

264
weekly/scripts/validate_site.py Executable file
View file

@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""Validate built routes and basic accessibility hooks for the weekly site."""
from __future__ import annotations
import argparse
import json
import sys
from html.parser import HTMLParser
from pathlib import Path
VOID_ELEMENTS = {
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
}
NON_CONTENT_ELEMENTS = {"caption", "h1", "h2", "h3", "h4", "h5", "h6", "th"}
ALLOWED_PANELS = {"panel-overview", "panel-health", "panel-flow", "panel-method"}
class PageParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.h1_count = 0
self.tabs = 0
self.panels = 0
self.tab_controls: set[str] = set()
self.panel_ids: set[str] = set()
self.panel_modules: dict[str, int] = {}
self.current_panel: str | None = None
self.panel_depth = 0
self.module_stack: list[dict[str, object]] = []
self.module_counts: dict[str, int] = {}
self.module_panels: dict[str, set[str]] = {}
self.module_names: set[str] = set()
self.empty_modules: set[str] = set()
self.table_stack: list[dict[str, int]] = []
self.empty_table_count = 0
self.ids: set[str] = set()
self.duplicate_ids: set[str] = set()
self.external_assets: list[str] = []
self.non_content_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
is_void = tag in VOID_ELEMENTS
if self.current_panel is not None and not is_void:
self.panel_depth += 1
if not is_void:
for module in self.module_stack:
module["depth"] = int(module["depth"]) + 1
for table in self.table_stack:
table["depth"] += 1
if tag in NON_CONTENT_ELEMENTS:
self.non_content_depth += 1
values = dict(attrs)
element_id = values.get("id")
if element_id:
if element_id in self.ids:
self.duplicate_ids.add(element_id)
self.ids.add(element_id)
if tag == "h1":
self.h1_count += 1
if values.get("role") == "tab":
self.tabs += 1
controls = values.get("aria-controls")
if controls:
self.tab_controls.add(controls)
if values.get("role") == "tabpanel":
self.panels += 1
panel_id = values.get("id") or ""
if panel_id:
self.panel_ids.add(panel_id)
self.panel_modules.setdefault(panel_id, 0)
self.current_panel = panel_id
self.panel_depth = 1
if tag == "table":
self.table_stack.append({"depth": 1, "data_cells": 0})
elif tag == "td":
for table in self.table_stack:
table["data_cells"] += 1
module_name = values.get("data-module")
if module_name:
self.module_names.add(module_name)
self.module_counts[module_name] = self.module_counts.get(module_name, 0) + 1
if self.current_panel:
self.module_panels.setdefault(module_name, set()).add(self.current_panel)
if is_void:
self.empty_modules.add(module_name)
else:
self.module_stack.append(
{"depth": 1, "name": module_name, "has_meaningful_content": False}
)
if self.current_panel:
self.panel_modules[self.current_panel] = self.panel_modules.get(self.current_panel, 0) + 1
if tag == "script" and values.get("src"):
self.external_assets.append(values["src"] or "")
if tag == "link" and "stylesheet" in (values.get("rel") or ""):
self.external_assets.append(values.get("href") or "")
def handle_startendtag(
self, tag: str, attrs: list[tuple[str, str | None]]
) -> None:
self.handle_starttag(tag, attrs)
def handle_data(self, data: str) -> None:
if data.strip() and not self.non_content_depth:
for module in self.module_stack:
module["has_meaningful_content"] = True
def handle_endtag(self, tag: str) -> None:
if tag in NON_CONTENT_ELEMENTS and self.non_content_depth:
self.non_content_depth -= 1
for module in self.module_stack:
module["depth"] = int(module["depth"]) - 1
while self.module_stack and int(self.module_stack[-1]["depth"]) == 0:
module = self.module_stack.pop()
if not module["has_meaningful_content"]:
self.empty_modules.add(str(module["name"]))
for table in self.table_stack:
table["depth"] -= 1
while self.table_stack and self.table_stack[-1]["depth"] == 0:
table = self.table_stack.pop()
if table["data_cells"] == 0:
self.empty_table_count += 1
if self.current_panel is not None:
self.panel_depth -= 1
if self.panel_depth == 0:
self.current_panel = None
def validate(root: Path) -> list[str]:
errors: list[str] = []
required = (root / "index.html", root / "archive.html", root / ".nojekyll")
for path in required:
if not path.exists():
errors.append(f"missing built route: {path}")
manifest_path = root / "reports.json"
if not manifest_path.is_file():
errors.append(f"missing manifest: {manifest_path}")
return errors
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
latest = payload.get("latest")
report_paths: list[Path] = []
for report in payload.get("reports", []):
report_file = root / str(report.get("path", "")) / "index.html"
if not report_file.is_file():
errors.append(f"missing report: {report_file}")
else:
report_paths.append(report_file)
if not latest:
errors.append("manifest latest is empty")
archive_path = root / "archive.html"
if archive_path.is_file():
archive_source = archive_path.read_text(encoding="utf-8")
archive_parser = PageParser()
archive_parser.feed(archive_source)
if archive_parser.h1_count != 1:
errors.append(
f"{archive_path}: expected one h1, found {archive_parser.h1_count}"
)
if 'data-site-theme="notion-light"' not in archive_source:
errors.append(f"{archive_path}: missing notion-light theme marker")
report_link_count = archive_source.count('class="report-link"')
if report_link_count != len(payload.get("reports", [])):
errors.append(
f"{archive_path}: expected one archive link per report, "
f"found {report_link_count}"
)
if archive_parser.external_assets:
errors.append(
f"{archive_path}: external assets are not allowed: "
f"{archive_parser.external_assets}"
)
pages_to_validate = [root / "index.html", *report_paths]
for index_path in pages_to_validate:
if not index_path.is_file():
continue
parser = PageParser()
parser.feed(index_path.read_text(encoding="utf-8"))
if parser.h1_count != 1:
errors.append(f"{index_path}: expected one h1, found {parser.h1_count}")
if not 2 <= parser.tabs <= 4 or parser.tabs != parser.panels:
errors.append(
f"{index_path}: expected two to four matching report tabs/panels, "
f"found {parser.tabs}/{parser.panels}"
)
if parser.tab_controls != parser.panel_ids:
errors.append(f"{index_path}: tab aria-controls values do not match panel ids")
if not {"panel-overview", "panel-method"}.issubset(parser.panel_ids):
errors.append(f"{index_path}: overview and data panels are required")
unexpected_panels = sorted(parser.panel_ids - ALLOWED_PANELS)
if unexpected_panels:
errors.append(f"{index_path}: unexpected panels: {unexpected_panels}")
if "repository-summary" not in parser.module_names:
errors.append(f"{index_path}: missing required repository-summary module")
elif parser.module_panels.get("repository-summary") != {"panel-overview"}:
errors.append(
f"{index_path}: repository-summary must appear in panel-overview"
)
duplicate_modules = sorted(
name for name, count in parser.module_counts.items() if count > 1
)
if duplicate_modules:
errors.append(f"{index_path}: duplicate module names: {duplicate_modules}")
if parser.empty_modules:
errors.append(
f"{index_path}: modules without meaningful content: "
f"{sorted(parser.empty_modules)}"
)
if parser.empty_table_count:
errors.append(
f"{index_path}: empty tables without data cells: "
f"{parser.empty_table_count}"
)
if parser.module_stack:
errors.append(f"{index_path}: unclosed data-module element")
if parser.table_stack:
errors.append(f"{index_path}: unclosed table element")
empty_panels = sorted(
panel_id for panel_id, module_count in parser.panel_modules.items() if module_count == 0
)
if empty_panels:
errors.append(f"{index_path}: panels without modules: {empty_panels}")
if parser.duplicate_ids:
errors.append(f"{index_path}: duplicate ids: {sorted(parser.duplicate_ids)}")
if parser.external_assets:
errors.append(f"{index_path}: external assets are not allowed: {parser.external_assets}")
if "{{" in index_path.read_text(encoding="utf-8"):
errors.append(f"{index_path}: unresolved template placeholder")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("site", type=Path, nargs="?", default=Path("_site"))
args = parser.parse_args()
errors = validate(args.site.resolve())
for error in errors:
print(f"ERROR: {error}")
if errors:
print(f"FAIL: {len(errors)} error(s)")
return 1
print(f"PASS: {args.site.resolve()}")
return 0
if __name__ == "__main__":
sys.exit(main())

26
weekly/site/reports.json Normal file
View file

@ -0,0 +1,26 @@
{
"latest": "2026-W31",
"reports": [
{
"week": "2026-W31",
"title": "SkillHub 开源周报2026 年第 31 周",
"period": "2026-07-23—2026-07-30",
"snapshot": "2026-07-30 16:36 Asia/Shanghai",
"path": "reports/2026-W31/"
},
{
"week": "2026-W30",
"title": "SkillHub 开源周报2026 年第 30 周",
"period": "2026-07-16—2026-07-23",
"snapshot": "2026-07-23 22:00 Asia/Shanghai",
"path": "reports/2026-W30/"
},
{
"week": "2026-W29",
"title": "SkillHub 开源周报2026 年第 29 周",
"period": "2026-07-09—2026-07-16",
"snapshot": "2026-07-23 22:00 Asia/Shanghai",
"path": "reports/2026-W29/"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

4
weekly/source.json Normal file
View file

@ -0,0 +1,4 @@
{
"repository": "https://github.com/XiaoSeS/skillhub-weekly.git",
"commit": "81cabe5ba7a73a938e06bb986d20aaed28fa9695"
}