mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
Merge pull request #356 from iflytek/feature/skill-subscription-notification
feat(subscription): add skill subscription notification feature
This commit is contained in:
commit
f41723e0dc
41 changed files with 1101 additions and 1 deletions
|
|
@ -56,4 +56,16 @@ public class MeController extends BaseApiController {
|
|||
|
||||
return ok("response.success.read", mySkillAppService.listMyStars(principal.userId(), page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/subscriptions")
|
||||
public ApiResponse<PageResponse<SkillSummaryResponse>> 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ public class SkillController extends BaseApiController {
|
|||
detail.status(),
|
||||
detail.downloadCount(),
|
||||
detail.starCount(),
|
||||
detail.subscriptionCount(),
|
||||
detail.ratingAvg(),
|
||||
detail.ratingCount(),
|
||||
detail.hidden(),
|
||||
|
|
|
|||
|
|
@ -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<Void> subscribeSkill(
|
||||
@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
skillSubscriptionService.subscribe(skillId, principal.userId());
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{skillId}/subscription")
|
||||
public ApiResponse<Void> unsubscribeSkill(
|
||||
@PathVariable Long skillId,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
skillSubscriptionService.unsubscribe(skillId, principal.userId());
|
||||
return ok("response.success.updated", null);
|
||||
}
|
||||
|
||||
@GetMapping("/{skillId}/subscription")
|
||||
public ApiResponse<Boolean> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ public record SkillDetailResponse(
|
|||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
Integer subscriptionCount,
|
||||
BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
|||
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 class NotificationEventListener {
|
|||
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 class NotificationEventListener {
|
|||
});
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener
|
||||
public void onSkillPublishedForSubscribers(SkillPublishedEvent event) {
|
||||
skillRepository.findById(event.skillId()).ifPresent(skill -> {
|
||||
List<String> subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId());
|
||||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String title = "Skill updated: " + skillDisplayName(skill);
|
||||
Map<String, Object> 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<String> subscribers = skillSubscriptionService.findSubscribersBySkillId(event.skillId());
|
||||
if (subscribers.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String title = "Skill version yanked: " + skillDisplayName(skill);
|
||||
Map<String, Object> 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) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
|
|||
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 class MySkillAppService {
|
|||
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 class 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 class MySkillAppService {
|
|||
return new PageResponse<>(items, starPage.getTotalElements(), starPage.getNumber(), starPage.getSize());
|
||||
}
|
||||
|
||||
public PageResponse<SkillSummaryResponse> listMySubscriptions(String userId, int page, int size) {
|
||||
Page<com.iflytek.skillhub.domain.social.SkillSubscription> subPage = skillSubscriptionRepository.findByUserId(
|
||||
userId,
|
||||
PageRequest.of(page, size)
|
||||
);
|
||||
List<com.iflytek.skillhub.domain.social.SkillSubscription> subs = subPage.getContent();
|
||||
|
||||
List<Long> skillIds = subs.stream()
|
||||
.map(com.iflytek.skillhub.domain.social.SkillSubscription::getSkillId)
|
||||
.distinct()
|
||||
.toList();
|
||||
java.util.Map<Long, Skill> 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<Skill> orderedSkills = subs.stream()
|
||||
.map(sub -> skillsById.get(sub.getSkillId()))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.toList();
|
||||
List<SkillSummaryResponse> items = mySkillQueryRepository.getSkillSummaries(orderedSkills, userId);
|
||||
|
||||
return new PageResponse<>(items, subPage.getTotalElements(), subPage.getNumber(), subPage.getSize());
|
||||
}
|
||||
|
||||
private Page<Skill> filterSkillsByLifecycle(String userId,
|
||||
int page,
|
||||
int size,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ public class ReviewSkillDetailAppService {
|
|||
snapshot.skill().getStatus().name(),
|
||||
snapshot.skill().getDownloadCount(),
|
||||
snapshot.skill().getStarCount(),
|
||||
snapshot.skill().getSubscriptionCount(),
|
||||
snapshot.skill().getRatingAvg(),
|
||||
snapshot.skill().getRatingCount(),
|
||||
snapshot.skill().isHidden(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -178,6 +178,7 @@ class ReviewPortalControllerTest {
|
|||
"ACTIVE",
|
||||
8L,
|
||||
2,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ class SkillControllerTest {
|
|||
"ACTIVE",
|
||||
10L,
|
||||
2,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SkillSubscribedEvent> 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<SkillUnsubscribedEvent> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
|||
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 @@ class MySkillAppServiceTest {
|
|||
skillRepository,
|
||||
skillVersionRepository,
|
||||
skillStarRepository,
|
||||
skillSubscriptionRepository,
|
||||
mySkillQueryRepository,
|
||||
skillLifecycleProjectionService
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
|
||||
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId) {}
|
||||
|
|
@ -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 class Skill {
|
|||
return starCount;
|
||||
}
|
||||
|
||||
public Integer getSubscriptionCount() {
|
||||
return subscriptionCount;
|
||||
}
|
||||
|
||||
public BigDecimal getRatingAvg() {
|
||||
return ratingAvg;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ public interface SkillRepository {
|
|||
List<Skill> findByOwnerId(String ownerId);
|
||||
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);
|
||||
void incrementDownloadCount(Long skillId);
|
||||
void incrementSubscriptionCount(Long skillId);
|
||||
void decrementSubscriptionCount(Long skillId);
|
||||
List<Skill> findBySlug(String slug);
|
||||
List<Skill> findByNamespaceSlugAndSlug(String namespaceSlug, String slug);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,8 @@ public class SkillGovernanceService {
|
|||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ public class SkillQueryService {
|
|||
String status,
|
||||
Long downloadCount,
|
||||
Integer starCount,
|
||||
Integer subscriptionCount,
|
||||
java.math.BigDecimal ratingAvg,
|
||||
Integer ratingCount,
|
||||
boolean hidden,
|
||||
|
|
@ -184,6 +185,7 @@ public class SkillQueryService {
|
|||
skill.getStatus().name(),
|
||||
skill.getDownloadCount(),
|
||||
skill.getStarCount(),
|
||||
skill.getSubscriptionCount(),
|
||||
skill.getRatingAvg(),
|
||||
skill.getRatingCount(),
|
||||
skill.isHidden(),
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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<SkillSubscription> findBySkillIdAndUserId(Long skillId, String userId);
|
||||
void delete(SkillSubscription subscription);
|
||||
void deleteBySkillId(Long skillId);
|
||||
Page<SkillSubscription> findByUserId(String userId, Pageable pageable);
|
||||
List<SkillSubscription> findAllBySkillId(Long skillId);
|
||||
long countBySkillId(Long skillId);
|
||||
}
|
||||
|
|
@ -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<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.domain.social.event;
|
||||
|
||||
public record SkillSubscribedEvent(Long skillId, String userId) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.domain.social.event;
|
||||
|
||||
public record SkillUnsubscribedEvent(Long skillId, String userId) {}
|
||||
|
|
@ -87,6 +87,16 @@ public class JpaSkillRepositoryAdapter implements SkillRepository {
|
|||
delegate.incrementDownloadCount(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementSubscriptionCount(Long skillId) {
|
||||
delegate.incrementSubscriptionCount(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void decrementSubscriptionCount(Long skillId) {
|
||||
delegate.decrementSubscriptionCount(skillId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Skill> findBySlug(String slug) {
|
||||
return delegate.findBySlug(slug);
|
||||
|
|
|
|||
|
|
@ -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<SkillSubscription, Long>, SkillSubscriptionRepository {
|
||||
Optional<SkillSubscription> findBySkillIdAndUserId(Long skillId, String userId);
|
||||
void deleteBySkillId(Long skillId);
|
||||
Page<SkillSubscription> findByUserId(String userId, Pageable pageable);
|
||||
List<SkillSubscription> findAllBySkillId(Long skillId);
|
||||
long countBySkillId(Long skillId);
|
||||
}
|
||||
|
|
@ -44,6 +44,16 @@ public interface SkillJpaRepository extends JpaRepository<Skill, Long>, SkillRep
|
|||
@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<Skill> 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")
|
||||
|
|
|
|||
117
web/e2e/skill-subscription.spec.ts
Normal file
117
web/e2e/skill-subscription.spec.ts
Normal file
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1020,6 +1020,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<SkillSummary[]> {
|
||||
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 = {
|
||||
|
|
|
|||
112
web/src/api/generated/schema.d.ts
vendored
112
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
web/src/features/social/subscribe-button.tsx
Normal file
45
web/src/features/social/subscribe-button.tsx
Normal file
|
|
@ -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 } = useSubscription(skillId)
|
||||
const toggleMutation = useToggleSubscription(skillId)
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!isAuthenticated) {
|
||||
onRequireLogin?.()
|
||||
return
|
||||
}
|
||||
if (subscriptionStatus) {
|
||||
toggleMutation.mutate(subscriptionStatus.subscribed)
|
||||
}
|
||||
}
|
||||
|
||||
if (!subscriptionStatus) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={subscriptionStatus.subscribed ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="justify-between"
|
||||
onClick={handleToggle}
|
||||
disabled={toggleMutation.isPending}
|
||||
>
|
||||
<Bell className={`w-4 h-4 mr-2 ${subscriptionStatus.subscribed ? 'fill-current' : ''}`} />
|
||||
{subscriptionStatus.subscribed ? t('subscribeButton.subscribed') : t('subscribeButton.subscribe')} ({subscriptionCount})
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
22
web/src/features/social/use-subscription.test.ts
Normal file
22
web/src/features/social/use-subscription.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
67
web/src/features/social/use-subscription.ts
Normal file
67
web/src/features/social/use-subscription.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
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<SubscriptionStatus> {
|
||||
try {
|
||||
const subscribed = await fetchJson<boolean>(`${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<void> {
|
||||
if (subscribed) {
|
||||
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${skillId}/subscription`, {
|
||||
method: 'DELETE',
|
||||
headers: getCsrfHeaders(),
|
||||
})
|
||||
} else {
|
||||
await fetchJson<void>(`${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),
|
||||
onMutate: async (currentSubscribed: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['skills', skillId, 'subscription'] })
|
||||
const previous = queryClient.getQueryData<SubscriptionStatus>(['skills', skillId, 'subscription'])
|
||||
queryClient.setQueryData<SubscriptionStatus>(
|
||||
['skills', skillId, 'subscription'],
|
||||
{ subscribed: !currentSubscribed },
|
||||
)
|
||||
return { previous }
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previous !== undefined) {
|
||||
queryClient.setQueryData(['skills', skillId, 'subscription'], context.previous)
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'subscription'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['skills', 'subscriptions'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
@ -520,6 +522,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",
|
||||
|
|
@ -1191,6 +1198,10 @@
|
|||
"starred": "Starred",
|
||||
"star": "Star"
|
||||
},
|
||||
"subscribeButton": {
|
||||
"subscribed": "Subscribed",
|
||||
"subscribe": "Subscribe"
|
||||
},
|
||||
"skillCard": {
|
||||
"starred": "Starred",
|
||||
"starredAction": "Click to unstar",
|
||||
|
|
@ -1215,6 +1226,7 @@
|
|||
"myNamespaces": "My Namespaces",
|
||||
"governance": "Governance Center",
|
||||
"stars": "Starred",
|
||||
"subscriptions": "My Subscriptions",
|
||||
"reviews": "Review Management",
|
||||
"promotions": "Promotion Management",
|
||||
"reports": "Report Management",
|
||||
|
|
|
|||
|
|
@ -314,6 +314,8 @@
|
|||
"platformRoles": "平台角色",
|
||||
"starsAndRatings": "收藏与评分",
|
||||
"viewStars": "查看我的收藏",
|
||||
"subscriptions": "订阅",
|
||||
"viewSubscriptions": "查看我的订阅",
|
||||
"mySkillsTitle": "我的技能",
|
||||
"openMySkills": "查看我的技能",
|
||||
"mySkillsPreviewDescription": "展示最近的 5 个技能,可进入详情或前往“我的技能”查看全部。",
|
||||
|
|
@ -520,6 +522,11 @@
|
|||
"subtitle": "查看你标记过的技能",
|
||||
"empty": "还没有收藏任何技能"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "我的订阅",
|
||||
"subtitle": "您订阅的技能更新通知",
|
||||
"empty": "您还没有订阅任何技能。"
|
||||
},
|
||||
"promotions": {
|
||||
"title": "提升审核",
|
||||
"subtitle": "审核团队技能提升到全局空间的申请",
|
||||
|
|
@ -1192,6 +1199,10 @@
|
|||
"starred": "已收藏",
|
||||
"star": "收藏"
|
||||
},
|
||||
"subscribeButton": {
|
||||
"subscribed": "已订阅",
|
||||
"subscribe": "订阅"
|
||||
},
|
||||
"skillCard": {
|
||||
"starred": "已收藏",
|
||||
"starredAction": "点击取消收藏",
|
||||
|
|
@ -1216,6 +1227,7 @@
|
|||
"myNamespaces": "我的命名空间",
|
||||
"governance": "治理中心",
|
||||
"stars": "我的收藏",
|
||||
"subscriptions": "我的订阅",
|
||||
"reviews": "审核管理",
|
||||
"promotions": "推广管理",
|
||||
"reports": "举报管理",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ export function DashboardPage() {
|
|||
{t('dashboard.viewStars')}
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.subscriptions')}</div>
|
||||
<Link to="/dashboard/subscriptions" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
{t('dashboard.viewSubscriptions')}
|
||||
</Link>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="text-sm text-muted-foreground">{t('dashboard.mySkillsTitle')}</div>
|
||||
<Link to="/dashboard/skills" className="mt-2 inline-block font-semibold text-primary hover:underline">
|
||||
|
|
|
|||
54
web/src/pages/dashboard/subscriptions.tsx
Normal file
54
web/src/pages/dashboard/subscriptions.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-4 animate-fade-up">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-shimmer rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-up">
|
||||
<DashboardPageHeader title={t('subscriptions.title')} subtitle={t('subscriptions.subtitle')} />
|
||||
|
||||
{!skills || skills.length === 0 ? (
|
||||
<Card className="p-12 text-center text-muted-foreground">{t('subscriptions.empty')}</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
onClick={() => navigate({ to: `/space/${skill.namespace}/${encodeURIComponent(skill.slug)}` })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{data && data.total > PAGE_SIZE ? (
|
||||
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,8 @@ export function SkillDetailPage() {
|
|||
{!isFetchingSkill ? (
|
||||
<>
|
||||
<StarButton skillId={skill.id} starCount={skill.starCount} onRequireLogin={requireLogin} />
|
||||
<div className="h-px bg-border/40" />
|
||||
<SubscribeButton skillId={skill.id} subscriptionCount={(skill as { subscriptionCount?: number }).subscriptionCount ?? 0} onRequireLogin={requireLogin} />
|
||||
<RatingInput skillId={skill.id} onRequireLogin={requireLogin} />
|
||||
</>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) {
|
|||
<Link to="/dashboard/stars" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.stars')}
|
||||
</Link>
|
||||
<Link to="/dashboard/subscriptions" className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.subscriptions')}
|
||||
</Link>
|
||||
{reviewCenterVisible ? (
|
||||
<Link to={buildGlobalReviewsPath()} className={menuItemClassName} onClick={closeMenu}>
|
||||
{t('user.menu.reviews')}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ async function getMyStarsPage(params: { page?: number; size?: number } = {}): Pr
|
|||
return meApi.getStarsPage(params)
|
||||
}
|
||||
|
||||
async function getMySubscriptions(): Promise<SkillSummary[]> {
|
||||
return meApi.getSubscriptions()
|
||||
}
|
||||
|
||||
async function getMySubscriptionsPage(params: { page?: number; size?: number } = {}): Promise<PagedResponse<SkillSummary>> {
|
||||
return meApi.getSubscriptionsPage(params)
|
||||
}
|
||||
|
||||
async function submitPromotion(params: { sourceSkillId: number; sourceVersionId: number }): Promise<void> {
|
||||
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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue