feat(notification): add in-app notifications and harden delivery

This commit is contained in:
yun-zhi-ztl 2026-03-23 12:31:11 +08:00
parent 3bc97ff1b8
commit 6e8b257abb
140 changed files with 5315 additions and 148 deletions

1
.gitignore vendored
View file

@ -67,6 +67,7 @@ __pycache__/
# Superpowers (AI planning artifacts)
docs/superpowers/
.superpowers/
docs/review/
docs/requirements/

View file

@ -31,6 +31,7 @@
<module>skillhub-search</module>
<module>skillhub-storage</module>
<module>skillhub-infra</module>
<module>skillhub-notification</module>
</modules>
<dependencyManagement>
@ -60,6 +61,11 @@
<artifactId>skillhub-infra</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-notification</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

View file

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

View file

@ -47,6 +47,10 @@
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-search</artifactId>
</dependency>
<dependency>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-notification</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>

View file

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

View file

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

View file

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

View file

@ -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<PageResponse<NotificationResponse>> 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<Notification> result = notificationService.list(
userId, cat, PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")));
Page<NotificationResponse> mapped = result.map(this::toResponse);
return ok("response.success.read", PageResponse.from(mapped));
}
@GetMapping("/unread-count")
public ApiResponse<Map<String, Long>> unreadCount(@RequestAttribute("userId") String userId) {
long count = notificationService.getUnreadCount(userId);
return ok("response.success.read", Map.of("count", count));
}
@PutMapping("/{id}/read")
public ApiResponse<Void> markRead(@PathVariable Long id,
@RequestAttribute("userId") String userId) {
notificationService.markRead(id, userId);
return ok("response.success.updated", null);
}
@PutMapping("/read-all")
public ApiResponse<Map<String, Integer>> markAllRead(@RequestAttribute("userId") String userId) {
int updated = notificationService.markAllRead(userId);
return ok("response.success.updated", Map.of("updated", updated));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> 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<String, Object> 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<String, Object> 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) {}
}

View file

@ -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<List<NotificationPreferenceResponse>> getPreferences(
@RequestAttribute("userId") String userId) {
List<NotificationPreferenceResponse> prefs = preferenceService.getPreferences(userId).stream()
.map(this::toResponse)
.toList();
return ok("response.success.read", prefs);
}
@PutMapping
public ApiResponse<List<NotificationPreferenceResponse>> updatePreferences(
@RequestAttribute("userId") String userId,
@RequestBody NotificationPreferenceUpdateRequest request) {
if (request == null || request.preferences() == null) {
throw new DomainBadRequestException("error.notification.preference.request.invalid");
}
List<PreferenceCommand> commands = request.preferences().stream()
.map(item -> new PreferenceCommand(
parseCategory(item.category()),
parseChannel(item.channel()),
item.enabled()
))
.toList();
preferenceService.updatePreferences(userId, commands);
List<NotificationPreferenceResponse> 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()
);
}
}

View file

@ -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<List<SecurityAuditResponse>> 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<Long, NamespaceRole> 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<SecurityAudit> 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<Long, NamespaceRole> userNsRoles) {
if (principal == null) {
return false;
}
Set<String> platformRoles = principal.platformRoles() != null ? principal.platformRoles() : Set.of();
if (platformRoles.contains("SUPER_ADMIN") || platformRoles.contains("SKILL_ADMIN")) {
return true;
}
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles);
}
private SecurityAuditResponse toResponse(SecurityAudit audit) {
return new SecurityAuditResponse(
audit.getId(),

View file

@ -0,0 +1,3 @@
package com.iflytek.skillhub.dto;
public record NotificationPreferenceResponse(String category, String channel, boolean enabled) {}

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record NotificationPreferenceUpdateRequest(
List<PreferenceItem> preferences
) {
public record PreferenceItem(String category, String channel, boolean enabled) {}
}

View file

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

View file

@ -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<String, Object> 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<String, Object> body = bodyWithSkill(skill);
body.put("reviewId", event.reviewId());
body.put("submitterId", event.submitterId());
versionLabel(event.versionId(), body);
String json = toJson(body);
List<String> 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<String, Object> 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<String, Object> 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<String, Object> body = bodyWithSkill(skill);
body.put("promotionId", event.promotionId());
body.put("submitterId", event.submitterId());
versionLabel(event.versionId(), body);
String json = toJson(body);
List<String> 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<String, Object> 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<String, Object> 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<String, Object> body = bodyWithSkill(skill);
body.put("reportId", event.reportId());
body.put("reporterId", event.reporterId());
String json = toJson(body);
List<String> 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<String, Object> 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<String, Object> bodyWithSkill(Skill skill) {
Map<String, Object> 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<String, Object> body) {
if (versionId != null) {
skillVersionRepository.findById(versionId).ifPresent(v ->
body.put("version", v.getVersion()));
}
}
private String toJson(Map<String, Object> body) {
try {
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize notification body", e);
return "{}";
}
}
}

View file

@ -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<String> resolveNamespaceAdmins(Long namespaceId) {
return namespaceMemberRepository
.findByNamespaceIdAndRoleIn(namespaceId, Set.of(NamespaceRole.OWNER, NamespaceRole.ADMIN))
.stream()
.map(NamespaceMember::getUserId)
.toList();
}
public List<String> 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
));
}
}

View file

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

View file

@ -29,6 +29,7 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
private final String streamKey;
private final String groupName;
private final String consumerName;
private StringRedisTemplate redisTemplate;
private StreamMessageListenerContainer<String, MapRecord<String, String, String>> container;
@ -46,6 +47,7 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
if (connectionFactory == null) {
return;
}
this.redisTemplate = createRedisTemplate();
initializeStreamAndGroup();
startConsumer();
}
@ -59,7 +61,7 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
private void initializeStreamAndGroup() {
try {
StringRedisTemplate template = new StringRedisTemplate(connectionFactory);
StringRedisTemplate template = redisTemplate();
if (Boolean.FALSE.equals(template.hasKey(streamKey))) {
template.opsForStream().add(streamKey, Map.of("_init", "true"));
}
@ -82,7 +84,7 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
.build();
container = StreamMessageListenerContainer.create(connectionFactory, options);
Subscription ignored = container.receiveAutoAck(
Subscription ignored = container.receive(
Consumer.from(groupName, consumerName),
StreamOffset.create(streamKey, ReadOffset.lastConsumed()),
this
@ -94,6 +96,7 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
public void onMessage(MapRecord<String, String, String> message) {
T payload = parsePayload(message.getId().getValue(), message.getValue());
if (payload == null) {
acknowledge(message);
return;
}
@ -102,8 +105,10 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
markProcessing(payload);
processBusiness(payload);
markCompleted(payload);
acknowledge(message);
} catch (Exception e) {
handleFailure(payload, retryCount, e);
acknowledge(message);
}
}
@ -132,6 +137,21 @@ public abstract class AbstractStreamConsumer<T> implements StreamListener<String
return error.length() > 500 ? error.substring(0, 500) : error;
}
protected StringRedisTemplate createRedisTemplate() {
return new StringRedisTemplate(connectionFactory);
}
protected void acknowledge(MapRecord<String, String, String> 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();

View file

@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.Map;
public class ScanTaskConsumer extends AbstractStreamConsumer<ScanTaskConsumer.ScanTaskPayload> {
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<ScanTaskConsumer.Sc
private void cleanupTempPath(String skillPath) {
try {
Path path = Paths.get(skillPath);
Path path = Paths.get(skillPath).toAbsolutePath().normalize();
if (!path.startsWith(SCAN_TEMP_DIR)) {
log.warn("Skipping cleanup for path outside scan temp directory: {}", skillPath);
return;
}
if (Files.isDirectory(path)) {
try (var walk = Files.walk(path)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String> EVENT_HANDLER_METHODS = Set.of(
"onSkillPublished",
"onReviewSubmitted",
"onReviewApproved",
"onReviewRejected",
"onPromotionSubmitted",
"onPromotionApproved",
"onPromotionRejected",
"onReportSubmitted",
"onReportResolved"
);
@Test
void notificationHandlers_bindToSkillhubEventExecutor() {
Set<String> 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");
}
}

View file

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

View file

@ -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<String> result = resolver.resolveNamespaceAdmins(1L);
assertThat(result).containsExactlyInAnyOrder("user-owner", "user-admin");
}
@Test
void resolveNamespaceAdmins_shouldReturnEmptyWhenNoAdmins() {
when(namespaceMemberRepository.findByNamespaceIdAndRoleIn(anyLong(), anySet()))
.thenReturn(List.of());
List<String> 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<String> 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<String> result = resolver.resolvePlatformSkillAdmins();
assertThat(result).containsExactly("skill-admin", "super-admin");
}
}

View file

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

View file

@ -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<String, Object, Object> streamOperations = mock(StreamOperations.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
org.mockito.Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
TestConsumer consumer = new TestConsumer(redisTemplate);
MapRecord<String, String, String> 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<String, Object, Object> 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<String, String, String> 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<String, Object, Object> streamOperations = mock(StreamOperations.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
org.mockito.Mockito.when(redisTemplate.opsForStream()).thenReturn(streamOperations);
CountingConsumer consumer = new CountingConsumer(redisTemplate);
MapRecord<String, String, String> first = StreamRecords.newRecord()
.in("scan-stream")
.withId(RecordId.of("3-0"))
.ofMap(Map.of("payload", "one"));
MapRecord<String, String, String> 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<String> {
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<String, String> 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();
}
}
}

View file

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

View file

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

View file

@ -14,5 +14,7 @@ import java.util.List;
public interface UserRoleBindingRepository extends JpaRepository<UserRoleBinding, Long> {
List<UserRoleBinding> findByUserId(String userId);
List<UserRoleBinding> findByUserIdIn(Collection<String> userIds);
List<UserRoleBinding> findByRole_Code(String roleCode);
List<UserRoleBinding> findByRole_CodeIn(Collection<String> roleCodes);
long deleteByUserId(String userId);
}

View file

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

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {}

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {}

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record PromotionSubmittedEvent(Long promotionId, Long skillId, Long versionId, String submitterId) {}

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record ReportResolvedEvent(Long reportId, Long skillId, String handlerId, String reporterId, String action) {}

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record ReportSubmittedEvent(Long reportId, Long skillId, String reporterId) {}

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {}

View file

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

View file

@ -0,0 +1,2 @@
package com.iflytek.skillhub.domain.event;
public record ReviewSubmittedEvent(Long reviewId, Long skillId, Long versionId, String submitterId, Long namespaceId) {}

View file

@ -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<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
NamespaceMember save(NamespaceMember member);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PackageEntry> 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<PackageEntry> 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.

View file

@ -17,6 +17,7 @@ public interface SkillRepository {
Optional<Skill> findByNamespaceIdAndSlugAndOwnerId(Long namespaceId, String slug, String ownerId);
List<Skill> findByNamespaceIdAndStatus(Long namespaceId, SkillStatus status);
Skill save(Skill skill);
void flush();
void delete(Skill skill);
List<Skill> findByOwnerId(String ownerId);
Page<Skill> findByOwnerId(String ownerId, Pageable pageable);

View file

@ -105,6 +105,7 @@ public class SkillHardDeleteService {
skill.setLatestVersionId(null);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
skillRepository.flush();
if (!versionIds.isEmpty()) {
reviewTaskRepository.deleteBySkillVersionIdIn(versionIds);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-notification</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>

View file

@ -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> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType);
<T> T postMultipart(String uri, MultiValueMap<String, Object> parts, HttpHeaders headers, Class<T> responseType);
boolean isHealthy(String healthUri);
}

View file

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

View file

@ -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> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
return postMultipart(uri, parts, new HttpHeaders(), responseType);
}
@Override
public <T> T postMultipart(String uri,
MultiValueMap<String, Object> parts,
HttpHeaders headers,
Class<T> 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()

View file

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

View file

@ -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<NamespaceMember> findByNamespaceIdAndUserId(Long namespaceId, String userId);
List<NamespaceMember> findByUserId(String userId);
Page<NamespaceMember> findByNamespaceId(Long namespaceId, Pageable pageable);
List<NamespaceMember> findByNamespaceIdAndRoleIn(Long namespaceId, Collection<NamespaceRole> roles);
void deleteByNamespaceIdAndUserId(Long namespaceId, String userId);
}

View file

@ -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<Notification, Long>, NotificationRepository {
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(String recipientId, Pageable pageable);
Page<Notification> findByRecipientIdAndCategoryOrderByCreatedAtDesc(String recipientId, NotificationCategory category, Pageable pageable);
long countByRecipientIdAndStatus(String recipientId, NotificationStatus status);
@Override
default Page<Notification> findByRecipientId(String recipientId, Pageable pageable) {
return findByRecipientIdOrderByCreatedAtDesc(recipientId, pageable);
}
@Override
default Page<Notification> 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);
}

View file

@ -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<NotificationPreference, Long>, NotificationPreferenceRepository {
List<NotificationPreference> findByUserId(String userId);
Optional<NotificationPreference> findByUserIdAndCategoryAndChannel(
String userId, NotificationCategory category, NotificationChannel channel);
}

View file

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

View file

@ -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<String, Object> 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 "<empty>";
}
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***");
}
}

View file

@ -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> T postMultipart(String uri,
org.springframework.util.MultiValueMap<String, Object> parts,
HttpHeaders headers,
Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public boolean isHealthy(String healthUri) {
return false;

View file

@ -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<ILoggingEvent> 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<String> 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<String> 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<String> 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> T get(String uri, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T post(String uri, Object body, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri,
MultiValueMap<String, Object> parts,
HttpHeaders headers,
Class<T> 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> T get(String uri, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T post(String uri, Object body, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public <T> T postMultipart(String uri,
MultiValueMap<String, Object> parts,
HttpHeaders headers,
Class<T> responseType) {
throw new UnsupportedOperationException();
}
@Override
public boolean isHealthy(String healthUri) {
return false;
}
}
}

View file

@ -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<String, Object> lastMultipartParts;
private HttpHeaders lastMultipartHeaders;
private String lastHealthUri;
private boolean healthy;
@ -118,8 +167,18 @@ class SkillScannerServiceTest {
@Override
@SuppressWarnings("unchecked")
public <T> T postMultipart(String uri, MultiValueMap<String, Object> parts, Class<T> responseType) {
return postMultipart(uri, parts, new HttpHeaders(), responseType);
}
@Override
@SuppressWarnings("unchecked")
public <T> T postMultipart(String uri,
MultiValueMap<String, Object> parts,
HttpHeaders headers,
Class<T> responseType) {
this.lastMultipartUri = uri;
this.lastMultipartParts = parts;
this.lastMultipartHeaders = headers;
return (T) multipartResponse;
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-parent</artifactId>
<version>0.1.0</version>
</parent>
<artifactId>skillhub-notification</artifactId>
<dependencies>
<dependency>
<groupId>com.iflytek.skillhub</groupId>
<artifactId>skillhub-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View file

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

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.notification.domain;
public enum NotificationCategory {
PUBLISH, REVIEW, PROMOTION, REPORT
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.notification.domain;
public enum NotificationChannel {
IN_APP
// Future: EMAIL, FEISHU, DINGTALK
}

View file

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

View file

@ -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<NotificationPreference> findByUserId(String userId);
Optional<NotificationPreference> findByUserIdAndCategoryAndChannel(
String userId, NotificationCategory category, NotificationChannel channel);
}

View file

@ -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<Notification> findById(Long id);
Page<Notification> findByRecipientId(String recipientId, Pageable pageable);
Page<Notification> 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);
}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.notification.domain;
public enum NotificationStatus {
UNREAD, READ
}

View file

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

View file

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

View file

@ -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<PreferenceView> getPreferences(String userId) {
Map<NotificationCategory, Boolean> 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<PreferenceCommand> 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());
}
}
}

View file

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

View file

@ -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<String, CopyOnWriteArrayList<TrackedEmitter>> emitters = new ConcurrentHashMap<>();
private final AtomicInteger totalCount = new AtomicInteger(0);
private final Function<String, SseEmitter> emitterFactory;
public SseEmitterManager() {
this(userId -> new SseEmitter(SSE_TIMEOUT));
}
SseEmitterManager(Function<String, SseEmitter> emitterFactory) {
this.emitterFactory = emitterFactory;
}
public SseEmitter register(String userId) {
if (totalCount.get() >= MAX_TOTAL_EMITTERS) {
throw new IllegalStateException("SSE connection limit reached");
}
CopyOnWriteArrayList<TrackedEmitter> 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<TrackedEmitter> 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<TrackedEmitter> 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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Notification> page = new PageImpl<>(List.of(n));
when(notificationRepository.findByRecipientId(eq("user-1"), any())).thenReturn(page);
Page<Notification> result = service.list("user-1", null, PageRequest.of(0, 20));
assertEquals(1, result.getTotalElements());
}
@Test
void list_withCategory_shouldFilterByCategory() {
Page<Notification> 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());
}
}

View file

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

View file

@ -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<void> {
async deleteSkill(namespace: string, slug: string): Promise<SkillDeleteResult> {
const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace
await fetchJson<void>(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${slug}`, {
return fetchJson<SkillDeleteResult>(`${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<NotificationUnreadCount>(`${WEB_API_PREFIX}/notifications/unread-count`)
},
async markRead(id: number) {
await fetchJson<void>(`${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<void>(`${WEB_API_PREFIX}/notifications/${id}`, {
method: 'DELETE',
headers: getCsrfHeaders(),
})
},
async getPreferences() {
return fetchJson<NotificationPreferenceItem[]>(`${WEB_API_PREFIX}/notification-preferences`)
},
async updatePreferences(preferences: NotificationPreferenceItem[]) {
await fetchJson<void>(`${WEB_API_PREFIX}/notification-preferences`, {
method: 'PUT',
headers: getCsrfHeaders({
'Content-Type': 'application/json',
}),
body: JSON.stringify({ preferences }),
})
},
}

View file

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

View file

@ -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() {
<div className="flex items-center gap-6 text-[15px] font-normal" style={{ color: 'hsl(var(--text-secondary))' }}>
<LanguageSwitcher />
{user && <NotificationBell />}
{isLoading ? null : user ? (
<UserMenu user={user} />
) : (

View file

@ -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<string, unknown>): { 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,

View file

@ -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<User | null>(['auth', 'me'], user)
},
})
@ -22,6 +24,7 @@ export function useLocalRegister() {
return useMutation({
mutationFn: (request: LocalRegisterRequest) => authApi.localRegister(request),
onSuccess: (user) => {
clearSessionScopedQueries(queryClient)
queryClient.setQueryData<User | null>(['auth', 'me'], user)
},
})

View file

@ -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<User | null>(['auth', 'me'], user)
},
onError: (error) => {

View file

@ -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<User, Error, string>({
mutationFn: (provider) => authApi.bootstrapSession(provider),
onSuccess: (user) => {
clearSessionScopedQueries(queryClient)
queryClient.setQueryData(['auth', 'me'], user)
},
})

View file

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

Some files were not shown because too many files have changed in this diff Show more