From 556e65efef9357270ea1ef528eb7b347caa3db46 Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 14:57:43 +0800 Subject: [PATCH] feat: complete skill promotion submission flow --- scripts/promotion-smoke-test.sh | 195 ++++++++++++++++++ .../controller/portal/SkillController.java | 2 + .../skillhub/dto/SkillDetailResponse.java | 2 + .../skillhub/dto/SkillSummaryResponse.java | 4 +- .../skillhub/service/MySkillAppService.java | 43 ++-- .../service/SkillSearchAppService.java | 4 +- .../compat/ClawHubCompatControllerTest.java | 4 +- .../controller/SkillControllerTest.java | 4 + .../service/MySkillAppServiceTest.java | 30 +++ .../skill/service/SkillQueryService.java | 23 +++ .../skill/service/SkillQueryServiceTest.java | 30 +++ web/src/api/client.ts | 10 + web/src/api/types.ts | 10 + web/src/i18n/locales/en.json | 18 ++ web/src/i18n/locales/zh.json | 18 ++ web/src/pages/dashboard/my-skills.tsx | 68 +++++- web/src/pages/skill-detail.tsx | 63 +++++- web/src/shared/hooks/use-skill-queries.ts | 24 ++- 18 files changed, 533 insertions(+), 19 deletions(-) create mode 100755 scripts/promotion-smoke-test.sh diff --git a/scripts/promotion-smoke-test.sh b/scripts/promotion-smoke-test.sh new file mode 100755 index 00000000..b65e23ef --- /dev/null +++ b/scripts/promotion-smoke-test.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +USER_COOKIE="$(mktemp)" +ADMIN_COOKIE="$(mktemp)" +WORK_DIR="$(mktemp -d)" +SLUG="psmoke$(date +%s)" + +cleanup() { + rm -f "$USER_COOKIE" "$ADMIN_COOKIE" + rm -rf "$WORK_DIR" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +csrf_token() { + local cookie_file="$1" + awk '$6 == "XSRF-TOKEN" { print $7 }' "$cookie_file" | tail -n 1 +} + +bootstrap_csrf() { + local cookie_file="$1" + local user_id="$2" + curl -s -c "$cookie_file" -H "X-Mock-User-Id: $user_id" "$BASE_URL/api/v1/auth/providers" >/dev/null +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split("."): + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +echo "=== Promotion Workflow Smoke Test ===" +echo "Target: $BASE_URL" +echo "Slug: $SLUG" +echo + +bootstrap_csrf "$USER_COOKIE" "local-user" +bootstrap_csrf "$ADMIN_COOKIE" "local-admin" + +USER_CSRF="$(csrf_token "$USER_COOKIE")" +ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")" + +if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then + echo "Could not bootstrap CSRF tokens" + exit 1 +fi + +GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/namespaces/global")" +assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0" +GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")" + +CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces" \ + -d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")" +assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0" +NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")" + +cat > "$WORK_DIR/SKILL.md" <<'EOF' +--- +name: Promotion Smoke Skill +description: Promotion smoke test +version: 1.0.0 +--- +Body +EOF +(cd "$WORK_DIR" && zip -q skill.zip SKILL.md) + +PUBLISH_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -F "file=@$WORK_DIR/skill.zip;type=application/zip" \ + -F "visibility=PUBLIC" \ + "$BASE_URL/api/web/skills/$SLUG/publish")" +assert_code "Owner can publish a team skill" "$PUBLISH_RESPONSE" "0" +SKILL_ID="$(json_field "$PUBLISH_RESPONSE" "data.skillId")" +SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" "data.slug")" + +PENDING_REVIEWS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$NAMESPACE_ID")" +assert_code "Admin can list pending namespace reviews" "$PENDING_REVIEWS_RESPONSE" "0" +REVIEW_ID="$(json_field "$PENDING_REVIEWS_RESPONSE" "data.items.0.id")" + +APPROVE_REVIEW_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/reviews/$REVIEW_ID/approve" \ + -d '{"comment":"ok"}')" +assert_code "Admin can approve team skill review" "$APPROVE_REVIEW_RESPONSE" "0" + +SKILL_DETAIL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/skills/$SLUG/$SKILL_SLUG")" +assert_code "Owner can load team skill detail" "$SKILL_DETAIL_RESPONSE" "0" +VERSION_ID="$(json_field "$SKILL_DETAIL_RESPONSE" "data.latestVersionId")" +CAN_SUBMIT_PROMOTION="$(json_field "$SKILL_DETAIL_RESPONSE" "data.canSubmitPromotion")" +if [[ "$CAN_SUBMIT_PROMOTION" == "True" || "$CAN_SUBMIT_PROMOTION" == "true" ]]; then + pass "Approved team skill is marked promotable" +else + fail "Approved team skill should expose canSubmitPromotion=true" +fi + +MY_SKILLS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + "$BASE_URL/api/web/me/skills")" +assert_code "Owner can list my skills with promotion metadata" "$MY_SKILLS_RESPONSE" "0" +if JSON_INPUT="$MY_SKILLS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"] +match = next(item for item in items if item["id"] == skill_id) +raise SystemExit(0 if match["canSubmitPromotion"] and match["latestVersionId"] else 1) +PY +then + pass "My skills response exposes promotion submission fields" +else + fail "My skills response should expose latestVersionId and canSubmitPromotion" +fi + +SUBMIT_PROMOTION_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ + -H "X-XSRF-TOKEN: $USER_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/promotions" \ + -d "{\"sourceSkillId\":$SKILL_ID,\"sourceVersionId\":$VERSION_ID,\"targetNamespaceId\":$GLOBAL_NAMESPACE_ID}")" +assert_code "Owner can submit promotion to global namespace" "$SUBMIT_PROMOTION_RESPONSE" "0" + +PENDING_PROMOTIONS_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + "$BASE_URL/api/web/promotions?status=PENDING")" +assert_code "Admin can list pending promotions" "$PENDING_PROMOTIONS_RESPONSE" "0" +if JSON_INPUT="$PENDING_PROMOTIONS_RESPONSE" python3 - "$SKILL_ID" <<'PY' +import json +import os +import sys + +skill_id = int(sys.argv[1]) +items = json.loads(os.environ["JSON_INPUT"])["data"]["items"] +raise SystemExit(0 if any(item["sourceSkillId"] == skill_id for item in items) else 1) +PY +then + pass "Pending promotions list contains the submitted team skill" +else + fail "Pending promotions list should include submitted team skill" +fi + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi 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 b2ad113d..49e6491c 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 @@ -70,8 +70,10 @@ public class SkillController extends BaseApiController { detail.ratingCount(), detail.hidden(), detail.latestVersion(), + detail.latestVersionId(), namespace, detail.canManageLifecycle(), + detail.canSubmitPromotion(), detail.viewingVersionStatus(), detail.canInteract() ); 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 f7152d7b..566b2848 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 @@ -15,8 +15,10 @@ public record SkillDetailResponse( Integer ratingCount, boolean hidden, String latestVersion, + Long latestVersionId, String namespace, boolean canManageLifecycle, + boolean canSubmitPromotion, String viewingVersionStatus, boolean canInteract ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index 4cc2e91e..2a5c60bc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -14,7 +14,9 @@ public record SkillSummaryResponse( BigDecimal ratingAvg, Integer ratingCount, String latestVersion, + Long latestVersionId, String latestVersionStatus, String namespace, - LocalDateTime updatedAt + LocalDateTime updatedAt, + boolean canSubmitPromotion ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java index 1b465f07..73c65ba0 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/MySkillAppService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.service; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; @@ -50,15 +52,13 @@ public class MySkillAppService { .map(Skill::getNamespaceId) .distinct() .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() + Map namespacesById = namespaceIds.isEmpty() ? Map.of() : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap( - com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); return skills.stream() - .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById)) .toList(); } @@ -80,18 +80,16 @@ public class MySkillAppService { .map(Skill::getNamespaceId) .distinct() .toList(); - Map namespaceSlugsById = namespaceIds.isEmpty() + Map namespacesById = namespaceIds.isEmpty() ? Map.of() : namespaceRepository.findByIdIn(namespaceIds).stream() - .collect(Collectors.toMap( - com.iflytek.skillhub.domain.namespace.Namespace::getId, - com.iflytek.skillhub.domain.namespace.Namespace::getSlug)); + .collect(Collectors.toMap(com.iflytek.skillhub.domain.namespace.Namespace::getId, Function.identity())); return stars.stream() .sorted(Comparator.comparing(com.iflytek.skillhub.domain.social.SkillStar::getCreatedAt).reversed()) .map(star -> skillsById.get(star.getSkillId())) .filter(java.util.Objects::nonNull) - .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespaceSlugsById)) + .map(skill -> toSummaryResponse(skill, versionsBySkillId, namespacesById)) .toList(); } @@ -116,8 +114,9 @@ public class MySkillAppService { private SkillSummaryResponse toSummaryResponse( Skill skill, Map versionsBySkillId, - Map namespaceSlugsById) { + Map namespacesById) { SkillVersion latestVersion = versionsBySkillId.get(skill.getId()); + com.iflytek.skillhub.domain.namespace.Namespace namespace = namespacesById.get(skill.getNamespaceId()); return new SkillSummaryResponse( skill.getId(), @@ -130,12 +129,30 @@ public class MySkillAppService { skill.getRatingAvg(), skill.getRatingCount(), Optional.ofNullable(latestVersion).map(SkillVersion::getVersion).orElse(null), + Optional.ofNullable(latestVersion).map(SkillVersion::getId).orElse(null), Optional.ofNullable(latestVersion).map(SkillVersion::getStatus).map(Enum::name).orElse(null), - namespaceSlugsById.get(skill.getNamespaceId()), - skill.getUpdatedAt() + namespace != null ? namespace.getSlug() : null, + skill.getUpdatedAt(), + canSubmitPromotion(skill, latestVersion, namespace) ); } + private boolean canSubmitPromotion( + Skill skill, + SkillVersion latestVersion, + com.iflytek.skillhub.domain.namespace.Namespace namespace) { + if (namespace == null) { + return false; + } + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != com.iflytek.skillhub.domain.skill.SkillStatus.ACTIVE) { + return false; + } + return latestVersion != null && latestVersion.getStatus() == SkillVersionStatus.PUBLISHED; + } + private Map loadLatestRelevantVersions(java.util.Collection skills) { if (skills.isEmpty()) { return Map.of(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java index 2e92f77a..1e5da1c5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSearchAppService.java @@ -198,9 +198,11 @@ public class SkillSearchAppService { skill.getRatingAvg(), skill.getRatingCount(), latestVersion, + skill.getLatestVersionId(), latestVersion == null ? null : "PUBLISHED", namespaceSlug, - skill.getUpdatedAt() + skill.getUpdatedAt(), + false ); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 691ac8ad..36da9c89 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -62,9 +62,11 @@ class ClawHubCompatControllerTest { BigDecimal.valueOf(4.5), 2, "1.2.0", + 11L, "PUBLISHED", "global", - LocalDateTime.of(2026, 3, 13, 9, 0))), + LocalDateTime.of(2026, 3, 13, 9, 0), + false)), 1, 0, 20 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 fefca1cc..81047387 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 @@ -126,7 +126,9 @@ class SkillControllerTest { LocalDateTime.of(2026, 3, 15, 10, 0), LocalDateTime.of(2026, 3, 15, 10, 0), null, + 11L, true, + false, "PENDING_REVIEW", false )); @@ -135,6 +137,8 @@ class SkillControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.latestVersion").value("1.1.0")) + .andExpect(jsonPath("$.data.latestVersionId").value(11L)) + .andExpect(jsonPath("$.data.canSubmitPromotion").value(false)) .andExpect(jsonPath("$.data.viewingVersionStatus").value("PENDING_REVIEW")) .andExpect(jsonPath("$.data.canInteract").value(false)); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index d5cef846..352f26d1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -110,6 +110,36 @@ class MySkillAppServiceTest { assertThat(skills).hasSize(1); assertThat(skills.get(0).latestVersion()).isEqualTo("1.0.0"); + assertThat(skills.get(0).latestVersionId()).isEqualTo(11L); assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(skills.get(0).canSubmitPromotion()).isFalse(); + } + + @Test + void listMySkills_marksTeamPublishedSkillAsPromotable() { + Skill skill = new Skill(101L, "team-skill", "user-1", SkillVisibility.PUBLIC); + skill.setDisplayName("Team Skill"); + skill.setSummary("published"); + ReflectionTestUtils.setField(skill, "id", 2L); + ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 11, 0)); + + SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); + publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); + ReflectionTestUtils.setField(publishedVersion, "id", 22L); + ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30)); + + Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); + ReflectionTestUtils.setField(namespace, "id", 101L); + + given(skillRepository.findByOwnerId("user-1")).willReturn(List.of(skill)); + given(skillVersionRepository.findBySkillIdIn(List.of(2L))).willReturn(List.of(publishedVersion)); + given(namespaceRepository.findByIdIn(List.of(101L))).willReturn(List.of(namespace)); + + var skills = service.listMySkills("user-1"); + + assertThat(skills).hasSize(1); + assertThat(skills.get(0).latestVersionId()).isEqualTo(22L); + assertThat(skills.get(0).latestVersionStatus()).isEqualTo("PUBLISHED"); + assertThat(skills.get(0).canSubmitPromotion()).isTrue(); } } 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 b63e0053..a1827346 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 @@ -4,6 +4,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.namespace.NamespaceType; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.skill.*; @@ -71,7 +72,9 @@ public class SkillQueryService { java.time.LocalDateTime createdAt, java.time.LocalDateTime updatedAt, SkillVersion latestVersionEntity, + Long latestVersionId, boolean canManageLifecycle, + boolean canSubmitPromotion, String viewingVersionStatus, boolean canInteract ) {} @@ -137,7 +140,9 @@ public class SkillQueryService { skill.getCreatedAt(), skill.getUpdatedAt(), latestVersionEntity, + latestVersionEntity != null ? latestVersionEntity.getId() : null, canManageRestrictedSkill(skill, currentUserId, userNsRoles), + canSubmitPromotion(namespace, skill, latestVersionEntity, currentUserId, userNsRoles), latestVersionEntity != null ? latestVersionEntity.getStatus().name() : null, latestVersionEntity == null || latestVersionEntity.getStatus() == SkillVersionStatus.PUBLISHED ); @@ -481,6 +486,24 @@ public class SkillQueryService { || role == NamespaceRole.OWNER; } + private boolean canSubmitPromotion( + Namespace namespace, + Skill skill, + SkillVersion latestVersionEntity, + String currentUserId, + Map userNsRoles) { + if (namespace.getType() == NamespaceType.GLOBAL) { + return false; + } + if (namespace.getStatus() != NamespaceStatus.ACTIVE || skill.getStatus() != SkillStatus.ACTIVE) { + return false; + } + if (latestVersionEntity == null || latestVersionEntity.getStatus() != SkillVersionStatus.PUBLISHED) { + return false; + } + return canManageRestrictedSkill(skill, currentUserId, userNsRoles); + } + private boolean isOwner(Skill skill, String currentUserId) { return currentUserId != null && skill.getOwnerId().equals(currentUserId); } 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 dd9b40a9..9ef0ec6c 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 @@ -463,6 +463,35 @@ class SkillQueryServiceTest { assertTrue(result.canManageLifecycle()); } + @Test + void testGetSkillDetail_ShouldAllowPromotionForTeamOwnerOnPublishedSkill() throws Exception { + String namespaceSlug = "team-ns"; + String skillSlug = "team-skill"; + String userId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Team NS", userId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, userId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", userId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(Optional.of(skill)); + when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); + + assertEquals(11L, result.latestVersionId()); + assertTrue(result.canSubmitPromotion()); + } + @Test void testGetSkillDetail_ShouldNotFlagLifecyclePermissionForRegularViewer() throws Exception { String namespaceSlug = "test-ns"; @@ -483,6 +512,7 @@ class SkillQueryServiceTest { SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles); assertFalse(result.canManageLifecycle()); + assertFalse(result.canSubmitPromotion()); } @Test diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a46c7bd5..fb0b5b5b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -693,6 +693,16 @@ export const reviewApi = { } export const promotionApi = { + async submit(request: { sourceSkillId: number; sourceVersionId: number; targetNamespaceId: number }): Promise { + await fetchJson(`${WEB_API_PREFIX}/promotions`, { + method: 'POST', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async list(params: { status?: string; page?: number; size?: number }) { const searchParams = new URLSearchParams() searchParams.set('status', params.status ?? 'PENDING') diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b9fe330a..954bc531 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -139,9 +139,11 @@ export interface SkillSummary { ratingAvg?: number ratingCount: number latestVersion?: string + latestVersionId?: number latestVersionStatus?: string namespace: string updatedAt: string + canSubmitPromotion: boolean } export interface SkillDetail { @@ -157,12 +159,20 @@ export interface SkillDetail { ratingCount: number hidden: boolean latestVersion?: string + latestVersionId?: number namespace: string canManageLifecycle: boolean + canSubmitPromotion: boolean viewingVersionStatus?: string canInteract: boolean } +export interface SubmitPromotionRequest { + sourceSkillId: number + sourceVersionId: number + targetNamespaceId: number +} + export interface SkillVersion { id: number version: string diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 61749095..c27177a9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -256,6 +256,14 @@ "withdrawSuccessTitle": "Upload withdrawn", "withdrawSuccessDescription": "The pending version for \"{{skill}}\" has been withdrawn.", "withdrawErrorTitle": "Failed to withdraw upload", + "promoteToGlobal": "Promote to Global", + "promotionConfirmTitle": "Submit promotion request", + "promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?", + "promotionSuccessTitle": "Promotion request submitted", + "promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.", + "promotionDuplicateTitle": "Promotion already pending", + "promotionDuplicateDescription": "This version already has a pending promotion request.", + "promotionErrorTitle": "Failed to submit promotion request", "emptyTitle": "No skills yet", "emptyDescription": "Start publishing your first skill", "publishSkill": "Publish Skill" @@ -568,6 +576,16 @@ "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", "rereleaseErrorTitle": "Failed to re-release version", "yankVersion": "Yank Current Version", + "promoteToGlobal": "Promote to Global", + "promotionSectionTitle": "Promote to Global", + "promotionSectionDescription": "Submit the currently published version v{{version}} for review into the global namespace.", + "promotionConfirmTitle": "Submit promotion request", + "promotionConfirmDescription": "Submit v{{version}} of \"{{skill}}\" for promotion into the global namespace?", + "promotionSuccessTitle": "Promotion request submitted", + "promotionSuccessDescription": "v{{version}} of \"{{skill}}\" is now in the global promotion review queue.", + "promotionDuplicateTitle": "Promotion already pending", + "promotionDuplicateDescription": "This version already has a pending promotion request.", + "promotionErrorTitle": "Failed to submit promotion request", "reportSkill": "Report Skill", "reportDialogTitle": "Report skill", "reportDialogDescription": "Provide a reason so administrators can review and act on it quickly.", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 12f6879b..065a0150 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -256,6 +256,14 @@ "withdrawSuccessTitle": "已撤销上传", "withdrawSuccessDescription": "“{{skill}}”的待审核版本已撤销。", "withdrawErrorTitle": "撤销上传失败", + "promoteToGlobal": "申请提升到全局", + "promotionConfirmTitle": "确认提交提升申请", + "promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?", + "promotionSuccessTitle": "提升申请已提交", + "promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。", + "promotionDuplicateTitle": "已存在待处理提升申请", + "promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。", + "promotionErrorTitle": "提交提升申请失败", "emptyTitle": "还没有技能", "emptyDescription": "开始发布你的第一个技能吧", "publishSkill": "发布技能" @@ -568,6 +576,16 @@ "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", "yankVersion": "撤回当前版本", + "promoteToGlobal": "申请提升到全局", + "promotionSectionTitle": "提升到全局", + "promotionSectionDescription": "将当前已发布版本 v{{version}} 提交到全局空间审核。", + "promotionConfirmTitle": "确认提交提升申请", + "promotionConfirmDescription": "确认将“{{skill}}”的 v{{version}} 提交为提升到全局空间的申请吗?", + "promotionSuccessTitle": "提升申请已提交", + "promotionSuccessDescription": "“{{skill}}”的 v{{version}} 已提交到全局空间审核队列。", + "promotionDuplicateTitle": "已存在待处理提升申请", + "promotionDuplicateDescription": "这个版本已经有待处理的提升申请,无需重复提交。", + "promotionErrorTitle": "提交提升申请失败", "reportSkill": "举报技能", "reportDialogTitle": "举报技能", "reportDialogDescription": "请填写举报原因,帮助管理员快速判断和处理。", diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 84ab687d..2507918a 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -6,9 +6,20 @@ import { Card } from '@/shared/ui/card' import { EmptyState } from '@/shared/components/empty-state' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' -import { useArchiveSkill, useMySkills, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' +import { useArchiveSkill, useMySkills, useSubmitPromotion, useUnarchiveSkill, useWithdrawSkillReview } from '@/shared/hooks/use-skill-queries' import { formatCompactCount } from '@/shared/lib/number-format' import { toast } from '@/shared/lib/toast' +import { ApiError } from '@/api/client' + +function isDuplicatePromotionMessage(message?: string): boolean { + if (!message) { + return false + } + + return message.includes('promotion.duplicate_pending') + || message.includes('已有待处理的提升申请') + || message.includes('Duplicate pending promotion') +} export function MySkillsPage() { const navigate = useNavigate() @@ -16,10 +27,12 @@ export function MySkillsPage() { const [archiveTarget, setArchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [unarchiveTarget, setUnarchiveTarget] = useState<{ namespace: string; slug: string; name: string } | null>(null) const [withdrawTarget, setWithdrawTarget] = useState<{ namespace: string; slug: string; name: string; version: string } | null>(null) + const [promotionTarget, setPromotionTarget] = useState<{ skillId: number; versionId: number; name: string; version: string } | null>(null) const { data: skills, isLoading } = useMySkills() const archiveMutation = useArchiveSkill() const unarchiveMutation = useUnarchiveSkill() const withdrawMutation = useWithdrawSkillReview() + const submitPromotionMutation = useSubmitPromotion() const handleSkillClick = (namespace: string, slug: string) => { navigate({ to: `/space/${namespace}/${slug}` }) @@ -112,6 +125,30 @@ export function MySkillsPage() { } } + const handleSubmitPromotion = async () => { + if (!promotionTarget) { + return + } + try { + await submitPromotionMutation.mutateAsync({ + sourceSkillId: promotionTarget.skillId, + sourceVersionId: promotionTarget.versionId, + }) + toast.success( + t('mySkills.promotionSuccessTitle'), + t('mySkills.promotionSuccessDescription', { skill: promotionTarget.name, version: promotionTarget.version }), + ) + setPromotionTarget(null) + } catch (error) { + if (error instanceof ApiError && isDuplicatePromotionMessage(error.serverMessage || error.message)) { + toast.error(t('mySkills.promotionDuplicateTitle'), t('mySkills.promotionDuplicateDescription')) + return + } + toast.error(t('mySkills.promotionErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + if (isLoading) { return (
@@ -194,6 +231,22 @@ export function MySkillsPage() { > {t('mySkills.withdrawReview')} + ) : skill.canSubmitPromotion && skill.latestVersionId && skill.latestVersion ? ( + ) : skill.status === 'ARCHIVED' ? ( + + )} + {governanceVisible && (
{t('skillDetail.governance')}
@@ -680,6 +729,18 @@ export function SkillDetailPage() { + + { return namespaceApi.listMembers(slug) } +async function submitPromotion(params: { sourceSkillId: number; sourceVersionId: number }): Promise { + const globalNamespace = await namespaceApi.getDetail('global') + await promotionApi.submit({ + sourceSkillId: params.sourceSkillId, + sourceVersionId: params.sourceVersionId, + targetNamespaceId: globalNamespace.id, + }) +} + async function searchNamespaceMemberCandidates(params: { slug: string; search: string }): Promise { return namespaceApi.searchMemberCandidates(params.slug, params.search) } @@ -210,6 +219,19 @@ export function useNamespaceMemberCandidates(slug: string, search: string, enabl }) } +export function useSubmitPromotion() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: submitPromotion, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['promotions'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + }, + }) +} + function invalidateNamespaceQueries(queryClient: ReturnType, slug: string) { queryClient.invalidateQueries({ queryKey: ['namespaces', 'my'] }) queryClient.invalidateQueries({ queryKey: ['namespaces', slug] })