This commit is contained in:
2026-04-17 16:31:32 +08:00
parent adadb3bf1d
commit 2476655b28
116 changed files with 3875 additions and 583 deletions

View File

@@ -5,8 +5,14 @@ public final class SecurityConstants {
public static final String HEADER_USER_ID = "X-User-Id";
public static final String HEADER_USERNAME = "X-Username";
public static final String HEADER_DISPLAY_NAME = "X-Display-Name";
public static final String HEADER_ADCODE = "X-Adcode";
public static final String HEADER_TENANT_ID = "X-Tenant-Id";
public static final String HEADER_TENANT_PATH = "X-Tenant-Path";
public static final String HEADER_DEPT_ID = "X-Dept-Id";
public static final String HEADER_DEPT_PATH = "X-Dept-Path";
public static final String HEADER_ROLE_CODES = "X-Role-Codes";
public static final String HEADER_CLIENT_TYPE = "X-Client-Type";
public static final String HEADER_SESSION_ID = "X-Session-Id";
private SecurityConstants() {
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.k12study</groupId>
<artifactId>k12study-backend</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>common-feign</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,76 @@
package com.k12study.common.feign.config;
import com.k12study.common.feign.contract.FeignContractValidator;
import com.k12study.common.feign.interceptor.FeignAuthRelayInterceptor;
import feign.Client;
import feign.Logger;
import feign.RequestInterceptor;
import feign.okhttp.OkHttpClient;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
/**
* @description Feign 自动装配入口;引入本模块即启用 @EnableFeignClients、OkHttp 客户端、Authorization 透传拦截器与启动期契约自检
* @filename FeignAutoConfiguration.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@AutoConfiguration
@ConditionalOnClass(name = "org.springframework.cloud.openfeign.FeignClient")
@EnableConfigurationProperties(FeignClientProperties.class)
@EnableFeignClients(basePackages = "com.k12study.api")
public class FeignAutoConfiguration {
/** 默认透传 Authorization 到下游 Feign 调用;业务方自定义时会覆盖此 Bean */
@Bean
@ConditionalOnMissingBean(FeignAuthRelayInterceptor.class)
public RequestInterceptor feignAuthRelayInterceptor() {
return new FeignAuthRelayInterceptor();
}
/** 将配置中的日志级别转为 Feign Logger.Level Bean,Feign 会自动应用到所有客户端 */
@Bean
@ConditionalOnMissingBean(Logger.Level.class)
public Logger.Level feignLoggerLevel(FeignClientProperties properties) {
return properties.getLogLevel();
}
/**
* 使用 OkHttp 作为底层 HTTP 客户端;相比 JDK HttpURLConnection 提供连接池、HTTP/2、细粒度超时,
* 生产环境高并发必备。classpath 无 okhttp3 时不生效(Conditional)。
*/
@Bean
@ConditionalOnMissingBean(Client.class)
@ConditionalOnClass(name = "okhttp3.OkHttpClient")
public Client feignOkHttpClient(FeignClientProperties properties) {
okhttp3.OkHttpClient delegate = new okhttp3.OkHttpClient.Builder()
.connectTimeout(properties.getConnectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(properties.getReadTimeout().toMillis(), TimeUnit.MILLISECONDS)
.writeTimeout(properties.getWriteTimeout().toMillis(), TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(properties.isRetryOnConnectionFailure())
.connectionPool(new okhttp3.ConnectionPool(
properties.getMaxIdleConnections(),
properties.getKeepAliveDuration().toMillis(),
TimeUnit.MILLISECONDS))
.build();
return new OkHttpClient(delegate);
}
/**
* 启动期契约自检:对比 Feign 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,
* 不一致即告警。仅 Provider 进程(同时持有接口 + Controller)生效,纯 Consumer 自动跳过。
*/
@Bean
@ConditionalOnClass(name = "org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")
@ConditionalOnProperty(prefix = "k12study.feign", name = "contract-validate", havingValue = "true", matchIfMissing = true)
public FeignContractValidator feignContractValidator() {
return new FeignContractValidator("com.k12study.api");
}
}

View File

@@ -0,0 +1,58 @@
package com.k12study.common.feign.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @description Feign 客户端外部化参数;前缀 k12study.feign,覆盖 OkHttp 超时/连接池/日志级别/契约自检开关
* @filename FeignClientProperties.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@ConfigurationProperties(prefix = "k12study.feign")
public class FeignClientProperties {
/** 连接建立超时 */
private Duration connectTimeout = Duration.ofSeconds(5);
/** 读超时 */
private Duration readTimeout = Duration.ofSeconds(10);
/** 写超时 */
private Duration writeTimeout = Duration.ofSeconds(10);
/** 是否在连接失败时重试 */
private boolean retryOnConnectionFailure = true;
/** 连接池最大空闲连接数 */
private int maxIdleConnections = 64;
/** 连接池 keep-alive 时长 */
private Duration keepAliveDuration = Duration.ofMinutes(5);
/** Feign 日志级别:NONE / BASIC / HEADERS / FULL */
private feign.Logger.Level logLevel = feign.Logger.Level.NONE;
/** 启动时校验 Feign 契约与本地 Controller 一致 */
private boolean contractValidate = true;
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; }
public Duration getWriteTimeout() { return writeTimeout; }
public void setWriteTimeout(Duration writeTimeout) { this.writeTimeout = writeTimeout; }
public boolean isRetryOnConnectionFailure() { return retryOnConnectionFailure; }
public void setRetryOnConnectionFailure(boolean retryOnConnectionFailure) {
this.retryOnConnectionFailure = retryOnConnectionFailure;
}
public int getMaxIdleConnections() { return maxIdleConnections; }
public void setMaxIdleConnections(int maxIdleConnections) { this.maxIdleConnections = maxIdleConnections; }
public Duration getKeepAliveDuration() { return keepAliveDuration; }
public void setKeepAliveDuration(Duration keepAliveDuration) { this.keepAliveDuration = keepAliveDuration; }
public feign.Logger.Level getLogLevel() { return logLevel; }
public void setLogLevel(feign.Logger.Level logLevel) { this.logLevel = logLevel; }
public boolean isContractValidate() { return contractValidate; }
public void setContractValidate(boolean contractValidate) { this.contractValidate = contractValidate; }
}

View File

@@ -0,0 +1,203 @@
package com.k12study.common.feign.contract;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* @description 启动期 Feign 契约自检;扫描 @FeignClient 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,不一致则告警
* @filename FeignContractValidator.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignContractValidator
implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(FeignContractValidator.class);
private final String basePackage;
private ApplicationContext applicationContext;
public FeignContractValidator(String basePackage) {
this.basePackage = basePackage;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
ObjectProvider<RequestMappingHandlerMapping> provider =
applicationContext.getBeanProvider(RequestMappingHandlerMapping.class);
RequestMappingHandlerMapping handlerMapping = provider.getIfAvailable();
if (handlerMapping == null) {
return;
}
Set<Endpoint> mvcEndpoints = collectMvcEndpoints(handlerMapping);
if (mvcEndpoints.isEmpty()) {
return;
}
Set<Class<?>> feignInterfaces = scanFeignInterfaces(basePackage);
if (feignInterfaces.isEmpty()) {
return;
}
int totalChecked = 0;
int mismatchCount = 0;
for (Class<?> feignInterface : feignInterfaces) {
FeignClient feignClient = AnnotationUtils.findAnnotation(feignInterface, FeignClient.class);
String basePath = feignClient == null ? "" : trimSlashes(feignClient.path());
for (Method method : feignInterface.getDeclaredMethods()) {
Endpoint expected = resolveEndpoint(method, basePath);
if (expected == null) {
continue;
}
totalChecked++;
if (!mvcEndpoints.contains(expected)) {
mismatchCount++;
log.warn("[feign-contract] mismatch: {}#{}() expects {} {} but no matching @RequestMapping found",
feignInterface.getSimpleName(), method.getName(),
expected.method(), expected.path());
}
}
}
if (mismatchCount > 0) {
log.warn("[feign-contract] validation finished: {} method(s) checked, {} mismatch(es)",
totalChecked, mismatchCount);
} else {
log.info("[feign-contract] validation passed: {} method(s) aligned with local controllers",
totalChecked);
}
}
private Set<Endpoint> collectMvcEndpoints(RequestMappingHandlerMapping handlerMapping) {
Set<Endpoint> endpoints = new LinkedHashSet<>();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMapping.getHandlerMethods().entrySet()) {
RequestMappingInfo info = entry.getKey();
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
Set<String> patterns = info.getPathPatternsCondition() != null
? info.getPathPatternsCondition().getPatternValues()
: Set.of();
if (methods.isEmpty() || patterns.isEmpty()) {
continue;
}
for (RequestMethod m : methods) {
for (String p : patterns) {
endpoints.add(new Endpoint(m.name(), normalize(p)));
}
}
}
return endpoints;
}
private Set<Class<?>> scanFeignInterfaces(String basePackage) {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false) {
@Override
protected boolean isCandidateComponent(
org.springframework.beans.factory.annotation.AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isInterface();
}
};
TypeFilter filter = new AnnotationTypeFilter(FeignClient.class) {
@Override
protected boolean matchClassName(String className) {
return false;
}
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory factory) {
return metadataReader.getAnnotationMetadata().hasAnnotation(FeignClient.class.getName());
}
};
scanner.addIncludeFilter(filter);
Set<Class<?>> classes = new LinkedHashSet<>();
scanner.findCandidateComponents(basePackage).forEach(beanDef -> {
try {
classes.add(Class.forName(beanDef.getBeanClassName()));
} catch (ClassNotFoundException ignore) {
// skip
}
});
return classes;
}
private Endpoint resolveEndpoint(Method method, String basePath) {
for (Annotation annotation : method.getAnnotations()) {
HttpDescriptor descriptor = describe(annotation);
if (descriptor != null) {
String path = normalize("/" + basePath + "/" + trimSlashes(descriptor.path()));
return new Endpoint(descriptor.method(), path);
}
}
return null;
}
private HttpDescriptor describe(Annotation annotation) {
if (annotation instanceof GetMapping a) return new HttpDescriptor("GET", firstPath(a.value(), a.path()));
if (annotation instanceof PostMapping a) return new HttpDescriptor("POST", firstPath(a.value(), a.path()));
if (annotation instanceof PutMapping a) return new HttpDescriptor("PUT", firstPath(a.value(), a.path()));
if (annotation instanceof DeleteMapping a) return new HttpDescriptor("DELETE", firstPath(a.value(), a.path()));
if (annotation instanceof PatchMapping a) return new HttpDescriptor("PATCH", firstPath(a.value(), a.path()));
if (annotation instanceof RequestMapping a && a.method().length > 0) {
return new HttpDescriptor(a.method()[0].name(), firstPath(a.value(), a.path()));
}
return null;
}
private String firstPath(String[] values, String[] paths) {
if (values != null && values.length > 0) return values[0];
if (paths != null && paths.length > 0) return paths[0];
return "";
}
private String trimSlashes(String s) {
if (s == null || s.isEmpty()) return "";
String result = s;
while (result.startsWith("/")) result = result.substring(1);
while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
return result;
}
private String normalize(String path) {
String n = path.replaceAll("/+", "/");
if (n.length() > 1 && n.endsWith("/")) {
n = n.substring(0, n.length() - 1);
}
return n;
}
private record Endpoint(String method, String path) {}
private record HttpDescriptor(String method, String path) {}
}

View File

@@ -0,0 +1,37 @@
package com.k12study.common.feign.interceptor;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* @description Feign 请求拦截器:从当前 Servlet 请求提取 Authorization 并透传下游,消费方调用时无需显式传递 token
* @filename FeignAuthRelayInterceptor.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignAuthRelayInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 调用方如已显式 setHeader("Authorization"),保留调用方意图
if (template.headers().containsKey(HttpHeaders.AUTHORIZATION)) {
return;
}
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 非 HTTP 请求上下文(定时任务/MQ 消费)下不做任何注入
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization != null && !authorization.isBlank()) {
template.header(HttpHeaders.AUTHORIZATION, authorization);
}
}
}

View File

@@ -0,0 +1 @@
com.k12study.common.feign.config.FeignAutoConfiguration

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.context;
import java.util.List;
public record RequestUserContext(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String deptId
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) {
}

View File

@@ -5,8 +5,10 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey;
public class JwtTokenProvider {
@@ -19,15 +21,29 @@ public class JwtTokenProvider {
}
public String createAccessToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getAccessTokenTtl());
}
public String createRefreshToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getRefreshTokenTtl());
}
private String createToken(JwtUserPrincipal principal, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.subject(principal.userId())
.claim("username", principal.username())
.claim("displayName", principal.displayName())
.claim("adcode", principal.adcode())
.claim("tenantId", principal.tenantId())
.claim("tenantPath", principal.tenantPath())
.claim("deptId", principal.deptId())
.claim("deptPath", principal.deptPath())
.claim("roleCodes", principal.roleCodes())
.claim("clientType", principal.clientType())
.claim("sessionId", principal.sessionId())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(authProperties.getAccessTokenTtl())))
.expiration(Date.from(now.plus(ttl)))
.signWith(secretKey)
.compact();
}
@@ -38,12 +54,23 @@ public class JwtTokenProvider {
.build()
.parseSignedClaims(token)
.getPayload();
@SuppressWarnings("unchecked")
List<String> roleCodes = claims.get("roleCodes", List.class);
if (roleCodes == null) {
roleCodes = List.of();
}
return new JwtUserPrincipal(
claims.getSubject(),
claims.get("username", String.class),
claims.get("displayName", String.class),
claims.get("adcode", String.class),
claims.get("tenantId", String.class),
claims.get("deptId", String.class)
claims.get("tenantPath", String.class),
claims.get("deptId", String.class),
claims.get("deptPath", String.class),
roleCodes,
claims.get("clientType", String.class),
claims.get("sessionId", String.class)
);
}
}

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.jwt;
import java.util.List;
public record JwtUserPrincipal(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String deptId
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) {
}

View File

@@ -9,6 +9,8 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -27,8 +29,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
request.getHeader(SecurityConstants.HEADER_USER_ID),
request.getHeader(SecurityConstants.HEADER_USERNAME),
request.getHeader(SecurityConstants.HEADER_DISPLAY_NAME),
request.getHeader(SecurityConstants.HEADER_ADCODE),
request.getHeader(SecurityConstants.HEADER_TENANT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_ID)
request.getHeader(SecurityConstants.HEADER_TENANT_PATH),
request.getHeader(SecurityConstants.HEADER_DEPT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_PATH),
parseRoleCodes(request.getHeader(SecurityConstants.HEADER_ROLE_CODES)),
request.getHeader(SecurityConstants.HEADER_CLIENT_TYPE),
request.getHeader(SecurityConstants.HEADER_SESSION_ID)
));
response.setHeader(SecurityConstants.TRACE_ID, TraceIdHolder.getOrCreate());
filterChain.doFilter(request, response);
@@ -37,4 +45,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
RequestUserContextHolder.clear();
}
}
private List<String> parseRoleCodes(String roleCodesHeader) {
if (roleCodesHeader == null || roleCodesHeader.isBlank()) {
return List.of();
}
return Arrays.stream(roleCodesHeader.split(","))
.map(String::trim)
.filter(code -> !code.isBlank())
.toList();
}
}

View File

@@ -20,5 +20,6 @@
<module>common-security</module>
<module>common-mybatis</module>
<module>common-redis</module>
<module>common-feign</module>
</modules>
</project>