diff --git a/server/skillhub-app/pom.xml b/server/skillhub-app/pom.xml index aeeee7a3..8914e00e 100644 --- a/server/skillhub-app/pom.xml +++ b/server/skillhub-app/pom.xml @@ -26,6 +26,11 @@ io.micrometer micrometer-registry-prometheus + + net.logstash.logback + logstash-logback-encoder + 7.4 + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index b6fc5b1f..c2b583f3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -21,12 +21,12 @@ import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.social.SkillStarService; import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.service.SkillSearchAppService; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; -import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; @@ -49,6 +49,7 @@ public class ClawHubCompatAppService { private final AuditLogService auditLogService; private final CompatSkillLookupService compatSkillLookupService; private final SkillStarService skillStarService; + private final RequestIdAccessor requestIdAccessor; public ClawHubCompatAppService(CanonicalSlugMapper mapper, SkillSearchAppService skillSearchAppService, @@ -58,7 +59,8 @@ public class ClawHubCompatAppService { MultipartPackageExtractor multipartPackageExtractor, AuditLogService auditLogService, CompatSkillLookupService compatSkillLookupService, - SkillStarService skillStarService) { + SkillStarService skillStarService, + RequestIdAccessor requestIdAccessor) { this.mapper = mapper; this.skillSearchAppService = skillSearchAppService; this.skillQueryService = skillQueryService; @@ -68,6 +70,7 @@ public class ClawHubCompatAppService { this.auditLogService = auditLogService; this.compatSkillLookupService = compatSkillLookupService; this.skillStarService = skillStarService; + this.requestIdAccessor = requestIdAccessor; } public ClawHubSearchResponse search(String q, @@ -430,7 +433,7 @@ public class ClawHubCompatAppService { "COMPAT_PUBLISH", "SKILL_VERSION", versionId, - MDC.get("requestId"), + requestIdAccessor.current(), clientIp, userAgent, detailJson diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java index c2f47d4c..71f27a19 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/DeviceAuthWebController.java @@ -6,8 +6,8 @@ import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.MessageResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,13 +24,16 @@ public class DeviceAuthWebController extends BaseApiController { private final DeviceAuthService deviceAuthService; private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; public DeviceAuthWebController(ApiResponseFactory responseFactory, DeviceAuthService deviceAuthService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { super(responseFactory); this.deviceAuthService = deviceAuthService; this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; } @PostMapping("/authorize") @@ -45,7 +48,7 @@ public class DeviceAuthWebController extends BaseApiController { "DEVICE_AUTHORIZE", "DEVICE_CODE", null, - MDC.get("requestId"), + requestIdAccessor.current(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent"), "{\"userCode\":\"" + request.userCode() + "\"}" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java index 3265c4b7..2849455e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/UserProfileController.java @@ -21,6 +21,7 @@ import com.iflytek.skillhub.dto.UpdateProfileRequest; import com.iflytek.skillhub.dto.UpdateProfileResponse; import com.iflytek.skillhub.dto.UserProfileResponse; import com.iflytek.skillhub.exception.UnauthorizedException; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.security.core.Authentication; @@ -53,19 +54,22 @@ public class UserProfileController extends BaseApiController { private final ProfileChangeRequestRepository changeRequestRepository; private final PlatformSessionService platformSessionService; private final ProfileFieldPolicyConfig fieldPolicyConfig; + private final RequestIdAccessor requestIdAccessor; public UserProfileController(ApiResponseFactory responseFactory, UserProfileService userProfileService, UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, PlatformSessionService platformSessionService, - ProfileFieldPolicyConfig fieldPolicyConfig) { + ProfileFieldPolicyConfig fieldPolicyConfig, + RequestIdAccessor requestIdAccessor) { super(responseFactory); this.userProfileService = userProfileService; this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.platformSessionService = platformSessionService; this.fieldPolicyConfig = fieldPolicyConfig; + this.requestIdAccessor = requestIdAccessor; } /** @@ -143,7 +147,7 @@ public class UserProfileController extends BaseApiController { UpdateProfileResult result = userProfileService.updateProfile( principal.userId(), changes, - httpRequest.getHeader("X-Request-Id"), + requestIdAccessor.current(), resolveClientIp(httpRequest), httpRequest.getHeader("User-Agent") ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java index 9e0a87f4..cc2edcc7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminProfileReviewController.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ProfileReviewMutationResponse; import com.iflytek.skillhub.dto.ProfileReviewRejectRequest; import com.iflytek.skillhub.dto.ProfileReviewSummaryResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.service.AdminProfileReviewAppService; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -28,13 +29,16 @@ public class AdminProfileReviewController extends BaseApiController { private final AdminProfileReviewAppService appService; private final ProfileReviewService reviewService; + private final RequestIdAccessor requestIdAccessor; public AdminProfileReviewController(ApiResponseFactory responseFactory, AdminProfileReviewAppService appService, - ProfileReviewService reviewService) { + ProfileReviewService reviewService, + RequestIdAccessor requestIdAccessor) { super(responseFactory); this.appService = appService; this.reviewService = reviewService; + this.requestIdAccessor = requestIdAccessor; } /** List profile change requests filtered by status (default: PENDING). */ @@ -58,7 +62,7 @@ public class AdminProfileReviewController extends BaseApiController { var result = reviewService.approve( id, principal.userId(), - httpRequest.getHeader("X-Request-Id"), + requestIdAccessor.current(), resolveClientIp(httpRequest), httpRequest.getHeader("User-Agent")); return ok("response.success.updated", @@ -77,7 +81,7 @@ public class AdminProfileReviewController extends BaseApiController { id, principal.userId(), request.comment(), - httpRequest.getHeader("X-Request-Id"), + requestIdAccessor.current(), resolveClientIp(httpRequest), httpRequest.getHeader("User-Agent")); return ok("response.success.updated", diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java index b6dc264a..87770644 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSearchController.java @@ -5,8 +5,8 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; -import org.slf4j.MDC; import com.iflytek.skillhub.search.SearchRebuildService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -23,13 +23,16 @@ public class AdminSearchController extends BaseApiController { private final SearchRebuildService searchRebuildService; private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; public AdminSearchController(ApiResponseFactory responseFactory, SearchRebuildService searchRebuildService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { super(responseFactory); this.searchRebuildService = searchRebuildService; this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; } @PostMapping("/rebuild") @@ -42,7 +45,7 @@ public class AdminSearchController extends BaseApiController { "REBUILD_SEARCH_INDEX", "SEARCH_INDEX", null, - MDC.get("requestId"), + requestIdAccessor.current(), httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent"), "{\"scope\":\"ALL\"}" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java index 720958f4..13e7f701 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java @@ -1,8 +1,8 @@ package com.iflytek.skillhub.dto; +import com.iflytek.skillhub.observability.RequestIdAccessor; import org.springframework.context.MessageSource; import org.springframework.stereotype.Component; -import org.slf4j.MDC; import org.springframework.context.i18n.LocaleContextHolder; import java.time.Clock; @@ -13,23 +13,27 @@ public class ApiResponseFactory { private final MessageSource messageSource; private final Clock clock; + private final RequestIdAccessor requestIdAccessor; - public ApiResponseFactory(MessageSource messageSource, Clock clock) { + public ApiResponseFactory(MessageSource messageSource, + Clock clock, + RequestIdAccessor requestIdAccessor) { this.messageSource = messageSource; this.clock = clock; + this.requestIdAccessor = requestIdAccessor; } public ApiResponse ok(String messageCode, T data, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(0, msg, data, Instant.now(clock), MDC.get("requestId")); + return new ApiResponse<>(0, msg, data, Instant.now(clock), requestIdAccessor.current()); } public ApiResponse error(int code, String messageCode, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current()); } public ApiResponse errorMessage(int code, String msg) { - return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), requestIdAccessor.current()); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index 2d30bd54..71241186 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -7,12 +7,12 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.shared.exception.LocalizedMessage; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.security.SensitiveLogSanitizer; import com.iflytek.skillhub.storage.StorageAccessException; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -34,13 +34,16 @@ public class GlobalExceptionHandler { private final ApiResponseFactory apiResponseFactory; private final SensitiveLogSanitizer sensitiveLogSanitizer; private final SkillHubMetrics metrics; + private final RequestIdAccessor requestIdAccessor; public GlobalExceptionHandler(ApiResponseFactory apiResponseFactory, SensitiveLogSanitizer sensitiveLogSanitizer, - SkillHubMetrics metrics) { + SkillHubMetrics metrics, + RequestIdAccessor requestIdAccessor) { this.apiResponseFactory = apiResponseFactory; this.sensitiveLogSanitizer = sensitiveLogSanitizer; this.metrics = metrics; + this.requestIdAccessor = requestIdAccessor; } @ExceptionHandler(LocalizedException.class) @@ -111,7 +114,7 @@ public class GlobalExceptionHandler { metrics.incrementStorageAccessFailure(ex.getOperation()); logger.warn( "Object storage unavailable [requestId={}, method={}, path={}, userId={}, operation={}, key={}]", - MDC.get("requestId"), + requestIdAccessor.current(), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), resolveUserId(request), @@ -127,7 +130,7 @@ public class GlobalExceptionHandler { public ResponseEntity handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) { String path = request.getRequestURI(); if (path != null && path.endsWith("/sse")) { - logger.debug("SSE timeout [requestId={}, path={}]", MDC.get("requestId"), path); + logger.debug("SSE timeout [requestId={}, path={}]", requestIdAccessor.current(), path); return ResponseEntity.noContent().build(); } @@ -140,7 +143,7 @@ public class GlobalExceptionHandler { public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) { logger.error( "Unhandled API exception [requestId={}, method={}, path={}, userId={}]", - MDC.get("requestId"), + requestIdAccessor.current(), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), resolveUserId(request), @@ -153,7 +156,7 @@ public class GlobalExceptionHandler { private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) { logger.info( "API request failed [requestId={}, status={}, method={}, path={}, userId={}, code={}]", - MDC.get("requestId"), + requestIdAccessor.current(), status.value(), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java index 9a950d60..e517f9a8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -5,6 +5,7 @@ import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus; import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.data.redis.core.StringRedisTemplate; @@ -34,15 +35,18 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private final IdempotencyRecordRepository idempotencyRecordRepository; private final ObjectMapper objectMapper; private final Clock clock; + private final RequestIdAccessor requestIdAccessor; public IdempotencyInterceptor(StringRedisTemplate redisTemplate, IdempotencyRecordRepository idempotencyRecordRepository, ObjectMapper objectMapper, - Clock clock) { + Clock clock, + RequestIdAccessor requestIdAccessor) { this.redisTemplate = redisTemplate; this.idempotencyRecordRepository = idempotencyRecordRepository; this.objectMapper = objectMapper; this.clock = clock; + this.requestIdAccessor = requestIdAccessor; } /** @@ -56,8 +60,8 @@ public class IdempotencyInterceptor implements HandlerInterceptor { return true; } - String requestId = request.getHeader(REQUEST_ID_HEADER); - if (requestId == null || requestId.isEmpty()) { + String requestId = resolveRequestId(request); + if (requestId == null) { return true; } @@ -116,8 +120,8 @@ public class IdempotencyInterceptor implements HandlerInterceptor { return; } - String requestId = request.getHeader(REQUEST_ID_HEADER); - if (requestId == null || requestId.isEmpty()) { + String requestId = resolveRequestId(request); + if (requestId == null) { return; } @@ -139,8 +143,16 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private void writeDuplicateResponse(HttpServletResponse response) throws Exception { ApiResponse body = new ApiResponse<>(409, "error.request.duplicate", null, - Instant.now(clock), null); + Instant.now(clock), requestIdAccessor.current()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(body)); } + + private String resolveRequestId(HttpServletRequest request) { + String suppliedRequestId = request.getHeader(REQUEST_ID_HEADER); + if (suppliedRequestId == null || suppliedRequestId.isEmpty()) { + return null; + } + return requestIdAccessor.current(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java index cc5c0932..f5b88bc3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestIdFilter.java @@ -1,10 +1,10 @@ package com.iflytek.skillhub.filter; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -12,6 +12,7 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; import java.util.UUID; +import java.util.regex.Pattern; /** * Ensures every request has a request identifier for logs, responses, and downstream audit @@ -22,23 +23,27 @@ import java.util.UUID; public class RequestIdFilter extends OncePerRequestFilter { private static final String REQUEST_ID_HEADER = "X-Request-Id"; - private static final String REQUEST_ID_MDC_KEY = "requestId"; + private static final Pattern VALID_REQUEST_ID = + Pattern.compile("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"); + + private final RequestIdAccessor requestIdAccessor; + + public RequestIdFilter(RequestIdAccessor requestIdAccessor) { + this.requestIdAccessor = requestIdAccessor; + } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String requestId = request.getHeader(REQUEST_ID_HEADER); - if (requestId == null || requestId.isBlank()) { + if (requestId == null || !VALID_REQUEST_ID.matcher(requestId).matches()) { requestId = UUID.randomUUID().toString(); } - MDC.put(REQUEST_ID_MDC_KEY, requestId); response.setHeader(REQUEST_ID_HEADER, requestId); - try { + try (RequestIdAccessor.Scope ignored = requestIdAccessor.open(requestId)) { filterChain.doFilter(request, response); - } finally { - MDC.remove(REQUEST_ID_MDC_KEY); } } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java new file mode 100644 index 00000000..ecdfb803 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/RequestIdAccessor.java @@ -0,0 +1,78 @@ +package com.iflytek.skillhub.observability; + +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.util.Objects; + +/** + * Holds the current SkillHub request identifier independently from the logging implementation. + * + *

The thread-local value is authoritative. MDC is maintained only as a mirror for log + * correlation.

+ */ +@Component +public class RequestIdAccessor { + + public static final String MDC_KEY = "requestId"; + + private final ThreadLocal currentRequestId = new ThreadLocal<>(); + + /** + * Returns the current request identifier, or {@code null} outside a request/task scope. + */ + public String current() { + return currentRequestId.get(); + } + + /** + * Opens a nested request identifier scope on the current thread. + */ + public Scope open(String requestId) { + Objects.requireNonNull(requestId, "requestId must not be null"); + if (requestId.isBlank()) { + throw new IllegalArgumentException("requestId must not be blank"); + } + + String previousRequestId = currentRequestId.get(); + replace(requestId); + return new Scope(previousRequestId, requestId); + } + + void replace(String requestId) { + if (requestId == null) { + currentRequestId.remove(); + MDC.remove(MDC_KEY); + return; + } + currentRequestId.set(requestId); + MDC.put(MDC_KEY, requestId); + } + + /** + * A same-thread, LIFO scope for the request identifier. + */ + public final class Scope implements AutoCloseable { + + private final String previousRequestId; + private final String installedRequestId; + private boolean closed; + + private Scope(String previousRequestId, String installedRequestId) { + this.previousRequestId = previousRequestId; + this.installedRequestId = installedRequestId; + } + + @Override + public void close() { + if (closed) { + return; + } + if (!Objects.equals(currentRequestId.get(), installedRequestId)) { + throw new IllegalStateException("Request ID scopes must close on the owning thread in LIFO order"); + } + replace(previousRequestId); + closed = true; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java new file mode 100644 index 00000000..814f33ce --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/CorrelationJsonProvider.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.observability.logging; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.fasterxml.jackson.core.JsonGenerator; +import net.logstash.logback.composite.AbstractJsonProvider; + +import java.io.IOException; +import java.util.Map; + +/** + * Writes only the approved correlation fields from MDC. + */ +final class CorrelationJsonProvider extends AbstractJsonProvider { + + private static final String REQUEST_ID_KEY = "requestId"; + private static final String TRACE_ID_KEY = "traceId"; + private static final String SPAN_ID_KEY = "spanId"; + private static final String EXTERNAL_TRACE_ID_KEY = "tid"; + + @Override + public void writeTo(JsonGenerator generator, ILoggingEvent event) throws IOException { + Map mdc = event.getMDCPropertyMap(); + if (mdc == null || mdc.isEmpty()) { + return; + } + + writeIfPresent(generator, "request.id", mdc.get(REQUEST_ID_KEY)); + writeIfPresent( + generator, + "trace.id", + firstPresent(mdc.get(TRACE_ID_KEY), mdc.get(EXTERNAL_TRACE_ID_KEY)) + ); + writeIfPresent(generator, "span.id", mdc.get(SPAN_ID_KEY)); + } + + private String firstPresent(String preferred, String fallback) { + return isPresent(preferred) ? preferred : fallback; + } + + private void writeIfPresent(JsonGenerator generator, String fieldName, String value) + throws IOException { + if (isPresent(value)) { + generator.writeStringField(fieldName, value); + } + } + + private boolean isPresent(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java new file mode 100644 index 00000000..7761b49d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoder.java @@ -0,0 +1,108 @@ +package com.iflytek.skillhub.observability.logging; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import net.logstash.logback.composite.GlobalCustomFieldsJsonProvider; +import net.logstash.logback.composite.loggingevent.LogLevelJsonProvider; +import net.logstash.logback.composite.loggingevent.LoggerNameJsonProvider; +import net.logstash.logback.composite.loggingevent.LoggingEventFormattedTimestampJsonProvider; +import net.logstash.logback.composite.loggingevent.LoggingEventJsonProviders; +import net.logstash.logback.composite.loggingevent.LoggingEventThreadNameJsonProvider; +import net.logstash.logback.composite.loggingevent.MessageJsonProvider; +import net.logstash.logback.composite.loggingevent.StackTraceJsonProvider; +import net.logstash.logback.composite.loggingevent.ThrowableClassNameJsonProvider; +import net.logstash.logback.composite.loggingevent.ThrowableMessageJsonProvider; +import net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder; + +/** + * ECS-style JSON encoder with an explicit field allowlist. + */ +public class SkillHubEcsEncoder extends LoggingEventCompositeJsonEncoder { + + private static final String ECS_VERSION = "1.2.0"; + + private String serviceName = "skillhub"; + private String serviceVersion = "unknown"; + private String serviceEnvironment = "local"; + + @Override + public void start() { + if (isStarted()) { + return; + } + setLineSeparator("UNIX"); + setProviders(createProviders()); + super.start(); + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + public void setServiceVersion(String serviceVersion) { + this.serviceVersion = serviceVersion; + } + + public void setServiceEnvironment(String serviceEnvironment) { + this.serviceEnvironment = serviceEnvironment; + } + + private LoggingEventJsonProviders createProviders() { + LoggingEventJsonProviders providers = new LoggingEventJsonProviders(); + + LoggingEventFormattedTimestampJsonProvider timestamp = + new LoggingEventFormattedTimestampJsonProvider(); + timestamp.setFieldName("@timestamp"); + timestamp.setTimeZone("UTC"); + providers.addTimestamp(timestamp); + + LogLevelJsonProvider level = new LogLevelJsonProvider(); + level.setFieldName("log.level"); + providers.addLogLevel(level); + + MessageJsonProvider message = new MessageJsonProvider(); + message.setFieldName("message"); + providers.addMessage(message); + + LoggerNameJsonProvider logger = new LoggerNameJsonProvider(); + logger.setFieldName("log.logger"); + providers.addLoggerName(logger); + + LoggingEventThreadNameJsonProvider thread = new LoggingEventThreadNameJsonProvider(); + thread.setFieldName("process.thread.name"); + providers.addThreadName(thread); + + providers.addGlobalCustomFields(serviceFields()); + providers.addProvider(new CorrelationJsonProvider()); + + ThrowableClassNameJsonProvider errorType = new ThrowableClassNameJsonProvider(); + errorType.setFieldName("error.type"); + errorType.setUseSimpleClassName(false); + providers.addThrowableClassName(errorType); + + ThrowableMessageJsonProvider errorMessage = new ThrowableMessageJsonProvider(); + errorMessage.setFieldName("error.message"); + providers.addThrowableMessage(errorMessage); + + StackTraceJsonProvider stackTrace = new StackTraceJsonProvider(); + stackTrace.setFieldName("error.stack_trace"); + providers.addStackTrace(stackTrace); + + return providers; + } + + private GlobalCustomFieldsJsonProvider serviceFields() { + ObjectNode fields = JsonNodeFactory.instance.objectNode(); + fields.put("ecs.version", ECS_VERSION); + fields.put("service.name", serviceName); + fields.put("service.version", serviceVersion); + fields.put("service.environment", serviceEnvironment); + fields.put("event.dataset", serviceName); + + GlobalCustomFieldsJsonProvider provider = + new GlobalCustomFieldsJsonProvider<>(); + provider.setCustomFieldsNode(fields); + return provider; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java new file mode 100644 index 00000000..6877f0ca --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/logging/package-info.java @@ -0,0 +1,4 @@ +/** + * Structured logging adapters for SkillHub correlation fields. + */ +package com.iflytek.skillhub.observability.logging; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java new file mode 100644 index 00000000..2c10c355 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/observability/package-info.java @@ -0,0 +1,4 @@ +/** + * Application-level observability context and integration boundaries. + */ +package com.iflytek.skillhub.observability; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java index 2c930aa6..36ea746e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAccessDeniedHandler.java @@ -4,11 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.auth.token.ApiTokenAccessDeniedException; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; @@ -26,13 +26,16 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; private final SensitiveLogSanitizer sensitiveLogSanitizer; + private final RequestIdAccessor requestIdAccessor; public ApiAccessDeniedHandler(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory, - SensitiveLogSanitizer sensitiveLogSanitizer) { + SensitiveLogSanitizer sensitiveLogSanitizer, + RequestIdAccessor requestIdAccessor) { this.objectMapper = objectMapper; this.apiResponseFactory = apiResponseFactory; this.sensitiveLogSanitizer = sensitiveLogSanitizer; + this.requestIdAccessor = requestIdAccessor; } @Override @@ -45,7 +48,7 @@ public class ApiAccessDeniedHandler implements AccessDeniedHandler { : null; logger.info( "Forbidden API request [requestId={}, method={}, path={}, reason={}, detail={}]", - MDC.get("requestId"), + requestIdAccessor.current(), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), accessDeniedException.getClass().getSimpleName(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java index 8f5de8d2..50c82768 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/security/ApiAuthenticationEntryPoint.java @@ -3,11 +3,11 @@ package com.iflytek.skillhub.security; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.slf4j.MDC; import org.springframework.http.MediaType; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -25,13 +25,16 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { private final ObjectMapper objectMapper; private final ApiResponseFactory apiResponseFactory; private final SensitiveLogSanitizer sensitiveLogSanitizer; + private final RequestIdAccessor requestIdAccessor; public ApiAuthenticationEntryPoint(ObjectMapper objectMapper, ApiResponseFactory apiResponseFactory, - SensitiveLogSanitizer sensitiveLogSanitizer) { + SensitiveLogSanitizer sensitiveLogSanitizer, + RequestIdAccessor requestIdAccessor) { this.objectMapper = objectMapper; this.apiResponseFactory = apiResponseFactory; this.sensitiveLogSanitizer = sensitiveLogSanitizer; + this.requestIdAccessor = requestIdAccessor; } @Override @@ -40,7 +43,7 @@ public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { AuthenticationException authException) throws IOException { logger.info( "Unauthorized API request [requestId={}, method={}, path={}, reason={}]", - MDC.get("requestId"), + requestIdAccessor.current(), request.getMethod(), sensitiveLogSanitizer.sanitizeRequestTarget(request), authException.getClass().getSimpleName() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java index 23bace70..88316c50 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelAdminAppService.java @@ -11,9 +11,9 @@ import com.iflytek.skillhub.dto.AdminLabelUpdateRequest; import com.iflytek.skillhub.dto.LabelDefinitionResponse; import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest; import com.iflytek.skillhub.dto.LabelTranslationResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import java.util.List; import java.util.Set; -import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -27,17 +27,20 @@ public class LabelAdminAppService { private final AuditLogService auditLogService; private final RbacService rbacService; private final LabelSearchSyncService labelSearchSyncService; + private final RequestIdAccessor requestIdAccessor; public LabelAdminAppService(LabelDefinitionService labelDefinitionService, SkillLabelService skillLabelService, AuditLogService auditLogService, RbacService rbacService, - LabelSearchSyncService labelSearchSyncService) { + LabelSearchSyncService labelSearchSyncService, + RequestIdAccessor requestIdAccessor) { this.labelDefinitionService = labelDefinitionService; this.skillLabelService = skillLabelService; this.auditLogService = auditLogService; this.rbacService = rbacService; this.labelSearchSyncService = labelSearchSyncService; + this.requestIdAccessor = requestIdAccessor; } public List listAll() { @@ -153,7 +156,7 @@ public class LabelAdminAppService { action, "LABEL", targetId, - MDC.get("requestId"), + requestIdAccessor.current(), auditContext != null ? auditContext.clientIp() : null, auditContext != null ? auditContext.userAgent() : null, detailJson diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index aab28382..1c7f6b44 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -12,11 +12,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.util.Locale; import java.util.Map; import java.util.Set; -import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -32,17 +32,20 @@ public class PromotionPortalAppService { private final GovernanceQueryRepository governanceQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; public PromotionPortalAppService(PromotionService promotionService, PromotionRequestRepository promotionRequestRepository, GovernanceQueryRepository governanceQueryRepository, RbacService rbacService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { this.promotionService = promotionService; this.promotionRequestRepository = promotionRequestRepository; this.governanceQueryRepository = governanceQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; } public PromotionResponseDto submitPromotion(Long sourceSkillId, @@ -235,7 +238,7 @@ public class PromotionPortalAppService { action, "PROMOTION_REQUEST", targetId, - MDC.get("requestId"), + requestIdAccessor.current(), auditContext != null ? auditContext.clientIp() : null, auditContext != null ? auditContext.userAgent() : null, detailJson diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index 47d8eed5..96663c9e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -13,11 +13,11 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.util.List; import java.util.Map; import java.util.Set; -import org.slf4j.MDC; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -34,19 +34,22 @@ public class ReviewPortalAppService { private final GovernanceQueryRepository governanceQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; public ReviewPortalAppService(ReviewService reviewService, ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, GovernanceQueryRepository governanceQueryRepository, RbacService rbacService, - AuditLogService auditLogService) { + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { this.reviewService = reviewService; this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.governanceQueryRepository = governanceQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; } public ReviewTaskResponse submitReview(Long skillVersionId, @@ -256,7 +259,7 @@ public class ReviewPortalAppService { action, "REVIEW_TASK", targetId, - MDC.get("requestId"), + requestIdAccessor.current(), auditContext != null ? auditContext.clientIp() : null, auditContext != null ? auditContext.userAgent() : null, detailJson diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java index 25b92081..5947934e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLabelAppService.java @@ -18,12 +18,12 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; import com.iflytek.skillhub.dto.MessageResponse; import com.iflytek.skillhub.dto.SkillLabelDto; +import com.iflytek.skillhub.observability.RequestIdAccessor; import java.util.List; import java.util.Map; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; -import org.slf4j.MDC; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -42,6 +42,7 @@ public class SkillLabelAppService { private final AuditLogService auditLogService; private final LabelSearchSyncService labelSearchSyncService; private final SkillSlugResolutionService skillSlugResolutionService; + private final RequestIdAccessor requestIdAccessor; public SkillLabelAppService(NamespaceRepository namespaceRepository, SkillRepository skillRepository, @@ -52,7 +53,8 @@ public class SkillLabelAppService { RbacService rbacService, AuditLogService auditLogService, LabelSearchSyncService labelSearchSyncService, - SkillSlugResolutionService skillSlugResolutionService) { + SkillSlugResolutionService skillSlugResolutionService, + RequestIdAccessor requestIdAccessor) { this.namespaceRepository = namespaceRepository; this.skillRepository = skillRepository; this.visibilityChecker = visibilityChecker; @@ -63,6 +65,7 @@ public class SkillLabelAppService { this.auditLogService = auditLogService; this.labelSearchSyncService = labelSearchSyncService; this.skillSlugResolutionService = skillSlugResolutionService; + this.requestIdAccessor = requestIdAccessor; } public List listSkillLabels(String namespaceSlug, @@ -188,7 +191,7 @@ public class SkillLabelAppService { action, "SKILL", targetId, - MDC.get("requestId"), + requestIdAccessor.current(), auditContext != null ? auditContext.clientIp() : null, auditContext != null ? auditContext.userAgent() : null, detailJson diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 421e27f6..9983d5e8 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -94,6 +94,11 @@ spring: enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false} skillhub: + observability: + log-format: ${SKILLHUB_LOG_FORMAT:text} + log-async-queue-size: ${SKILLHUB_LOG_ASYNC_QUEUE_SIZE:1024} + service-version: ${SKILLHUB_SERVICE_VERSION:unknown} + service-environment: ${SKILLHUB_SERVICE_ENVIRONMENT:local} builtin-skills: enabled: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:true} redis: diff --git a/server/skillhub-app/src/main/resources/logback-spring.xml b/server/skillhub-app/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..30152e8e --- /dev/null +++ b/server/skillhub-app/src/main/resources/logback-spring.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + ${CONSOLE_LOG_CHARSET} + ${CONSOLE_LOG_PATTERN} + + + + + + ${serviceName} + ${serviceVersion} + ${serviceEnvironment} + + + + + ${asyncQueueSize} + 0 + true + false + + + + + + + diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java index c5921f59..f5eac2d1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillPublishService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.social.SkillStarService; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.service.SkillSearchAppService; import java.util.Map; import java.util.Optional; @@ -40,7 +41,8 @@ class ClawHubCompatAppServiceTest { multipartPackageExtractor, auditLogService, compatSkillLookupService, - skillStarService + skillStarService, + new RequestIdAccessor() ); @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java index 43b35ff1..d3e11795 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/UserProfileControllerUnitTest.java @@ -10,6 +10,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserProfileService; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.observability.RequestIdAccessor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -54,9 +55,11 @@ class UserProfileControllerUnitTest { void setUp() { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("response.success.read", Locale.getDefault(), "response.success.read"); + RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, - Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC) + Clock.fixed(Instant.parse("2026-03-19T08:00:00Z"), ZoneOffset.UTC), + requestIdAccessor ); controller = new UserProfileController( responseFactory, @@ -64,7 +67,8 @@ class UserProfileControllerUnitTest { userAccountRepository, changeRequestRepository, platformSessionService, - fieldPolicyConfig + fieldPolicyConfig, + requestIdAccessor ); given(fieldPolicyConfig.fieldPolicies()).willReturn(Map.of( "displayName", new ProfileFieldPolicyConfig.FieldPolicy(true, false), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 2f344f2c..f44d2941 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -12,6 +12,7 @@ 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 com.iflytek.skillhub.observability.RequestIdAccessor; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -42,7 +43,8 @@ class NotificationControllerTest { 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) + Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC), + new RequestIdAccessor() ); controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java index c89520c3..a160323f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationPreferenceControllerTest.java @@ -15,6 +15,7 @@ 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.PreferenceView; +import com.iflytek.skillhub.observability.RequestIdAccessor; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -41,7 +42,8 @@ class NotificationPreferenceControllerTest { messageSource.addMessage("response.success.updated", java.util.Locale.getDefault(), "ok"); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, - Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC) + Clock.fixed(Instant.parse("2026-03-23T00:00:00Z"), ZoneOffset.UTC), + new RequestIdAccessor() ); controller = new NotificationPreferenceController(preferenceService, responseFactory); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index d8c4ac64..6aae4cb0 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.security.SensitiveLogSanitizer; import jakarta.servlet.http.HttpServletRequest; import java.time.Clock; @@ -40,11 +41,18 @@ class GlobalExceptionHandlerTest { void setUp() { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out"); + RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, - Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC) + Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC), + requestIdAccessor + ); + handler = new GlobalExceptionHandler( + responseFactory, + sensitiveLogSanitizer, + metrics, + requestIdAccessor ); - handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index 6bf56337..f3d2d117 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpSession; import java.time.Clock; @@ -47,7 +48,8 @@ class AuthContextFilterTest { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled"); Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); - ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock); + ApiResponseFactory apiResponseFactory = + new ApiResponseFactory(messageSource, clock, new RequestIdAccessor()); filter = new AuthContextFilter( namespaceMemberRepository, userAccountRepository, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java index 485534fb..c7e8be1f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecord; import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository; import com.iflytek.skillhub.domain.idempotency.IdempotencyStatus; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; @@ -38,6 +39,8 @@ class IdempotencyInterceptorTest { @Mock private ValueOperations valueOperations; + @Mock + private RequestIdAccessor requestIdAccessor; @Mock private HttpServletRequest request; @@ -53,13 +56,20 @@ class IdempotencyInterceptorTest { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); - interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock); + interceptor = new IdempotencyInterceptor( + redisTemplate, + idempotencyRecordRepository, + objectMapper, + clock, + requestIdAccessor + ); } @Test void testNewRequestPassesThrough() throws Exception { when(request.getMethod()).thenReturn("POST"); when(request.getHeader("X-Request-Id")).thenReturn("req-123"); + when(requestIdAccessor.current()).thenReturn("req-123"); when(redisTemplate.opsForValue()).thenReturn(valueOperations); when(valueOperations.get("idempotency:req-123")).thenReturn(null); when(idempotencyRecordRepository.findByRequestId("req-123")).thenReturn(Optional.empty()); @@ -70,10 +80,28 @@ class IdempotencyInterceptorTest { verify(idempotencyRecordRepository).save(any(IdempotencyRecord.class)); } + @Test + void testProvidedInvalidHeaderUsesEffectiveRequestContext() throws Exception { + when(request.getMethod()).thenReturn("POST"); + when(request.getHeader("X-Request-Id")).thenReturn("invalid request id"); + when(requestIdAccessor.current()).thenReturn("generated-valid-id"); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get("idempotency:generated-valid-id")).thenReturn(null); + when(idempotencyRecordRepository.findByRequestId("generated-valid-id")) + .thenReturn(Optional.empty()); + + boolean result = interceptor.preHandle(request, response, new Object()); + + assertTrue(result); + verify(idempotencyRecordRepository).findByRequestId("generated-valid-id"); + verify(idempotencyRecordRepository, never()).findByRequestId("invalid request id"); + } + @Test void testDuplicateRequestReturnsCachedResponse() throws Exception { when(request.getMethod()).thenReturn("POST"); when(request.getHeader("X-Request-Id")).thenReturn("req-456"); + when(requestIdAccessor.current()).thenReturn("req-456"); when(redisTemplate.opsForValue()).thenReturn(valueOperations); when(valueOperations.get("idempotency:req-456")).thenReturn("COMPLETED"); @@ -114,6 +142,7 @@ class IdempotencyInterceptorTest { void testAfterCompletionUpdatesRecord() throws Exception { when(request.getMethod()).thenReturn("POST"); when(request.getHeader("X-Request-Id")).thenReturn("req-789"); + when(requestIdAccessor.current()).thenReturn("req-789"); when(response.getStatus()).thenReturn(200); when(redisTemplate.opsForValue()).thenReturn(valueOperations); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java index 74ac9647..9386e39c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestIdFilterTest.java @@ -1,14 +1,19 @@ package com.iflytek.skillhub.filter; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest @@ -23,7 +28,8 @@ class RequestIdFilterTest { void shouldGenerateRequestIdWhenNotProvided() throws Exception { mockMvc.perform(get("/api/v1/health")) .andExpect(status().isOk()) - .andExpect(header().exists("X-Request-Id")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.requestId").isNotEmpty()); } @Test @@ -32,6 +38,50 @@ class RequestIdFilterTest { mockMvc.perform(get("/api/v1/health") .header("X-Request-Id", requestId)) .andExpect(status().isOk()) - .andExpect(header().string("X-Request-Id", requestId)); + .andExpect(header().string("X-Request-Id", requestId)) + .andExpect(jsonPath("$.requestId").value(requestId)); + } + + @Test + void shouldPreserveRequestIdAtMaximumLength() throws Exception { + String requestId = "a".repeat(64); + + mockMvc.perform(get("/api/v1/health") + .header("X-Request-Id", requestId)) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", requestId)) + .andExpect(jsonPath("$.requestId").value(requestId)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + "-starts-with-symbol", + "contains space", + "contains/slash", + "包含中文" + }) + void shouldReplaceInvalidRequestId(String requestId) throws Exception { + assertInvalidRequestIdIsReplaced(requestId); + } + + @Test + void shouldReplaceRequestIdLongerThanMaximumLength() throws Exception { + assertInvalidRequestIdIsReplaced("a".repeat(65)); + } + + private void assertInvalidRequestIdIsReplaced(String invalidRequestId) throws Exception { + MvcResult result = mockMvc.perform(get("/api/v1/health") + .header("X-Request-Id", invalidRequestId)) + .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.requestId").isNotEmpty()) + .andReturn(); + + String effectiveRequestId = result.getResponse().getHeader("X-Request-Id"); + assertThat(effectiveRequestId) + .isNotEqualTo(invalidRequestId) + .matches("^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$"); + assertThat(result.getResponse().getContentAsString()).contains(effectiveRequestId); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java new file mode 100644 index 00000000..e2c49d7f --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/RequestIdAccessorTest.java @@ -0,0 +1,61 @@ +package com.iflytek.skillhub.observability; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class RequestIdAccessorTest { + + private final RequestIdAccessor accessor = new RequestIdAccessor(); + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + void shouldMirrorRequestIdToMdcAndClearItWhenScopeCloses() { + assertThat(accessor.current()).isNull(); + assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull(); + + try (RequestIdAccessor.Scope ignored = accessor.open("req-123")) { + assertThat(accessor.current()).isEqualTo("req-123"); + assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("req-123"); + } + + assertThat(accessor.current()).isNull(); + assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull(); + } + + @Test + void shouldRestoreOuterScope() { + try (RequestIdAccessor.Scope ignored = accessor.open("outer")) { + try (RequestIdAccessor.Scope nested = accessor.open("inner")) { + assertThat(accessor.current()).isEqualTo("inner"); + } + assertThat(accessor.current()).isEqualTo("outer"); + assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isEqualTo("outer"); + } + } + + @Test + void shouldUseThreadLocalAsAuthorityWhenMdcIsChangedExternally() { + try (RequestIdAccessor.Scope ignored = accessor.open("authoritative")) { + MDC.put(RequestIdAccessor.MDC_KEY, "logging-only"); + + assertThat(accessor.current()).isEqualTo("authoritative"); + } + + assertThat(accessor.current()).isNull(); + assertThat(MDC.get(RequestIdAccessor.MDC_KEY)).isNull(); + } + + @Test + void shouldRejectBlankRequestId() { + assertThatIllegalArgumentException() + .isThrownBy(() -> accessor.open(" ")); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java new file mode 100644 index 00000000..9d0df479 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/observability/logging/SkillHubEcsEncoderTest.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.observability.logging; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.LoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxy; +import ch.qos.logback.classic.util.LogbackMDCAdapter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillHubEcsEncoderTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final LoggerContext loggerContext = new LoggerContext(); + private final SkillHubEcsEncoder encoder = new SkillHubEcsEncoder(); + + @BeforeEach + void setUp() { + loggerContext.setMDCAdapter(new LogbackMDCAdapter()); + encoder.setContext(loggerContext); + encoder.setServiceName("skillhub"); + encoder.setServiceVersion("test-sha"); + encoder.setServiceEnvironment("test"); + encoder.start(); + } + + @AfterEach + void tearDown() { + encoder.stop(); + loggerContext.stop(); + } + + @Test + void shouldWriteEcsFieldsAndOnlyApprovedMdcValues() throws Exception { + LoggingEvent event = event("hello"); + event.setMDCPropertyMap(Map.of( + "requestId", "req-123", + "traceId", "trace-123", + "spanId", "span-123", + "authorization", "must-not-leak", + "userEmail", "must-not-leak" + )); + + JsonNode json = encode(event); + + assertThat(json.path("log.level").asText()).isEqualTo("INFO"); + assertThat(json.path("log.logger").asText()).isEqualTo("test.logger"); + assertThat(json.path("message").asText()).isEqualTo("hello"); + assertThat(json.path("service.name").asText()).isEqualTo("skillhub"); + assertThat(json.path("service.version").asText()).isEqualTo("test-sha"); + assertThat(json.path("service.environment").asText()).isEqualTo("test"); + assertThat(json.path("request.id").asText()).isEqualTo("req-123"); + assertThat(json.path("trace.id").asText()).isEqualTo("trace-123"); + assertThat(json.path("span.id").asText()).isEqualTo("span-123"); + assertThat(json.has("authorization")).isFalse(); + assertThat(json.has("userEmail")).isFalse(); + } + + @Test + void shouldPreferMicrometerTraceIdOverExternalAgentFallback() throws Exception { + LoggingEvent event = event("trace precedence"); + event.setMDCPropertyMap(Map.of( + "traceId", "micrometer-trace", + "tid", "external-agent-trace" + )); + + JsonNode json = encode(event); + + assertThat(json.path("trace.id").asText()).isEqualTo("micrometer-trace"); + assertThat(json.fieldNames()).toIterable() + .filteredOn("trace.id"::equals) + .hasSize(1); + } + + @Test + void shouldWriteStructuredExceptionFields() throws Exception { + LoggingEvent event = event("failed"); + event.setThrowableProxy(new ThrowableProxy(new IllegalStateException("boom"))); + + JsonNode json = encode(event); + + assertThat(json.path("error.type").asText()) + .isEqualTo(IllegalStateException.class.getName()); + assertThat(json.path("error.message").asText()).isEqualTo("boom"); + assertThat(json.path("error.stack_trace").asText()) + .contains("IllegalStateException: boom"); + } + + private LoggingEvent event(String message) { + Logger logger = loggerContext.getLogger("test.logger"); + LoggingEvent event = new LoggingEvent( + getClass().getName(), + logger, + Level.INFO, + message, + null, + null + ); + event.setThreadName("test-thread"); + event.setTimeStamp(1_785_465_600_000L); + return event; + } + + private JsonNode encode(LoggingEvent event) throws Exception { + return objectMapper.readTree(new String(encoder.encode(event), StandardCharsets.UTF_8)); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java index db8982b7..00ca5e8a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/security/ApiAccessDeniedHandlerTest.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.auth.token.ApiTokenScopeService; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.FilterChain; import java.time.Clock; import java.time.Instant; @@ -19,7 +20,6 @@ import java.util.Set; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.slf4j.MDC; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.context.support.ResourceBundleMessageSource; import org.springframework.mock.web.MockHttpServletRequest; @@ -33,28 +33,32 @@ class ApiAccessDeniedHandlerTest { private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private ApiAccessDeniedHandler handler; + private RequestIdAccessor.Scope requestIdScope; @BeforeEach void setUp() { ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasename("messages"); messageSource.setDefaultEncoding("UTF-8"); + RequestIdAccessor requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, - Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC) + Clock.fixed(Instant.parse("2026-07-28T00:00:00Z"), ZoneOffset.UTC), + requestIdAccessor ); handler = new ApiAccessDeniedHandler( objectMapper, responseFactory, - new SensitiveLogSanitizer() + new SensitiveLogSanitizer(), + requestIdAccessor ); - MDC.put("requestId", "req-610"); + requestIdScope = requestIdAccessor.open("req-610"); LocaleContextHolder.setLocale(Locale.ENGLISH); } @AfterEach void tearDown() { - MDC.clear(); + requestIdScope.close(); LocaleContextHolder.resetLocaleContext(); SecurityContextHolder.clearContext(); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java index d6092f65..60a527d3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelAdminAppServiceTest.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.LabelDefinitionResponse; import com.iflytek.skillhub.dto.LabelSortOrderItemRequest; import com.iflytek.skillhub.dto.LabelSortOrderUpdateRequest; import com.iflytek.skillhub.dto.LabelTranslationItemRequest; +import com.iflytek.skillhub.observability.RequestIdAccessor; import java.time.Instant; import java.util.List; import java.util.Set; @@ -40,7 +41,8 @@ class LabelAdminAppServiceTest { skillLabelService, auditLogService, rbacService, - labelSearchSyncService + labelSearchSyncService, + new RequestIdAccessor() ); @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java index bc824b89..9d8a5acc 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/PromotionPortalAppServiceTest.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.review.PromotionRequest; import com.iflytek.skillhub.domain.review.PromotionRequestRepository; import com.iflytek.skillhub.domain.review.PromotionService; import com.iflytek.skillhub.dto.PromotionResponseDto; +import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; import java.lang.reflect.Field; import java.util.Set; @@ -47,7 +48,8 @@ class PromotionPortalAppServiceTest { promotionRequestRepository, governanceQueryRepository, rbacService, - auditLogService + auditLogService, + new RequestIdAccessor() ); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java index e51b4e9a..4d552196 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLabelAppServiceTest.java @@ -15,6 +15,7 @@ import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService; +import com.iflytek.skillhub.observability.RequestIdAccessor; import java.lang.reflect.Field; import java.util.List; import java.util.Map; @@ -70,7 +71,8 @@ class SkillLabelAppServiceTest { rbacService, auditLogService, labelSearchSyncService, - skillSlugResolutionService + skillSlugResolutionService, + new RequestIdAccessor() ); }