Merge pull request #356 from iflytek/feature/skill-subscription-notification

feat(subscription): add skill subscription notification feature
This commit is contained in:
dongmucat 2026-04-29 15:20:40 +08:00 • committed by GitHub
commit f41723e0dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1101 additions and 1 deletions

View file

@ -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));
}
}

View file

@ -86,6 +86,7 @@ public class SkillController extends BaseApiController {
detail.status(),
detail.downloadCount(),
detail.starCount(),
detail.subscriptionCount(),
detail.ratingAvg(),
detail.ratingCount(),
detail.hidden(),

View file

@ -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);
}
}

View file

@ -14,6 +14,7 @@ public record SkillDetailResponse(
String status,
Long downloadCount,
Integer starCount,
Integer subscriptionCount,
BigDecimal ratingAvg,
Integer ratingCount,
boolean hidden,

View file

@ -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) {

View file

@ -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,

View file

@ -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(),

View file

@ -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;

View file

@ -178,6 +178,7 @@ class ReviewPortalControllerTest {
"ACTIVE",
8L,
2,
0,
null,
0,
false,

View file

@ -161,6 +161,7 @@ class SkillControllerTest {
"ACTIVE",
10L,
2,
0,
null,
0,
false,

View file

@ -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));
}
}

View file

@ -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();
}
}

View file

@ -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
);

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.domain.event;
public record SkillVersionYankedEvent(Long skillId, Long versionId, String actorUserId) {}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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(),

View file

@ -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; }
}

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.domain.social.event;
public record SkillSubscribedEvent(Long skillId, String userId) {}

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.domain.social.event;
public record SkillUnsubscribedEvent(Long skillId, String userId) {}

View file

@ -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);

View file

@ -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);
}

View file

@ -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")

View 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()
}
})
})

View file

@ -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 = {

View file

@ -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: {

View file

@ -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,

View file

@ -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,

View 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>
)
}

View 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')
})
})

View 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'] })
},
})
}

View file

@ -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",

View file

@ -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": "举报管理",

View file

@ -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">

View 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>
)
}

View file

@ -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}

View file

@ -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')}

View file

@ -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()