diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java index f8dbd5b39..f5869eb17 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MeController.java @@ -56,4 +56,16 @@ public ApiResponse> listMyStars( return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size)); } + + @GetMapping("/subscriptions") + public ApiResponse> listMySubscriptions( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "12") int size, + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + throw new UnauthorizedException("error.auth.required"); + } + + return ok("response.success.read", mySkillAppService.listMySubscriptions(principal.userId(), page, size)); + } } 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 3e4800601..e0788b442 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 @@ -86,6 +86,7 @@ public ApiResponse getSkillDetail( detail.status(), detail.downloadCount(), detail.starCount(), + detail.subscriptionCount(), detail.ratingAvg(), detail.ratingCount(), detail.hidden(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionController.java new file mode 100644 index 000000000..8584d0388 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionController.java @@ -0,0 +1,49 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.domain.social.SkillSubscriptionService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillSubscriptionController extends BaseApiController { + + private final SkillSubscriptionService skillSubscriptionService; + + public SkillSubscriptionController(ApiResponseFactory responseFactory, + SkillSubscriptionService skillSubscriptionService) { + super(responseFactory); + this.skillSubscriptionService = skillSubscriptionService; + } + + @PutMapping("/{skillId}/subscription") + public ApiResponse subscribeSkill( + @PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal) { + skillSubscriptionService.subscribe(skillId, principal.userId()); + return ok("response.success.updated", null); + } + + @DeleteMapping("/{skillId}/subscription") + public ApiResponse unsubscribeSkill( + @PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal) { + skillSubscriptionService.unsubscribe(skillId, principal.userId()); + return ok("response.success.updated", null); + } + + @GetMapping("/{skillId}/subscription") + public ApiResponse checkSubscribed( + @PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal) { + if (principal == null) { + return ok("response.success.read", false); + } + boolean subscribed = skillSubscriptionService.isSubscribed(skillId, principal.userId()); + return ok("response.success.read", subscribed); + } +} 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 3fb4101c6..07029180e 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 @@ -14,6 +14,7 @@ public record SkillDetailResponse( String status, Long downloadCount, Integer starCount, + Integer subscriptionCount, BigDecimal ratingAvg, Integer ratingCount, boolean hidden, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 5134e4dd5..74ad10d8b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.social.SkillSubscriptionService; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationDispatcher; import org.slf4j.Logger; @@ -29,6 +30,7 @@ public class NotificationEventListener { private final NamespaceRepository namespaceRepository; private final RecipientResolver recipientResolver; private final NotificationDispatcher dispatcher; + private final SkillSubscriptionService skillSubscriptionService; private final ObjectMapper objectMapper; public NotificationEventListener(SkillRepository skillRepository, @@ -36,12 +38,14 @@ public NotificationEventListener(SkillRepository skillRepository, NamespaceRepository namespaceRepository, RecipientResolver recipientResolver, NotificationDispatcher dispatcher, + SkillSubscriptionService skillSubscriptionService, ObjectMapper objectMapper) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.namespaceRepository = namespaceRepository; this.recipientResolver = recipientResolver; this.dispatcher = dispatcher; + this.skillSubscriptionService = skillSubscriptionService; this.objectMapper = objectMapper; } @@ -61,6 +65,50 @@ public void onSkillPublished(SkillPublishedEvent event) { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onSkillPublishedForSubscribers(SkillPublishedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + List subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId()); + if (subscribers.isEmpty()) { + return; + } + String title = "Skill updated: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + versionLabel(event.versionId(), body); + String json = toJson(body); + for (String subscriberId : subscribers) { + if (subscriberId.equals(event.publisherId())) { + continue; // skip the publisher + } + dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH, + "SUBSCRIPTION_NEW_VERSION", title, json, "SKILL", event.skillId()); + } + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onSkillVersionYankedForSubscribers(SkillVersionYankedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + List subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId()); + if (subscribers.isEmpty()) { + return; + } + String title = "Skill version yanked: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + versionLabel(event.versionId(), body); + String json = toJson(body); + for (String subscriberId : subscribers) { + if (subscriberId.equals(event.actorUserId())) { + continue; // skip the actor + } + dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH, + "SUBSCRIPTION_VERSION_YANKED", title, json, "SKILL", event.skillId()); + } + }); + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewSubmitted(ReviewSubmittedEvent event) { 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 03d8e9d5b..9ad9e6f3d 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 @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.repository.MySkillQueryRepository; @@ -32,6 +33,7 @@ public enum MySkillFilter { private final SkillRepository skillRepository; private final SkillVersionRepository skillVersionRepository; private final SkillStarRepository skillStarRepository; + private final SkillSubscriptionRepository skillSubscriptionRepository; private final MySkillQueryRepository mySkillQueryRepository; private final SkillLifecycleProjectionService skillLifecycleProjectionService; @@ -39,11 +41,13 @@ public MySkillAppService( SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillStarRepository skillStarRepository, + SkillSubscriptionRepository skillSubscriptionRepository, MySkillQueryRepository mySkillQueryRepository, SkillLifecycleProjectionService skillLifecycleProjectionService) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillStarRepository = skillStarRepository; + this.skillSubscriptionRepository = skillSubscriptionRepository; this.mySkillQueryRepository = mySkillQueryRepository; this.skillLifecycleProjectionService = skillLifecycleProjectionService; } @@ -90,6 +94,30 @@ public PageResponse listMyStars(String userId, int page, i return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize()); } + public PageResponse listMySubscriptions(String userId, int page, int size) { + Page subPage = skillSubscriptionRepository.findByUserId( + userId, + PageRequest.of(page, size) + ); + List subs = subPage.getContent(); + + List skillIds = subs.stream() + .map(com.iflytek.skillhub.domain.social.SkillSubscription::getSkillId) + .distinct() + .toList(); + java.util.Map skillsById = skillIds.isEmpty() + ? java.util.Map.of() + : skillRepository.findByIdIn(skillIds).stream() + .collect(java.util.stream.Collectors.toMap(Skill::getId, java.util.function.Function.identity())); + List orderedSkills = subs.stream() + .map(sub -> skillsById.get(sub.getSkillId())) + .filter(java.util.Objects::nonNull) + .toList(); + List items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId); + + return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize()); + } + private Page filterSkillsByLifecycle(String userId, int page, int size, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 2044664da..077f74d54 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -67,6 +67,7 @@ public ReviewSkillDetailResponse getReviewSkillDetail(Long reviewId, snapshot.skill().getStatus().name(), snapshot.skill().getDownloadCount(), snapshot.skill().getStarCount(), + snapshot.skill().getSubscriptionCount(), snapshot.skill().getRatingAvg(), snapshot.skill().getRatingCount(), snapshot.skill().isHidden(), diff --git a/server/skillhub-app/src/main/resources/db/migration/V40__skill_subscription.sql b/server/skillhub-app/src/main/resources/db/migration/V40__skill_subscription.sql new file mode 100644 index 000000000..a157ae1ff --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V40__skill_subscription.sql @@ -0,0 +1,14 @@ +-- Skill subscription: users subscribe to skills for update notifications +CREATE TABLE skill_subscription ( + id BIGSERIAL PRIMARY KEY, + skill_id BIGINT NOT NULL REFERENCES skill(id) ON DELETE CASCADE, + user_id VARCHAR(128) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_skill_subscription UNIQUE (skill_id, user_id) +); + +CREATE INDEX idx_skill_subscription_user ON skill_subscription(user_id, created_at DESC); +CREATE INDEX idx_skill_subscription_skill ON skill_subscription(skill_id); + +-- Add subscription_count column to skill table +ALTER TABLE skill ADD COLUMN subscription_count INTEGER NOT NULL DEFAULT 0; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index a69ec302e..82e868052 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -178,6 +178,7 @@ void getReviewSkillDetail_returnsReviewBoundPayload() throws Exception { "ACTIVE", 8L, 2, + 0, null, 0, false, 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 e47da366d..b57d811a2 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 @@ -161,6 +161,7 @@ void getSkillDetailShouldExposePendingPreviewFlags() throws Exception { "ACTIVE", 10L, 2, + 0, null, 0, false, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java new file mode 100644 index 000000000..809c22a97 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillSubscriptionControllerTest.java @@ -0,0 +1,134 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.social.SkillSubscriptionService; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Set; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SkillSubscriptionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SkillSubscriptionService skillSubscriptionService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + private static UsernamePasswordAuthenticationToken authenticatedUser() { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", + "tester", + "tester@example.com", + "https://example.com/avatar.png", + "github", + Set.of("SUPER_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN")) + ); + } + + // --- PUT /api/web/skills/{skillId}/subscription --- + + @Test + void subscribe_skill_returns_envelope() throws Exception { + mockMvc.perform(put("/api/web/skills/10/subscription") + .with(authentication(authenticatedUser()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + + verify(skillSubscriptionService).subscribe(eq(10L), eq("user-42")); + } + + @Test + void subscribe_skill_unauthenticated_returns_401() throws Exception { + mockMvc.perform(put("/api/web/skills/10/subscription")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + // --- DELETE /api/web/skills/{skillId}/subscription --- + + @Test + void unsubscribe_skill_returns_envelope() throws Exception { + mockMvc.perform(delete("/api/web/skills/10/subscription") + .with(authentication(authenticatedUser()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + + verify(skillSubscriptionService).unsubscribe(eq(10L), eq("user-42")); + } + + @Test + void unsubscribe_skill_unauthenticated_returns_401() throws Exception { + mockMvc.perform(delete("/api/web/skills/10/subscription")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + // --- GET /api/web/skills/{skillId}/subscription --- + + @Test + void check_subscribed_returns_true() throws Exception { + when(skillSubscriptionService.isSubscribed(eq(10L), eq("user-42"))).thenReturn(true); + + mockMvc.perform(get("/api/web/skills/10/subscription") + .with(authentication(authenticatedUser()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } + + @Test + void check_subscribed_returns_false() throws Exception { + when(skillSubscriptionService.isSubscribed(eq(10L), eq("user-42"))).thenReturn(false); + + mockMvc.perform(get("/api/web/skills/10/subscription") + .with(authentication(authenticatedUser()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(false)) + .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.requestId").isNotEmpty()); + } + + @Test + void check_subscribed_unauthenticated_returns_false() throws Exception { + mockMvc.perform(get("/api/web/skills/10/subscription")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(false)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/domain/social/SkillSubscriptionServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/domain/social/SkillSubscriptionServiceTest.java new file mode 100644 index 000000000..5e47524d9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/domain/social/SkillSubscriptionServiceTest.java @@ -0,0 +1,105 @@ +package com.iflytek.skillhub.domain.social; + +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent; +import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillSubscriptionServiceTest { + + @Mock private SkillSubscriptionRepository subscriptionRepository; + @Mock private SkillRepository skillRepository; + @Mock private ApplicationEventPublisher eventPublisher; + + private SkillSubscriptionService service; + + @BeforeEach + void setUp() { + service = new SkillSubscriptionService(subscriptionRepository, skillRepository, eventPublisher); + } + + @Test + void subscribe_createsSubscriptionAndPublishesEvent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + when(subscriptionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.subscribe(1L, "user-1"); + + verify(subscriptionRepository).save(any(SkillSubscription.class)); + verify(skillRepository).incrementSubscriptionCount(1L); + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillSubscribedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().skillId()).isEqualTo(1L); + assertThat(captor.getValue().userId()).isEqualTo("user-1"); + } + + @Test + void subscribe_idempotent_doesNotDuplicate() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")) + .thenReturn(Optional.of(mock(SkillSubscription.class))); + + service.subscribe(1L, "user-1"); + + verify(subscriptionRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void unsubscribe_deletesSubscriptionAndPublishesEvent() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + SkillSubscription existing = mock(SkillSubscription.class); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.of(existing)); + + service.unsubscribe(1L, "user-1"); + + verify(subscriptionRepository).delete(existing); + verify(skillRepository).decrementSubscriptionCount(1L); + ArgumentCaptor captor = ArgumentCaptor.forClass(SkillUnsubscribedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().skillId()).isEqualTo(1L); + } + + @Test + void unsubscribe_noOp_whenNotSubscribed() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + + service.unsubscribe(1L, "user-1"); + + verify(subscriptionRepository, never()).delete(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void isSubscribed_returnsTrue_whenSubscriptionExists() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")) + .thenReturn(Optional.of(mock(SkillSubscription.class))); + + assertThat(service.isSubscribed(1L, "user-1")).isTrue(); + } + + @Test + void isSubscribed_returnsFalse_whenNoSubscription() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(mock(Skill.class))); + when(subscriptionRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + + assertThat(service.isSubscribed(1L, "user-1")).isFalse(); + } +} 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 a4497731d..c4faf1366 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 @@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService; import com.iflytek.skillhub.domain.social.SkillStar; import com.iflytek.skillhub.domain.social.SkillStarRepository; +import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; import com.iflytek.skillhub.repository.JpaMySkillQueryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -50,6 +51,9 @@ class MySkillAppServiceTest { @Mock private SkillStarRepository skillStarRepository; + @Mock + private SkillSubscriptionRepository skillSubscriptionRepository; + @Mock private PromotionRequestRepository promotionRequestRepository; @@ -69,6 +73,7 @@ void setUp() { skillRepository, skillVersionRepository, skillStarRepository, + skillSubscriptionRepository, mySkillQueryRepository, skillLifecycleProjectionService ); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java new file mode 100644 index 000000000..9df82b5d3 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillVersionYankedEvent.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.domain.event; + +public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index def07ce83..cc18cba08 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -57,6 +57,9 @@ public class Skill { @Column(name = "star_count", nullable = false) private Integer starCount = 0; + @Column(name = "subscription_count", nullable = false) + private Integer subscriptionCount = 0; + @Column(name = "rating_avg", precision = 3, scale = 2, nullable = false) private BigDecimal ratingAvg = BigDecimal.ZERO; @@ -158,6 +161,10 @@ public Integer getStarCount() { return starCount; } + public Integer getSubscriptionCount() { + return subscriptionCount; + } + public BigDecimal getRatingAvg() { return ratingAvg; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index 14115318d..facf30919 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -22,6 +22,8 @@ public interface SkillRepository { List findByOwnerId(String ownerId); Page findByOwnerId(String ownerId, Pageable pageable); void incrementDownloadCount(Long skillId); + void incrementSubscriptionCount(Long skillId); + void decrementSubscriptionCount(Long skillId); List findBySlug(String slug); List findByNamespaceSlugAndSlug(String namespaceSlug, String slug); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 0dfcb5f35..820bbba95 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -271,6 +271,8 @@ public SkillVersion yankVersion(Long versionId, String actorUserId, String clien } }); auditLogService.record(actorUserId, "YANK_SKILL_VERSION", "SKILL_VERSION", versionId, null, clientIp, userAgent, jsonReason(reason)); + eventPublisher.publishEvent(new com.iflytek.skillhub.domain.event.SkillVersionYankedEvent( + version.getSkillId(), versionId, actorUserId)); return saved; } 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 8bf887675..76fe42114 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 @@ -94,6 +94,7 @@ public record SkillDetailDTO( String status, Long downloadCount, Integer starCount, + Integer subscriptionCount, java.math.BigDecimal ratingAvg, Integer ratingCount, boolean hidden, @@ -184,6 +185,7 @@ public SkillDetailDTO getSkillDetail( skill.getStatus().name(), skill.getDownloadCount(), skill.getStarCount(), + skill.getSubscriptionCount(), skill.getRatingAvg(), skill.getRatingCount(), skill.isHidden(), diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscription.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscription.java new file mode 100644 index 000000000..07fbfe489 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscription.java @@ -0,0 +1,39 @@ +package com.iflytek.skillhub.domain.social; + +import jakarta.persistence.*; +import java.time.Clock; +import java.time.Instant; + +@Entity +@Table(name = "skill_subscription", + uniqueConstraints = @UniqueConstraint(columnNames = {"skill_id", "user_id"})) +public class SkillSubscription { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + protected SkillSubscription() {} + + public SkillSubscription(Long skillId, String userId) { + this.skillId = skillId; + this.userId = userId; + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + } + + public Long getId() { return id; } + public Long getSkillId() { return skillId; } + public String getUserId() { return userId; } + public Instant getCreatedAt() { return createdAt; } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionRepository.java new file mode 100644 index 000000000..6f52e277d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionRepository.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.social; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SkillSubscriptionRepository { + SkillSubscription save(SkillSubscription subscription); + Optional findBySkillIdAndUserId(Long skillId, String userId); + void delete(SkillSubscription subscription); + void deleteBySkillId(Long skillId); + Page findByUserId(String userId, Pageable pageable); + List findAllBySkillId(Long skillId); + long countBySkillId(Long skillId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionService.java new file mode 100644 index 000000000..22710e421 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillSubscriptionService.java @@ -0,0 +1,65 @@ +package com.iflytek.skillhub.domain.social; + +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.social.event.SkillSubscribedEvent; +import com.iflytek.skillhub.domain.social.event.SkillUnsubscribedEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class SkillSubscriptionService { + private final SkillSubscriptionRepository subscriptionRepository; + private final SkillRepository skillRepository; + private final ApplicationEventPublisher eventPublisher; + + public SkillSubscriptionService(SkillSubscriptionRepository subscriptionRepository, + SkillRepository skillRepository, + ApplicationEventPublisher eventPublisher) { + this.subscriptionRepository = subscriptionRepository; + this.skillRepository = skillRepository; + this.eventPublisher = eventPublisher; + } + + @Transactional + public void subscribe(Long skillId, String userId) { + ensureSkillExists(skillId); + if (subscriptionRepository.findBySkillIdAndUserId(skillId, userId).isPresent()) { + return; // idempotent + } + subscriptionRepository.save(new SkillSubscription(skillId, userId)); + skillRepository.incrementSubscriptionCount(skillId); + eventPublisher.publishEvent(new SkillSubscribedEvent(skillId, userId)); + } + + @Transactional + public void unsubscribe(Long skillId, String userId) { + ensureSkillExists(skillId); + subscriptionRepository.findBySkillIdAndUserId(skillId, userId).ifPresent(subscription -> { + subscriptionRepository.delete(subscription); + skillRepository.decrementSubscriptionCount(skillId); + eventPublisher.publishEvent(new SkillUnsubscribedEvent(skillId, userId)); + }); + } + + public boolean isSubscribed(Long skillId, String userId) { + ensureSkillExists(skillId); + return subscriptionRepository.findBySkillIdAndUserId(skillId, userId).isPresent(); + } + + public List findSubscribersBySkillId(Long skillId) { + return subscriptionRepository.findAllBySkillId(skillId).stream() + .map(SkillSubscription::getUserId) + .distinct() + .toList(); + } + + private void ensureSkillExists(Long skillId) { + if (skillRepository.findById(skillId).isEmpty()) { + throw new DomainNotFoundException("skill.not_found", skillId); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillSubscribedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillSubscribedEvent.java new file mode 100644 index 000000000..36a721c7f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillSubscribedEvent.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.domain.social.event; + +public record SkillSubscribedEvent(Long skillId, String userId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillUnsubscribedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillUnsubscribedEvent.java new file mode 100644 index 000000000..1068ccaa1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/event/SkillUnsubscribedEvent.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.domain.social.event; + +public record SkillUnsubscribedEvent(Long skillId, String userId) {} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index 27c595835..1249f12d8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -87,6 +87,16 @@ public void incrementDownloadCount(Long skillId) { delegate.incrementDownloadCount(skillId); } + @Override + public void incrementSubscriptionCount(Long skillId) { + delegate.incrementSubscriptionCount(skillId); + } + + @Override + public void decrementSubscriptionCount(Long skillId) { + delegate.decrementSubscriptionCount(skillId); + } + @Override public List findBySlug(String slug) { return delegate.findBySlug(slug); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillSubscriptionRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillSubscriptionRepository.java new file mode 100644 index 000000000..83d5eb6c9 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillSubscriptionRepository.java @@ -0,0 +1,20 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.social.SkillSubscription; +import com.iflytek.skillhub.domain.social.SkillSubscriptionRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface JpaSkillSubscriptionRepository extends JpaRepository, SkillSubscriptionRepository { + Optional findBySkillIdAndUserId(Long skillId, String userId); + void deleteBySkillId(Long skillId); + Page findByUserId(String userId, Pageable pageable); + List findAllBySkillId(Long skillId); + long countBySkillId(Long skillId); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java index e29a80d26..55ad2aad8 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillJpaRepository.java @@ -44,6 +44,16 @@ default Page findByOwnerId(String ownerId, Pageable pageable) { @Query("UPDATE Skill s SET s.downloadCount = s.downloadCount + 1 WHERE s.id = :skillId") void incrementDownloadCount(@Param("skillId") Long skillId); + @Modifying + @Transactional + @Query("UPDATE Skill s SET s.subscriptionCount = s.subscriptionCount + 1 WHERE s.id = :skillId") + void incrementSubscriptionCount(@Param("skillId") Long skillId); + + @Modifying + @Transactional + @Query("UPDATE Skill s SET s.subscriptionCount = CASE WHEN s.subscriptionCount > 0 THEN s.subscriptionCount - 1 ELSE 0 END WHERE s.id = :skillId") + void decrementSubscriptionCount(@Param("skillId") Long skillId); + List findBySlug(String slug); @Query("SELECT s FROM Skill s JOIN Namespace n ON s.namespaceId = n.id WHERE n.slug = :namespaceSlug AND s.slug = :slug") diff --git a/web/e2e/skill-subscription.spec.ts b/web/e2e/skill-subscription.spec.ts new file mode 100644 index 000000000..e14cda1d7 --- /dev/null +++ b/web/e2e/skill-subscription.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { loginWithCredentials, registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' + +function getOptionalEnv(name: string): string | undefined { + const value = process.env[name]?.trim() + return value ? value : undefined +} + +function adminCredentials() { + return { + username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin', + password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026', + } +} + +test.describe('Skill Subscription (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('subscribe and unsubscribe to a skill', async ({ page, browser }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skill = await builder.publishSkill(namespace.slug) + + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + + await page.goto(`/space/${namespace.slug}/${skill.slug}`) + + await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible() + + const subscribeButton = page.getByRole('button', { name: /Subscribe/ }) + await expect(subscribeButton).toBeVisible() + + const initialCount = await subscribeButton.textContent() + const initialCountMatch = initialCount?.match(/\((\d+)\)/) + const initialCountValue = initialCountMatch ? Number.parseInt(initialCountMatch[1], 10) : 0 + + await subscribeButton.click() + + await expect(page.getByRole('button', { name: /Subscribed/ })).toBeVisible() + + const subscribedButton = page.getByRole('button', { name: /Subscribed/ }) + const subscribedCount = await subscribedButton.textContent() + const subscribedCountMatch = subscribedCount?.match(/\((\d+)\)/) + const subscribedCountValue = subscribedCountMatch ? Number.parseInt(subscribedCountMatch[1], 10) : 0 + + expect(subscribedCountValue).toBe(initialCountValue + 1) + + await subscribedButton.click() + + await expect(page.getByRole('button', { name: /Subscribe/ })).toBeVisible() + + const unsubscribedButton = page.getByRole('button', { name: /Subscribe/ }) + const unsubscribedCount = await unsubscribedButton.textContent() + const unsubscribedCountMatch = unsubscribedCount?.match(/\((\d+)\)/) + const unsubscribedCountValue = unsubscribedCountMatch ? Number.parseInt(unsubscribedCountMatch[1], 10) : 0 + + expect(unsubscribedCountValue).toBe(initialCountValue) + } finally { + await adminBuilder.cleanup() + await adminContext.close() + await builder.cleanup() + } + }) + + test('shows subscribed skill in My Subscriptions page', async ({ page, browser }, testInfo) => { + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + + const adminContext = await browser.newContext() + const adminPage = await adminContext.newPage() + const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) + await loginWithCredentials(adminPage, adminCredentials(), testInfo) + await adminBuilder.init() + + try { + const namespace = await builder.ensureWritableNamespace() + const skill = await builder.publishSkill(namespace.slug) + + const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version) + await adminBuilder.approveReview(reviewTaskId) + + await page.goto(`/space/${namespace.slug}/${skill.slug}`) + + await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible() + + const subscribeButton = page.getByRole('button', { name: /Subscribe/ }) + await expect(subscribeButton).toBeVisible() + await subscribeButton.click() + + await expect(page.getByRole('button', { name: /Subscribed/ })).toBeVisible() + + await page.goto('/dashboard/subscriptions') + + await expect(page.getByRole('heading', { name: 'My Subscriptions' })).toBeVisible() + await expect(page.getByText(`@${skill.namespace}`).first()).toBeVisible() + } finally { + await adminBuilder.cleanup() + await adminContext.close() + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e7351ff9..18d54003b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1009,6 +1009,30 @@ export const meApi = { return items }, + + async getSubscriptionsPage(params?: { page?: number; size?: number }): Promise<{ items: SkillSummary[]; total: number; page: number; size: number }> { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params?.page ?? 0)) + searchParams.set('size', String(params?.size ?? 12)) + return fetchJson<{ items: SkillSummary[]; total: number; page: number; size: number }>(`${WEB_API_PREFIX}/me/subscriptions?${searchParams.toString()}`) + }, + + async getSubscriptions(): Promise { + const items: SkillSummary[] = [] + let page = 0 + const size = 100 + let hasMore = true + + while (hasMore) { + const response = await meApi.getSubscriptionsPage({ page, size }) + items.push(...response.items) + + hasMore = (page + 1) * response.size < response.total && response.items.length > 0 + page++ + } + + return items + }, } export const profileApi = { diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 27fb3c1b6..316f17b7d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -916,6 +916,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/namespaces/{slug}/members/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchAddMembers"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/namespaces/{slug}/members/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["batchAddMembers_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/namespaces/{slug}/members": { parameters: { query?: never; @@ -3429,11 +3461,38 @@ export interface components { /** Format: int64 */ targetNamespaceId?: number; }; + BatchMemberRequest: { + members: components["schemas"]["MemberRequest"][]; + }; MemberRequest: { userId: string; /** @enum {string} */ role: "OWNER" | "ADMIN" | "MEMBER"; }; + ApiResponseBatchMemberResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["BatchMemberResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + BatchMemberResponse: { + /** Format: int32 */ + totalCount?: number; + /** Format: int32 */ + successCount?: number; + /** Format: int32 */ + failureCount?: number; + results?: components["schemas"]["BatchMemberResult"][]; + }; + BatchMemberResult: { + userId?: string; + role?: string; + success?: boolean; + error?: string; + }; NamespaceLifecycleRequest: { reason?: string; }; @@ -3719,7 +3778,6 @@ export interface components { slug?: string; displayName?: string; summary?: string; - visibility?: string; status?: string; /** Format: int64 */ downloadCount?: number; @@ -6457,6 +6515,58 @@ export interface operations { }; }; }; + batchAddMembers: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + }; + }; + }; + }; + batchAddMembers_1: { + parameters: { + query?: never; + header?: never; + path: { + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BatchMemberRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseBatchMemberResponse"]; + }; + }; + }; + }; listMembers: { parameters: { query: { diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 73cb0ff35..d7b564ca1 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -104,6 +104,7 @@ const PromotionsPage = createRoleProtectedRouteComponent( ['SKILL_ADMIN', 'SUPER_ADMIN'], ) const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage') const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') @@ -325,6 +326,13 @@ const dashboardStarsRoute = createRoute({ component: MyStarsPage, }) +const dashboardSubscriptionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/subscriptions', + beforeLoad: requireAuth, + component: MySubscriptionsPage, +}) + const dashboardNotificationsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/notifications', @@ -429,6 +437,7 @@ const routeTree = rootRoute.addChildren([ dashboardReviewDetailRoute, dashboardPromotionsRoute, dashboardStarsRoute, + dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, cliAuthRoute, diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts index f68d30d19..e034e0867 100644 --- a/web/src/features/notification/notification-content.ts +++ b/web/src/features/notification/notification-content.ts @@ -79,6 +79,16 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str title: zh ? '技能发布成功' : 'Skill published', description: skillName ? (zh ? `${skillName}${versionSuffix} 已发布。` : `${skillName}${versionSuffix} was published.`) : '', } + case 'SUBSCRIPTION_NEW_VERSION': + return { + title: zh ? '订阅技能更新' : 'Subscribed skill updated', + description: skillName ? (zh ? `${skillName}${versionSuffix} 发布了新版本。` : `${skillName}${versionSuffix} published a new version.`) : '', + } + case 'SUBSCRIPTION_VERSION_YANKED': + return { + title: zh ? '订阅技能版本撤回' : 'Subscribed skill version yanked', + description: skillName ? (zh ? `${skillName}${versionSuffix} 版本已撤回。` : `${skillName}${versionSuffix} version was yanked.`) : '', + } default: return { title: item.title, diff --git a/web/src/features/social/subscribe-button.tsx b/web/src/features/social/subscribe-button.tsx new file mode 100644 index 000000000..9c655dcca --- /dev/null +++ b/web/src/features/social/subscribe-button.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { Button } from '@/shared/ui/button' +import { useSubscription, useToggleSubscription } from './use-subscription' +import { Bell } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' + +interface SubscribeButtonProps { + skillId: number + subscriptionCount: number + onRequireLogin?: () => void +} + +export function SubscribeButton({ skillId, subscriptionCount, onRequireLogin }: SubscribeButtonProps) { + const { t } = useTranslation() + const { data: subscriptionStatus, isLoading } = useSubscription(skillId) + const toggleMutation = useToggleSubscription(skillId) + const { isAuthenticated } = useAuth() + + const handleToggle = () => { + if (!isAuthenticated) { + onRequireLogin?.() + return + } + if (subscriptionStatus) { + toggleMutation.mutate(subscriptionStatus.subscribed) + } + } + + if (isLoading || !subscriptionStatus) { + return null + } + + return ( + + ) +} diff --git a/web/src/features/social/use-subscription.test.ts b/web/src/features/social/use-subscription.test.ts new file mode 100644 index 000000000..4d4069cf1 --- /dev/null +++ b/web/src/features/social/use-subscription.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import * as mod from './use-subscription' + +/** + * use-subscription.ts exports useSubscription and useToggleSubscription hooks. + * Both are thin wrappers around useQuery/useMutation with no exported pure helpers, + * query-key functions, or data transformations. + * + * We verify the export contract so downstream consumers break fast if + * the module shape changes. + */ +describe('use-subscription module exports', () => { + it('exports useSubscription as a function', () => { + expect(mod.useSubscription).toBeDefined() + expect(typeof mod.useSubscription).toBe('function') + }) + + it('exports useToggleSubscription as a function', () => { + expect(mod.useToggleSubscription).toBeDefined() + expect(typeof mod.useToggleSubscription).toBe('function') + }) +}) diff --git a/web/src/features/social/use-subscription.ts b/web/src/features/social/use-subscription.ts new file mode 100644 index 000000000..8d91b5631 --- /dev/null +++ b/web/src/features/social/use-subscription.ts @@ -0,0 +1,53 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { ApiError, fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client' + +interface SubscriptionStatus { + subscribed: boolean +} + +async function getSubscriptionStatus(skillId: number): Promise { + try { + const subscribed = await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/subscription`) + return { subscribed } + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + return { subscribed: false } + } + throw error + } +} + +async function toggleSubscription(skillId: number, subscribed: boolean): Promise { + if (subscribed) { + await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/subscription`, { + method: 'DELETE', + headers: getCsrfHeaders(), + }) + } else { + await fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/subscription`, { + method: 'PUT', + headers: getCsrfHeaders(), + }) + } +} + +export function useSubscription(skillId: number, enabled = true) { + return useQuery({ + queryKey: ['skills', skillId, 'subscription'], + queryFn: () => getSubscriptionStatus(skillId), + enabled: !!skillId && enabled, + }) +} + +export function useToggleSubscription(skillId: number) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (subscribed: boolean) => toggleSubscription(skillId, subscribed), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'subscription'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + queryClient.invalidateQueries({ queryKey: ['skills', 'subscriptions'] }) + }, + }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5d51b7e32..6a1badc3e 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -314,6 +314,8 @@ "platformRoles": "Platform Roles", "starsAndRatings": "Stars & Ratings", "viewStars": "View My Stars", + "subscriptions": "Subscriptions", + "viewSubscriptions": "View My Subscriptions", "mySkillsTitle": "My Skills", "openMySkills": "View My Skills", "mySkillsPreviewDescription": "Showing your 5 most recent skills. Open a skill or go to My Skills to view all.", @@ -519,6 +521,11 @@ "subtitle": "View your starred skills", "empty": "No starred skills yet" }, + "subscriptions": { + "title": "My Subscriptions", + "subtitle": "Skills you subscribed to for updates", + "empty": "You haven't subscribed to any skills yet." + }, "promotions": { "title": "Promotion Review", "subtitle": "Review team skill promotion requests to global namespace", @@ -1150,6 +1157,10 @@ "starred": "Starred", "star": "Star" }, + "subscribeButton": { + "subscribed": "Subscribed", + "subscribe": "Subscribe" + }, "skillCard": { "starred": "Starred", "starredAction": "Click to unstar", @@ -1174,6 +1185,7 @@ "myNamespaces": "My Namespaces", "governance": "Governance Center", "stars": "Starred", + "subscriptions": "My Subscriptions", "reviews": "Review Management", "promotions": "Promotion Management", "reports": "Report Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 42eed50ab..0f6b71741 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -314,6 +314,8 @@ "platformRoles": "平台角色", "starsAndRatings": "收藏与评分", "viewStars": "查看我的收藏", + "subscriptions": "订阅", + "viewSubscriptions": "查看我的订阅", "mySkillsTitle": "我的技能", "openMySkills": "查看我的技能", "mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往“我的技能”查看全部。", @@ -519,6 +521,11 @@ "subtitle": "查看你标记过的技能", "empty": "还没有收藏任何技能" }, + "subscriptions": { + "title": "我的订阅", + "subtitle": "您订阅的技能更新通知", + "empty": "您还没有订阅任何技能。" + }, "promotions": { "title": "提升审核", "subtitle": "审核团队技能提升到全局空间的申请", @@ -1151,6 +1158,10 @@ "starred": "已收藏", "star": "收藏" }, + "subscribeButton": { + "subscribed": "已订阅", + "subscribe": "订阅" + }, "skillCard": { "starred": "已收藏", "starredAction": "点击取消收藏", @@ -1175,6 +1186,7 @@ "myNamespaces": "我的命名空间", "governance": "治理中心", "stars": "我的收藏", + "subscriptions": "我的订阅", "reviews": "审核管理", "promotions": "推广管理", "reports": "举报管理", diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index 89ff5d70d..6baf033b0 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -83,6 +83,12 @@ export function DashboardPage() { {t('dashboard.viewStars')} + +
{t('dashboard.subscriptions')}
+ + {t('dashboard.viewSubscriptions')} + +
{t('dashboard.mySkillsTitle')}
diff --git a/web/src/pages/dashboard/subscriptions.tsx b/web/src/pages/dashboard/subscriptions.tsx new file mode 100644 index 000000000..6c0523dd6 --- /dev/null +++ b/web/src/pages/dashboard/subscriptions.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { SkillCard } from '@/features/skill/skill-card' +import { Pagination } from '@/shared/components/pagination' +import { useMySubscriptionsPage } from '@/shared/hooks/use-user-queries' +import { Card } from '@/shared/ui/card' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' + +const PAGE_SIZE = 12 + +export function MySubscriptionsPage() { + const { t } = useTranslation() + const navigate = useNavigate() + const [page, setPage] = useState(0) + const { data, isLoading } = useMySubscriptionsPage({ page, size: PAGE_SIZE }) + const skills = data?.items ?? [] + const totalPages = data ? Math.max(Math.ceil(data.total / data.size), 1) : 1 + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) + } + + return ( +
+ + + {!skills || skills.length === 0 ? ( + {t('subscriptions.empty')} + ) : ( + <> +
+ {skills.map((skill) => ( + navigate({ to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}` })} + /> + ))} +
+ {data && data.total > PAGE_SIZE ? ( + + ) : null} + + )} +
+ ) +} diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 19f649835..7439241e5 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -21,6 +21,7 @@ import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeleted import { isSkillDetailQueriesEnabled } from './skill-detail-query' import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' +import { SubscribeButton } from '@/features/social/subscribe-button' import { useAuth } from '@/features/auth/use-auth' import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client' import { useSubmitSkillReport } from '@/features/report/use-skill-reports' @@ -1069,6 +1070,7 @@ export function SkillDetailPage() { {!isFetchingSkill ? ( <> + ) : null} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index f0281a83d..1cb10a318 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -159,6 +159,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.stars')} + + {t('user.menu.subscriptions')} + {reviewCenterVisible ? ( {t('user.menu.reviews')} diff --git a/web/src/shared/hooks/use-user-queries.ts b/web/src/shared/hooks/use-user-queries.ts index cd8536c7b..a3e7877bc 100644 --- a/web/src/shared/hooks/use-user-queries.ts +++ b/web/src/shared/hooks/use-user-queries.ts @@ -14,6 +14,14 @@ async function getMyStarsPage(params: { page?: number; size?: number } = {}): Pr return meApi.getStarsPage(params) } +async function getMySubscriptions(): Promise { + return meApi.getSubscriptions() +} + +async function getMySubscriptionsPage(params: { page?: number; size?: number } = {}): Promise> { + return meApi.getSubscriptionsPage(params) +} + async function submitPromotion(params: { sourceSkillId: number; sourceVersionId: number }): Promise { const globalNamespace = await namespaceApi.getDetail('global') await promotionApi.submit({ @@ -46,6 +54,22 @@ export function useMyStarsPage(params: { page?: number; size?: number } = {}, en }) } +export function useMySubscriptions(enabled = true) { + return useQuery({ + queryKey: ['skills', 'subscriptions'], + queryFn: getMySubscriptions, + enabled, + }) +} + +export function useMySubscriptionsPage(params: { page?: number; size?: number } = {}, enabled = true) { + return useQuery({ + queryKey: ['skills', 'subscriptions', 'page', params], + queryFn: () => getMySubscriptionsPage(params), + enabled, + }) +} + export function useSubmitPromotion() { const queryClient = useQueryClient()