fix: skill upload constraints, rejected label, and last-version guard (#82)

* fix: keep download counts consistent across skill pages

* fix: stabilize empty search ordering across sorts

* fix: show disabled-account reason on login redirect

* fix: mute report input placeholder text

* fix: return skill detail to my skills page

* test: stabilize auth context filter coverage

* feat(publish): increase single file limit to 10MB

* feat(publish): expand allowed file extensions

* feat(publish): extend secret scanning to new text file types

* feat(publish): add content validation for new file types

* refactor(publish): inject configurable limits into SkillPackageArchiveExtractor

* feat(publish): support zip with single root directory wrapper

* feat(publish): expand determineContentType for new file types

* test(publish): update tests for new upload constraints

* fix(web): add REJECTED status label and styling to my-skills page

The resolveStatusLabel and resolveStatusClassName functions were missing
the REJECTED case, causing rejected skills to show raw status string
with no color styling.


* fix: prevent deleting the last remaining version of a skill

Backend: added version count check in SkillGovernanceService.deleteVersion()
Frontend: hide delete button when only one version remains


* test: fix and add tests for last-version deletion guard

- Fix deleteVersion_removesDraftFilesAndBundle: mock findBySkillId to
  return 2 versions so the new guard doesn't block the happy path
- Add deleteVersion_rejectsLastRemainingVersion: verify that deleting
  the only remaining version is rejected with the correct error code

---------
This commit is contained in:
yun-zhi-ztl 2026-03-18 16:40:51 +08:00 committed by GitHub
parent b7fd95459c
commit 6b2d8ce7ef
9 changed files with 37 additions and 1 deletions

View file

@ -94,6 +94,7 @@ error.skill.version.exists=Version already exists: {0}
error.skill.version.notFound=Version not found: {0}
error.skill.version.notPublished=Version is not published: {0}
error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0}
error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0}
error.skill.report.reason.required=Please provide a report reason
error.skill.report.unavailable=This skill cannot be reported right now: {0}
error.skill.report.self=You cannot report your own skill

View file

@ -94,6 +94,7 @@ error.skill.version.exists=版本已存在:{0}
error.skill.version.notFound=未找到版本:{0}
error.skill.version.notPublished=版本未发布:{0}
error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0}
error.skill.version.delete.lastVersion=无法删除最后一个版本:{0}
error.skill.report.reason.required=请填写举报原因
error.skill.report.unavailable=当前无法举报该技能:{0}
error.skill.report.self=不能举报自己发布的技能

View file

@ -141,6 +141,11 @@ public class SkillGovernanceService {
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
}
long versionCount = skillVersionRepository.findBySkillId(skill.getId()).size();
if (versionCount <= 1) {
throw new DomainBadRequestException("error.skill.version.delete.lastVersion", version.getVersion());
}
List<SkillFile> files = skillFileRepository.findByVersionId(version.getId());
if (!files.isEmpty()) {
objectStorageService.deleteObjects(files.stream().map(SkillFile::getStorageKey).toList());

View file

@ -181,6 +181,10 @@ class SkillGovernanceServiceTest {
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
setField(version, "id", 2L);
version.setStatus(SkillVersionStatus.DRAFT);
SkillVersion otherVersion = new SkillVersion(2L, "2.0.0", "owner");
setField(otherVersion, "id", 3L);
otherVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version, otherVersion));
SkillFile readme = new SkillFile(version.getId(), "README.md", 10L, "text/markdown", "sha1", "skills/demo/readme");
SkillFile icon = new SkillFile(version.getId(), "icon.png", 20L, "image/png", "sha2", "skills/demo/icon");
given(skillFileRepository.findByVersionId(version.getId())).willReturn(java.util.List.of(readme, icon));
@ -212,6 +216,21 @@ class SkillGovernanceServiceTest {
verify(objectStorageService, never()).deleteObject(any());
}
@Test
void deleteVersion_rejectsLastRemainingVersion() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion version = new SkillVersion(2L, "1.0.0", "owner");
setField(version, "id", 2L);
version.setStatus(SkillVersionStatus.DRAFT);
given(skillVersionRepository.findBySkillId(1L)).willReturn(java.util.List.of(version));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class,
() -> service.deleteVersion(skill, version, "owner", Map.of(), "127.0.0.1", "JUnit"));
assertThat(ex.messageCode()).isEqualTo("error.skill.version.delete.lastVersion");
verify(skillVersionRepository, never()).delete(any());
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);

View file

@ -289,6 +289,7 @@
"statusArchived": "Archived",
"statusPendingReview": "Pending Review",
"statusPublished": "Published",
"statusRejected": "Rejected",
"archiveConfirmTitle": "Archive skill",
"archiveConfirmDescription": "After archiving, regular users will no longer be able to view or download \"{{skill}}\". Continue?",
"unarchiveConfirmTitle": "Restore skill",

View file

@ -289,6 +289,7 @@
"statusArchived": "已归档",
"statusPendingReview": "审核中",
"statusPublished": "已发布",
"statusRejected": "已拒绝",
"archiveConfirmTitle": "确认归档技能",
"archiveConfirmDescription": "归档后普通用户将无法看到或下载“{{skill}}”,确定继续吗?",
"unarchiveConfirmTitle": "确认恢复技能",

View file

@ -344,6 +344,7 @@
.status-pill--published { background: #16a34a; }
.status-pill--review { background: #ea580c; }
.status-pill--archived { background: #6b7280; }
.status-pill--rejected { background: #dc2626; }
/* ─── Role pill ─── */
.role-pill {

View file

@ -58,6 +58,9 @@ export function MySkillsPage() {
if (status === 'PUBLISHED') {
return t('mySkills.statusPublished')
}
if (status === 'REJECTED') {
return t('mySkills.statusRejected')
}
return status
}
@ -71,6 +74,9 @@ export function MySkillsPage() {
if (status === 'PUBLISHED') {
return 'status-pill status-pill--published'
}
if (status === 'REJECTED') {
return 'status-pill status-pill--rejected'
}
return 'status-pill'
}

View file

@ -307,6 +307,7 @@ export function SkillDetailPage() {
}
const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED'
const isLastVersion = versions?.length === 1
const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW'
const canRereleaseVersion = (status?: string) => status === 'PUBLISHED'
@ -687,7 +688,7 @@ export function SkillDetailPage() {
</Button>
</>
)}
{skill.canManageLifecycle && canDeleteVersion(version.status) && (
{skill.canManageLifecycle && canDeleteVersion(version.status) && !isLastVersion && (
<Button
size="sm"
variant="outline"