mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat(observability): establish request correlation boundary
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
c1b44d01be
commit
893c0d49ca
37 changed files with 721 additions and 75 deletions
|
|
@ -26,6 +26,11 @@
|
|||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.logstash.logback</groupId>
|
||||
<artifactId>logstash-logback-encoder</artifactId>
|
||||
<version>7.4</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() + "\"}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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\"}"
|
||||
|
|
|
|||
|
|
@ -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 <T> ApiResponse<T> 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<Void> 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<Void> 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiResponse<Void>> 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),
|
||||
|
|
|
|||
|
|
@ -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<Void> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>The thread-local value is authoritative. MDC is maintained only as a mirror for log
|
||||
* correlation.</p>
|
||||
*/
|
||||
@Component
|
||||
public class RequestIdAccessor {
|
||||
|
||||
public static final String MDC_KEY = "requestId";
|
||||
|
||||
private final ThreadLocal<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ILoggingEvent> {
|
||||
|
||||
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<String, String> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ILoggingEvent> 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<ILoggingEvent> provider =
|
||||
new GlobalCustomFieldsJsonProvider<>();
|
||||
provider.setCustomFieldsNode(fields);
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Structured logging adapters for SkillHub correlation fields.
|
||||
*/
|
||||
package com.iflytek.skillhub.observability.logging;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Application-level observability context and integration boundaries.
|
||||
*/
|
||||
package com.iflytek.skillhub.observability;
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<LabelDefinitionResponse> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<SkillLabelDto> 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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
47
server/skillhub-app/src/main/resources/logback-spring.xml
Normal file
47
server/skillhub-app/src/main/resources/logback-spring.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<springProperty name="activeAppender"
|
||||
source="skillhub.observability.log-format"
|
||||
defaultValue="text"/>
|
||||
<springProperty name="asyncQueueSize"
|
||||
source="skillhub.observability.log-async-queue-size"
|
||||
defaultValue="1024"/>
|
||||
<springProperty name="serviceName"
|
||||
source="spring.application.name"
|
||||
defaultValue="skillhub"/>
|
||||
<springProperty name="serviceVersion"
|
||||
source="skillhub.observability.service-version"
|
||||
defaultValue="unknown"/>
|
||||
<springProperty name="serviceEnvironment"
|
||||
source="skillhub.observability.service-environment"
|
||||
defaultValue="local"/>
|
||||
|
||||
<appender name="text" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<charset>${CONSOLE_LOG_CHARSET}</charset>
|
||||
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="json-console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="com.iflytek.skillhub.observability.logging.SkillHubEcsEncoder">
|
||||
<serviceName>${serviceName}</serviceName>
|
||||
<serviceVersion>${serviceVersion}</serviceVersion>
|
||||
<serviceEnvironment>${serviceEnvironment}</serviceEnvironment>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<appender name="json" class="ch.qos.logback.classic.AsyncAppender">
|
||||
<queueSize>${asyncQueueSize}</queueSize>
|
||||
<discardingThreshold>0</discardingThreshold>
|
||||
<neverBlock>true</neverBlock>
|
||||
<includeCallerData>false</includeCallerData>
|
||||
<appender-ref ref="json-console"/>
|
||||
</appender>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="${activeAppender}"/>
|
||||
</root>
|
||||
</configuration>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(" "));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue