mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
feat(notification): add in-app notifications and harden delivery
This commit is contained in:
parent
3bc97ff1b8
commit
6e8b257abb
140 changed files with 5315 additions and 148 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,6 +67,7 @@ __pycache__/
|
|||
|
||||
# Superpowers (AI planning artifacts)
|
||||
docs/superpowers/
|
||||
.superpowers/
|
||||
docs/review/
|
||||
docs/requirements/
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record NotificationPreferenceResponse(String category, String channel, boolean enabled) {}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record PromotionApprovedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record PromotionRejectedEvent(Long promotionId, Long skillId, String reviewerId, String submitterId, String reason) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record PromotionSubmittedEvent(Long promotionId, Long skillId, Long versionId, String submitterId) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReportResolvedEvent(Long reportId, Long skillId, String handlerId, String reporterId, String action) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReportSubmittedEvent(Long reportId, Long skillId, String reporterId) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReviewApprovedEvent(Long reviewId, Long skillId, Long versionId, String reviewerId, String submitterId) {}
|
||||
|
|
@ -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) {}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
package com.iflytek.skillhub.domain.event;
|
||||
public record ReviewSubmittedEvent(Long reviewId, Long skillId, Long versionId, String submitterId, Long namespaceId) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ public class SkillHardDeleteService {
|
|||
skill.setLatestVersionId(null);
|
||||
skill.setUpdatedBy(actorUserId);
|
||||
skillRepository.save(skill);
|
||||
skillRepository.flush();
|
||||
|
||||
if (!versionIds.isEmpty()) {
|
||||
reviewTaskRepository.deleteBySkillVersionIdIn(versionIds);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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***");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
31
server/skillhub-notification/pom.xml
Normal file
31
server/skillhub-notification/pom.xml
Normal 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>
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.notification.domain;
|
||||
|
||||
public enum NotificationCategory {
|
||||
PUBLISH, REVIEW, PROMOTION, REPORT
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.notification.domain;
|
||||
|
||||
public enum NotificationChannel {
|
||||
IN_APP
|
||||
// Future: EMAIL, FEISHU, DINGTALK
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.iflytek.skillhub.notification.domain;
|
||||
|
||||
public enum NotificationStatus {
|
||||
UNREAD, READ
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
13
web/src/features/notification/notification-bell.test.ts
Normal file
13
web/src/features/notification/notification-bell.test.ts
Normal 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
Loading…
Add table
Reference in a new issue