diff --git a/.gitignore b/.gitignore index b0035ade..0e1f80a9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ __pycache__/ # Superpowers (AI planning artifacts) docs/superpowers/ +.superpowers/ docs/review/ docs/requirements/ diff --git a/server/pom.xml b/server/pom.xml index c256ee63..8322b723 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -31,6 +31,7 @@ skillhub-search skillhub-storage skillhub-infra + skillhub-notification @@ -60,6 +61,11 @@ skillhub-infra ${project.version} + + com.iflytek.skillhub + skillhub-notification + ${project.version} + diff --git a/server/scripts/run-dev-app.sh b/server/scripts/run-dev-app.sh index 843bf182..7de865d4 100755 --- a/server/scripts/run-dev-app.sh +++ b/server/scripts/run-dev-app.sh @@ -7,7 +7,7 @@ PROFILE="${SPRING_PROFILES_ACTIVE:-local}" cd "$SERVER_DIR" -./mvnw -pl skillhub-app -am package -DskipTests >/dev/null +./mvnw -pl skillhub-app -am clean package -DskipTests >/dev/null APP_JAR="$(find skillhub-app/target -maxdepth 1 -type f -name 'skillhub-app-*.jar' ! -name '*.original' | head -n 1)" if [[ -z "$APP_JAR" ]]; then diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index ec5dfdb0..4b57b318 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -47,6 +47,10 @@ com.iflytek.skillhub skillhub-search + + com.iflytek.skillhub + skillhub-notification + org.springframework.boot spring-boot-starter-data-jpa diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java index bbc43dd1..7e7da3bc 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistrySecurityConfig.java @@ -6,6 +6,8 @@ import org.springframework.core.annotation.Order; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; /** * Declares a dedicated stateless security chain for public compatibility endpoints used by @@ -16,9 +18,32 @@ public class ClawHubRegistrySecurityConfig { @Bean @Order(0) + public SecurityFilterChain publicLabelFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher( + new OrRequestMatcher( + new AntPathRequestMatcher("/api/v1/labels"), + new AntPathRequestMatcher("/api/web/labels") + ) + ) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .csrf(csrf -> csrf.disable()) + .requestCache(cache -> cache.disable()) + .securityContext(context -> context.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + return http.build(); + } + + @Bean + @Order(1) public SecurityFilterChain clawHubRegistryFilterChain(HttpSecurity http) throws Exception { http - .securityMatcher("/api/v1/search", "/api/v1/download", "/api/v1/skills/*") + .securityMatcher( + "/api/v1/search", + "/api/v1/download", + "/api/v1/skills/*" + ) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .requestCache(cache -> cache.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java index 8e921cd4..7feb8955 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; @@ -14,6 +15,7 @@ import java.util.concurrent.ThreadPoolExecutor; */ @Configuration @EnableAsync +@EnableScheduling public class AsyncConfig { @Bean(name = "skillhubEventExecutor") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java index 0a7bfd93..431b46a8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.config; +import com.iflytek.skillhub.notification.sse.SseEmitterManager; import com.iflytek.skillhub.ratelimit.RateLimitInterceptor; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -22,4 +24,11 @@ public class WebMvcRateLimitConfig implements WebMvcConfigurer { registry.addInterceptor(rateLimitInterceptor) .addPathPatterns("/api/**"); } + + @Override + public void configureAsyncSupport(AsyncSupportConfigurer configurer) { + // Keep MVC async timeouts above the SSE emitter timeout so EventSource + // connections are not forcibly torn down every few seconds. + configurer.setDefaultTimeout(SseEmitterManager.defaultTimeoutMillis()); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java new file mode 100644 index 00000000..6a1f1c68 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -0,0 +1,151 @@ +package com.iflytek.skillhub.controller.portal; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import com.iflytek.skillhub.notification.sse.SseEmitterManager; +import java.util.Collections; +import java.util.Map; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@Validated +@RequestMapping({"/api/v1/notifications", "/api/web/notifications"}) +public class NotificationController extends BaseApiController { + + private final NotificationService notificationService; + private final SseEmitterManager sseEmitterManager; + private final ObjectMapper objectMapper; + + public NotificationController(NotificationService notificationService, + SseEmitterManager sseEmitterManager, + ObjectMapper objectMapper, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.notificationService = notificationService; + this.sseEmitterManager = sseEmitterManager; + this.objectMapper = objectMapper; + } + + @GetMapping + public ApiResponse> list( + @RequestAttribute("userId") String userId, + @RequestParam(required = false) String category, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + NotificationCategory cat = parseCategory(category); + Page result = notificationService.list( + userId, cat, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"))); + Page mapped = result.map(this::toResponse); + return ok("response.success.read", PageResponse.from(mapped)); + } + + @GetMapping("/unread-count") + public ApiResponse> unreadCount(@RequestAttribute("userId") String userId) { + long count = notificationService.getUnreadCount(userId); + return ok("response.success.read", Map.of("count", count)); + } + + @PutMapping("/{id}/read") + public ApiResponse markRead(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + notificationService.markRead(id, userId); + return ok("response.success.updated", null); + } + + @PutMapping("/read-all") + public ApiResponse> markAllRead(@RequestAttribute("userId") String userId) { + int updated = notificationService.markAllRead(userId); + return ok("response.success.updated", Map.of("updated", updated)); + } + + @DeleteMapping("/{id}") + public ApiResponse deleteRead(@PathVariable Long id, + @RequestAttribute("userId") String userId) { + notificationService.deleteRead(id, userId); + return ok("response.success.deleted", null); + } + + @GetMapping("/sse") + public SseEmitter sse(@RequestAttribute("userId") String userId) { + return sseEmitterManager.register(userId); + } + + private NotificationResponse toResponse(Notification n) { + NotificationTarget target = resolveTarget(n); + return new NotificationResponse( + n.getId(), + n.getCategory().name(), + n.getEventType(), + n.getTitle(), + n.getBodyJson(), + n.getEntityType(), + n.getEntityId(), + n.getStatus().name(), + n.getCreatedAt() != null ? n.getCreatedAt().toString() : null, + n.getReadAt() != null ? n.getReadAt().toString() : null, + target.targetType(), + target.targetId(), + target.targetRoute() + ); + } + + private NotificationTarget resolveTarget(Notification notification) { + String eventType = notification.getEventType(); + String entityType = notification.getEntityType(); + Long entityId = notification.getEntityId(); + Map body = parseBody(notification.getBodyJson()); + String namespace = body.get("namespace") instanceof String value ? value : null; + String slug = body.get("slug") instanceof String value ? value : null; + + if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); + } + if ("PROMOTION_SUBMITTED".equals(eventType)) { + return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); + } + if ("REPORT_SUBMITTED".equals(eventType)) { + return new NotificationTarget("REPORT", entityId, "/dashboard/reports"); + } + if (namespace != null && slug != null && ("SKILL".equals(entityType) || notification.getCategory() == NotificationCategory.PUBLISH)) { + return new NotificationTarget("SKILL", entityId, "/space/" + namespace + "/" + slug); + } + return new NotificationTarget(entityType, entityId, "/dashboard/notifications"); + } + + private Map parseBody(String bodyJson) { + if (bodyJson == null || bodyJson.isBlank()) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(bodyJson, new TypeReference<>() {}); + } catch (Exception ex) { + return Collections.emptyMap(); + } + } + + private NotificationCategory parseCategory(String category) { + if (category == null || category.isBlank()) { + return null; + } + try { + return NotificationCategory.valueOf(category); + } catch (IllegalArgumentException ex) { + throw new DomainBadRequestException("error.notification.category.invalid", category); + } + } + + private record NotificationTarget(String targetType, Long targetId, String targetRoute) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceController.java new file mode 100644 index 00000000..7b586112 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceController.java @@ -0,0 +1,82 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.dto.*; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService.PreferenceCommand; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService.PreferenceView; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@Validated +@RequestMapping({"/api/v1/notification-preferences", "/api/web/notification-preferences"}) +public class NotificationPreferenceController extends BaseApiController { + + private final NotificationPreferenceService preferenceService; + + public NotificationPreferenceController(NotificationPreferenceService preferenceService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.preferenceService = preferenceService; + } + + @GetMapping + public ApiResponse> getPreferences( + @RequestAttribute("userId") String userId) { + List prefs = preferenceService.getPreferences(userId).stream() + .map(this::toResponse) + .toList(); + return ok("response.success.read", prefs); + } + + @PutMapping + public ApiResponse> updatePreferences( + @RequestAttribute("userId") String userId, + @RequestBody NotificationPreferenceUpdateRequest request) { + if (request == null || request.preferences() == null) { + throw new DomainBadRequestException("error.notification.preference.request.invalid"); + } + List commands = request.preferences().stream() + .map(item -> new PreferenceCommand( + parseCategory(item.category()), + parseChannel(item.channel()), + item.enabled() + )) + .toList(); + preferenceService.updatePreferences(userId, commands); + List prefs = preferenceService.getPreferences(userId).stream() + .map(this::toResponse) + .toList(); + return ok("response.success.updated", prefs); + } + + private NotificationCategory parseCategory(String category) { + try { + return NotificationCategory.valueOf(category); + } catch (Exception ex) { + throw new DomainBadRequestException("error.notification.preference.category.invalid", category); + } + } + + private NotificationChannel parseChannel(String channel) { + try { + return NotificationChannel.valueOf(channel); + } catch (Exception ex) { + throw new DomainBadRequestException("error.notification.preference.channel.invalid", channel); + } + } + + private NotificationPreferenceResponse toResponse(PreferenceView pv) { + return new NotificationPreferenceResponse( + pv.category().name(), + pv.channel().name(), + pv.enabled() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index e54aeec4..cf233a74 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -1,36 +1,58 @@ package com.iflytek.skillhub.controller.portal; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.security.ScannerType; import com.iflytek.skillhub.domain.security.SecurityAudit; import com.iflytek.skillhub.domain.security.SecurityAuditRepository; import com.iflytek.skillhub.domain.security.SecurityFinding; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SecurityAuditResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Set; @RestController @RequestMapping("/api/v1/skills/{skillId}/versions/{versionId}/security-audit") public class SecurityAuditController extends BaseApiController { private final SecurityAuditRepository securityAuditRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final VisibilityChecker visibilityChecker; private final ObjectMapper objectMapper; public SecurityAuditController(SecurityAuditRepository securityAuditRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + VisibilityChecker visibilityChecker, ApiResponseFactory responseFactory, ObjectMapper objectMapper) { super(responseFactory); this.securityAuditRepository = securityAuditRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.visibilityChecker = visibilityChecker; this.objectMapper = objectMapper; } @@ -38,7 +60,20 @@ public class SecurityAuditController extends BaseApiController { public ApiResponse> getSecurityAudits( @PathVariable Long skillId, @PathVariable Long versionId, - @RequestParam(required = false) String scannerType) { + @RequestParam(required = false) String scannerType, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + if (!version.getSkillId().equals(skillId)) { + throw new DomainBadRequestException("error.skill.version.notFound", versionId); + } + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + if (!canViewAudit(skill, principal, userNsRoles)) { + throw new DomainForbiddenException("error.forbidden"); + } List audits; if (scannerType != null && !scannerType.isBlank()) { @@ -57,6 +92,20 @@ public class SecurityAuditController extends BaseApiController { return ok("security_audit.found", responses); } + private boolean canViewAudit(Skill skill, + PlatformPrincipal principal, + Map userNsRoles) { + if (principal == null) { + return false; + } + Set platformRoles = principal.platformRoles() != null ? principal.platformRoles() : Set.of(); + if (platformRoles.contains("SUPER_ADMIN") || platformRoles.contains("SKILL_ADMIN")) { + return true; + } + Map namespaceRoles = userNsRoles != null ? userNsRoles : Map.of(); + return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles); + } + private SecurityAuditResponse toResponse(SecurityAudit audit) { return new SecurityAuditResponse( audit.getId(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceResponse.java new file mode 100644 index 00000000..c9dd8646 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceResponse.java @@ -0,0 +1,3 @@ +package com.iflytek.skillhub.dto; + +public record NotificationPreferenceResponse(String category, String channel, boolean enabled) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceUpdateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceUpdateRequest.java new file mode 100644 index 00000000..c3c5765d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationPreferenceUpdateRequest.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +public record NotificationPreferenceUpdateRequest( + List preferences +) { + public record PreferenceItem(String category, String channel, boolean enabled) {} +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationResponse.java new file mode 100644 index 00000000..75b83cf7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NotificationResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record NotificationResponse( + Long id, String category, String eventType, String title, + String bodyJson, String entityType, Long entityId, + String status, String createdAt, String readAt, + String targetType, Long targetId, String targetRoute +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java new file mode 100644 index 00000000..5134e4dd --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -0,0 +1,224 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.*; +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.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationDispatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class NotificationEventListener { + + private static final Logger log = LoggerFactory.getLogger(NotificationEventListener.class); + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final RecipientResolver recipientResolver; + private final NotificationDispatcher dispatcher; + private final ObjectMapper objectMapper; + + public NotificationEventListener(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + RecipientResolver recipientResolver, + NotificationDispatcher dispatcher, + ObjectMapper objectMapper) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.recipientResolver = recipientResolver; + this.dispatcher = dispatcher; + this.objectMapper = objectMapper; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onSkillPublished(SkillPublishedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + if (!event.publisherId().equals(skill.getCreatedBy())) { + return; + } + String title = "Skill published: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + versionLabel(event.versionId(), body); + String json = toJson(body); + dispatcher.dispatch(event.publisherId(), NotificationCategory.PUBLISH, + "SKILL_PUBLISHED", title, json, "SKILL", event.skillId()); + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewSubmitted(ReviewSubmittedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "New review submitted for: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("reviewId", event.reviewId()); + body.put("submitterId", event.submitterId()); + versionLabel(event.versionId(), body); + String json = toJson(body); + List admins = recipientResolver.resolveNamespaceAdmins(event.namespaceId()); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "REVIEW_SUBMITTED", title, json, "REVIEW", event.reviewId()); + } + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewApproved(ReviewApprovedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Review approved: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("reviewId", event.reviewId()); + body.put("reviewerId", event.reviewerId()); + versionLabel(event.versionId(), body); + String json = toJson(body); + dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW, + "REVIEW_APPROVED", title, json, "SKILL", event.skillId()); + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewRejected(ReviewRejectedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Review rejected: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("reviewId", event.reviewId()); + body.put("reviewerId", event.reviewerId()); + body.put("reason", event.reason()); + versionLabel(event.versionId(), body); + String json = toJson(body); + dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW, + "REVIEW_REJECTED", title, json, "SKILL", event.skillId()); + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onPromotionSubmitted(PromotionSubmittedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Promotion submitted for: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("promotionId", event.promotionId()); + body.put("submitterId", event.submitterId()); + versionLabel(event.versionId(), body); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformSkillAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.PROMOTION, + "PROMOTION_SUBMITTED", title, json, "PROMOTION", event.promotionId()); + } + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onPromotionApproved(PromotionApprovedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Promotion approved: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("promotionId", event.promotionId()); + body.put("reviewerId", event.reviewerId()); + String json = toJson(body); + dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION, + "PROMOTION_APPROVED", title, json, "SKILL", event.skillId()); + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onPromotionRejected(PromotionRejectedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Promotion rejected: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("promotionId", event.promotionId()); + body.put("reviewerId", event.reviewerId()); + body.put("reason", event.reason()); + String json = toJson(body); + dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION, + "PROMOTION_REJECTED", title, json, "SKILL", event.skillId()); + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReportSubmitted(ReportSubmittedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Skill reported: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("reportId", event.reportId()); + body.put("reporterId", event.reporterId()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformSkillAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REPORT, + "REPORT_SUBMITTED", title, json, "REPORT", event.reportId()); + } + }); + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReportResolved(ReportResolvedEvent event) { + skillRepository.findById(event.skillId()).ifPresent(skill -> { + String title = "Report resolved: " + skillDisplayName(skill); + Map body = bodyWithSkill(skill); + body.put("reportId", event.reportId()); + body.put("handlerId", event.handlerId()); + body.put("action", event.action()); + String json = toJson(body); + dispatcher.dispatch(event.reporterId(), NotificationCategory.REPORT, + "REPORT_RESOLVED", title, json, "SKILL", event.skillId()); + }); + } + + // --- helpers --- + + private String skillDisplayName(Skill skill) { + String name = skill.getDisplayName(); + return (name != null && !name.isBlank()) ? name : skill.getSlug(); + } + + private Map bodyWithSkill(Skill skill) { + Map map = new LinkedHashMap<>(); + map.put("skillId", skill.getId()); + map.put("skillName", skillDisplayName(skill)); + map.put("slug", skill.getSlug()); + namespaceRepository.findById(skill.getNamespaceId()) + .ifPresent(namespace -> map.put("namespace", namespace.getSlug())); + return map; + } + + private void versionLabel(Long versionId, Map body) { + if (versionId != null) { + skillVersionRepository.findById(versionId).ifPresent(v -> + body.put("version", v.getVersion())); + } + } + + private String toJson(Map body) { + try { + return objectMapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize notification body", e); + return "{}"; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java new file mode 100644 index 00000000..90e076d7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import java.util.LinkedHashSet; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Set; + +@Component +public class RecipientResolver { + + private final NamespaceMemberRepository namespaceMemberRepository; + private final UserRoleBindingRepository userRoleBindingRepository; + + public RecipientResolver(NamespaceMemberRepository namespaceMemberRepository, + UserRoleBindingRepository userRoleBindingRepository) { + this.namespaceMemberRepository = namespaceMemberRepository; + this.userRoleBindingRepository = userRoleBindingRepository; + } + + public List resolveNamespaceAdmins(Long namespaceId) { + return namespaceMemberRepository + .findByNamespaceIdAndRoleIn(namespaceId, Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN)) + .stream() + .map(NamespaceMember::getUserId) + .toList(); + } + + public List resolvePlatformSkillAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("SKILL_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillDeleteAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillDeleteAppService.java index 25bd1b6b..581b4a78 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillDeleteAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillDeleteAppService.java @@ -69,6 +69,7 @@ public class SkillDeleteAppService { if (enforcePortalOwnership && !canDeleteFromPortal(skill, principal)) { throw new DomainForbiddenException("error.forbidden"); } + searchIndexService.remove(skill.getId()); skillHardDeleteService.hardDeleteSkill( skill, namespace, @@ -76,7 +77,6 @@ public class SkillDeleteAppService { auditRequestContext != null ? auditRequestContext.clientIp() : null, auditRequestContext != null ? auditRequestContext.userAgent() : null ); - searchIndexService.remove(skill.getId()); return new DeleteResult(skill.getId(), namespace, slug, true); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java index f76e952b..812aa84c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java @@ -29,6 +29,7 @@ public abstract class AbstractStreamConsumer implements StreamListener> container; @@ -46,6 +47,7 @@ public abstract class AbstractStreamConsumer implements StreamListener implements StreamListener implements StreamListener implements StreamListener message) { T payload = parsePayload(message.getId().getValue(), message.getValue()); if (payload == null) { + acknowledge(message); return; } @@ -102,8 +105,10 @@ public abstract class AbstractStreamConsumer implements StreamListener implements StreamListener 500 ? error.substring(0, 500) : error; } + protected StringRedisTemplate createRedisTemplate() { + return new StringRedisTemplate(connectionFactory); + } + + protected void acknowledge(MapRecord message) { + redisTemplate().opsForStream().acknowledge(streamKey, groupName, message.getId()); + } + + private StringRedisTemplate redisTemplate() { + if (redisTemplate == null) { + redisTemplate = createRedisTemplate(); + } + return redisTemplate; + } + protected abstract String taskDisplayName(); protected abstract String consumerPrefix(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index 0e39a6e2..05142b9a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -22,6 +22,7 @@ import java.util.Comparator; import java.util.Map; public class ScanTaskConsumer extends AbstractStreamConsumer { + private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize(); private final SecurityScanner securityScanner; private final SecurityScanService securityScanService; @@ -136,7 +137,11 @@ public class ScanTaskConsumer extends AbstractStreamConsumer { diff --git a/server/skillhub-app/src/main/resources/application-local.yml b/server/skillhub-app/src/main/resources/application-local.yml index 54336f95..259375ee 100644 --- a/server/skillhub-app/src/main/resources/application-local.yml +++ b/server/skillhub-app/src/main/resources/application-local.yml @@ -27,6 +27,10 @@ skillhub: auth: mock: enabled: true + notification: + cleanup: + read-retention-days: 30 + unread-retention-days: 90 security: scanner: enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:false} diff --git a/server/skillhub-app/src/main/resources/db/migration/V34__notification_system.sql b/server/skillhub-app/src/main/resources/db/migration/V34__notification_system.sql new file mode 100644 index 00000000..43a5c6c9 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V34__notification_system.sql @@ -0,0 +1,25 @@ +CREATE TABLE notification ( + id BIGSERIAL PRIMARY KEY, + recipient_id VARCHAR(128) NOT NULL, + category VARCHAR(32) NOT NULL, + event_type VARCHAR(64) NOT NULL, + title VARCHAR(200) NOT NULL, + body_json TEXT, + entity_type VARCHAR(64), + entity_id BIGINT, + status VARCHAR(20) NOT NULL DEFAULT 'UNREAD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ +); + +CREATE INDEX idx_notification_recipient_created ON notification(recipient_id, created_at DESC); +CREATE INDEX idx_notification_recipient_status ON notification(recipient_id, status, created_at DESC); + +CREATE TABLE notification_preference ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + category VARCHAR(32) NOT NULL, + channel VARCHAR(32) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + UNIQUE(user_id, category, channel) +); diff --git a/server/skillhub-app/src/main/resources/db/migration/V34__skill_label_system.sql b/server/skillhub-app/src/main/resources/db/migration/V35__skill_label_system.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V34__skill_label_system.sql rename to server/skillhub-app/src/main/resources/db/migration/V35__skill_label_system.sql diff --git a/server/skillhub-app/src/main/resources/db/migration/V35__security_audit.sql b/server/skillhub-app/src/main/resources/db/migration/V36__security_audit.sql similarity index 100% rename from server/skillhub-app/src/main/resources/db/migration/V35__security_audit.sql rename to server/skillhub-app/src/main/resources/db/migration/V36__security_audit.sql diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java new file mode 100644 index 00000000..a5ac41d6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +class AsyncConfigTest { + + @Test + void asyncConfig_enablesAsyncAndScheduling() { + assertThat(AsyncConfig.class).hasAnnotation(EnableAsync.class); + assertThat(AsyncConfig.class).hasAnnotation(EnableScheduling.class); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java new file mode 100644 index 00000000..daf845c8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java @@ -0,0 +1,28 @@ +package com.iflytek.skillhub.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.iflytek.skillhub.notification.sse.SseEmitterManager; +import com.iflytek.skillhub.ratelimit.RateLimitInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; + +class WebMvcRateLimitConfigTest { + + @Test + void configureAsyncSupport_shouldSetTimeoutToMatchSseTimeout() { + WebMvcRateLimitConfig config = new WebMvcRateLimitConfig(mock(RateLimitInterceptor.class)); + TestAsyncSupportConfigurer asyncSupportConfigurer = new TestAsyncSupportConfigurer(); + + config.configureAsyncSupport(asyncSupportConfigurer); + + assertThat(asyncSupportConfigurer.timeout()).isEqualTo(SseEmitterManager.defaultTimeoutMillis()); + } + + private static final class TestAsyncSupportConfigurer extends AsyncSupportConfigurer { + private Long timeout() { + return getTimeout(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LabelControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LabelControllerTest.java index f830c446..7e93b54c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LabelControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LabelControllerTest.java @@ -48,4 +48,16 @@ class LabelControllerTest { .andExpect(jsonPath("$.data[1].type").value("PRIVILEGED")) .andExpect(jsonPath("$.data[1].displayName").value("Verified")); } + + @Test + void listVisibleLabelsShouldAlsoBePublicOnV1Path() throws Exception { + when(publicLabelAppService.listVisibleFilters()) + .thenReturn(List.of(new SkillLabelDto("code-generation", "RECOMMENDED", "Code Generation"))); + + mockMvc.perform(get("/api/v1/labels")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].slug").value("code-generation")) + .andExpect(jsonPath("$.data[0].displayName").value("Code Generation")); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java new file mode 100644 index 00000000..73a2a180 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -0,0 +1,121 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.NotificationResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import com.iflytek.skillhub.notification.sse.SseEmitterManager; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class NotificationControllerTest { + + @Mock + private NotificationService notificationService; + + @Mock + private SseEmitterManager sseEmitterManager; + + private NotificationController controller; + + @BeforeEach + void setUp() { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("response.success.read", java.util.Locale.getDefault(), "ok"); + ApiResponseFactory responseFactory = new ApiResponseFactory( + messageSource, + Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC) + ); + controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory); + } + + @Test + void list_shouldExposeReviewTargetRouteForSubmittedReviewNotifications() { + Notification notification = notification( + 11L, + NotificationCategory.REVIEW, + "REVIEW_SUBMITTED", + "{\"namespace\":\"demo\",\"slug\":\"skill-a\"}", + "REVIEW", + 99L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("user-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("REVIEW"); + assertThat(item.targetId()).isEqualTo(99L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews/99"); + }); + verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); + } + + @Test + void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { + Notification notification = notification( + 12L, + NotificationCategory.REVIEW, + "REVIEW_APPROVED", + "{\"namespace\":\"demo\",\"slug\":\"skill-a\"}", + "SKILL", + 101L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("user-1", null, 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("SKILL"); + assertThat(item.targetId()).isEqualTo(101L); + assertThat(item.targetRoute()).isEqualTo("/space/demo/skill-a"); + }); + } + + @Test + void deleteRead_shouldDelegateToService() { + controller.deleteRead(10L, "user-1"); + + verify(notificationService).deleteRead(10L, "user-1"); + } + + private Notification notification(Long id, + NotificationCategory category, + String eventType, + String bodyJson, + String entityType, + Long entityId) { + Notification notification = new Notification( + "user-1", + category, + eventType, + "Title", + bodyJson, + entityType, + entityId, + Instant.parse("2026-03-20T00:00:00Z") + ); + ReflectionTestUtils.setField(notification, "id", id); + return notification; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerValidationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerValidationTest.java new file mode 100644 index 00000000..35e7365e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerValidationTest.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.controller.portal; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import org.junit.jupiter.api.Test; +import org.springframework.validation.annotation.Validated; + +class NotificationControllerValidationTest { + + @Test + void notificationController_enablesRequestValidation() { + assertThat(NotificationController.class).hasAnnotation(Validated.class); + } + + @Test + void list_appliesReasonablePageBounds() throws Exception { + Method method = NotificationController.class.getMethod( + "list", + String.class, + String.class, + int.class, + int.class + ); + Parameter[] parameters = method.getParameters(); + + assertThat(parameters[2].getAnnotation(Min.class)).isNotNull(); + assertThat(parameters[3].getAnnotation(Min.class)).isNotNull(); + assertThat(parameters[3].getAnnotation(Max.class)).isNotNull(); + assertThat(parameters[3].getAnnotation(Max.class).value()).isEqualTo(100); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index e7dd235d..ab4ddb2b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -1,11 +1,18 @@ package com.iflytek.skillhub.controller.portal; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.security.ScannerType; import com.iflytek.skillhub.domain.security.SecurityAudit; import com.iflytek.skillhub.domain.security.SecurityAuditRepository; import com.iflytek.skillhub.domain.security.ScanTaskProducer; import com.iflytek.skillhub.domain.security.SecurityVerdict; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -20,6 +27,7 @@ import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.lang.reflect.Field; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Set; import static org.mockito.BDDMockito.given; @@ -39,6 +47,12 @@ class SecurityAuditControllerTest { @MockBean private SecurityAuditRepository securityAuditRepository; + @MockBean + private SkillRepository skillRepository; + + @MockBean + private SkillVersionRepository skillVersionRepository; + @MockBean private ScanTaskProducer scanTaskProducer; @@ -56,10 +70,14 @@ class SecurityAuditControllerTest { """.trim()); audit.setScanDurationSeconds(1.25); audit.setScannedAt(Instant.parse("2026-03-20T08:00:00Z")); + given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L))); + given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "reviewer-1"))); given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit)); - mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit").with(auth("reviewer-1"))) + mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit") + .with(auth("reviewer-1")) + .requestAttr("userNsRoles", Map.of(5L, NamespaceRole.ADMIN))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data[0].id").value(7L)) @@ -72,9 +90,13 @@ class SecurityAuditControllerTest { @Test void getSecurityAudit_returnsEmptyListWhenAuditMissing() throws Exception { + given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L))); + given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "reviewer-1"))); given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of()); - mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit").with(auth("reviewer-1"))) + mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit") + .with(auth("reviewer-1")) + .requestAttr("userNsRoles", Map.of(5L, NamespaceRole.ADMIN))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data").isArray()) @@ -88,6 +110,25 @@ class SecurityAuditControllerTest { .andExpect(jsonPath("$.code").value(401)); } + @Test + void getSecurityAudit_rejectsVersionSkillMismatch() throws Exception { + given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 9L))); + + mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit").with(auth("reviewer-1"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void getSecurityAudit_forbidsUnauthorizedViewer() throws Exception { + given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L))); + given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "owner-1"))); + + mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit").with(auth("viewer-1"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + } + private RequestPostProcessor auth(String userId) { PlatformPrincipal principal = new PlatformPrincipal( userId, @@ -105,6 +146,19 @@ class SecurityAuditControllerTest { return authentication(authenticationToken); } + private SkillVersion skillVersion(Long versionId, Long skillId) { + SkillVersion version = new SkillVersion(skillId, "1.0.0", "owner-1"); + setField(version, "id", versionId); + return version; + } + + private Skill skill(Long skillId, String ownerId) { + Skill skill = new Skill(5L, "caldav-calendar", ownerId, SkillVisibility.PRIVATE); + setField(skill, "id", skillId); + setField(skill, "status", SkillStatus.ACTIVE); + return skill; + } + private void setField(Object target, String fieldName, Object value) { try { Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerAsyncTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerAsyncTest.java new file mode 100644 index 00000000..b7c6c6fa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerAsyncTest.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.listener; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.springframework.scheduling.annotation.Async; + +class NotificationEventListenerAsyncTest { + + private static final Set EVENT_HANDLER_METHODS = Set.of( + "onSkillPublished", + "onReviewSubmitted", + "onReviewApproved", + "onReviewRejected", + "onPromotionSubmitted", + "onPromotionApproved", + "onPromotionRejected", + "onReportSubmitted", + "onReportResolved" + ); + + @Test + void notificationHandlers_bindToSkillhubEventExecutor() { + Set testedMethods = Arrays.stream(NotificationEventListener.class.getDeclaredMethods()) + .filter(method -> EVENT_HANDLER_METHODS.contains(method.getName())) + .peek(this::assertUsesNamedExecutor) + .map(Method::getName) + .collect(Collectors.toSet()); + + assertThat(testedMethods).isEqualTo(EVENT_HANDLER_METHODS); + } + + private void assertUsesNamedExecutor(Method method) { + Async async = method.getAnnotation(Async.class); + assertThat(async) + .withFailMessage("Expected @Async on %s", method.getName()) + .isNotNull(); + assertThat(async.value()).isEqualTo("skillhubEventExecutor"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java new file mode 100644 index 00000000..8e59b434 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.*; +import com.iflytek.skillhub.domain.namespace.Namespace; +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.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationDispatcher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationEventListenerTest { + + @Mock SkillRepository skillRepository; + @Mock SkillVersionRepository skillVersionRepository; + @Mock NamespaceRepository namespaceRepository; + @Mock RecipientResolver recipientResolver; + @Mock NotificationDispatcher dispatcher; + @Mock ObjectMapper objectMapper; + + @InjectMocks + NotificationEventListener listener; + + private Skill mockSkill(Long id) { + Skill skill = mock(Skill.class); + when(skill.getId()).thenReturn(id); + when(skill.getNamespaceId()).thenReturn(5L); + when(skill.getDisplayName()).thenReturn("Test Skill"); + when(skill.getSlug()).thenReturn("test-skill"); + return skill; + } + + private void mockNamespace() { + Namespace namespace = mock(Namespace.class); + when(namespace.getSlug()).thenReturn("demo"); + when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace)); + } + + @Test + void onSkillPublished_shouldDispatchToPublisher() throws Exception { + Skill skill = mockSkill(1L); + when(skill.getCreatedBy()).thenReturn("publisher-1"); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "publisher-1")); + + verify(dispatcher).dispatch(eq("publisher-1"), eq(NotificationCategory.PUBLISH), + eq("SKILL_PUBLISHED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + + @Test + void onSkillPublished_shouldSkipWhenPublisherIsNotSkillCreator() throws Exception { + Skill skill = mock(Skill.class); + when(skill.getCreatedBy()).thenReturn("submitter-1"); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + + listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); + + verifyNoInteractions(dispatcher); + } + + @Test + void onSkillPublished_shouldSkipWhenSkillNotFound() { + when(skillRepository.findById(99L)).thenReturn(Optional.empty()); + + listener.onSkillPublished(new SkillPublishedEvent(99L, 10L, "publisher-1")); + + verifyNoInteractions(dispatcher); + } + + @Test + void onReviewSubmitted_shouldDispatchToNamespaceAdmins() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("admin-1", "admin-2")); + + listener.onReviewSubmitted(new ReviewSubmittedEvent(100L, 1L, 10L, "submitter-1", 5L)); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); + verify(dispatcher).dispatch(eq("admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); + } + + @Test + void onReviewApproved_shouldDispatchToSubmitter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onReviewApproved(new ReviewApprovedEvent(100L, 1L, 10L, "reviewer-1", "submitter-1")); + + verify(dispatcher).dispatch(eq("submitter-1"), eq(NotificationCategory.REVIEW), + eq("REVIEW_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } + + @Test + void onPromotionSubmitted_shouldDispatchToPlatformAdmins() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformSkillAdmins()) + .thenReturn(List.of("platform-admin-1", "super-admin-1")); + + listener.onPromotionSubmitted(new PromotionSubmittedEvent(200L, 1L, 10L, "submitter-1")); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); + verify(dispatcher).dispatch(eq("platform-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + + @Test + void onPromotionSubmitted_shouldDispatchOncePerUniqueRecipient() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformSkillAdmins()) + .thenReturn(List.of("platform-admin-1", "platform-admin-1")); + + listener.onPromotionSubmitted(new PromotionSubmittedEvent(200L, 1L, 10L, "submitter-1")); + + verify(dispatcher, times(1)).dispatch(eq("platform-admin-1"), eq(NotificationCategory.PROMOTION), + eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); + } + + @Test + void onReportResolved_shouldDispatchToReporter() throws Exception { + Skill skill = mockSkill(1L); + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill)); + mockNamespace(); + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + + listener.onReportResolved(new ReportResolvedEvent(300L, 1L, "handler-1", "reporter-1", "DISMISSED")); + + verify(dispatcher).dispatch(eq("reporter-1"), eq(NotificationCategory.REPORT), + eq("REPORT_RESOLVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java new file mode 100644 index 00000000..20325b9d --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -0,0 +1,83 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.auth.entity.UserRoleBinding; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMember; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RecipientResolverTest { + + @Mock + NamespaceMemberRepository namespaceMemberRepository; + + @Mock + UserRoleBindingRepository userRoleBindingRepository; + + @InjectMocks + RecipientResolver resolver; + + @Test + void resolveNamespaceAdmins_shouldReturnAdminAndOwnerUserIds() { + NamespaceMember owner = new NamespaceMember(1L, "user-owner", NamespaceRole.OWNER); + NamespaceMember admin = new NamespaceMember(1L, "user-admin", NamespaceRole.ADMIN); + when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(1L, Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN))) + .thenReturn(List.of(owner, admin)); + + List result = resolver.resolveNamespaceAdmins(1L); + + assertThat(result).containsExactlyInAnyOrder("user-owner", "user-admin"); + } + + @Test + void resolveNamespaceAdmins_shouldReturnEmptyWhenNoAdmins() { + when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(anyLong(), anySet())) + .thenReturn(List.of()); + + List result = resolver.resolveNamespaceAdmins(99L); + + assertThat(result).isEmpty(); + } + + @Test + void resolvePlatformSkillAdmins_shouldReturnSkillAdminUserIds() { + UserRoleBinding b1 = mock(UserRoleBinding.class); + UserRoleBinding b2 = mock(UserRoleBinding.class); + when(b1.getUserId()).thenReturn("admin-1"); + when(b2.getUserId()).thenReturn("admin-2"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("SKILL_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(b1, b2)); + + List result = resolver.resolvePlatformSkillAdmins(); + + assertThat(result).containsExactlyInAnyOrder("admin-1", "admin-2"); + } + + @Test + void resolvePlatformSkillAdmins_shouldIncludeSuperAdminsAndDeduplicateUsers() { + UserRoleBinding skillAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(skillAdmin.getUserId()).thenReturn("skill-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("skill-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("SKILL_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(skillAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformSkillAdmins(); + + assertThat(result).containsExactly("skill-admin", "super-admin"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillDeleteAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillDeleteAppServiceTest.java index 177b5ab9..68ec8d3d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillDeleteAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillDeleteAppServiceTest.java @@ -18,6 +18,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import org.mockito.InOrder; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -49,8 +51,10 @@ class SkillDeleteAppServiceTest { assertThat(result.deleted()).isTrue(); assertThat(result.skillId()).isEqualTo(11L); + InOrder inOrder = inOrder(searchIndexService, skillHardDeleteService); + inOrder.verify(searchIndexService).remove(11L); + inOrder.verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "super-1", "127.0.0.1", "JUnit"); verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "super-1", "127.0.0.1", "JUnit"); - verify(searchIndexService).remove(11L); } @Test @@ -80,8 +84,10 @@ class SkillDeleteAppServiceTest { ); assertThat(result.deleted()).isTrue(); + InOrder inOrder = inOrder(searchIndexService, skillHardDeleteService); + inOrder.verify(searchIndexService).remove(11L); + inOrder.verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "owner-1", "127.0.0.1", "JUnit"); verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "owner-1", "127.0.0.1", "JUnit"); - verify(searchIndexService).remove(11L); } @Test @@ -98,6 +104,9 @@ class SkillDeleteAppServiceTest { ); assertThat(result.deleted()).isTrue(); + InOrder inOrder = inOrder(searchIndexService, skillHardDeleteService); + inOrder.verify(searchIndexService).remove(11L); + inOrder.verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "super-1", "127.0.0.1", "JUnit"); verify(skillHardDeleteService).hardDeleteSkill(skill, "global", "super-1", "127.0.0.1", "JUnit"); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java new file mode 100644 index 00000000..9fba24e5 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java @@ -0,0 +1,145 @@ +package com.iflytek.skillhub.stream; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.stream.MapRecord; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.data.redis.core.StringRedisTemplate; + +class AbstractStreamConsumerTest { + + @Test + void onMessage_acknowledgesAfterSuccessfulProcessing() { + StreamOperations streamOperations = mock(StreamOperations.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + org.mockito.Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations); + TestConsumer consumer = new TestConsumer(redisTemplate); + MapRecord message = StreamRecords.newRecord() + .in("scan-stream") + .withId(RecordId.of("1-0")) + .ofMap(Map.of("payload", "ok")); + + consumer.onMessage(message); + + verify(streamOperations).acknowledge("scan-stream", "scan-group", message.getId()); + } + + @Test + void onMessage_acknowledgesAfterRetryableFailure() { + StreamOperations streamOperations = mock(StreamOperations.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + org.mockito.Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations); + TestConsumer consumer = new TestConsumer(redisTemplate); + consumer.fail = true; + MapRecord message = StreamRecords.newRecord() + .in("scan-stream") + .withId(RecordId.of("2-0")) + .ofMap(Map.of("payload", "boom")); + + consumer.onMessage(message); + + verify(streamOperations).acknowledge("scan-stream", "scan-group", message.getId()); + verify(streamOperations, times(1)).acknowledge("scan-stream", "scan-group", message.getId()); + } + + @Test + void onMessage_reusesRedisTemplateForAcknowledgement() { + StreamOperations streamOperations = mock(StreamOperations.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + org.mockito.Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations); + CountingConsumer consumer = new CountingConsumer(redisTemplate); + MapRecord first = StreamRecords.newRecord() + .in("scan-stream") + .withId(RecordId.of("3-0")) + .ofMap(Map.of("payload", "one")); + MapRecord second = StreamRecords.newRecord() + .in("scan-stream") + .withId(RecordId.of("4-0")) + .ofMap(Map.of("payload", "two")); + + consumer.onMessage(first); + consumer.onMessage(second); + + org.junit.jupiter.api.Assertions.assertEquals(1, consumer.templateCreationCount.get()); + } + + private static class TestConsumer extends AbstractStreamConsumer { + private final StringRedisTemplate redisTemplate; + private boolean fail; + + private TestConsumer(StringRedisTemplate redisTemplate) { + super(mock(RedisConnectionFactory.class), "scan-stream", "scan-group"); + this.redisTemplate = redisTemplate; + } + + @Override + protected StringRedisTemplate createRedisTemplate() { + return redisTemplate; + } + + @Override + protected String taskDisplayName() { + return "Test"; + } + + @Override + protected String consumerPrefix() { + return "test"; + } + + @Override + protected String parsePayload(String messageId, Map data) { + return data.get("payload"); + } + + @Override + protected String payloadIdentifier(String payload) { + return payload; + } + + @Override + protected void markProcessing(String payload) { + } + + @Override + protected void processBusiness(String payload) { + if (fail) { + throw new IllegalStateException("boom"); + } + } + + @Override + protected void markCompleted(String payload) { + } + + @Override + protected void markFailed(String payload, String error) { + } + + @Override + protected void retryMessage(String payload, int retryCount) { + } + } + + private static final class CountingConsumer extends TestConsumer { + private final AtomicInteger templateCreationCount = new AtomicInteger(); + + private CountingConsumer(StringRedisTemplate redisTemplate) { + super(redisTemplate); + } + + @Override + protected StringRedisTemplate createRedisTemplate() { + templateCreationCount.incrementAndGet(); + return super.createRedisTemplate(); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerPathSafetyTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerPathSafetyTest.java new file mode 100644 index 00000000..4f8eb535 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerPathSafetyTest.java @@ -0,0 +1,42 @@ +package com.iflytek.skillhub.stream; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.security.ScanTaskProducer; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.security.SecurityScanner; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +class ScanTaskConsumerPathSafetyTest { + + @Test + void cleanupTempPath_ignoresPathsOutsideScanTempDirectory() throws Exception { + ScanTaskConsumer consumer = new ScanTaskConsumer( + org.mockito.Mockito.mock(RedisConnectionFactory.class), + "scan-stream", + "scan-group", + org.mockito.Mockito.mock(SecurityScanner.class), + org.mockito.Mockito.mock(SecurityScanService.class), + org.mockito.Mockito.mock(SkillVersionRepository.class), + org.mockito.Mockito.mock(SkillRepository.class), + org.mockito.Mockito.mock(ReviewTaskRepository.class), + org.mockito.Mockito.mock(ScanTaskProducer.class) + ); + Path outsideFile = Files.createTempFile("scan-cleanup-", ".txt"); + Files.writeString(outsideFile, "keep"); + + Method cleanup = ScanTaskConsumer.class.getDeclaredMethod("cleanupTempPath", String.class); + cleanup.setAccessible(true); + cleanup.invoke(consumer, outsideFile.toString()); + + assertThat(Files.exists(outsideFile)).isTrue(); + Files.deleteIfExists(outsideFile); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 7d5af32e..c51d9d22 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -31,6 +31,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; class ScanTaskConsumerTest { + private static final Path SCAN_TEMP_DIR = Path.of("/tmp/skillhub-scans"); @Test void processBusiness_andMarkCompleted_updatesAuditAndCleansTempDirectory() throws Exception { @@ -52,7 +53,8 @@ class ScanTaskConsumerTest { new InMemoryReviewTaskRepository(), new InMemoryScanTaskProducer() ); - Path tempDir = Files.createTempDirectory("scan-task-consumer-success"); + Files.createDirectories(SCAN_TEMP_DIR); + Path tempDir = Files.createTempDirectory(SCAN_TEMP_DIR, "scan-task-consumer-success"); Files.writeString(tempDir.resolve("README.md"), "# demo"); ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload("task-1", 42L, tempDir.toString(), ScannerType.SKILL_SCANNER); @@ -91,7 +93,8 @@ class ScanTaskConsumerTest { reviewTaskRepository, new InMemoryScanTaskProducer() ); - Path tempFile = Files.createTempFile("scan-task-consumer-failure", ".zip"); + Files.createDirectories(SCAN_TEMP_DIR); + Path tempFile = Files.createTempFile(SCAN_TEMP_DIR, "scan-task-consumer-failure", ".zip"); ScanTaskConsumer.ScanTaskPayload payload = new ScanTaskConsumer.ScanTaskPayload("task-2", 42L, tempFile.toString(), ScannerType.SKILL_SCANNER); consumer.invokeMarkFailed(payload, "scan failed"); @@ -322,6 +325,11 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public void flush() { + throw unsupported(); + } + @Override public void delete(Skill skill) { throw unsupported(); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java index 162ed4fd..6df0ac87 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/UserRoleBindingRepository.java @@ -14,5 +14,7 @@ import java.util.List; public interface UserRoleBindingRepository extends JpaRepository { List findByUserId(String userId); List findByUserIdIn(Collection userIds); + List findByRole_Code(String roleCode); + List findByRole_CodeIn(Collection roleCodes); long deleteByUserId(String userId); } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 3b785021..8c227dd3 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -43,6 +43,21 @@ class RouteSecurityPolicyRegistryTest { assertTrue(matched); } + @Test + void authorizationPolicies_shouldKeepPublicLabelsEndpointsAnonymous() { + boolean matchedV1 = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/v1/labels".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + boolean matchedWeb = registry.authorizationPolicies().stream() + .anyMatch(policy -> policy.method() == HttpMethod.GET + && "/api/web/labels".equals(policy.pattern()) + && policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL); + + assertTrue(matchedV1); + assertTrue(matchedWeb); + } + @Test void shouldIgnoreCsrf_forBearerAndApiPaths() { assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java new file mode 100644 index 00000000..4bb09904 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionApprovedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java new file mode 100644 index 00000000..d3a04812 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionRejectedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionSubmittedEvent.java new file mode 100644 index 00000000..5d67a501 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/PromotionSubmittedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record PromotionSubmittedEvent(Long promotionId, Long skillId, Long versionId, String submitterId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportResolvedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportResolvedEvent.java new file mode 100644 index 00000000..501ea534 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportResolvedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record ReportResolvedEvent(Long reportId, Long skillId, String handlerId, String reporterId, String action) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportSubmittedEvent.java new file mode 100644 index 00000000..7f38245a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReportSubmittedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record ReportSubmittedEvent(Long reportId, Long skillId, String reporterId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java new file mode 100644 index 00000000..b600c9fb --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewApprovedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java new file mode 100644 index 00000000..dd09b8ad --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewRejectedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record ReviewRejectedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId, String reason) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewSubmittedEvent.java new file mode 100644 index 00000000..7173ec29 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ReviewSubmittedEvent.java @@ -0,0 +1,2 @@ +package com.iflytek.skillhub.domain.event; +public record ReviewSubmittedEvent(Long reviewId, Long skillId, Long versionId, String submitterId, Long namespaceId) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java index a07150f9..7b4cab44 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMemberRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.namespace; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -13,6 +14,7 @@ public interface NamespaceMemberRepository { Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); List findByUserId(String userId); Page findByNamespaceId(Long namespaceId, Pageable pageable); + List findByNamespaceIdAndRoleIn(Long namespaceId, Collection roles); NamespaceMember save(NamespaceMember member); void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 52cbd871..3a9b82b1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.report; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ReportResolvedEvent; +import com.iflytek.skillhub.domain.event.ReportSubmittedEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -10,6 +12,7 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import java.time.Clock; import java.time.Instant; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +28,7 @@ public class SkillReportService { private final AuditLogService auditLogService; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final ApplicationEventPublisher eventPublisher; private final Clock clock; public SkillReportService(SkillRepository skillRepository, @@ -32,12 +36,14 @@ public class SkillReportService { AuditLogService auditLogService, SkillGovernanceService skillGovernanceService, GovernanceNotificationService governanceNotificationService, + ApplicationEventPublisher eventPublisher, Clock clock) { this.skillRepository = skillRepository; this.skillReportRepository = skillReportRepository; this.auditLogService = auditLogService; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.eventPublisher = eventPublisher; this.clock = clock; } @@ -73,6 +79,8 @@ public class SkillReportService { )); auditLogService.record(reporterId, "REPORT_SKILL", "SKILL", skillId, null, clientIp, userAgent, "{\"reportId\":" + saved.getId() + "}"); + eventPublisher.publishEvent(new ReportSubmittedEvent( + saved.getId(), saved.getSkillId(), saved.getReporterId())); return saved; } @@ -104,6 +112,8 @@ public class SkillReportService { report.setHandledAt(currentTime()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + eventPublisher.publishEvent(new ReportResolvedEvent( + saved.getId(), saved.getSkillId(), actorUserId, saved.getReporterId(), "resolved")); governanceNotificationService.notifyUser( report.getReporterId(), "REPORT", @@ -128,6 +138,8 @@ public class SkillReportService { report.setHandledAt(currentTime()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + eventPublisher.publishEvent(new ReportResolvedEvent( + saved.getId(), saved.getSkillId(), actorUserId, saved.getReporterId(), "dismissed")); governanceNotificationService.notifyUser( report.getReporterId(), "REPORT", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index c4701bcf..16f888b9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.domain.review; +import com.iflytek.skillhub.domain.event.PromotionApprovedEvent; +import com.iflytek.skillhub.domain.event.PromotionRejectedEvent; +import com.iflytek.skillhub.domain.event.PromotionSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; @@ -110,7 +113,11 @@ public class PromotionService { }); PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); - return promotionRequestRepository.save(request); + PromotionRequest saved = promotionRequestRepository.save(request); + eventPublisher.publishEvent(new PromotionSubmittedEvent( + saved.getId(), saved.getSourceSkillId(), saved.getSourceVersionId(), + saved.getSubmittedBy())); + return saved; } @Transactional @@ -156,7 +163,11 @@ public class PromotionService { }); PromotionRequest request = new PromotionRequest(sourceSkillId, sourceVersionId, targetNamespaceId, userId); - return promotionRequestRepository.save(request); + PromotionRequest saved = promotionRequestRepository.save(request); + eventPublisher.publishEvent(new PromotionSubmittedEvent( + saved.getId(), saved.getSourceSkillId(), saved.getSourceVersionId(), + saved.getSubmittedBy())); + return saved; } /** @@ -234,6 +245,9 @@ public class PromotionService { eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); + eventPublisher.publishEvent(new PromotionApprovedEvent( + approvedRequest.getId(), approvedRequest.getSourceSkillId(), + reviewerId, approvedRequest.getSubmittedBy())); governanceNotificationService.notifyUser( approvedRequest.getSubmittedBy(), "PROMOTION", @@ -268,6 +282,9 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + eventPublisher.publishEvent(new PromotionRejectedEvent( + request.getId(), request.getSourceSkillId(), + reviewerId, request.getSubmittedBy(), comment)); governanceNotificationService.notifyUser( request.getSubmittedBy(), "PROMOTION", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 8fce796f..0aeb63a9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -5,6 +5,9 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.event.ReviewApprovedEvent; +import com.iflytek.skillhub.domain.event.ReviewRejectedEvent; +import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; @@ -103,7 +106,11 @@ public class ReviewService { ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); try { - return reviewTaskRepository.save(task); + ReviewTask saved = reviewTaskRepository.save(task); + eventPublisher.publishEvent(new ReviewSubmittedEvent( + saved.getId(), skillVersion.getSkillId(), skillVersion.getId(), + saved.getSubmittedBy(), saved.getNamespaceId())); + return saved; } catch (DataIntegrityViolationException e) { throw new DomainBadRequestException("review.submit.duplicate", skillVersionId); } @@ -139,7 +146,11 @@ public class ReviewService { ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); try { - return reviewTaskRepository.save(task); + ReviewTask saved = reviewTaskRepository.save(task); + eventPublisher.publishEvent(new ReviewSubmittedEvent( + saved.getId(), skillVersion.getSkillId(), skillVersion.getId(), + saved.getSubmittedBy(), saved.getNamespaceId())); + return saved; } catch (DataIntegrityViolationException e) { throw new DomainBadRequestException("review.submit.duplicate", skillVersionId); } @@ -211,6 +222,9 @@ public class ReviewService { eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); + eventPublisher.publishEvent(new ReviewApprovedEvent( + task.getId(), skill.getId(), skillVersion.getId(), + reviewerId, task.getSubmittedBy())); governanceNotificationService.notifyUser( task.getSubmittedBy(), "REVIEW", @@ -256,8 +270,13 @@ public class ReviewService { SkillVersion skillVersion = skillVersionRepository.findById(task.getSkillVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); + Skill skill = skillRepository.findById(skillVersion.getSkillId()) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillVersion.getSkillId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); + eventPublisher.publishEvent(new ReviewRejectedEvent( + task.getId(), skill.getId(), skillVersion.getId(), + reviewerId, task.getSubmittedBy(), comment)); governanceNotificationService.notifyUser( task.getSubmittedBy(), "REVIEW", diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListener.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListener.java index 6a479d00..6845ef72 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListener.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListener.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.domain.security; +import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -20,13 +22,16 @@ public class ScanCompletedEventListener { private final SkillVersionRepository skillVersionRepository; private final SkillRepository skillRepository; private final ReviewTaskRepository reviewTaskRepository; + private final ApplicationEventPublisher eventPublisher; public ScanCompletedEventListener(SkillVersionRepository skillVersionRepository, SkillRepository skillRepository, - ReviewTaskRepository reviewTaskRepository) { + ReviewTaskRepository reviewTaskRepository, + ApplicationEventPublisher eventPublisher) { this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; this.reviewTaskRepository = reviewTaskRepository; + this.eventPublisher = eventPublisher; } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -35,14 +40,30 @@ public class ScanCompletedEventListener { try { skillVersionRepository.findById(event.versionId()) .flatMap(version -> skillRepository.findById(version.getSkillId()) - .map(skill -> new ReviewTask( - event.versionId(), + .map(skill -> new PendingReviewContext( + skill.getId(), + version.getId(), skill.getNamespaceId(), version.getCreatedBy() ))) - .ifPresent(reviewTaskRepository::save); + .ifPresent(context -> { + ReviewTask task = reviewTaskRepository.save(new ReviewTask( + context.versionId(), + context.namespaceId(), + context.submitterId() + )); + eventPublisher.publishEvent(new ReviewSubmittedEvent( + task.getId(), + context.skillId(), + context.versionId(), + context.submitterId(), + context.namespaceId() + )); + }); } catch (Exception e) { log.error("Failed to create review task after scan completed, versionId={}", event.versionId(), e); } } + + private record PendingReviewContext(Long skillId, Long versionId, Long namespaceId, String submitterId) {} } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 4c8f5167..1f445864 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -31,6 +31,7 @@ public class SecurityScanService { private static final Logger log = LoggerFactory.getLogger(SecurityScanService.class); private static final String TEMP_DIR = "/tmp/skillhub-scans"; + private static final Path TEMP_BASE_DIR = Paths.get(TEMP_DIR).toAbsolutePath().normalize(); private final SecurityAuditRepository auditRepository; private final SkillVersionRepository skillVersionRepository; @@ -122,10 +123,10 @@ public class SecurityScanService { private Path saveTempDirectory(Long versionId, List entries) { try { - Path skillDir = Paths.get(TEMP_DIR, String.valueOf(versionId)); + Path skillDir = TEMP_BASE_DIR.resolve(String.valueOf(versionId)).normalize(); Files.createDirectories(skillDir); for (PackageEntry entry : entries) { - Path filePath = skillDir.resolve(entry.path()); + Path filePath = resolveSafeChild(skillDir, entry.path()); Path parent = filePath.getParent(); if (parent != null) { Files.createDirectories(parent); @@ -140,14 +141,14 @@ public class SecurityScanService { private Path saveTempZip(Long versionId, List entries) { try { - Path dir = Paths.get(TEMP_DIR); + Path dir = TEMP_BASE_DIR; Files.createDirectories(dir); Path zipPath = dir.resolve(versionId + ".zip"); try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ZipOutputStream zos = new ZipOutputStream(baos)) { for (PackageEntry entry : entries) { - zos.putNextEntry(new ZipEntry(entry.path())); + zos.putNextEntry(new ZipEntry(safeZipEntryName(entry.path()))); zos.write(entry.content()); zos.closeEntry(); } @@ -170,6 +171,26 @@ public class SecurityScanService { } } + private Path resolveSafeChild(Path baseDir, String entryPath) { + Path resolved = baseDir.resolve(entryPath).normalize(); + if (!resolved.startsWith(baseDir)) { + throw new IllegalStateException("Unsafe scan path: " + entryPath); + } + return resolved; + } + + private String safeZipEntryName(String entryPath) { + Path normalized = Paths.get(entryPath).normalize(); + if (normalized.isAbsolute() || normalized.startsWith("..")) { + throw new IllegalStateException("Unsafe scan path: " + entryPath); + } + String safePath = normalized.toString().replace('\\', '/'); + if (safePath.isBlank() || safePath.startsWith("../")) { + throw new IllegalStateException("Unsafe scan path: " + entryPath); + } + return safePath; + } + /** * Soft delete all audit records for a given skill version. * Called before physically deleting a skill version to preserve audit history. diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java index d8ca3d44..922bd512 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillRepository.java @@ -17,6 +17,7 @@ public interface SkillRepository { Optional findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId); List findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status); Skill save(Skill skill); + void flush(); void delete(Skill skill); List findByOwnerId(String ownerId); Page findByOwnerId(String ownerId, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index 17d698f5..eb64da51 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -105,6 +105,7 @@ public class SkillHardDeleteService { skill.setLatestVersionId(null); skill.setUpdatedBy(actorUserId); skillRepository.save(skill); + skillRepository.flush(); if (!versionIds.isEmpty()) { reviewTaskRepository.deleteBySkillVersionIdIn(versionIds); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index f176a511..e46630b9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; @@ -352,7 +353,14 @@ public class SkillPublishService { securityScanService.triggerScan(version.getId(), entries, publisherId); } else { ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); - reviewTaskRepository.save(reviewTask); + ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); + eventPublisher.publishEvent(new ReviewSubmittedEvent( + savedReviewTask.getId(), + skill.getId(), + version.getId(), + savedReviewTask.getSubmittedBy(), + savedReviewTask.getNamespaceId() + )); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 86df099e..174d2352 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) class SkillReportServiceTest { @@ -44,6 +45,9 @@ class SkillReportServiceTest { @Mock private GovernanceNotificationService governanceNotificationService; + @Mock + private ApplicationEventPublisher eventPublisher; + private SkillReportService service; @BeforeEach @@ -54,6 +58,7 @@ class SkillReportServiceTest { auditLogService, skillGovernanceService, governanceNotificationService, + eventPublisher, CLOCK ); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index fcff0daa..c1b71673 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -326,6 +326,7 @@ class ReviewServiceTest { REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Needs work", task.getVersion())) .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", @@ -477,6 +478,7 @@ class ReviewServiceTest { when(permissionChecker.canReview(any(), any(), any(), anyMap(), anySet())).thenReturn(true); when(reviewTaskRepository.updateStatusWithVersion(any(), any(), any(), any(), any())).thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.rejectReview( @@ -527,6 +529,7 @@ class ReviewServiceTest { REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, USER_ID, "self rejected", task.getVersion())) .thenReturn(1); when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill())); when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); ReviewTask result = reviewService.rejectReview( diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListenerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListenerTest.java index beb237e1..5b318671 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListenerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/ScanCompletedEventListenerTest.java @@ -1,5 +1,9 @@ package com.iflytek.skillhub.domain.security; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.review.ReviewTask; import com.iflytek.skillhub.domain.review.ReviewTaskRepository; import com.iflytek.skillhub.domain.skill.Skill; @@ -7,59 +11,68 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - import java.lang.reflect.Field; import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.mockito.ArgumentCaptor; @ExtendWith(MockitoExtension.class) class ScanCompletedEventListenerTest { @Mock private SkillVersionRepository skillVersionRepository; - @Mock private SkillRepository skillRepository; - @Mock private ReviewTaskRepository reviewTaskRepository; - - @InjectMocks - private ScanCompletedEventListener listener; + @Mock + private ApplicationEventPublisher eventPublisher; @Test - void onScanCompleted_createsReviewTaskForScannedVersion() throws Exception { + void onScanCompleted_createsReviewTaskAndPublishesReviewSubmittedEvent() throws Exception { + ScanCompletedEventListener listener = new ScanCompletedEventListener( + skillVersionRepository, + skillRepository, + reviewTaskRepository, + eventPublisher + ); SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); - setId(version, 42L); + setField(version, "id", 42L); + Skill skill = new Skill(5L, "demo-skill", "publisher-1", SkillVisibility.PUBLIC); + setField(skill, "id", 8L); - Skill skill = new Skill(20L, "demo-skill", "publisher-1", SkillVisibility.PUBLIC); - setId(skill, 8L); - - given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); - given(skillRepository.findById(8L)).willReturn(Optional.of(skill)); + when(skillVersionRepository.findById(42L)).thenReturn(Optional.of(version)); + when(skillRepository.findById(8L)).thenReturn(Optional.of(skill)); + when(reviewTaskRepository.save(org.mockito.ArgumentMatchers.any(ReviewTask.class))) + .thenAnswer(invocation -> { + ReviewTask task = invocation.getArgument(0); + setField(task, "id", 100L); + return task; + }); listener.onScanCompleted(new ScanCompletedEvent(42L, SecurityVerdict.SAFE, 0)); ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class); verify(reviewTaskRepository).save(reviewTaskCaptor.capture()); - ReviewTask reviewTask = reviewTaskCaptor.getValue(); - assertThat(reviewTask.getSkillVersionId()).isEqualTo(42L); - assertThat(reviewTask.getNamespaceId()).isEqualTo(20L); - assertThat(reviewTask.getSubmittedBy()).isEqualTo("publisher-1"); + org.assertj.core.api.Assertions.assertThat(reviewTaskCaptor.getValue().getSkillVersionId()).isEqualTo(42L); + org.assertj.core.api.Assertions.assertThat(reviewTaskCaptor.getValue().getNamespaceId()).isEqualTo(5L); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ReviewSubmittedEvent submittedEvent = (ReviewSubmittedEvent) eventCaptor.getValue(); + org.assertj.core.api.Assertions.assertThat(submittedEvent.reviewId()).isEqualTo(100L); + org.assertj.core.api.Assertions.assertThat(submittedEvent.skillId()).isEqualTo(8L); + org.assertj.core.api.Assertions.assertThat(submittedEvent.versionId()).isEqualTo(42L); + org.assertj.core.api.Assertions.assertThat(submittedEvent.submitterId()).isEqualTo("publisher-1"); + org.assertj.core.api.Assertions.assertThat(submittedEvent.namespaceId()).isEqualTo(5L); } - private void setId(Object target, Long id) throws Exception { - Field field = target.getClass().getDeclaredField("id"); + private void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); - field.set(target, id); + field.set(target, value); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java index 44343282..f502521d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java @@ -14,9 +14,11 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import java.lang.reflect.Field; +import java.nio.file.Path; import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; @@ -94,6 +96,51 @@ class SecurityScanServiceTest { assertThat(task.skillPath()).contains("42"); } + @Test + void triggerScan_rejectsDirectoryTraversalEntries() throws Exception { + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + PackageEntry entry = new PackageEntry( + "../escape.txt", + "boom".getBytes(), + 4L, + "text/plain" + ); + + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + assertThatThrownBy(() -> service.triggerScan(42L, List.of(entry), "publisher-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsafe scan path"); + } + + @Test + void triggerScan_rejectsZipSlipEntriesWhenUploadModeEnabled() throws Exception { + service = new SecurityScanService( + auditRepository, + skillVersionRepository, + scanTaskProducer, + eventPublisher, + new ObjectMapper(), + "upload", + true + ); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + PackageEntry entry = new PackageEntry( + "../../escape.txt", + "boom".getBytes(), + 4L, + "text/plain" + ); + + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + assertThatThrownBy(() -> service.triggerScan(42L, List.of(entry), "publisher-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsafe scan path"); + } + @Test void processScanResult_updatesAuditAndMovesVersionToPendingReview() { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index 93528e4f..f47436f6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.InOrder; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -32,6 +33,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -118,7 +120,10 @@ class SkillHardDeleteServiceTest { service.hardDeleteSkill(skill, "global", "super-1", "127.0.0.1", "JUnit"); - verify(skillRepository).save(skill); + InOrder inOrder = inOrder(skillRepository, skillVersionRepository); + inOrder.verify(skillRepository).save(skill); + inOrder.verify(skillRepository).flush(); + inOrder.verify(skillVersionRepository).deleteBySkillId(7L); verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L)); verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L); verify(skillTagRepository).deleteBySkillId(7L); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index c7d9cbbf..8e6cfc98 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.skill.service; import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.ReviewSubmittedEvent; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceMember; @@ -96,6 +97,7 @@ class SkillPublishServiceTest { lenient().when(securityScanService.isEnabled()).thenReturn(false); lenient().when(skillVersionRepository.findBySkillIdAndStatus(anyLong(), eq(SkillVersionStatus.PENDING_REVIEW))) .thenReturn(List.of()); + lenient().when(reviewTaskRepository.save(any(ReviewTask.class))).thenAnswer(invocation -> invocation.getArgument(0)); } @Test @@ -151,7 +153,13 @@ class SkillPublishServiceTest { verify(skillFileRepository).saveAll(anyList()); verify(objectStorageService, atLeastOnce()).putObject(anyString(), any(), anyLong(), anyString()); verify(reviewTaskRepository).save(any(ReviewTask.class)); - verify(eventPublisher, never()).publishEvent(any()); + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ReviewSubmittedEvent submittedEvent = (ReviewSubmittedEvent) eventCaptor.getValue(); + assertEquals(1L, submittedEvent.skillId()); + assertEquals(10L, submittedEvent.versionId()); + assertEquals(publisherId, submittedEvent.submitterId()); + assertEquals(1L, submittedEvent.namespaceId()); } @Test diff --git a/server/skillhub-infra/pom.xml b/server/skillhub-infra/pom.xml index f09af971..96d63ecd 100644 --- a/server/skillhub-infra/pom.xml +++ b/server/skillhub-infra/pom.xml @@ -19,6 +19,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + com.iflytek.skillhub + skillhub-notification + org.springframework.boot spring-boot-starter-webflux diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClient.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClient.java index e1ae1063..f99ed121 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClient.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClient.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.infra.http; +import org.springframework.http.HttpHeaders; import org.springframework.util.MultiValueMap; public interface HttpClient { @@ -10,5 +11,7 @@ public interface HttpClient { T postMultipart(String uri, MultiValueMap parts, Class responseType); + T postMultipart(String uri, MultiValueMap parts, HttpHeaders headers, Class responseType); + boolean isHealthy(String healthUri); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientConfig.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientConfig.java index 99bc1ac5..d12befb1 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientConfig.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientConfig.java @@ -18,6 +18,7 @@ public class WebClientConfig { .build(); reactor.netty.http.client.HttpClient reactorClient = reactor.netty.http.client.HttpClient.create() + .followRedirect(false) .responseTimeout(Duration.ofMinutes(5)); return WebClient.builder() diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientHttpClient.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientHttpClient.java index cdd6a7b4..0181d168 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientHttpClient.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/WebClientHttpClient.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.infra.http; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.util.MultiValueMap; import org.springframework.web.reactive.function.BodyInserters; @@ -56,10 +57,19 @@ public class WebClientHttpClient implements HttpClient { @Override public T postMultipart(String uri, MultiValueMap parts, Class responseType) { + return postMultipart(uri, parts, new HttpHeaders(), responseType); + } + + @Override + public T postMultipart(String uri, + MultiValueMap parts, + HttpHeaders headers, + Class responseType) { log.debug("POST multipart {}", uri); try { return webClient.post() .uri(uri) + .headers(httpHeaders -> httpHeaders.addAll(headers)) .contentType(MediaType.MULTIPART_FORM_DATA) .body(BodyInserters.fromMultipartData(parts)) .retrieve() diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java index cf991282..cc894bdd 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRepositoryAdapter.java @@ -62,6 +62,11 @@ public class JpaSkillRepositoryAdapter implements SkillRepository { return jpaDelegate.save(skill); } + @Override + public void flush() { + jpaDelegate.flush(); + } + @Override public void delete(Skill skill) { jpaDelegate.delete(skill); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java index 13094042..ccbb7074 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NamespaceMemberJpaRepository.java @@ -2,11 +2,13 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; 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.Collection; import java.util.List; import java.util.Optional; @@ -19,5 +21,6 @@ public interface NamespaceMemberJpaRepository Optional findByNamespaceIdAndUserId(Long namespaceId, String userId); List findByUserId(String userId); Page findByNamespaceId(Long namespaceId, Pageable pageable); + List findByNamespaceIdAndRoleIn(Long namespaceId, Collection roles); void deleteByNamespaceIdAndUserId(Long namespaceId, String userId); } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationJpaRepository.java new file mode 100644 index 00000000..03aab701 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationJpaRepository.java @@ -0,0 +1,45 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.notification.domain.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +public interface NotificationJpaRepository extends JpaRepository, NotificationRepository { + + Page findByRecipientIdOrderByCreatedAtDesc(String recipientId, Pageable pageable); + + Page findByRecipientIdAndCategoryOrderByCreatedAtDesc(String recipientId, NotificationCategory category, Pageable pageable); + + long countByRecipientIdAndStatus(String recipientId, NotificationStatus status); + + @Override + default Page findByRecipientId(String recipientId, Pageable pageable) { + return findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable); + } + + @Override + default Page findByRecipientIdAndCategory(String recipientId, NotificationCategory category, Pageable pageable) { + return findByRecipientIdAndCategoryOrderByCreatedAtDesc(recipientId, category, pageable); + } + + @Modifying + @Transactional + @Query("UPDATE Notification n SET n.status = 'READ', n.readAt = :readAt WHERE n.recipientId = :recipientId AND n.status = 'UNREAD'") + int markAllReadByRecipientId(String recipientId, Instant readAt); + + @Modifying + @Transactional + @Query("DELETE FROM Notification n WHERE n.id = :id AND n.recipientId = :recipientId AND n.status = :status") + int deleteByIdAndRecipientIdAndStatus(Long id, String recipientId, NotificationStatus status); + + @Modifying + @Transactional + @Query("DELETE FROM Notification n WHERE n.status = :status AND n.createdAt < :before") + int deleteByStatusAndCreatedAtBefore(NotificationStatus status, Instant before); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationPreferenceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationPreferenceJpaRepository.java new file mode 100644 index 00000000..d5152ae2 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/NotificationPreferenceJpaRepository.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.notification.domain.*; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NotificationPreferenceJpaRepository extends JpaRepository, NotificationPreferenceRepository { + List findByUserId(String userId); + Optional findByUserIdAndCategoryAndChannel( + String userId, NotificationCategory category, NotificationChannel channel); +} diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java index e311283b..ad0d8c09 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java @@ -59,16 +59,6 @@ public class SkillScannerAdapter implements SecurityScanner { apiResponse.scanId(), apiResponse.skillName(), apiResponse.isSafe(), apiResponse.maxSeverity(), apiResponse.findingsCount(), apiResponse.scanDurationSeconds()); - if (apiResponse.findings() != null) { - for (SkillScannerApiResponse.Finding f : apiResponse.findings()) { - log.info("Scanner API finding: id={}, ruleId={}, severity={}, category={}, title={}, " + - "description={}, filePath={}, lineNumber={}, snippet={}, remediation={}, analyzer={}, metadata={}", - f.id(), f.ruleId(), f.severity(), f.category(), f.title(), - f.description(), f.filePath(), f.lineNumber(), f.snippet(), - f.remediation(), f.analyzer(), f.metadata()); - } - } - SecurityScanResponse response = new SecurityScanResponse( apiResponse.scanId(), mapVerdict(apiResponse.isSafe(), apiResponse.maxSeverity()), @@ -80,11 +70,11 @@ public class SkillScannerAdapter implements SecurityScanner { log.info("Mapped response: scanId={}, verdict={}, findingsCount={}, maxSeverity={}", response.scanId(), response.verdict(), response.findingsCount(), response.maxSeverity()); - for (SecurityFinding f : response.findings()) { - log.info("Mapped finding: ruleId={}, severity={}, category={}, title={}, message={}, " + - "filePath={}, lineNumber={}, codeSnippet={}, remediation={}, analyzer={}, metadata={}", - f.ruleId(), f.severity(), f.category(), f.title(), f.message(), - f.filePath(), f.lineNumber(), f.codeSnippet(), f.remediation(), f.analyzer(), f.metadata()); + if (!response.findings().isEmpty()) { + for (SecurityFinding f : response.findings()) { + log.debug("Mapped finding: ruleId={}, severity={}, category={}, filePath={}, lineNumber={}", + f.ruleId(), f.severity(), f.category(), f.filePath(), f.lineNumber()); + } } return response; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerService.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerService.java index 07a41acb..6aab4eb3 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerService.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerService.java @@ -5,9 +5,11 @@ import com.iflytek.skillhub.infra.http.HttpClientException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpHeaders; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import java.net.URI; import java.nio.file.Path; import java.util.Map; @@ -25,7 +27,7 @@ public class SkillScannerService { String scanPath, String healthPath) { this.httpClient = httpClient; - this.baseUrl = baseUrl; + this.baseUrl = normalizeBaseUrl(baseUrl); this.scanPath = scanPath; this.healthPath = healthPath; } @@ -38,21 +40,22 @@ public class SkillScannerService { try { return httpClient.post(uri, body, SkillScannerApiResponse.class); } catch (HttpClientException e) { - log.error("Scanner API error: status={}, body={}", e.getStatusCode(), e.getResponseBody()); + log.error("Scanner API error: status={}, body={}", e.getStatusCode(), summarizeResponseBody(e.getResponseBody())); throw e; } } public SkillScannerApiResponse scanUpload(Path skillPackagePath, ScanOptions options) { String uri = buildUploadUri(options); - log.info("Uploading skill package to scanner: {}", uri); + log.info("Uploading skill package to scanner: {}", sanitizeUri(uri)); MultiValueMap parts = new LinkedMultiValueMap<>(); parts.add("file", new FileSystemResource(skillPackagePath)); + HttpHeaders headers = buildScannerHeaders(options); try { - return httpClient.postMultipart(uri, parts, SkillScannerApiResponse.class); + return httpClient.postMultipart(uri, parts, headers, SkillScannerApiResponse.class); } catch (HttpClientException e) { - log.error("Scanner API error: status={}, body={}", e.getStatusCode(), e.getResponseBody()); + log.error("Scanner API error: status={}, body={}", e.getStatusCode(), summarizeResponseBody(e.getResponseBody())); throw e; } } @@ -84,11 +87,44 @@ public class SkillScannerService { uri.append("&llm_provider=").append(options.llmProvider()); uri.append("&enable_meta=").append(options.enableMeta()); uri.append("&use_aidefense=").append(options.useAidefense()); - if (options.useAidefense() && !options.aidefenseApiKey().isEmpty()) { - uri.append("&aidefense_api_key=").append(options.aidefenseApiKey()); - } uri.append("&use_virustotal=").append(options.useVirusTotal()); uri.append("&use_trigger=").append(options.useTrigger()); return uri.toString(); } + + private HttpHeaders buildScannerHeaders(ScanOptions options) { + HttpHeaders headers = new HttpHeaders(); + if (options.useAidefense() && !options.aidefenseApiKey().isEmpty()) { + headers.add("X-AIDefense-Api-Key", options.aidefenseApiKey()); + } + return headers; + } + + private String normalizeBaseUrl(String rawBaseUrl) { + URI uri = URI.create(rawBaseUrl); + String scheme = uri.getScheme(); + if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { + throw new IllegalArgumentException("Scanner base URL must use http or https"); + } + if (uri.getHost() == null || uri.getHost().isBlank()) { + throw new IllegalArgumentException("Scanner base URL must include a host"); + } + if (uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null) { + throw new IllegalArgumentException("Scanner base URL must not include user info, query, or fragment"); + } + String normalized = uri.toString(); + return normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized; + } + + private String summarizeResponseBody(String body) { + if (body == null || body.isBlank()) { + return ""; + } + String singleLine = body.replaceAll("\\s+", " ").trim(); + return singleLine.length() > 200 ? singleLine.substring(0, 200) + "...[truncated]" : singleLine; + } + + private String sanitizeUri(String uri) { + return uri.replaceAll("([?&]aidefense_api_key=)[^&]+", "$1***"); + } } diff --git a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java index 1d8d933f..e7eff1f1 100644 --- a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java +++ b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.security.SecurityVerdict; import com.iflytek.skillhub.infra.http.HttpClient; import com.iflytek.skillhub.infra.http.HttpClientException; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import java.nio.file.Path; import java.util.List; @@ -106,7 +107,7 @@ class SkillScannerAdapterTest { private RuntimeException directoryException; private StubSkillScannerService() { - super(new NoOpHttpClient(), "", "", ""); + super(new NoOpHttpClient(), "http://scanner.test", "/scan-upload", "/health"); } @Override @@ -142,6 +143,14 @@ class SkillScannerAdapterTest { throw new UnsupportedOperationException(); } + @Override + public T postMultipart(String uri, + org.springframework.util.MultiValueMap parts, + HttpHeaders headers, + Class responseType) { + throw new UnsupportedOperationException(); + } + @Override public boolean isHealthy(String healthUri) { return false; diff --git a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerLoggingTest.java b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerLoggingTest.java new file mode 100644 index 00000000..008ece0d --- /dev/null +++ b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerLoggingTest.java @@ -0,0 +1,186 @@ +package com.iflytek.skillhub.infra.scanner; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.iflytek.skillhub.domain.security.SecurityScanRequest; +import com.iflytek.skillhub.infra.http.HttpClient; +import com.iflytek.skillhub.infra.http.HttpClientException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.util.MultiValueMap; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SkillScannerLoggingTest { + + private final Logger serviceLogger = (Logger) LoggerFactory.getLogger(SkillScannerService.class); + private final Logger adapterLogger = (Logger) LoggerFactory.getLogger(SkillScannerAdapter.class); + private ListAppender appender; + + @AfterEach + void tearDown() { + if (appender != null) { + serviceLogger.detachAppender(appender); + adapterLogger.detachAppender(appender); + appender.stop(); + } + } + + @Test + void scanUpload_truncatesLoggedScannerErrorBody() { + HttpClient httpClient = new ThrowingHttpClient("sensitive-body-".repeat(100)); + SkillScannerService service = new SkillScannerService( + httpClient, + "http://scanner.test", + "/scan-upload", + "/health" + ); + attachAppender(serviceLogger); + + assertThatThrownBy(() -> service.scanUpload(Path.of("/tmp/demo.zip"), ScanOptions.disabled())) + .isInstanceOf(HttpClientException.class); + + List messages = loggedMessages(); + assertThat(messages).anyMatch(message -> message.contains("Scanner API error: status=500")); + assertThat(messages).noneMatch(message -> message.contains("sensitive-body-".repeat(20))); + } + + @Test + void scan_logsSummaryWithoutDumpingFindingDetailsAtInfo() { + StubSkillScannerService service = new StubSkillScannerService(); + service.directoryResponse = new SkillScannerApiResponse( + "scan-9", + "skill", + false, + "HIGH", + 1, + List.of(new SkillScannerApiResponse.Finding( + "ID-1", + "RULE-1", + "HIGH", + "code-execution", + "Danger title", + "Very sensitive description", + "src/main.py", + 7, + "eval(secret)", + "Use safe api", + "static", + Map.of("token", "secret") + )), + 1.0, + "2026-03-23T00:00:00" + ); + SkillScannerAdapter adapter = new SkillScannerAdapter(service, "local", ScanOptions.disabled()); + attachAppender(adapterLogger); + + adapter.scan(new SecurityScanRequest("task-1", 1L, "/tmp/skill", Map.of())); + + List messages = loggedMessages(); + assertThat(messages).anyMatch(message -> message.contains("Scanner API raw response")); + assertThat(messages).noneMatch(message -> message.contains("Very sensitive description")); + assertThat(messages).noneMatch(message -> message.contains("eval(secret)")); + assertThat(messages).noneMatch(message -> message.contains("Mapped finding:")); + } + + private void attachAppender(Logger logger) { + logger.setLevel(Level.INFO); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + } + + private List loggedMessages() { + return appender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static final class ThrowingHttpClient implements HttpClient { + private final String body; + + private ThrowingHttpClient(String body) { + this.body = body; + } + + @Override + public T get(String uri, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T post(String uri, Object body, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T postMultipart(String uri, MultiValueMap parts, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T postMultipart(String uri, + MultiValueMap parts, + HttpHeaders headers, + Class responseType) { + throw new HttpClientException(500, body); + } + + @Override + public boolean isHealthy(String healthUri) { + return false; + } + } + + private static final class StubSkillScannerService extends SkillScannerService { + private SkillScannerApiResponse directoryResponse; + + private StubSkillScannerService() { + super(new NoOpHttpClient(), "http://scanner.test", "/scan-upload", "/health"); + } + + @Override + public SkillScannerApiResponse scanDirectory(String skillDirectory, ScanOptions options) { + return directoryResponse; + } + } + + private static final class NoOpHttpClient implements HttpClient { + @Override + public T get(String uri, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T post(String uri, Object body, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T postMultipart(String uri, MultiValueMap parts, Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public T postMultipart(String uri, + MultiValueMap parts, + HttpHeaders headers, + Class responseType) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isHealthy(String healthUri) { + return false; + } + } +} diff --git a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerServiceTest.java b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerServiceTest.java index ffc54c83..276a9193 100644 --- a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerServiceTest.java +++ b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerServiceTest.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.infra.scanner; import com.iflytek.skillhub.infra.http.HttpClient; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.util.MultiValueMap; import java.nio.file.Path; @@ -11,6 +13,25 @@ import static org.assertj.core.api.Assertions.assertThat; class SkillScannerServiceTest { + @Test + @DisplayName("constructor rejects scanner base URLs with unsafe components") + void constructor_rejectsUnsafeBaseUrl() { + FakeHttpClient httpClient = new FakeHttpClient(); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new SkillScannerService(httpClient, "file:///tmp/scanner", "/scan-upload", "/health")) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new SkillScannerService(httpClient, "http://user:secret@scanner.test", "/scan-upload", "/health")) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new SkillScannerService(httpClient, "http://scanner.test?x=1", "/scan-upload", "/health")) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new SkillScannerService(httpClient, "http://scanner.test#frag", "/scan-upload", "/health")) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void scanDirectory_postsToLocalScanEndpoint() { FakeHttpClient httpClient = new FakeHttpClient(); @@ -75,6 +96,33 @@ class SkillScannerServiceTest { assertThat(httpClient.lastMultipartParts.getFirst("file")).isNotNull(); } + @Test + void scanUpload_sendsAidefenseApiKeyViaHeaderInsteadOfQueryString() { + FakeHttpClient httpClient = new FakeHttpClient(); + httpClient.multipartResponse = new SkillScannerApiResponse( + "scan-3", + "test-skill", + true, + "LOW", + 0, + null, + 0.5, + "2026-03-22T07:00:00" + ); + SkillScannerService service = new SkillScannerService( + httpClient, + "http://scanner.test", + "/scan-upload", + "/health" + ); + ScanOptions options = new ScanOptions(false, true, "openai", true, true, "secret-key", false, false); + + service.scanUpload(Path.of("/tmp/demo.zip"), options); + + assertThat(httpClient.lastMultipartUri).doesNotContain("aidefense_api_key"); + assertThat(httpClient.lastMultipartHeaders.getFirst("X-AIDefense-Api-Key")).isEqualTo("secret-key"); + } + @Test void isHealthy_checksConfiguredHealthEndpoint() { FakeHttpClient httpClient = new FakeHttpClient(); @@ -99,6 +147,7 @@ class SkillScannerServiceTest { private Object lastPostBody; private String lastMultipartUri; private MultiValueMap lastMultipartParts; + private HttpHeaders lastMultipartHeaders; private String lastHealthUri; private boolean healthy; @@ -118,8 +167,18 @@ class SkillScannerServiceTest { @Override @SuppressWarnings("unchecked") public T postMultipart(String uri, MultiValueMap parts, Class responseType) { + return postMultipart(uri, parts, new HttpHeaders(), responseType); + } + + @Override + @SuppressWarnings("unchecked") + public T postMultipart(String uri, + MultiValueMap parts, + HttpHeaders headers, + Class responseType) { this.lastMultipartUri = uri; this.lastMultipartParts = parts; + this.lastMultipartHeaders = headers; return (T) multipartResponse; } diff --git a/server/skillhub-notification/pom.xml b/server/skillhub-notification/pom.xml new file mode 100644 index 00000000..5c01d1ba --- /dev/null +++ b/server/skillhub-notification/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + + com.iflytek.skillhub + skillhub-parent + 0.1.0 + + skillhub-notification + + + com.iflytek.skillhub + skillhub-domain + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/Notification.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/Notification.java new file mode 100644 index 00000000..192333b1 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/Notification.java @@ -0,0 +1,77 @@ +package com.iflytek.skillhub.notification.domain; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "notification") +public class Notification { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "recipient_id", nullable = false, length = 128) + private String recipientId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NotificationCategory category; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "body_json", columnDefinition = "TEXT") + private String bodyJson; + + @Column(name = "entity_type", length = 64) + private String entityType; + + @Column(name = "entity_id") + private Long entityId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationStatus status = NotificationStatus.UNREAD; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "read_at") + private Instant readAt; + + protected Notification() {} + + public Notification(String recipientId, NotificationCategory category, String eventType, + String title, String bodyJson, String entityType, Long entityId, + Instant createdAt) { + this.recipientId = recipientId; + this.category = category; + this.eventType = eventType; + this.title = title; + this.bodyJson = bodyJson; + this.entityType = entityType; + this.entityId = entityId; + this.createdAt = createdAt; + } + + public void markRead(Instant readAt) { + this.status = NotificationStatus.READ; + this.readAt = readAt; + } + + // Getters + public Long getId() { return id; } + public String getRecipientId() { return recipientId; } + public NotificationCategory getCategory() { return category; } + public String getEventType() { return eventType; } + public String getTitle() { return title; } + public String getBodyJson() { return bodyJson; } + public String getEntityType() { return entityType; } + public Long getEntityId() { return entityId; } + public NotificationStatus getStatus() { return status; } + public Instant getCreatedAt() { return createdAt; } + public Instant getReadAt() { return readAt; } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationCategory.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationCategory.java new file mode 100644 index 00000000..9e72ea77 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationCategory.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.notification.domain; + +public enum NotificationCategory { + PUBLISH, REVIEW, PROMOTION, REPORT +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationChannel.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationChannel.java new file mode 100644 index 00000000..1800e88a --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationChannel.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.notification.domain; + +public enum NotificationChannel { + IN_APP + // Future: EMAIL, FEISHU, DINGTALK +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreference.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreference.java new file mode 100644 index 00000000..121c1140 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreference.java @@ -0,0 +1,43 @@ +package com.iflytek.skillhub.notification.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name = "notification_preference", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "category", "channel"})) +public class NotificationPreference { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NotificationCategory category; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private NotificationChannel channel; + + @Column(nullable = false) + private boolean enabled = true; + + protected NotificationPreference() {} + + public NotificationPreference(String userId, NotificationCategory category, + NotificationChannel channel, boolean enabled) { + this.userId = userId; + this.category = category; + this.channel = channel; + this.enabled = enabled; + } + + public Long getId() { return id; } + public String getUserId() { return userId; } + public NotificationCategory getCategory() { return category; } + public NotificationChannel getChannel() { return channel; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreferenceRepository.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreferenceRepository.java new file mode 100644 index 00000000..efc856e2 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationPreferenceRepository.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.notification.domain; + +import java.util.List; +import java.util.Optional; + +public interface NotificationPreferenceRepository { + NotificationPreference save(NotificationPreference preference); + List findByUserId(String userId); + Optional findByUserIdAndCategoryAndChannel( + String userId, NotificationCategory category, NotificationChannel channel); +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationRepository.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationRepository.java new file mode 100644 index 00000000..d8897e7c --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationRepository.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.notification.domain; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.time.Instant; +import java.util.Optional; + +public interface NotificationRepository { + Notification save(Notification notification); + Optional findById(Long id); + Page findByRecipientId(String recipientId, Pageable pageable); + Page findByRecipientIdAndCategory(String recipientId, NotificationCategory category, Pageable pageable); + long countByRecipientIdAndStatus(String recipientId, NotificationStatus status); + int markAllReadByRecipientId(String recipientId, Instant readAt); + int deleteByIdAndRecipientIdAndStatus(Long id, String recipientId, NotificationStatus status); + int deleteByStatusAndCreatedAtBefore(NotificationStatus status, Instant before); +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationStatus.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationStatus.java new file mode 100644 index 00000000..29b9b9d6 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/domain/NotificationStatus.java @@ -0,0 +1,5 @@ +package com.iflytek.skillhub.notification.domain; + +public enum NotificationStatus { + UNREAD, READ +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationCleanupTask.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationCleanupTask.java new file mode 100644 index 00000000..234eb52d --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationCleanupTask.java @@ -0,0 +1,44 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.domain.NotificationStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; + +@Component +public class NotificationCleanupTask { + + private static final Logger log = LoggerFactory.getLogger(NotificationCleanupTask.class); + + private final NotificationRepository notificationRepository; + private final Clock clock; + private final int readRetentionDays; + private final int unreadRetentionDays; + + public NotificationCleanupTask(NotificationRepository notificationRepository, + Clock clock, + @Value("${skillhub.notification.cleanup.read-retention-days:30}") int readRetentionDays, + @Value("${skillhub.notification.cleanup.unread-retention-days:90}") int unreadRetentionDays) { + this.notificationRepository = notificationRepository; + this.clock = clock; + this.readRetentionDays = readRetentionDays; + this.unreadRetentionDays = unreadRetentionDays; + } + + @Scheduled(cron = "0 0 2 * * *") + public void cleanup() { + Instant now = Instant.now(clock); + int readDeleted = notificationRepository.deleteByStatusAndCreatedAtBefore( + NotificationStatus.READ, now.minus(Duration.ofDays(readRetentionDays))); + int unreadDeleted = notificationRepository.deleteByStatusAndCreatedAtBefore( + NotificationStatus.UNREAD, now.minus(Duration.ofDays(unreadRetentionDays))); + log.info("Notification cleanup: deleted {} read, {} unread", readDeleted, unreadDeleted); + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java new file mode 100644 index 00000000..e162dbd9 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java @@ -0,0 +1,60 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.sse.SseEmitterManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class NotificationDispatcher { + + private static final Logger log = LoggerFactory.getLogger(NotificationDispatcher.class); + + private final NotificationService notificationService; + private final NotificationPreferenceService preferenceService; + private final SseEmitterManager sseEmitterManager; + + public NotificationDispatcher(NotificationService notificationService, + NotificationPreferenceService preferenceService, + SseEmitterManager sseEmitterManager) { + this.notificationService = notificationService; + this.preferenceService = preferenceService; + this.sseEmitterManager = sseEmitterManager; + } + + public void dispatch(String recipientId, NotificationCategory category, + String eventType, String title, String bodyJson, + String entityType, Long entityId) { + // Check user preference + if (!preferenceService.isEnabled(recipientId, category, NotificationChannel.IN_APP)) { + log.debug("Notification {} suppressed for user {} (preference disabled)", eventType, recipientId); + return; + } + + // Persist notification + Notification notification = notificationService.create( + recipientId, category, eventType, title, bodyJson, entityType, entityId); + + // Push via SSE + try { + sseEmitterManager.push(recipientId, Map.of( + "id", notification.getId(), + "category", notification.getCategory().name(), + "eventType", notification.getEventType(), + "title", notification.getTitle(), + "bodyJson", notification.getBodyJson() != null ? notification.getBodyJson() : "", + "entityType", notification.getEntityType() != null ? notification.getEntityType() : "", + "entityId", notification.getEntityId() != null ? notification.getEntityId() : 0, + "createdAt", notification.getCreatedAt().toString() + )); + } catch (Exception e) { + log.warn("Failed to push SSE notification to user {}", recipientId, e); + // Notification is already persisted, SSE push failure is non-critical + } + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationPreferenceService.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationPreferenceService.java new file mode 100644 index 00000000..2d9eb674 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationPreferenceService.java @@ -0,0 +1,74 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.notification.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class NotificationPreferenceService { + + private final NotificationPreferenceRepository preferenceRepository; + + public NotificationPreferenceService(NotificationPreferenceRepository preferenceRepository) { + this.preferenceRepository = preferenceRepository; + } + + public record PreferenceView(NotificationCategory category, NotificationChannel channel, boolean enabled) {} + public record PreferenceCommand(NotificationCategory category, NotificationChannel channel, boolean enabled) {} + + public boolean isEnabled(String userId, NotificationCategory category, NotificationChannel channel) { + return preferenceRepository.findByUserIdAndCategoryAndChannel(userId, category, channel) + .map(NotificationPreference::isEnabled) + .orElse(true); + } + + @Transactional(readOnly = true) + public List getPreferences(String userId) { + Map saved = preferenceRepository.findByUserId(userId).stream() + .filter(p -> p.getChannel() == NotificationChannel.IN_APP) + .collect(Collectors.toMap(NotificationPreference::getCategory, NotificationPreference::isEnabled)); + return Arrays.stream(NotificationCategory.values()) + .map(cat -> new PreferenceView(cat, NotificationChannel.IN_APP, saved.getOrDefault(cat, true))) + .toList(); + } + + @Transactional + public void updatePreference(String userId, NotificationCategory category, + NotificationChannel channel, boolean enabled) { + if (channel != NotificationChannel.IN_APP) { + throw new DomainBadRequestException("error.notification.preference.channel.unsupported", channel.name()); + } + NotificationPreference pref = preferenceRepository + .findByUserIdAndCategoryAndChannel(userId, category, channel) + .orElse(null); + if (pref == null) { + pref = new NotificationPreference(userId, category, channel, enabled); + } else { + pref.setEnabled(enabled); + } + preferenceRepository.save(pref); + } + + @Transactional + public void updatePreferences(String userId, List commands) { + if (commands == null) { + throw new DomainBadRequestException("error.notification.preference.request.invalid"); + } + long distinctCount = commands.stream() + .map(command -> command.category().name() + ":" + command.channel().name()) + .distinct() + .count(); + if (distinctCount != commands.size()) { + throw new DomainBadRequestException("error.notification.preference.duplicate"); + } + for (PreferenceCommand command : commands) { + updatePreference(userId, command.category(), command.channel(), command.enabled()); + } + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java new file mode 100644 index 00000000..e0a72837 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.*; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.Instant; + +@Service +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final Clock clock; + + public NotificationService(NotificationRepository notificationRepository, Clock clock) { + this.notificationRepository = notificationRepository; + this.clock = clock; + } + + @Transactional + public Notification create(String recipientId, NotificationCategory category, + String eventType, String title, String bodyJson, + String entityType, Long entityId) { + Notification notification = new Notification(recipientId, category, eventType, + title, bodyJson, entityType, entityId, Instant.now(clock)); + return notificationRepository.save(notification); + } + + @Transactional(readOnly = true) + public Page list(String recipientId, NotificationCategory category, Pageable pageable) { + if (category != null) { + return notificationRepository.findByRecipientIdAndCategory(recipientId, category, pageable); + } + return notificationRepository.findByRecipientId(recipientId, pageable); + } + + @Transactional(readOnly = true) + public long getUnreadCount(String recipientId) { + return notificationRepository.countByRecipientIdAndStatus(recipientId, NotificationStatus.UNREAD); + } + + @Transactional + public void markRead(Long notificationId, String userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId)); + if (!notification.getRecipientId().equals(userId)) { + throw new DomainForbiddenException("error.notification.noPermission"); + } + notification.markRead(Instant.now(clock)); + notificationRepository.save(notification); + } + + @Transactional + public int markAllRead(String userId) { + return notificationRepository.markAllReadByRecipientId(userId, Instant.now(clock)); + } + + @Transactional + public void deleteRead(Long notificationId, String userId) { + int deleted = notificationRepository.deleteByIdAndRecipientIdAndStatus( + notificationId, + userId, + NotificationStatus.READ + ); + if (deleted == 0) { + throw new DomainBadRequestException("error.notification.readNotFound", notificationId); + } + } +} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java new file mode 100644 index 00000000..341a4d50 --- /dev/null +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java @@ -0,0 +1,136 @@ +package com.iflytek.skillhub.notification.sse; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Component +public class SseEmitterManager { + + private static final Logger log = LoggerFactory.getLogger(SseEmitterManager.class); + private static final long SSE_TIMEOUT = 10 * 60_000L; + private static final long HEARTBEAT_INTERVAL = 30_000L; + private static final int MAX_EMITTERS_PER_USER = 5; + private static final int MAX_TOTAL_EMITTERS = 1000; + + private final ConcurrentHashMap> emitters = new ConcurrentHashMap<>(); + private final AtomicInteger totalCount = new AtomicInteger(0); + private final Function emitterFactory; + + public SseEmitterManager() { + this(userId -> new SseEmitter(SSE_TIMEOUT)); + } + + SseEmitterManager(Function emitterFactory) { + this.emitterFactory = emitterFactory; + } + + public SseEmitter register(String userId) { + if (totalCount.get() >= MAX_TOTAL_EMITTERS) { + throw new IllegalStateException("SSE connection limit reached"); + } + + CopyOnWriteArrayList userEmitters = emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()); + if (userEmitters.size() >= MAX_EMITTERS_PER_USER) { + TrackedEmitter oldest = userEmitters.get(0); + cleanup(userId, userEmitters, oldest); + try { + oldest.emitter().complete(); + } catch (IllegalStateException ex) { + log.debug("Emitter already completed during eviction for user {}", userId); + } + } + + TrackedEmitter trackedEmitter = new TrackedEmitter(emitterFactory.apply(userId)); + userEmitters.add(trackedEmitter); + totalCount.incrementAndGet(); + + Runnable cleanup = () -> cleanup(userId, userEmitters, trackedEmitter); + trackedEmitter.emitter().onCompletion(cleanup); + trackedEmitter.emitter().onTimeout(cleanup); + trackedEmitter.emitter().onError(e -> cleanup.run()); + + try { + trackedEmitter.emitter().send(SseEmitter.event().name("connected").data("ok")); + } catch (IOException e) { + cleanup.run(); + } + + return trackedEmitter.emitter(); + } + + public void push(String userId, Object data) { + CopyOnWriteArrayList userEmitters = emitters.get(userId); + if (userEmitters == null) return; + + for (TrackedEmitter trackedEmitter : userEmitters) { + try { + trackedEmitter.emitter().send(SseEmitter.event().name("notification").data(data)); + } catch (IOException e) { + log.debug("Failed to push to user {}, removing emitter", userId); + cleanup(userId, userEmitters, trackedEmitter); + } + } + } + + @Scheduled(fixedRate = HEARTBEAT_INTERVAL) + public void heartbeat() { + emitters.forEach((userId, userEmitters) -> { + for (TrackedEmitter trackedEmitter : userEmitters) { + try { + trackedEmitter.emitter().send(SseEmitter.event().comment("ping")); + } catch (IOException e) { + log.debug("Heartbeat failed for user {}", userId); + cleanup(userId, userEmitters, trackedEmitter); + } + } + }); + } + + int totalEmitters() { + return totalCount.get(); + } + + int emittersForUser(String userId) { + return emitters.getOrDefault(userId, new CopyOnWriteArrayList<>()).size(); + } + + public static long defaultTimeoutMillis() { + return SSE_TIMEOUT; + } + + public static long heartbeatIntervalMillis() { + return HEARTBEAT_INTERVAL; + } + + private void cleanup(String userId, + CopyOnWriteArrayList userEmitters, + TrackedEmitter trackedEmitter) { + if (!trackedEmitter.markCleaned()) { + return; + } + userEmitters.remove(trackedEmitter); + totalCount.decrementAndGet(); + if (userEmitters.isEmpty()) { + emitters.remove(userId, userEmitters); + } + } + + private record TrackedEmitter(SseEmitter emitter, AtomicBoolean cleaned) { + private TrackedEmitter(SseEmitter emitter) { + this(emitter, new AtomicBoolean(false)); + } + + boolean markCleaned() { + return cleaned.compareAndSet(false, true); + } + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/domain/NotificationTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/domain/NotificationTest.java new file mode 100644 index 00000000..3e1e0584 --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/domain/NotificationTest.java @@ -0,0 +1,48 @@ +package com.iflytek.skillhub.notification.domain; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.*; + +class NotificationTest { + + private static final Instant CREATED_AT = Instant.parse("2026-03-19T10:00:00Z"); + + @Test + void newNotification_shouldHaveUnreadStatus() { + Notification notification = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L, CREATED_AT); + + assertEquals(NotificationStatus.UNREAD, notification.getStatus()); + } + + @Test + void markRead_shouldSetStatusAndReadAt() { + Notification notification = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L, CREATED_AT); + Instant readAt = Instant.parse("2026-03-19T11:00:00Z"); + + notification.markRead(readAt); + + assertEquals(NotificationStatus.READ, notification.getStatus()); + assertEquals(readAt, notification.getReadAt()); + } + + @Test + void constructor_shouldSetAllFields() { + Notification notification = new Notification("user-1", NotificationCategory.PUBLISH, + "skill.published", "Skill Published", "{\"skillName\":\"test\"}", "skill", 42L, CREATED_AT); + + assertEquals("user-1", notification.getRecipientId()); + assertEquals(NotificationCategory.PUBLISH, notification.getCategory()); + assertEquals("skill.published", notification.getEventType()); + assertEquals("Skill Published", notification.getTitle()); + assertEquals("{\"skillName\":\"test\"}", notification.getBodyJson()); + assertEquals("skill", notification.getEntityType()); + assertEquals(42L, notification.getEntityId()); + assertEquals(CREATED_AT, notification.getCreatedAt()); + assertNull(notification.getReadAt()); + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationCleanupTaskTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationCleanupTaskTest.java new file mode 100644 index 00000000..b3bbb776 --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationCleanupTaskTest.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.domain.NotificationStatus; +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 java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationCleanupTaskTest { + + @Mock private NotificationRepository notificationRepository; + + private Clock clock; + private NotificationCleanupTask cleanupTask; + + private static final Instant NOW = Instant.parse("2026-03-19T02:00:00Z"); + + @BeforeEach + void setUp() { + clock = Clock.fixed(NOW, ZoneOffset.UTC); + cleanupTask = new NotificationCleanupTask(notificationRepository, clock, 30, 90); + } + + @Test + void cleanup_shouldDeleteReadNotificationsOlderThanRetention() { + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(Instant.class); + + cleanupTask.cleanup(); + + verify(notificationRepository).deleteByStatusAndCreatedAtBefore( + eq(NotificationStatus.READ), cutoffCaptor.capture()); + + Instant expectedCutoff = NOW.minusSeconds(30L * 24 * 60 * 60); + assertTrue(cutoffCaptor.getValue().equals(expectedCutoff), + "Cutoff should be 30 days before now"); + } + + @Test + void cleanup_shouldDeleteUnreadNotificationsOlderThanRetention() { + ArgumentCaptor cutoffCaptor = ArgumentCaptor.forClass(Instant.class); + + cleanupTask.cleanup(); + + verify(notificationRepository).deleteByStatusAndCreatedAtBefore( + eq(NotificationStatus.UNREAD), cutoffCaptor.capture()); + + Instant expectedCutoff = NOW.minusSeconds(90L * 24 * 60 * 60); + assertTrue(cutoffCaptor.getValue().equals(expectedCutoff), + "Cutoff should be 90 days before now"); + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java new file mode 100644 index 00000000..10611a40 --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.sse.SseEmitterManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationDispatcherTest { + + @Mock private NotificationService notificationService; + @Mock private NotificationPreferenceService preferenceService; + @Mock private SseEmitterManager sseEmitterManager; + + private NotificationDispatcher dispatcher; + + @BeforeEach + void setUp() { + dispatcher = new NotificationDispatcher(notificationService, preferenceService, sseEmitterManager); + } + + private Notification buildNotificationMock() { + Notification n = mock(Notification.class); + lenient().when(n.getId()).thenReturn(1L); + lenient().when(n.getCategory()).thenReturn(NotificationCategory.REVIEW); + lenient().when(n.getEventType()).thenReturn("review.approved"); + lenient().when(n.getTitle()).thenReturn("Title"); + lenient().when(n.getBodyJson()).thenReturn("{}"); + lenient().when(n.getEntityType()).thenReturn("skill"); + lenient().when(n.getEntityId()).thenReturn(1L); + lenient().when(n.getCreatedAt()).thenReturn(Instant.parse("2026-03-19T10:00:00Z")); + return n; + } + + @Test + void dispatch_shouldPersistAndPushWhenEnabled() { + Notification notification = buildNotificationMock(); + when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + when(notificationService.create(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(notification); + + dispatcher.dispatch("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L); + + verify(notificationService).create("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L); + verify(sseEmitterManager).push(eq("user-1"), any()); + } + + @Test + void dispatch_shouldSkipWhenPreferenceDisabled() { + when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(false); + + dispatcher.dispatch("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L); + + verify(notificationService, never()).create(any(), any(), any(), any(), any(), any(), any()); + verify(sseEmitterManager, never()).push(any(), any()); + } + + @Test + void dispatch_shouldStillPersistWhenSsePushFails() { + Notification notification = buildNotificationMock(); + when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + when(notificationService.create(any(), any(), any(), any(), any(), any(), any())) + .thenReturn(notification); + doThrow(new RuntimeException("SSE failure")).when(sseEmitterManager).push(any(), any()); + + dispatcher.dispatch("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L); + + verify(notificationService).create("user-1", NotificationCategory.REVIEW, + "review.approved", "Title", "{}", "skill", 1L); + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationPreferenceServiceTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationPreferenceServiceTest.java new file mode 100644 index 00000000..72b122e1 --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationPreferenceServiceTest.java @@ -0,0 +1,88 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.*; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationPreferenceServiceTest { + + @Mock private NotificationPreferenceRepository preferenceRepository; + private NotificationPreferenceService service; + + @BeforeEach + void setUp() { + service = new NotificationPreferenceService(preferenceRepository); + } + + @Test + void isEnabled_shouldReturnTrueByDefault() { + when(preferenceRepository.findByUserIdAndCategoryAndChannel( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(Optional.empty()); + assertTrue(service.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)); + } + + @Test + void isEnabled_shouldReturnSavedValue() { + NotificationPreference pref = new NotificationPreference( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP, false); + when(preferenceRepository.findByUserIdAndCategoryAndChannel( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(Optional.of(pref)); + assertFalse(service.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)); + } + + @Test + void getPreferences_shouldReturnAllCategoriesWithDefaults() { + when(preferenceRepository.findByUserId("user-1")).thenReturn(List.of()); + List prefs = service.getPreferences("user-1"); + assertEquals(NotificationCategory.values().length, prefs.size()); + assertTrue(prefs.stream().allMatch(NotificationPreferenceService.PreferenceView::enabled)); + } + + @Test + void updatePreference_shouldCreateNewWhenNotExists() { + when(preferenceRepository.findByUserIdAndCategoryAndChannel( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(Optional.empty()); + when(preferenceRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + service.updatePreference("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP, false); + verify(preferenceRepository).save(any(NotificationPreference.class)); + } + + @Test + void updatePreference_shouldUpdateExisting() { + NotificationPreference pref = new NotificationPreference( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP, true); + when(preferenceRepository.findByUserIdAndCategoryAndChannel( + "user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(Optional.of(pref)); + when(preferenceRepository.save(any())).thenReturn(pref); + service.updatePreference("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP, false); + assertFalse(pref.isEnabled()); + verify(preferenceRepository).save(pref); + } + + @Test + void updatePreferences_shouldRejectDuplicateItems() { + assertThrows(DomainBadRequestException.class, () -> service.updatePreferences( + "user-1", + List.of( + new NotificationPreferenceService.PreferenceCommand(NotificationCategory.REVIEW, NotificationChannel.IN_APP, true), + new NotificationPreferenceService.PreferenceCommand(NotificationCategory.REVIEW, NotificationChannel.IN_APP, false) + ) + )); + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java new file mode 100644 index 00000000..e92179cb --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java @@ -0,0 +1,135 @@ +package com.iflytek.skillhub.notification.service; + +import com.iflytek.skillhub.notification.domain.*; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock private NotificationRepository notificationRepository; + private Clock clock; + private NotificationService service; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-19T10:00:00Z"), ZoneOffset.UTC); + service = new NotificationService(notificationRepository, clock); + } + + @Test + void createNotification_shouldSaveAndReturn() { + Notification notification = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "notification.review.approved", + "{\"skillName\":\"test\"}", "skill", 1L, Instant.now(clock)); + when(notificationRepository.save(any())).thenReturn(notification); + + Notification result = service.create("user-1", NotificationCategory.REVIEW, + "review.approved", "notification.review.approved", + "{\"skillName\":\"test\"}", "skill", 1L); + + assertNotNull(result); + verify(notificationRepository).save(any(Notification.class)); + } + + @Test + void getUnreadCount_shouldReturnCount() { + when(notificationRepository.countByRecipientIdAndStatus("user-1", NotificationStatus.UNREAD)) + .thenReturn(5L); + long count = service.getUnreadCount("user-1"); + assertEquals(5L, count); + } + + @Test + void markRead_shouldUpdateNotification() { + Notification notification = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "title", null, "skill", 1L, Instant.now(clock)); + when(notificationRepository.findById(1L)).thenReturn(Optional.of(notification)); + when(notificationRepository.save(any())).thenReturn(notification); + + service.markRead(1L, "user-1"); + + assertEquals(NotificationStatus.READ, notification.getStatus()); + verify(notificationRepository).save(notification); + } + + @Test + void markRead_shouldRejectWrongUser() { + Notification notification = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "title", null, "skill", 1L, Instant.now(clock)); + when(notificationRepository.findById(1L)).thenReturn(Optional.of(notification)); + + assertThrows(DomainForbiddenException.class, () -> service.markRead(1L, "user-2")); + } + + @Test + void markAllRead_shouldDelegateToRepository() { + when(notificationRepository.markAllReadByRecipientId(eq("user-1"), any())).thenReturn(3); + int count = service.markAllRead("user-1"); + assertEquals(3, count); + } + + @Test + void deleteRead_shouldDeleteOwnedReadNotification() { + when(notificationRepository.deleteByIdAndRecipientIdAndStatus(1L, "user-1", NotificationStatus.READ)) + .thenReturn(1); + + assertDoesNotThrow(() -> service.deleteRead(1L, "user-1")); + verify(notificationRepository).deleteByIdAndRecipientIdAndStatus(1L, "user-1", NotificationStatus.READ); + } + + @Test + void deleteRead_shouldRejectUnreadOrForeignNotification() { + when(notificationRepository.deleteByIdAndRecipientIdAndStatus(1L, "user-1", NotificationStatus.READ)) + .thenReturn(0); + + assertThrows(DomainBadRequestException.class, () -> service.deleteRead(1L, "user-1")); + } + + @Test + void markRead_shouldRejectMissingNotification() { + when(notificationRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(DomainNotFoundException.class, () -> service.markRead(99L, "user-1")); + } + + @Test + void list_shouldReturnPagedResults() { + Notification n = new Notification("user-1", NotificationCategory.REVIEW, + "review.approved", "title", null, "skill", 1L, Instant.now(clock)); + Page page = new PageImpl<>(List.of(n)); + when(notificationRepository.findByRecipientId(eq("user-1"), any())).thenReturn(page); + + Page result = service.list("user-1", null, PageRequest.of(0, 20)); + assertEquals(1, result.getTotalElements()); + } + + @Test + void list_withCategory_shouldFilterByCategory() { + Page page = new PageImpl<>(List.of()); + when(notificationRepository.findByRecipientIdAndCategory(eq("user-1"), eq(NotificationCategory.REVIEW), any())) + .thenReturn(page); + + service.list("user-1", NotificationCategory.REVIEW, PageRequest.of(0, 20)); + verify(notificationRepository).findByRecipientIdAndCategory(eq("user-1"), eq(NotificationCategory.REVIEW), any()); + } +} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java new file mode 100644 index 00000000..82c502c5 --- /dev/null +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -0,0 +1,207 @@ +package com.iflytek.skillhub.notification.sse; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +class SseEmitterManagerTest { + + private Queue emitters; + private SseEmitterManager manager; + + @BeforeEach + void setUp() { + emitters = new ArrayDeque<>(); + manager = new SseEmitterManager(userId -> { + TestEmitter emitter = emitters.remove(); + emitter.registerUser(userId); + return emitter; + }); + } + + @Test + void register_shouldReturnEmitter() { + emitters.add(new TestEmitter()); + + SseEmitter emitter = manager.register("user-1"); + + assertNotNull(emitter); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + + @Test + void defaultTimeout_shouldOutliveManyHeartbeats() { + assertTrue(SseEmitterManager.defaultTimeoutMillis() >= 10 * 60_000L); + assertTrue(SseEmitterManager.defaultTimeoutMillis() > SseEmitterManager.heartbeatIntervalMillis() * 2); + } + + @Test + void register_shouldKeepAccurateCountWhenEvictingOldestEmitter() { + for (int i = 0; i < 6; i++) { + emitters.add(new TestEmitter()); + } + + for (int i = 0; i < 6; i++) { + manager.register("user-evict"); + } + + assertEquals(5, manager.totalEmitters()); + assertEquals(5, manager.emittersForUser("user-evict")); + } + + @Test + void register_shouldTolerateEmitterThatThrowsDuringEvictionCompletion() { + TestEmitter oldest = new TestEmitter(); + oldest.throwOnComplete(); + emitters.add(oldest); + for (int i = 0; i < 5; i++) { + emitters.add(new TestEmitter()); + } + + for (int i = 0; i < 5; i++) { + manager.register("user-evict"); + } + + assertDoesNotThrow(() -> manager.register("user-evict")); + assertEquals(5, manager.totalEmitters()); + assertEquals(5, manager.emittersForUser("user-evict")); + } + + @Test + void push_shouldRemoveEmitterWhenSendFails() { + TestEmitter healthy = new TestEmitter(); + TestEmitter broken = new TestEmitter(); + broken.failAfterConnected(); + emitters.add(healthy); + emitters.add(broken); + manager.register("user-1"); + manager.register("user-1"); + + manager.push("user-1", "payload"); + + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + + @Test + void heartbeat_shouldRemoveEmitterWhenSendFails() { + TestEmitter healthy = new TestEmitter(); + TestEmitter broken = new TestEmitter(); + broken.failAfterConnected(); + emitters.add(healthy); + emitters.add(broken); + manager.register("user-1"); + manager.register("user-1"); + + manager.heartbeat(); + + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + + @Test + void cleanup_shouldBeIdempotent() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + emitter.fireError(); + emitter.fireError(); + + assertEquals(0, manager.totalEmitters()); + assertEquals(0, manager.emittersForUser("user-1")); + } + + @Test + void push_shouldDoNothingForUnregisteredUser() { + assertDoesNotThrow(() -> manager.push("unknown-user", "some-data")); + assertEquals(0, manager.totalEmitters()); + } + + @Test + void register_multipleUsers_shouldTrackSeparately() { + emitters.add(new TestEmitter()); + emitters.add(new TestEmitter()); + + SseEmitter emitter1 = manager.register("user-1"); + SseEmitter emitter2 = manager.register("user-2"); + + assertNotNull(emitter1); + assertNotNull(emitter2); + assertEquals(2, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, manager.emittersForUser("user-2")); + } + + private static final class TestEmitter extends SseEmitter { + private final AtomicInteger errorCallbacks = new AtomicInteger(0); + private Runnable completionCallback = () -> {}; + private Runnable timeoutCallback = () -> {}; + private java.util.function.Consumer errorCallback = error -> {}; + private String userId; + private boolean failAfterConnected; + private boolean throwOnComplete; + private int sendCount; + + private TestEmitter() { + super(60_000L); + } + + void registerUser(String userId) { + this.userId = userId; + } + + void failAfterConnected() { + this.failAfterConnected = true; + } + + void throwOnComplete() { + this.throwOnComplete = true; + } + + void fireError() { + errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); + } + + @Override + public synchronized void onCompletion(Runnable callback) { + this.completionCallback = callback; + } + + @Override + public synchronized void onTimeout(Runnable callback) { + this.timeoutCallback = callback; + } + + @Override + public synchronized void onError(java.util.function.Consumer callback) { + this.errorCallback = callback; + } + + @Override + public void complete() { + if (throwOnComplete) { + throw new IllegalStateException("already complete"); + } + completionCallback.run(); + } + + @Override + public void send(SseEventBuilder builder) throws IOException { + sendCount++; + if (failAfterConnected && sendCount > 1) { + throw new IOException("send failed"); + } + } + } +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f94247e5..9b1782dc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -31,6 +31,10 @@ import type { CreateNamespaceRequest, NamespaceMember, NamespaceCandidateUser, + NotificationItem, + NotificationPreferenceItem, + NotificationUnreadCount, + SkillDeleteResult, AdminLabelInput, LabelDefinition, LabelItem, @@ -463,9 +467,9 @@ export const skillLifecycleApi = { }) }, - async deleteSkill(namespace: string, slug: string): Promise { + async deleteSkill(namespace: string, slug: string): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace - await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}`, { + return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}`, { method: 'DELETE', headers: await ensureCsrfHeaders(), }) @@ -1183,3 +1187,54 @@ export const adminApi = { }) }, } + +export const notificationApi = { + async list(params: { page?: number; size?: number; category?: string }) { + const searchParams = new URLSearchParams() + if (params.page !== undefined) searchParams.set('page', String(params.page)) + if (params.size !== undefined) searchParams.set('size', String(params.size)) + if (params.category) searchParams.set('category', params.category) + return fetchJson<{ items: NotificationItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/notifications?${searchParams.toString()}`, + ) + }, + + async getUnreadCount() { + return fetchJson(`${WEB_API_PREFIX}/notifications/unread-count`) + }, + + async markRead(id: number) { + await fetchJson(`${WEB_API_PREFIX}/notifications/${id}/read`, { + method: 'PUT', + headers: getCsrfHeaders(), + }) + }, + + async markAllRead() { + return fetchJson<{ count: number }>(`${WEB_API_PREFIX}/notifications/read-all`, { + method: 'PUT', + headers: getCsrfHeaders(), + }) + }, + + async deleteRead(id: number) { + await fetchJson(`${WEB_API_PREFIX}/notifications/${id}`, { + method: 'DELETE', + headers: getCsrfHeaders(), + }) + }, + + async getPreferences() { + return fetchJson(`${WEB_API_PREFIX}/notification-preferences`) + }, + + async updatePreferences(preferences: NotificationPreferenceItem[]) { + await fetchJson(`${WEB_API_PREFIX}/notification-preferences`, { + method: 'PUT', + headers: getCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ preferences }), + }) + }, +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b6469700..adfbab5c 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -282,6 +282,13 @@ export interface PublishResult { totalSize: number } +export interface SkillDeleteResult { + skillId?: number + namespace?: string + slug?: string + deleted?: boolean +} + export interface ReviewTask { id: number skillVersionId: number @@ -405,3 +412,30 @@ export interface AuditLogItem { timestamp: string ipAddress?: string } + +// Notification types +export interface NotificationItem { + id: number + category: 'PUBLISH' | 'REVIEW' | 'PROMOTION' | 'REPORT' + eventType: string + title: string + bodyJson?: string + entityType?: string + entityId?: number + targetType?: string + targetId?: number + targetRoute?: string + status: 'UNREAD' | 'READ' + createdAt: string + readAt?: string +} + +export interface NotificationPreferenceItem { + category: string + channel: string + enabled: boolean +} + +export interface NotificationUnreadCount { + count: number +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 5c703e72..4b48b25e 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { UserMenu } from '@/shared/components/user-menu' +import { NotificationBell } from '@/features/notification/notification-bell' import { getAppHeaderClassName } from './layout-header-style' import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' @@ -98,6 +99,7 @@ export function Layout() {
+ {user && } {isLoading ? null : user ? ( ) : ( diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 3462ce82..9c4d4292 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -3,6 +3,7 @@ import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/ import { Layout } from './layout' import { getCurrentUser } from '@/api/client' import { RoleGuard } from '@/shared/components/role-guard' +import { createRequireAuth } from '@/shared/lib/auth-route' import { normalizeSearchQuery } from '@/shared/lib/search-query' /** @@ -106,6 +107,7 @@ const PromotionsPage = createRoleProtectedRouteComponent( ['SKILL_ADMIN', 'SUPER_ADMIN'], ) const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/stars'), 'MyStarsPage') +const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage') const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( @@ -116,6 +118,10 @@ const ProfileSettingsPage = createLazyRouteComponent( () => import('@/pages/settings/profile'), 'ProfileSettingsPage', ) +const NotificationSettingsPage = createLazyRouteComponent( + () => import('@/pages/settings/notification-settings'), + 'NotificationSettingsPage', +) const AdminUsersPage = createRoleProtectedRouteComponent( () => import('@/pages/admin/users'), 'AdminUsersPage', @@ -145,21 +151,7 @@ const rootRoute = createRootRoute({ notFoundComponent: DefaultNotFound, }) -function buildReturnTo(location: { pathname: string; searchStr?: string; hash?: string }) { - return `${location.pathname}${location.searchStr ?? ''}${location.hash ?? ''}` -} - -async function requireAuth({ location }: { location: { pathname: string; searchStr?: string; hash?: string } }) { - // Resolve the current session before entering protected areas and preserve the full return URL. - const user = await getCurrentUser() - if (!user) { - throw redirect({ - to: '/login', - search: { returnTo: buildReturnTo(location) }, - }) - } - return { user } -} +const requireAuth = createRequireAuth(getCurrentUser) const landingRoute = createRoute({ getParentRoute: () => rootRoute, @@ -222,12 +214,14 @@ const termsRoute = createRoute({ const namespaceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace', + beforeLoad: requireAuth, component: NamespacePage, }) const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', + beforeLoad: requireAuth, validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, }), @@ -318,6 +312,13 @@ const dashboardStarsRoute = createRoute({ component: MyStarsPage, }) +const dashboardNotificationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/notifications', + beforeLoad: requireAuth, + component: NotificationsPage, +}) + const dashboardTokensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/tokens', @@ -354,6 +355,13 @@ const settingsProfileRoute = createRoute({ component: ProfileSettingsPage, }) +const settingsNotificationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'settings/notifications', + beforeLoad: requireAuth, + component: NotificationSettingsPage, +}) + const settingsAccountsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'settings/accounts', @@ -406,10 +414,12 @@ const routeTree = rootRoute.addChildren([ dashboardReviewDetailRoute, dashboardPromotionsRoute, dashboardStarsRoute, + dashboardNotificationsRoute, dashboardTokensRoute, cliAuthRoute, settingsSecurityRoute, settingsProfileRoute, + settingsNotificationsRoute, settingsAccountsRoute, adminUsersRoute, adminAuditLogRoute, diff --git a/web/src/features/auth/use-local-auth.ts b/web/src/features/auth/use-local-auth.ts index e1a33fc6..b5a7ac55 100644 --- a/web/src/features/auth/use-local-auth.ts +++ b/web/src/features/auth/use-local-auth.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { LocalLoginRequest, LocalRegisterRequest, User } from '@/api/types' +import { clearSessionScopedQueries } from '@/features/notification/notification-session' /** * Local-account auth mutations for classic username-password login and registration. @@ -11,6 +12,7 @@ export function useLocalLogin() { return useMutation({ mutationFn: (request: LocalLoginRequest) => authApi.localLogin(request), onSuccess: (user) => { + clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], user) }, }) @@ -22,6 +24,7 @@ export function useLocalRegister() { return useMutation({ mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request), onSuccess: (user) => { + clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], user) }, }) diff --git a/web/src/features/auth/use-password-login.ts b/web/src/features/auth/use-password-login.ts index a5f4f39d..909b9c04 100644 --- a/web/src/features/auth/use-password-login.ts +++ b/web/src/features/auth/use-password-login.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi, getDirectAuthRuntimeConfig } from '@/api/client' import { ApiError } from '@/shared/lib/api-error' import type { LocalLoginRequest, User } from '@/api/types' +import { clearSessionScopedQueries } from '@/features/notification/notification-session' /** * Password-login mutation that can switch between local auth and direct upstream auth based on @@ -19,6 +20,7 @@ export function usePasswordLogin() { return authApi.localLogin(request) }, onSuccess: (user) => { + clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], user) }, onError: (error) => { diff --git a/web/src/features/auth/use-session-bootstrap.ts b/web/src/features/auth/use-session-bootstrap.ts index 1c1269ce..f42473c7 100644 --- a/web/src/features/auth/use-session-bootstrap.ts +++ b/web/src/features/auth/use-session-bootstrap.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' import type { User } from '@/api/types' +import { clearSessionScopedQueries } from '@/features/notification/notification-session' /** * Session-bootstrap mutation used when the backend can mint a browser session from an upstream @@ -12,6 +13,7 @@ export function useSessionBootstrap() { return useMutation({ mutationFn: (provider) => authApi.bootstrapSession(provider), onSuccess: (user) => { + clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], user) }, }) diff --git a/web/src/features/notification/notification-bell.test.ts b/web/src/features/notification/notification-bell.test.ts new file mode 100644 index 00000000..b500b62a --- /dev/null +++ b/web/src/features/notification/notification-bell.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { resolveNotificationUserId } from './notification-bell' + +describe('resolveNotificationUserId', () => { + it('returns the current authenticated user id for notification-scoped queries', () => { + expect(resolveNotificationUserId({ userId: 'user-b' })).toBe('user-b') + }) + + it('returns undefined when there is no authenticated user', () => { + expect(resolveNotificationUserId(null)).toBeUndefined() + expect(resolveNotificationUserId(undefined)).toBeUndefined() + }) +}) diff --git a/web/src/features/notification/notification-bell.tsx b/web/src/features/notification/notification-bell.tsx new file mode 100644 index 00000000..1a22a06b --- /dev/null +++ b/web/src/features/notification/notification-bell.tsx @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useAuth } from '@/features/auth/use-auth' +import { useUnreadCount } from './use-notifications' +import { useNotificationSse } from './use-notification-sse' +import { NotificationDropdown } from './notification-dropdown' + +export function resolveNotificationUserId(user?: { userId?: string } | null) { + return user?.userId +} + +/** + * Bell icon with unread badge. Toggles the notification dropdown on click. + * SSE connection is established here at the authenticated user level. + */ +export function NotificationBell() { + const { t } = useTranslation() + const { user } = useAuth() + const [open, setOpen] = useState(false) + const containerRef = useRef(null) + + const notificationUserId = resolveNotificationUserId(user) + const { data: unreadData } = useUnreadCount(notificationUserId) + const unreadCount = unreadData?.count ?? 0 + + useNotificationSse(notificationUserId) + + // Close dropdown when clicking outside + useEffect(() => { + if (!open) return + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [open]) + + const badgeLabel = unreadCount > 99 ? '99+' : String(unreadCount) + + return ( +
+ + + {open && ( + setOpen(false)} /> + )} +
+ ) +} diff --git a/web/src/features/notification/notification-content.test.ts b/web/src/features/notification/notification-content.test.ts new file mode 100644 index 00000000..ef3d9a94 --- /dev/null +++ b/web/src/features/notification/notification-content.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { resolveNotificationDisplay } from './notification-content' + +describe('resolveNotificationDisplay', () => { + it('renders review submitted content in Chinese', () => { + const display = resolveNotificationDisplay({ + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'New review submitted for: Calendar', + bodyJson: JSON.stringify({ skillName: 'Calendar', version: '1.0.0' }), + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + id: 1, + }, 'zh-CN') + + expect(display.title).toBe('技能审核提交') + expect(display.description).toContain('Calendar') + expect(display.description).toContain('1.0.0') + }) + + it('renders review submitted content in English', () => { + const display = resolveNotificationDisplay({ + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'New review submitted for: Calendar', + bodyJson: JSON.stringify({ skillName: 'Calendar', version: '1.0.0' }), + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + id: 1, + }, 'en') + + expect(display.title).toBe('Review submitted') + expect(display.description).toContain('Calendar') + expect(display.description).toContain('1.0.0') + }) + + it('falls back to the backend title when the event type is unsupported', () => { + const display = resolveNotificationDisplay({ + category: 'PUBLISH', + eventType: 'CUSTOM_EVENT', + title: 'Backend supplied title', + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + id: 2, + }, 'en') + + expect(display.title).toBe('Backend supplied title') + expect(display.description).toBe('') + }) +}) diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts new file mode 100644 index 00000000..f68d30d1 --- /dev/null +++ b/web/src/features/notification/notification-content.ts @@ -0,0 +1,88 @@ +import type { NotificationItem } from '@/api/types' + +export type NotificationDisplay = { + title: string + description: string +} + +type NotificationBody = { + skillName?: string + version?: string +} + +function parseBody(bodyJson?: string): NotificationBody { + if (!bodyJson) { + return {} + } + try { + const parsed = JSON.parse(bodyJson) + return typeof parsed === 'object' && parsed !== null ? parsed as NotificationBody : {} + } catch { + return {} + } +} + +function isChinese(language: string) { + return language.toLowerCase().startsWith('zh') +} + +export function resolveNotificationDisplay(item: NotificationItem, language: string): NotificationDisplay { + const zh = isChinese(language) + const body = parseBody(item.bodyJson) + const skillName = body.skillName ?? '' + const version = body.version ?? '' + const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : '' + + switch (item.eventType) { + case 'REVIEW_SUBMITTED': + return { + title: zh ? '技能审核提交' : 'Review submitted', + description: skillName ? (zh ? `${skillName}${versionSuffix} 已提交审核。` : `${skillName}${versionSuffix} was submitted for review.`) : '', + } + case 'REVIEW_APPROVED': + return { + title: zh ? '技能审核通过' : 'Review approved', + description: skillName ? (zh ? `${skillName}${versionSuffix} 已审核通过。` : `${skillName}${versionSuffix} was approved.`) : '', + } + case 'REVIEW_REJECTED': + return { + title: zh ? '技能审核驳回' : 'Review rejected', + description: skillName ? (zh ? `${skillName}${versionSuffix} 审核未通过。` : `${skillName}${versionSuffix} was rejected.`) : '', + } + case 'PROMOTION_SUBMITTED': + return { + title: zh ? '技能推广提交' : 'Promotion submitted', + description: skillName ? (zh ? `${skillName}${versionSuffix} 已提交推广。` : `${skillName}${versionSuffix} was submitted for promotion.`) : '', + } + case 'PROMOTION_APPROVED': + return { + title: zh ? '技能推广通过' : 'Promotion approved', + description: skillName ? (zh ? `${skillName}${versionSuffix} 推广已通过。` : `${skillName}${versionSuffix} promotion was approved.`) : '', + } + case 'PROMOTION_REJECTED': + return { + title: zh ? '技能推广驳回' : 'Promotion rejected', + description: skillName ? (zh ? `${skillName}${versionSuffix} 推广未通过。` : `${skillName}${versionSuffix} promotion was rejected.`) : '', + } + case 'REPORT_SUBMITTED': + return { + title: zh ? '技能举报提交' : 'Report submitted', + description: skillName ? (zh ? `${skillName} 收到新的举报。` : `${skillName} received a new report.`) : '', + } + case 'REPORT_RESOLVED': + return { + title: zh ? '技能举报已处理' : 'Report resolved', + description: skillName ? (zh ? `${skillName} 的举报已处理。` : `${skillName} report has been resolved.`) : '', + } + case 'SKILL_PUBLISHED': + return { + title: zh ? '技能发布成功' : 'Skill published', + description: skillName ? (zh ? `${skillName}${versionSuffix} 已发布。` : `${skillName}${versionSuffix} was published.`) : '', + } + default: + return { + title: item.title, + description: '', + } + } +} diff --git a/web/src/features/notification/notification-dropdown.tsx b/web/src/features/notification/notification-dropdown.tsx new file mode 100644 index 00000000..a1c64532 --- /dev/null +++ b/web/src/features/notification/notification-dropdown.tsx @@ -0,0 +1,130 @@ +import { useTranslation } from 'react-i18next' +import { Link } from '@tanstack/react-router' +import type { NotificationItem } from '@/api/types' +import { getNotificationItems } from './notification-page' +import { resolveNotificationDisplay } from './notification-content' +import { useAuth } from '@/features/auth/use-auth' +import { useNotifications, useMarkAllRead, useMarkRead } from './use-notifications' +import { resolveNotificationTarget } from './notification-target' + +interface Props { + onClose: () => void +} + +function formatRelativeTime(dateStr: string, lang: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60_000) + const hours = Math.floor(diff / 3_600_000) + const days = Math.floor(diff / 86_400_000) + + const isChinese = lang.startsWith('zh') + + if (minutes < 1) return isChinese ? '刚刚' : 'just now' + if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m` + if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h` + if (days < 30) return isChinese ? `${days}天` : `${days}d` + return new Date(dateStr).toLocaleDateString() +} + +/** + * Dropdown panel showing the latest 5 notifications with mark-all-read and view-all actions. + */ +export function NotificationDropdown({ onClose }: Props) { + const { t, i18n } = useTranslation() + const { user } = useAuth() + const { data, isLoading } = useNotifications(user?.userId, 0, 5) + const markAllRead = useMarkAllRead(user?.userId) + const markRead = useMarkRead(user?.userId) + + const notifications = getNotificationItems(data) + + function handleItemClick(item: NotificationItem) { + if (item.status === 'UNREAD') { + markRead.mutate(item.id) + } + onClose() + } + + function handleMarkAllRead() { + markAllRead.mutate() + } + + return ( +
+ {/* Header */} +
+ + {t('notification.title')} + + +
+ + {/* Body */} +
    + {isLoading ? ( +
  • + … +
  • + ) : notifications.length === 0 ? ( +
  • + {t('notification.empty')} +
  • + ) : ( + notifications.map((item) => ( +
  • + {(() => { + const display = resolveNotificationDisplay(item, i18n.language) + return ( + handleItemClick(item)} + className="flex items-start gap-3 px-4 py-3 hover:bg-gray-50 transition-colors" + > + {/* Unread dot */} + +
    +

    + {display.title} +

    + {display.description ? ( +

    + {display.description} +

    + ) : null} +

    + {t('notification.timeAgo', { time: formatRelativeTime(item.createdAt, i18n.language) })} +

    +
    + + ) + })()} +
  • + )) + )} +
+ + {/* Footer */} +
+ + {t('notification.viewAll')} + +
+
+ ) +} diff --git a/web/src/features/notification/notification-page.test.ts b/web/src/features/notification/notification-page.test.ts new file mode 100644 index 00000000..1d5518b5 --- /dev/null +++ b/web/src/features/notification/notification-page.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { getNotificationItems, getNotificationTotal, shouldShowNotificationPagination } from './notification-page' + +describe('getNotificationItems', () => { + it('returns backend page items', () => { + expect(getNotificationItems({ + items: [ + { + id: 1, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Review submitted', + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + }, + ], + total: 1, + page: 0, + size: 20, + })).toHaveLength(1) + }) + + it('falls back to an empty array when page data is missing', () => { + expect(getNotificationItems(undefined)).toEqual([]) + }) +}) + +describe('getNotificationTotal', () => { + it('returns backend total field', () => { + expect(getNotificationTotal({ items: [], total: 7, page: 0, size: 20 })).toBe(7) + }) + + it('falls back to zero when page data is missing', () => { + expect(getNotificationTotal(undefined)).toBe(0) + }) +}) + +describe('shouldShowNotificationPagination', () => { + it('returns true when there is more than one page', () => { + expect(shouldShowNotificationPagination(21, 20)).toBe(true) + }) + + it('returns false when the first page already contains all items', () => { + expect(shouldShowNotificationPagination(20, 20)).toBe(false) + }) +}) diff --git a/web/src/features/notification/notification-page.ts b/web/src/features/notification/notification-page.ts new file mode 100644 index 00000000..bdda87f7 --- /dev/null +++ b/web/src/features/notification/notification-page.ts @@ -0,0 +1,13 @@ +import type { NotificationItem, PagedResponse } from '@/api/types' + +export function getNotificationItems(page?: PagedResponse) { + return page?.items ?? [] +} + +export function getNotificationTotal(page?: PagedResponse) { + return page?.total ?? 0 +} + +export function shouldShowNotificationPagination(total: number, pageSize: number) { + return pageSize > 0 && total > pageSize +} diff --git a/web/src/features/notification/notification-preference-form.tsx b/web/src/features/notification/notification-preference-form.tsx new file mode 100644 index 00000000..79818587 --- /dev/null +++ b/web/src/features/notification/notification-preference-form.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from 'react-i18next' +import type { NotificationPreferenceItem } from '@/api/types' +import { useNotificationPreferences, useUpdateNotificationPreferences } from './use-notification-preferences' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' + +const CATEGORIES = ['PUBLISH', 'REVIEW', 'PROMOTION', 'REPORT'] as const +type Category = (typeof CATEGORIES)[number] + +const CATEGORY_KEYS: Record = { + PUBLISH: { label: 'notification.preferences.publish', desc: 'notification.preferences.publishDesc' }, + REVIEW: { label: 'notification.preferences.review', desc: 'notification.preferences.reviewDesc' }, + PROMOTION: { label: 'notification.preferences.promotion', desc: 'notification.preferences.promotionDesc' }, + REPORT: { label: 'notification.preferences.report', desc: 'notification.preferences.reportDesc' }, +} + +function getEnabled(preferences: NotificationPreferenceItem[], category: string): boolean { + const item = preferences.find((p) => p.category === category && p.channel === 'IN_APP') + // Default to true when no record exists + return item?.enabled ?? true +} + +function buildUpdatedPreferences( + current: NotificationPreferenceItem[], + category: string, + enabled: boolean, +): NotificationPreferenceItem[] { + const existing = current.find((p) => p.category === category && p.channel === 'IN_APP') + if (existing) { + return current.map((p) => + p.category === category && p.channel === 'IN_APP' ? { ...p, enabled } : p, + ) + } + return [...current, { category, channel: 'IN_APP', enabled }] +} + +/** + * Renders the notification preference toggles for all supported categories. + */ +export function NotificationPreferenceForm() { + const { t } = useTranslation() + const { data: preferences = [], isLoading } = useNotificationPreferences() + const { mutate: updatePreferences, isPending } = useUpdateNotificationPreferences() + + function handleToggle(category: string) { + const current = getEnabled(preferences, category) + const updated = buildUpdatedPreferences(preferences, category, !current) + updatePreferences(updated) + } + + return ( + + + {t('notification.preferences.title')} + {t('notification.preferences.description')} + + +
+ {CATEGORIES.map((category) => { + const enabled = getEnabled(preferences, category) + const keys = CATEGORY_KEYS[category] + const toggleId = `pref-toggle-${category}` + + return ( +
+
+ +

{t(keys.desc)}

+
+ + {/* Accessible toggle switch */} + +
+ ) + })} +
+
+
+ ) +} diff --git a/web/src/features/notification/notification-session.test.ts b/web/src/features/notification/notification-session.test.ts new file mode 100644 index 00000000..0d02e257 --- /dev/null +++ b/web/src/features/notification/notification-session.test.ts @@ -0,0 +1,32 @@ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it } from 'vitest' +import { clearSessionScopedQueries, getNotificationQueryKeyScope } from './notification-session' + +describe('getNotificationQueryKeyScope', () => { + it('partitions notification caches by authenticated user id', () => { + expect(getNotificationQueryKeyScope('user-a')).toEqual(['notifications', 'user-a']) + expect(getNotificationQueryKeyScope('user-b')).toEqual(['notifications', 'user-b']) + }) + + it('falls back to a guest scope when there is no authenticated user', () => { + expect(getNotificationQueryKeyScope(undefined)).toEqual(['notifications', 'guest']) + expect(getNotificationQueryKeyScope(null)).toEqual(['notifications', 'guest']) + }) +}) + +describe('clearSessionScopedQueries', () => { + it('removes user-scoped notification and dashboard caches without touching public search caches', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['notifications', 'user-a', 'unread-count'], { count: 3 }) + queryClient.setQueryData(['labels', 'visible'], [{ slug: 'official' }]) + queryClient.setQueryData(['skills', 'my', { page: 0, size: 12 }], { items: [] }) + queryClient.setQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }], { items: [] }) + + clearSessionScopedQueries(queryClient) + + expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toBeUndefined() + expect(queryClient.getQueryData(['labels', 'visible'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'my', { page: 0, size: 12 }])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'search', { q: '', sort: 'relevance', page: 0, size: 12, starredOnly: false }])).toEqual({ items: [] }) + }) +}) diff --git a/web/src/features/notification/notification-session.ts b/web/src/features/notification/notification-session.ts new file mode 100644 index 00000000..2195e40c --- /dev/null +++ b/web/src/features/notification/notification-session.ts @@ -0,0 +1,18 @@ +import type { QueryClient } from '@tanstack/react-query' + +export function getNotificationQueryKeyScope(userId?: string | null) { + return userId ? ['notifications', userId] as const : ['notifications', 'guest'] as const +} + +export function clearSessionScopedQueries(queryClient: QueryClient) { + queryClient.removeQueries({ queryKey: ['notifications'] }) + queryClient.removeQueries({ queryKey: ['labels'] }) + queryClient.removeQueries({ queryKey: ['skills', 'my'] }) + queryClient.removeQueries({ queryKey: ['skills', 'stars'] }) + queryClient.removeQueries({ queryKey: ['namespaces', 'my'] }) + queryClient.removeQueries({ queryKey: ['governance'] }) + queryClient.removeQueries({ queryKey: ['reviews'] }) + queryClient.removeQueries({ queryKey: ['promotions'] }) + queryClient.removeQueries({ queryKey: ['reports'] }) + queryClient.removeQueries({ queryKey: ['admin', 'users'] }) +} diff --git a/web/src/features/notification/notification-sse-coordinator.test.ts b/web/src/features/notification/notification-sse-coordinator.test.ts new file mode 100644 index 00000000..faa28062 --- /dev/null +++ b/web/src/features/notification/notification-sse-coordinator.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { createNotificationSseConnection } from './notification-sse-coordinator' + +class FakeEventSource { + listeners = new Map void>>() + closed = false + + addEventListener(type: string, listener: (event: MessageEvent) => void) { + const current = this.listeners.get(type) ?? [] + current.push(listener) + this.listeners.set(type, current) + } + + close() { + this.closed = true + } + + emit(type: string) { + for (const listener of this.listeners.get(type) ?? []) { + listener(new MessageEvent(type)) + } + } +} + +describe('createNotificationSseConnection', () => { + it('backs off reconnect attempts after repeated errors', () => { + vi.useFakeTimers() + const sources: FakeEventSource[] = [] + const connection = createNotificationSseConnection( + '/api/web/notifications/sse', + () => { + const source = new FakeEventSource() + sources.push(source) + return source + }, + { setTimeout, clearTimeout }, + ) + + expect(sources).toHaveLength(1) + sources[0].emit('error') + expect(sources[0].closed).toBe(true) + expect(sources).toHaveLength(1) + + vi.advanceTimersByTime(999) + expect(sources).toHaveLength(1) + + vi.advanceTimersByTime(1) + expect(sources).toHaveLength(2) + + sources[1].emit('error') + vi.advanceTimersByTime(1_999) + expect(sources).toHaveLength(2) + + vi.advanceTimersByTime(1) + expect(sources).toHaveLength(3) + + connection.close() + vi.useRealTimers() + }) + + it('resets reconnect delay after a successful open event', () => { + vi.useFakeTimers() + const sources: FakeEventSource[] = [] + createNotificationSseConnection( + '/api/web/notifications/sse', + () => { + const source = new FakeEventSource() + sources.push(source) + return source + }, + { setTimeout, clearTimeout }, + ) + + sources[0].emit('error') + vi.advanceTimersByTime(1_000) + expect(sources).toHaveLength(2) + + sources[1].emit('open') + sources[1].emit('error') + vi.advanceTimersByTime(1_000) + expect(sources).toHaveLength(3) + + vi.useRealTimers() + }) +}) diff --git a/web/src/features/notification/notification-sse-coordinator.ts b/web/src/features/notification/notification-sse-coordinator.ts new file mode 100644 index 00000000..188c0943 --- /dev/null +++ b/web/src/features/notification/notification-sse-coordinator.ts @@ -0,0 +1,99 @@ +const SHARED_BROWSER_SSE_ENABLED = false +const INITIAL_RECONNECT_DELAY_MS = 1_000 +const MAX_RECONNECT_DELAY_MS = 30_000 + +type NotificationListener = (event: MessageEvent) => void +type SourceEventListener = (event: Event) => void +type NotificationEventSource = { + addEventListener: (type: string, listener: SourceEventListener) => void + close: () => void +} +type EventSourceFactory = (url: string) => NotificationEventSource +type TimerApi = { + setTimeout: typeof setTimeout + clearTimeout: typeof clearTimeout +} + +export type NotificationSseConnection = { + addEventListener: (type: string, listener: NotificationListener) => void + close: () => void +} + +export function isSharedBrowserSseEnabled() { + return SHARED_BROWSER_SSE_ENABLED +} + +export function createNotificationSseConnection( + url: string, + eventSourceFactory: EventSourceFactory = (targetUrl) => + new EventSource(targetUrl, { withCredentials: true }), + timerApi: TimerApi = { setTimeout, clearTimeout }, +): NotificationSseConnection { + return new ManagedNotificationSseConnection(url, eventSourceFactory, timerApi) +} + +class ManagedNotificationSseConnection implements NotificationSseConnection { + private readonly listeners = new Map() + private currentSource: NotificationEventSource | null = null + private reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS + private reconnectTimer: ReturnType | null = null + private closed = false + + constructor( + private readonly url: string, + private readonly eventSourceFactory: EventSourceFactory, + private readonly timerApi: TimerApi, + ) { + this.connect() + } + + addEventListener(type: string, listener: NotificationListener) { + const current = this.listeners.get(type) ?? [] + current.push(listener) + this.listeners.set(type, current) + } + + close() { + this.closed = true + if (this.reconnectTimer) { + this.timerApi.clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + this.currentSource?.close() + this.currentSource = null + } + + private connect() { + if (this.closed) { + return + } + const source = this.eventSourceFactory(this.url) + this.currentSource = source + + source.addEventListener('open', (event) => { + this.reconnectDelayMs = INITIAL_RECONNECT_DELAY_MS + this.emit('open', event as MessageEvent) + }) + source.addEventListener('notification', (event) => { + this.emit('notification', event as MessageEvent) + }) + source.addEventListener('error', () => { + source.close() + if (this.closed || this.reconnectTimer) { + return + } + const delay = this.reconnectDelayMs + this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS) + this.reconnectTimer = this.timerApi.setTimeout(() => { + this.reconnectTimer = null + this.connect() + }, delay) + }) + } + + private emit(type: string, event: MessageEvent) { + for (const listener of this.listeners.get(type) ?? []) { + listener(event) + } + } +} diff --git a/web/src/features/notification/notification-target.test.ts b/web/src/features/notification/notification-target.test.ts new file mode 100644 index 00000000..03be70d8 --- /dev/null +++ b/web/src/features/notification/notification-target.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { resolveNotificationTarget } from './notification-target' + +describe('resolveNotificationTarget', () => { + it('prefers explicit targetRoute when provided', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Review submitted', + targetRoute: '/dashboard/reviews/12', + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/reviews/12') + }) + + it('ignores unsafe absolute targetRoute values', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Review submitted', + targetRoute: 'https://evil.example/steal', + entityType: 'REVIEW', + entityId: 12, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/reviews/12') + }) + + it('ignores protocol-relative targetRoute values', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'PROMOTION', + eventType: 'PROMOTION_SUBMITTED', + title: 'Promotion submitted', + targetRoute: '//evil.example/steal', + entityType: 'PROMOTION', + entityId: 44, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/promotions') + }) + + it('falls back to legacy review detail route', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Review submitted', + entityType: 'REVIEW', + entityId: 12, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/reviews/12') + }) + + it('routes legacy report notifications to the reports page', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'REPORT', + eventType: 'REPORT_SUBMITTED', + title: 'Report submitted', + entityType: 'REPORT', + entityId: 33, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/reports') + }) + + it('routes legacy promotion notifications to the promotions page', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'PROMOTION', + eventType: 'PROMOTION_SUBMITTED', + title: 'Promotion submitted', + entityType: 'PROMOTION', + entityId: 44, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/promotions') + }) + + it('falls back to notifications page for unsupported items', () => { + expect(resolveNotificationTarget({ + id: 1, + category: 'PUBLISH', + eventType: 'SKILL_PUBLISHED', + title: 'Published', + entityType: 'SKILL', + entityId: 55, + status: 'UNREAD', + createdAt: '2026-03-20T00:00:00Z', + })).toBe('/dashboard/notifications') + }) +}) diff --git a/web/src/features/notification/notification-target.ts b/web/src/features/notification/notification-target.ts new file mode 100644 index 00000000..1181f9ce --- /dev/null +++ b/web/src/features/notification/notification-target.ts @@ -0,0 +1,25 @@ +import type { NotificationItem } from '@/api/types' + +export function resolveNotificationTarget(item: NotificationItem): string { + if (isSafeInternalRoute(item.targetRoute)) { + return item.targetRoute + } + + switch (item.entityType?.toLowerCase()) { + case 'review': + return item.entityId ? `/dashboard/reviews/${item.entityId}` : '/dashboard/reviews' + case 'report': + return '/dashboard/reports' + case 'promotion': + return '/dashboard/promotions' + default: + return '/dashboard/notifications' + } +} + +function isSafeInternalRoute(targetRoute?: string | null): targetRoute is string { + if (!targetRoute) { + return false + } + return targetRoute.startsWith('/') && !targetRoute.startsWith('//') +} diff --git a/web/src/features/notification/notification-unread-cache.test.ts b/web/src/features/notification/notification-unread-cache.test.ts new file mode 100644 index 00000000..093f7fac --- /dev/null +++ b/web/src/features/notification/notification-unread-cache.test.ts @@ -0,0 +1,42 @@ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it } from 'vitest' +import { decrementUnreadCount, incrementUnreadCount, resetUnreadCount } from './notification-unread-cache' +import { NOTIFICATION_QUERY_KEYS } from './use-notifications' + +describe('notification unread cache helpers', () => { + it('increments unread count from the existing cached value', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'), { count: 2 }) + + incrementUnreadCount(queryClient, 'user-a') + + expect(queryClient.getQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'))).toEqual({ count: 3 }) + }) + + it('initializes unread count cache when incrementing without existing data', () => { + const queryClient = new QueryClient() + + incrementUnreadCount(queryClient, 'user-a') + + expect(queryClient.getQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'))).toEqual({ count: 1 }) + }) + + it('decrements unread count without going below zero', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'), { count: 1 }) + + decrementUnreadCount(queryClient, 'user-a') + decrementUnreadCount(queryClient, 'user-a') + + expect(queryClient.getQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'))).toEqual({ count: 0 }) + }) + + it('resets unread count to zero', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'), { count: 5 }) + + resetUnreadCount(queryClient, 'user-a') + + expect(queryClient.getQueryData(NOTIFICATION_QUERY_KEYS.unreadCount('user-a'))).toEqual({ count: 0 }) + }) +}) diff --git a/web/src/features/notification/notification-unread-cache.ts b/web/src/features/notification/notification-unread-cache.ts new file mode 100644 index 00000000..23ca2612 --- /dev/null +++ b/web/src/features/notification/notification-unread-cache.ts @@ -0,0 +1,28 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { NotificationUnreadCount } from '@/api/types' +import { NOTIFICATION_QUERY_KEYS } from './use-notifications' + +function normalizeUnreadCount(data: NotificationUnreadCount | undefined) { + return Math.max(data?.count ?? 0, 0) +} + +export function incrementUnreadCount(queryClient: QueryClient, userId?: string | null) { + queryClient.setQueryData( + NOTIFICATION_QUERY_KEYS.unreadCount(userId), + (current) => ({ count: normalizeUnreadCount(current) + 1 }) + ) +} + +export function decrementUnreadCount(queryClient: QueryClient, userId?: string | null) { + queryClient.setQueryData( + NOTIFICATION_QUERY_KEYS.unreadCount(userId), + (current) => ({ count: Math.max(normalizeUnreadCount(current) - 1, 0) }) + ) +} + +export function resetUnreadCount(queryClient: QueryClient, userId?: string | null) { + queryClient.setQueryData( + NOTIFICATION_QUERY_KEYS.unreadCount(userId), + { count: 0 } + ) +} diff --git a/web/src/features/notification/use-notification-preferences.ts b/web/src/features/notification/use-notification-preferences.ts new file mode 100644 index 00000000..4f91c8c8 --- /dev/null +++ b/web/src/features/notification/use-notification-preferences.ts @@ -0,0 +1,42 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { notificationApi } from '@/api/client' +import type { NotificationPreferenceItem } from '@/api/types' + +const PREFERENCES_QUERY_KEY = ['notifications', 'preferences'] as const + +/** + * Fetches the current user's notification preferences. + * When no record exists for a category/channel, the backend defaults to enabled=true. + */ +export function useNotificationPreferences() { + return useQuery({ + queryKey: PREFERENCES_QUERY_KEY, + queryFn: () => notificationApi.getPreferences(), + staleTime: 60_000, + }) +} + +/** + * Mutation to update notification preferences with optimistic update. + */ +export function useUpdateNotificationPreferences() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (preferences: NotificationPreferenceItem[]) => + notificationApi.updatePreferences(preferences), + onMutate: async (newPreferences: NotificationPreferenceItem[]) => { + await queryClient.cancelQueries({ queryKey: PREFERENCES_QUERY_KEY }) + const previous = queryClient.getQueryData(PREFERENCES_QUERY_KEY) + queryClient.setQueryData(PREFERENCES_QUERY_KEY, newPreferences) + return { previous } + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + queryClient.setQueryData(PREFERENCES_QUERY_KEY, context.previous) + } + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: PREFERENCES_QUERY_KEY }) + }, + }) +} diff --git a/web/src/features/notification/use-notification-sse.test.ts b/web/src/features/notification/use-notification-sse.test.ts new file mode 100644 index 00000000..3b3e8213 --- /dev/null +++ b/web/src/features/notification/use-notification-sse.test.ts @@ -0,0 +1,77 @@ +import { QueryClient } from '@tanstack/react-query' +import { describe, expect, it, vi } from 'vitest' +import { attachNotificationSseListeners } from './use-notification-sse' + +function createFakeConnection() { + const listeners = new Map void>>() + return { + addEventListener(type: string, listener: (event: MessageEvent) => void) { + const current = listeners.get(type) ?? [] + current.push(listener) + listeners.set(type, current) + }, + close() { + // no-op for tests + }, + emit(type: string) { + for (const listener of listeners.get(type) ?? []) { + listener(new MessageEvent(type)) + } + }, + } +} + +describe('attachNotificationSseListeners', () => { + it('does not refetch unread count when the sse connection opens or reconnects', () => { + const queryClient = new QueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const connection = createFakeConnection() + + attachNotificationSseListeners(connection, queryClient, 'user-a') + + connection.emit('open') + connection.emit('open') + + expect(invalidateQueries).not.toHaveBeenCalledWith({ + queryKey: ['notifications', 'user-a', 'unread-count'], + }) + }) + + it('does not mutate the unread badge when the connection opens or reconnects', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['notifications', 'user-a', 'unread-count'], { count: 4 }) + const connection = createFakeConnection() + + attachNotificationSseListeners(connection, queryClient, 'user-a') + + connection.emit('open') + connection.emit('open') + + expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toEqual({ count: 4 }) + }) + + it('increments unread count and invalidates notification list on new notification events', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['notifications', 'user-a', 'unread-count'], { count: 1 }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const connection = createFakeConnection() + + attachNotificationSseListeners(connection, queryClient, 'user-a') + connection.emit('notification') + + expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toEqual({ count: 2 }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['notifications', 'user-a', 'list'], + }) + }) + + it('starts the unread badge from one when no cache exists yet', () => { + const queryClient = new QueryClient() + const connection = createFakeConnection() + + attachNotificationSseListeners(connection, queryClient, 'user-a') + connection.emit('notification') + + expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toEqual({ count: 1 }) + }) +}) diff --git a/web/src/features/notification/use-notification-sse.ts b/web/src/features/notification/use-notification-sse.ts new file mode 100644 index 00000000..6a6b17ce --- /dev/null +++ b/web/src/features/notification/use-notification-sse.ts @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' +import type { QueryClient } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' +import { WEB_API_PREFIX } from '@/api/client' +import { incrementUnreadCount } from './notification-unread-cache' +import { createNotificationSseConnection } from './notification-sse-coordinator' + +const SSE_URL = `${WEB_API_PREFIX}/notifications/sse` + +type NotificationSseConnectionLike = ReturnType + +export function attachNotificationSseListeners( + connection: NotificationSseConnectionLike, + queryClient: QueryClient, + userId: string, +) { + connection.addEventListener('open', () => { + // No unread-count sync here. The badge is hydrated once on page load and then + // updated locally from SSE events to avoid reconnect-driven request loops. + }) + + connection.addEventListener('notification', () => { + incrementUnreadCount(queryClient, userId) + void queryClient.invalidateQueries({ queryKey: ['notifications', userId, 'list'] }) + }) +} + +/** + * Opens an SSE connection to the notification stream. + * On receiving a "notification" event, updates the local unread badge and invalidates + * the notification list. Reconnects no longer refetch unread-count to avoid turning + * SSE churn into near-polling traffic. + */ +export function useNotificationSse(userId?: string | null) { + const queryClient = useQueryClient() + const esRef = useRef | null>(null) + + useEffect(() => { + if (!userId) return + + const es = createNotificationSseConnection(SSE_URL) + esRef.current = es + attachNotificationSseListeners(es, queryClient, userId) + + return () => { + es.close() + esRef.current = null + } + }, [userId, queryClient]) +} diff --git a/web/src/features/notification/use-notifications.ts b/web/src/features/notification/use-notifications.ts new file mode 100644 index 00000000..491d4f7c --- /dev/null +++ b/web/src/features/notification/use-notifications.ts @@ -0,0 +1,92 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { notificationApi } from '@/api/client' +import type { NotificationItem, PagedResponse } from '@/api/types' +import { decrementUnreadCount, resetUnreadCount } from './notification-unread-cache' +import { getNotificationQueryKeyScope } from './notification-session' + +export const NOTIFICATION_QUERY_KEYS = { + list: (userId?: string | null, page?: number, size?: number) => [...getNotificationQueryKeyScope(userId), 'list', page, size] as const, + unreadCount: (userId?: string | null) => [...getNotificationQueryKeyScope(userId), 'unread-count'] as const, + listByCategory: (userId?: string | null, page?: number, size?: number, category?: string) => + [...getNotificationQueryKeyScope(userId), 'list', page, size, category] as const, +} + +/** + * Fetches paginated notification list. + */ +export function useNotifications(userId?: string | null, page = 0, size = 5) { + return useQuery({ + queryKey: NOTIFICATION_QUERY_KEYS.list(userId, page, size), + queryFn: () => notificationApi.list({ page, size }) as Promise>, + enabled: !!userId, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) +} + +/** + * Fetches the current unread notification count for the badge. + */ +export function useUnreadCount(userId?: string | null) { + return useQuery({ + queryKey: NOTIFICATION_QUERY_KEYS.unreadCount(userId), + queryFn: () => notificationApi.getUnreadCount(), + enabled: !!userId, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) +} + +/** + * Fetches paginated notification list with optional category filter. + */ +export function useNotificationList(userId?: string | null, page = 0, size = 20, category?: string) { + return useQuery({ + queryKey: NOTIFICATION_QUERY_KEYS.listByCategory(userId, page, size, category), + queryFn: () => notificationApi.list({ page, size, category }) as Promise>, + enabled: !!userId, + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) +} + +/** + * Marks all notifications as read and invalidates relevant queries. + */ +export function useMarkAllRead(userId?: string | null) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: () => notificationApi.markAllRead(), + onSuccess: () => { + resetUnreadCount(queryClient, userId) + void queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} + +/** + * Marks a single notification as read and invalidates relevant queries. + */ +export function useMarkRead(userId?: string | null) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => notificationApi.markRead(id), + onSuccess: () => { + decrementUnreadCount(queryClient, userId) + void queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} + +export function useDeleteReadNotification(userId?: string | null) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => notificationApi.deleteRead(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: getNotificationQueryKeyScope(userId) }) + }, + }) +} diff --git a/web/src/features/skill/skill-delete-flow.test.ts b/web/src/features/skill/skill-delete-flow.test.ts index 3b9be00f..385fedb6 100644 --- a/web/src/features/skill/skill-delete-flow.test.ts +++ b/web/src/features/skill/skill-delete-flow.test.ts @@ -1,5 +1,6 @@ +import { QueryClient } from '@tanstack/react-query' import { describe, expect, it } from 'vitest' -import { isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from './skill-delete-flow' +import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from './skill-delete-flow' describe('isDeleteSlugConfirmationValid', () => { it('requires an exact slug match', () => { @@ -19,3 +20,29 @@ describe('resolveDeletedSkillReturnTo', () => { expect(resolveDeletedSkillReturnTo(undefined)).toBe('/search') }) }) + +describe('clearDeletedSkillQueries', () => { + it('removes deleted skill detail caches while keeping list caches refreshable', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(['skills', 'global', 'demo-skill'], { id: 1 }) + queryClient.setQueryData(['skills', 'global', 'demo-skill', 'versions'], [{ version: '1.0.0' }]) + queryClient.setQueryData(['skills', 'global', 'demo-skill', 'versions', '1.0.0', 'files'], [{ id: 1 }]) + queryClient.setQueryData(['skills', 1, 'star'], { starred: true }) + queryClient.setQueryData(['skills', 1, 'rating'], { score: 5, rated: true }) + queryClient.setQueryData(['skills', 'my'], { items: [{ slug: 'demo-skill' }], total: 1, page: 0, size: 12 }) + + clearDeletedSkillQueries(queryClient, 'global', 'demo-skill', 1) + + expect(queryClient.getQueryData(['skills', 'global', 'demo-skill'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'global', 'demo-skill', 'versions'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'global', 'demo-skill', 'versions', '1.0.0', 'files'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 1, 'star'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 1, 'rating'])).toBeUndefined() + expect(queryClient.getQueryData(['skills', 'my'])).toEqual({ + items: [{ slug: 'demo-skill' }], + total: 1, + page: 0, + size: 12, + }) + }) +}) diff --git a/web/src/features/skill/skill-delete-flow.ts b/web/src/features/skill/skill-delete-flow.ts index f31078b7..f210c701 100644 --- a/web/src/features/skill/skill-delete-flow.ts +++ b/web/src/features/skill/skill-delete-flow.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from '@tanstack/react-query' import { normalizeSkillDetailReturnTo } from '@/shared/lib/skill-navigation' export function isDeleteSlugConfirmationValid(expectedSlug: string, typedSlug: string) { @@ -7,3 +8,22 @@ export function isDeleteSlugConfirmationValid(expectedSlug: string, typedSlug: s export function resolveDeletedSkillReturnTo(returnTo?: string) { return normalizeSkillDetailReturnTo(returnTo) ?? '/search' } + +export function clearDeletedSkillQueries(queryClient: QueryClient, namespace: string, slug: string, skillId?: number) { + const baseKey = ['skills', namespace, slug] as const + + void queryClient.cancelQueries({ queryKey: baseKey }) + queryClient.setQueriesData({ queryKey: baseKey }, undefined) + queryClient.removeQueries({ queryKey: baseKey }) + if (skillId) { + void queryClient.cancelQueries({ queryKey: ['skills', skillId, 'star'], exact: true }) + void queryClient.cancelQueries({ queryKey: ['skills', skillId, 'rating'], exact: true }) + queryClient.setQueryData(['skills', skillId, 'star'], undefined) + queryClient.setQueryData(['skills', skillId, 'rating'], undefined) + queryClient.removeQueries({ queryKey: ['skills', skillId, 'star'], exact: true }) + queryClient.removeQueries({ queryKey: ['skills', skillId, 'rating'], exact: true }) + } + + void queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + void queryClient.invalidateQueries({ queryKey: ['skills'] }) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 85ff5b37..67c3a0ac 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1081,6 +1081,7 @@ "auditLog": "Audit Log", "security": "Security Settings", "profile": "Profile Settings", + "notifications": "Notification Settings", "accounts": "Account Merge", "logout": "Logout" } @@ -1270,5 +1271,31 @@ "notAuthenticated": "No authenticated external session was found" } } + }, + "notification": { + "title": "Notifications", + "empty": "No notifications", + "markAllRead": "Mark all as read", + "deleteRead": "Delete read notification", + "viewAll": "View all notifications", + "unread": "Unread", + "all": "All", + "publish": "Publish", + "review": "Review", + "promotion": "Promotion", + "report": "Report", + "timeAgo": "{{time}} ago", + "preferences": { + "title": "Notification Settings", + "description": "Manage your notification preferences", + "publish": "Publish Notifications", + "publishDesc": "Notify when skill is published", + "review": "Review Notifications", + "reviewDesc": "Notify on review submission, approval, or rejection", + "promotion": "Promotion Notifications", + "promotionDesc": "Notify on promotion request, approval, or rejection", + "report": "Report Notifications", + "reportDesc": "Notify on report submission or resolution" + } } } diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 24d7976d..45fac892 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1081,6 +1081,7 @@ "auditLog": "审计日志", "security": "安全设置", "profile": "个人设置", + "notifications": "通知设置", "accounts": "账号合并", "logout": "退出登录" } @@ -1270,5 +1271,31 @@ "notAuthenticated": "未检测到可用的外部登录会话" } } + }, + "notification": { + "title": "通知", + "empty": "暂无通知", + "markAllRead": "全部标记已读", + "deleteRead": "删除已读通知", + "viewAll": "查看全部通知", + "unread": "未读", + "all": "全部", + "publish": "发布", + "review": "审核", + "promotion": "提升", + "report": "举报", + "timeAgo": "{{time}}前", + "preferences": { + "title": "通知设置", + "description": "管理您的通知偏好", + "publish": "发布通知", + "publishDesc": "技能发布成功时通知", + "review": "审核通知", + "reviewDesc": "审核提交、通过或拒绝时通知", + "promotion": "提升通知", + "promotionDesc": "提升申请提交、通过或拒绝时通知", + "report": "举报通知", + "reportDesc": "举报提交或处理完成时通知" + } } } diff --git a/web/src/pages/notifications.test.tsx b/web/src/pages/notifications.test.tsx new file mode 100644 index 00000000..4ac67a7a --- /dev/null +++ b/web/src/pages/notifications.test.tsx @@ -0,0 +1,92 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const useAuthMock = vi.fn() +const useNotificationListMock = vi.fn() +const useMarkAllReadMock = vi.fn() +const useMarkReadMock = vi.fn() +const useDeleteReadNotificationMock = vi.fn() + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), + } +}) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => useAuthMock(), +})) + +vi.mock('@/features/notification/use-notifications', () => ({ + useNotificationList: (...args: unknown[]) => useNotificationListMock(...args), + useMarkAllRead: (...args: unknown[]) => useMarkAllReadMock(...args), + useMarkRead: (...args: unknown[]) => useMarkReadMock(...args), + useDeleteReadNotification: (...args: unknown[]) => useDeleteReadNotificationMock(...args), +})) + +vi.mock('@/features/notification/notification-content', () => ({ + resolveNotificationDisplay: (item: { title: string }) => ({ title: item.title, description: '' }), +})) + +vi.mock('@/features/notification/notification-target', () => ({ + resolveNotificationTarget: () => '/dashboard/notifications', +})) + +import { NotificationsPage } from './notifications' + +describe('NotificationsPage', () => { + beforeEach(() => { + useAuthMock.mockReturnValue({ user: { userId: 'user-1' } }) + useNotificationListMock.mockReturnValue({ + data: { items: [], total: 0, page: 0, size: 20 }, + isLoading: false, + }) + useMarkAllReadMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useMarkReadMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + useDeleteReadNotificationMock.mockReturnValue({ mutate: vi.fn(), isPending: false }) + }) + + it('shows delete action only for read notifications', () => { + useNotificationListMock.mockReturnValue({ + data: { + items: [ + { + id: 1, + category: 'REVIEW', + eventType: 'REVIEW_APPROVED', + title: 'Read notification', + status: 'READ', + createdAt: '2026-03-23T00:00:00Z', + }, + { + id: 2, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Unread notification', + status: 'UNREAD', + createdAt: '2026-03-23T00:00:00Z', + }, + ], + total: 2, + page: 0, + size: 20, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('notification.deleteRead') + expect(html).toContain('Read notification') + expect(html).toContain('Unread notification') + }) +}) diff --git a/web/src/pages/notifications.tsx b/web/src/pages/notifications.tsx new file mode 100644 index 00000000..891c5732 --- /dev/null +++ b/web/src/pages/notifications.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import { Trash2 } from 'lucide-react' +import type { NotificationItem } from '@/api/types' +import { useAuth } from '@/features/auth/use-auth' +import { resolveNotificationDisplay } from '@/features/notification/notification-content' +import { getNotificationItems, getNotificationTotal, shouldShowNotificationPagination } from '@/features/notification/notification-page' +import { resolveNotificationTarget } from '@/features/notification/notification-target' +import { useDeleteReadNotification, useNotificationList, useMarkAllRead, useMarkRead } from '@/features/notification/use-notifications' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Pagination } from '@/shared/components/pagination' +import { Button } from '@/shared/ui/button' +import { Card } from '@/shared/ui/card' + +const PAGE_SIZE = 20 + +type Category = 'ALL' | 'PUBLISH' | 'REVIEW' | 'PROMOTION' | 'REPORT' + +const CATEGORIES: Category[] = ['ALL', 'PUBLISH', 'REVIEW', 'PROMOTION', 'REPORT'] + +function getCategoryKey(cat: Category): string { + switch (cat) { + case 'ALL': return 'notification.all' + case 'PUBLISH': return 'notification.publish' + case 'REVIEW': return 'notification.review' + case 'PROMOTION': return 'notification.promotion' + case 'REPORT': return 'notification.report' + } +} + +function formatRelativeTime(dateStr: string, lang: string): string { + const diff = Date.now() - new Date(dateStr).getTime() + const minutes = Math.floor(diff / 60_000) + const hours = Math.floor(diff / 3_600_000) + const days = Math.floor(diff / 86_400_000) + const isChinese = lang.startsWith('zh') + if (minutes < 1) return isChinese ? '刚刚' : 'just now' + if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m` + if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h` + if (days < 30) return isChinese ? `${days}天` : `${days}d` + return new Date(dateStr).toLocaleDateString() +} + +function CategoryBadge({ category }: { category: NotificationItem['category'] }) { + const { t } = useTranslation() + const colorMap: Record = { + PUBLISH: 'bg-blue-100 text-blue-700', + REVIEW: 'bg-yellow-100 text-yellow-700', + PROMOTION: 'bg-green-100 text-green-700', + REPORT: 'bg-red-100 text-red-700', + } + return ( + + {t(`notification.${category.toLowerCase()}`)} + + ) +} + +export function NotificationsPage() { + const { t, i18n } = useTranslation() + const { user } = useAuth() + const navigate = useNavigate() + const [page, setPage] = useState(0) + const [activeCategory, setActiveCategory] = useState('ALL') + + const categoryParam = activeCategory === 'ALL' ? undefined : activeCategory + const { data, isLoading } = useNotificationList(user?.userId, page, PAGE_SIZE, categoryParam) + const markAllRead = useMarkAllRead(user?.userId) + const markRead = useMarkRead(user?.userId) + const deleteRead = useDeleteReadNotification(user?.userId) + + const notifications = getNotificationItems(data) + const totalPages = Math.max(Math.ceil(getNotificationTotal(data) / PAGE_SIZE), 1) + + function handleCategoryChange(cat: Category) { + setActiveCategory(cat) + setPage(0) + } + + function handleItemClick(item: NotificationItem) { + if (item.status === 'UNREAD') { + markRead.mutate(item.id) + } + void navigate({ to: resolveNotificationTarget(item) }) + } + + function handleDelete(item: NotificationItem) { + if (item.status !== 'READ') { + return + } + deleteRead.mutate(item.id) + } + + return ( +
+ markAllRead.mutate()} + disabled={markAllRead.isPending || notifications.length === 0} + > + {t('notification.markAllRead')} + + } + /> + + {/* Category tabs */} +
+ {CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Content */} + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) : notifications.length === 0 ? ( + {t('notification.empty')} + ) : ( + <> + + {notifications.map((item) => { + const display = resolveNotificationDisplay(item, i18n.language) + return ( +
+ +
+ + {item.status === 'READ' ? ( + + ) : null} +
+
+ ) + })} +
+ + {shouldShowNotificationPagination(getNotificationTotal(data), PAGE_SIZE) ? ( + + ) : null} + + )} +
+ ) +} diff --git a/web/src/pages/settings/notification-settings.tsx b/web/src/pages/settings/notification-settings.tsx new file mode 100644 index 00000000..97c38d53 --- /dev/null +++ b/web/src/pages/settings/notification-settings.tsx @@ -0,0 +1,12 @@ +import { NotificationPreferenceForm } from '@/features/notification/notification-preference-form' + +/** + * Settings page for managing notification preferences at /settings/notifications. + */ +export function NotificationSettingsPage() { + return ( +
+ +
+ ) +} diff --git a/web/src/pages/settings/security.tsx b/web/src/pages/settings/security.tsx index abd90d2b..d9285910 100644 --- a/web/src/pages/settings/security.tsx +++ b/web/src/pages/settings/security.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { ApiError, authApi } from '@/api/client' +import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { truncateErrorMessage } from '@/shared/lib/error-display' import { toast } from '@/shared/lib/toast' import { Button } from '@/shared/ui/button' @@ -52,6 +53,7 @@ export function SecuritySettingsPage() { } catch (error) { console.error('Logout after password change failed:', error) } finally { + clearSessionScopedQueries(queryClient) queryClient.setQueryData(['auth', 'me'], null) } await navigate({ to: '/login', search: { returnTo: '' } }) diff --git a/web/src/pages/skill-detail-query.test.ts b/web/src/pages/skill-detail-query.test.ts new file mode 100644 index 00000000..3edce045 --- /dev/null +++ b/web/src/pages/skill-detail-query.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { isSkillDetailQueriesEnabled } from './skill-detail-query' + +describe('isSkillDetailQueriesEnabled', () => { + it('keeps detail queries active before deletion', () => { + expect(isSkillDetailQueriesEnabled(false)).toBe(true) + }) + + it('stops detail queries immediately after deletion succeeds', () => { + expect(isSkillDetailQueriesEnabled(true)).toBe(false) + }) +}) diff --git a/web/src/pages/skill-detail-query.ts b/web/src/pages/skill-detail-query.ts new file mode 100644 index 00000000..ee7004e4 --- /dev/null +++ b/web/src/pages/skill-detail-query.ts @@ -0,0 +1,3 @@ +export function isSkillDetailQueriesEnabled(skillDeleted: boolean) { + return !skillDeleted +} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index e9b7aba5..eb62ca43 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -5,6 +5,7 @@ const navigateMock = vi.fn() const hasRoleMock = vi.fn((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() +const useSkillVersionsMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -83,11 +84,11 @@ vi.mock('@/features/skill/install-command', () => ({ })) vi.mock('@/features/social/rating-input', () => ({ - RatingInput: () =>
rating
, + RatingInput: () =>
__RATING_WIDGET__
, })) vi.mock('@/features/social/star-button', () => ({ - StarButton: () =>
star
, + StarButton: () =>
__STAR_WIDGET__
, })) vi.mock('@/shared/hooks/use-skill-queries', () => ({ @@ -100,20 +101,7 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useAdminLabelDefinitions: () => ({ data: [], isLoading: false }), useAttachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), useDetachSkillLabel: () => ({ mutate: vi.fn(), isPending: false }), - useSkillVersions: () => ({ - data: [ - { - id: 10, - version: '1.0.0', - status: 'PUBLISHED', - changelog: '', - fileCount: 1, - totalSize: 12, - publishedAt: '2026-03-20T00:00:00Z', - downloadAvailable: true, - }, - ], - }), + useSkillVersions: (...args: unknown[]) => useSkillVersionsMock(...args), useSkillVersionDetail: () => ({ data: undefined }), useSkillFiles: () => ({ data: [] }), useSkillReadme: () => ({ data: '# Demo', error: null }), @@ -163,8 +151,23 @@ describe('SkillDetailPage', () => { useSkillDetailMock.mockReturnValue({ data: createSkill(), isLoading: false, + isFetching: false, error: null, }) + useSkillVersionsMock.mockReturnValue({ + data: [ + { + id: 10, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 12, + publishedAt: '2026-03-20T00:00:00Z', + downloadAvailable: true, + }, + ], + }) useSkillLabelsMock.mockReturnValue({ data: undefined, }) @@ -218,4 +221,19 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.labelsSectionTitle') }) + + it('does not render dependent social controls while the detail query is still refetching', () => { + useSkillDetailMock.mockReturnValue({ + data: createSkill(), + isLoading: false, + isFetching: true, + error: null, + }) + + const html = renderToStaticMarkup() + + expect(useSkillVersionsMock).toHaveBeenCalledWith('global', 'demo-skill', false) + expect(html).not.toContain('__STAR_WIDGET__') + expect(html).not.toContain('__RATING_WIDGET__') + }) }) diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index e5bb759b..8bdd833d 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -13,7 +13,8 @@ import { shouldCollapseOverview, } from '@/features/skill/overview-collapse' import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error' -import { isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow' +import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow' +import { isSkillDetailQueriesEnabled } from './skill-detail-query' import { RatingInput } from '@/features/social/rating-input' import { StarButton } from '@/features/social/star-button' import { useAuth } from '@/features/auth/use-auth' @@ -104,6 +105,7 @@ export function SkillDetailPage() { const [deleteSkillConfirmOpen, setDeleteSkillConfirmOpen] = useState(false) const [deleteSkillInputOpen, setDeleteSkillInputOpen] = useState(false) const [deleteSkillInput, setDeleteSkillInput] = useState('') + const [skillDeleted, setSkillDeleted] = useState(false) const [deleteVersionTarget, setDeleteVersionTarget] = useState(null) const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) const [rereleaseTarget, setRereleaseTarget] = useState(null) @@ -117,25 +119,27 @@ export function SkillDetailPage() { const overviewSectionRef = useRef(null) const { namespace, slug } = useParams({ from: '/space/$namespace/$slug' }) const { user, hasRole } = useAuth() + const detailQueriesEnabled = isSkillDetailQueriesEnabled(skillDeleted) - const { data: skill, isLoading: isLoadingSkill, error: skillError } = useSkillDetail(namespace, slug) - const { data: versions } = useSkillVersions(namespace, slug) + const { data: skill, isLoading: isLoadingSkill, isFetching: isFetchingSkill, error: skillError } = useSkillDetail(namespace, slug, detailQueriesEnabled) + const skillReady = detailQueriesEnabled && Boolean(skill) && !isLoadingSkill && !isFetchingSkill && !skillError + const { data: versions } = useSkillVersions(namespace, slug, skillReady) const headlineVersion = skill ? getHeadlineVersion(skill) : null const publishedVersion = skill ? getPublishedVersion(skill) : null const ownerPreviewVersion = skill ? getOwnerPreviewVersion(skill) : null const selectedVersion = headlineVersion?.version ?? versions?.[0]?.version const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0] - const { data: files } = useSkillFiles(namespace, slug, selectedVersion) + const { data: files } = useSkillFiles(namespace, slug, selectedVersion, skillReady) const documentationPath = resolveDocumentationFilePath(files) - const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion, documentationPath) - const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined) - const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined) - const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined) - const { data: diffCompareFiles } = useSkillFiles(namespace, slug, diffCompareVersion ?? undefined) + const { data: readme, error: readmeError } = useSkillReadme(namespace, slug, selectedVersion, documentationPath, skillReady) + const { data: diffSourceDetail } = useSkillVersionDetail(namespace, slug, diffSourceVersion ?? undefined, skillReady) + const { data: diffCompareDetail } = useSkillVersionDetail(namespace, slug, diffCompareVersion ?? undefined, skillReady) + const { data: diffSourceFiles } = useSkillFiles(namespace, slug, diffSourceVersion ?? undefined, skillReady) + const { data: diffCompareFiles } = useSkillFiles(namespace, slug, diffCompareVersion ?? undefined, skillReady) const diffSourceDocumentationPath = resolveDocumentationFilePath(diffSourceFiles) const diffCompareDocumentationPath = resolveDocumentationFilePath(diffCompareFiles) - const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined, diffSourceDocumentationPath) - const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined, diffCompareDocumentationPath) + const { data: diffSourceReadme } = useSkillReadme(namespace, slug, diffSourceVersion ?? undefined, diffSourceDocumentationPath, skillReady) + const { data: diffCompareReadme } = useSkillReadme(namespace, slug, diffCompareVersion ?? undefined, diffCompareDocumentationPath, skillReady) const governanceVisible = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const canHideSkill = hasRole('SUPER_ADMIN') const isPendingPreview = skill ? isOwnerPreviewResolution(skill) : false @@ -333,6 +337,16 @@ export function SkillDetailPage() { const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' + const isNotFoundError = skillError instanceof ApiError + ? skillError.status === 400 || skillError.status === 404 || skillError.serverMessageKey === 'skill.not_found' + : false + + useEffect(() => { + if (!isNotFoundError) { + return + } + clearDeletedSkillQueries(queryClient, namespace, slug, skill?.id) + }, [isNotFoundError, namespace, queryClient, skill?.id, slug]) const metadataDiffEntries = (() => { const source = parseMetadataJson(diffSourceDetail?.parsedMetadataJson) @@ -410,6 +424,7 @@ export function SkillDetailPage() { } try { await deleteSkillMutation.mutateAsync({ namespace, slug }) + setSkillDeleted(true) toast.success( t('skillDetail.deleteSkillSuccessTitle'), t('skillDetail.deleteSkillSuccessDescription', { skill: skill.displayName }), @@ -550,6 +565,15 @@ export function SkillDetailPage() { ) } + if (isNotFoundError) { + return ( +
+

{t('skillDetail.notFound')}

+

{t('skillDetail.notFoundDesc')}

+
+ ) + } + return (

{t('skillDetail.accessDenied')}

@@ -567,6 +591,10 @@ export function SkillDetailPage() { ) } + if (skillDeleted) { + return null + } + return (
{/* Main Content */} @@ -844,8 +872,12 @@ export function SkillDetailPage() {
{canInteract ? ( <> - - + {!isFetchingSkill ? ( + <> + + + + ) : null} {canReport ? (