Initial commit

This commit is contained in:
2026-08-13 15:57:51 +08:00
commit eb4ab93ab2
102 changed files with 4295 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
<?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.baiyue.lastleg</groupId>
<artifactId>baiyue-lastleg-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>gateway</artifactId>
<name>gateway</name>
<dependencies>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.baiyue.gateway;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* API 网关启动入口,承载统一路由、认证校验和可信身份头转发。
*/
@SpringBootApplication
public class BaiyueGatewayApplication {
public static void main(String[] args) {
SpringApplication.run(BaiyueGatewayApplication.class, args);
}
}
@@ -0,0 +1,32 @@
package com.baiyue.gateway.config;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import reactor.core.publisher.Mono;
/**
* 网关应用范围授权器,根据 JWT 的 {@code app_code} 声明隔离商户端与平台端接口。
*/
final class AppScopeAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
private final String expectedAppCode;
AppScopeAuthorizationManager(String expectedAppCode) {
this.expectedAppCode = expectedAppCode;
}
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication,
AuthorizationContext context) {
return authentication.filter(Authentication::isAuthenticated)
.map(this::hasExpectedScope).map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
private boolean hasExpectedScope(Authentication authentication) {
return authentication instanceof JwtAuthenticationToken jwt
&& expectedAppCode.equals(jwt.getToken().getClaimAsString("app_code"));
}
}
@@ -0,0 +1,74 @@
package com.baiyue.gateway.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。
*/
@Configuration
@EnableConfigurationProperties(GatewaySecurityProperties.class)
public class GatewaySecurityConfig {
@Bean
SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
GatewaySecurityProperties properties,
ObjectMapper objectMapper) {
var exchanges = http.authorizeExchange();
properties.permitPaths().forEach(path -> exchanges.pathMatchers(path).permitAll());
exchanges.pathMatchers("/api/merchant/**")
.access(new AppScopeAuthorizationManager("MERCHANT"));
exchanges.pathMatchers("/api/platform/**")
.access(new AppScopeAuthorizationManager("PLATFORM"));
exchanges.anyExchange().authenticated();
return http.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { })
.authenticationEntryPoint((exchange, exception) -> writeError(exchange, objectMapper,
HttpStatus.UNAUTHORIZED, "A0401", "未登录或令牌已失效")))
.exceptionHandling(errors -> errors.accessDeniedHandler((exchange, exception) ->
writeError(exchange, objectMapper, HttpStatus.FORBIDDEN, "A0403", "无权访问该资源")))
.build();
}
@Bean
ReactiveJwtDecoder reactiveJwtDecoder(GatewaySecurityProperties properties) {
NimbusReactiveJwtDecoder decoder = NimbusReactiveJwtDecoder
.withJwkSetUri(properties.jwkSetUri()).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(properties.issuer()));
return decoder;
}
static Mono<Void> writeError(org.springframework.web.server.ServerWebExchange exchange,
ObjectMapper mapper, HttpStatus status, String code, String message) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
body.put("data", null);
body.put("requestId", exchange.getRequest().getId());
body.put("timestamp", Instant.now().toString());
try {
byte[] bytes = mapper.writeValueAsBytes(body);
return exchange.getResponse().writeWith(
Mono.just(exchange.getResponse().bufferFactory().wrap(bytes)));
} catch (Exception exception) {
return exchange.getResponse().setComplete();
}
}
}
@@ -0,0 +1,21 @@
package com.baiyue.gateway.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 海外网关安全配置,只保存验签所需的签发方和公钥集地址。
*/
@ConfigurationProperties(prefix = "baiyue.security")
public record GatewaySecurityProperties(String jwkSetUri, String issuer, List<String> permitPaths) {
public GatewaySecurityProperties {
if (jwkSetUri == null || jwkSetUri.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_JWK_SET_URI");
}
if (issuer == null || issuer.isBlank()) {
throw new IllegalArgumentException("必须配置 JWT_ISSUER");
}
permitPaths = permitPaths == null ? List.of() : List.copyOf(permitPaths);
}
}
@@ -0,0 +1,73 @@
package com.baiyue.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
/**
* 网关可信身份头过滤器,清除外部伪造身份头,并从已认证 JWT 注入用户、租户和应用范围信息。
*/
@Component
public class TrustedIdentityHeaderFilter implements GlobalFilter, Ordered {
private static final List<String> TRUSTED_HEADERS = List.of(
"X-User-Id", "X-Membership-Id", "X-Tenant-Id", "X-Operator-Tenant-Id",
"X-App-Code", "X-Client-Type"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return exchange.getPrincipal()
.cast(Authentication.class)
.defaultIfEmpty(new AnonymousAuthentication())
.flatMap(authentication -> {
var requestBuilder = exchange.getRequest().mutate();
requestBuilder.headers(headers -> TRUSTED_HEADERS.forEach(headers::remove));
if (authentication instanceof JwtAuthenticationToken jwt && authentication.isAuthenticated()) {
put(requestBuilder, "X-User-Id", jwt.getToken().getSubject());
put(requestBuilder, "X-Membership-Id", claim(jwt, "membership_id"));
put(requestBuilder, "X-Tenant-Id", claim(jwt, "tenant_id"));
put(requestBuilder, "X-Operator-Tenant-Id", claim(jwt, "operator_tenant_id"));
put(requestBuilder, "X-App-Code", claim(jwt, "app_code"));
put(requestBuilder, "X-Client-Type", claim(jwt, "client_type"));
}
return chain.filter(exchange.mutate().request(requestBuilder.build()).build());
});
}
private static String claim(JwtAuthenticationToken jwt, String name) {
Object value = jwt.getToken().getClaims().get(name);
return value == null ? null : value.toString();
}
private static void put(org.springframework.http.server.reactive.ServerHttpRequest.Builder builder,
String name,
String value) {
if (value != null && !value.isBlank()) {
builder.header(name, value);
}
}
@Override
public int getOrder() {
return -100;
}
private static final class AnonymousAuthentication implements Authentication {
@Override public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority> getAuthorities() { return List.of(); }
@Override public Object getCredentials() { return ""; }
@Override public Object getDetails() { return null; }
@Override public Object getPrincipal() { return "anonymous"; }
@Override public boolean isAuthenticated() { return false; }
@Override public void setAuthenticated(boolean isAuthenticated) { }
@Override public String getName() { return "anonymous"; }
}
}
@@ -0,0 +1,69 @@
spring:
config:
activate:
on-profile: overseas
cloud:
gateway:
discovery:
locator:
enabled: false
routes:
- id: lastleg-service
uri: lb://lastleg-service
predicates: ["Path=/api/merchant/v1/last-leg/**,/api/platform/v1/last-leg/**"]
- id: warehouse-service
uri: lb://warehouse-service
predicates: ["Path=/api/merchant/v1/warehouses/**,/api/platform/v1/warehouses/**,/api/platform/v1/warehouse-admin/**"]
- id: file-service
uri: lb://file-service
predicates: ["Path=/api/merchant/v1/files/**,/api/platform/v1/files/**"]
- id: print-service
uri: lb://print-service
predicates: ["Path=/api/merchant/v1/print/**,/api/platform/v1/print/**"]
- id: lastleg-openapi
uri: lb://lastleg-service
predicates: [Path=/lastleg-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: warehouse-openapi
uri: lb://warehouse-service
predicates: [Path=/warehouse-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: file-openapi
uri: lb://file-service
predicates: [Path=/file-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: print-openapi
uri: lb://print-service
predicates: [Path=/print-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: audit-openapi
uri: lb://audit-service
predicates: [Path=/audit-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: integration-openapi
uri: lb://integration-service
predicates: [Path=/integration-service/v3/api-docs/**]
filters: [StripPrefix=1]
- id: event-bridge-openapi
uri: lb://event-bridge-service
predicates: [Path=/event-bridge-service/v3/api-docs/**]
filters: [StripPrefix=1]
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
springdoc:
swagger-ui:
urls:
- { name: lastleg-merchant, url: /lastleg-service/v3/api-docs/merchant }
- { name: warehouse-merchant, url: /warehouse-service/v3/api-docs/merchant }
- { name: file-platform, url: /file-service/v3/api-docs/platform }
- { name: file-merchant, url: /file-service/v3/api-docs/merchant }
- { name: print-platform, url: /print-service/v3/api-docs/platform }
- { name: print-merchant, url: /print-service/v3/api-docs/merchant }
- { name: audit, url: /audit-service/v3/api-docs }
- { name: integration, url: /integration-service/v3/api-docs }
- { name: event-bridge, url: /event-bridge-service/v3/api-docs }
@@ -0,0 +1,66 @@
server:
port: ${GATEWAY_PORT:9300}
spring:
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: gateway
profiles:
default: overseas
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
namespace: ${NACOS_NAMESPACE:public}
gateway:
discovery:
locator:
enabled: false
baiyue:
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
permit-paths:
- /actuator/health
- /swagger-ui/**
- /webjars/**
- /v3/api-docs/**
- /lastleg-service/v3/api-docs/**
- /warehouse-service/v3/api-docs/**
- /file-service/v3/api-docs/**
- /print-service/v3/api-docs/**
- /audit-service/v3/api-docs/**
- /integration-service/v3/api-docs/**
- /event-bridge-service/v3/api-docs/**
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes:
enabled: true
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,55 @@
package com.baiyue.gateway.config;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.Instant;
class AppScopeAuthorizationManagerTest {
@Test
void acceptsMatchingApplicationScope() {
var manager = new AppScopeAuthorizationManager("MERCHANT");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> decision.isGranted())
.verifyComplete();
}
@Test
void rejectsDifferentApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
StepVerifier.create(manager.check(Mono.just(authentication("MERCHANT")), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
@Test
void rejectsMissingApplicationScope() {
var manager = new AppScopeAuthorizationManager("PLATFORM");
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.build();
StepVerifier.create(manager.check(Mono.just(new JwtAuthenticationToken(
jwt, java.util.List.of(new SimpleGrantedAuthority("authenticated")))), null))
.expectNextMatches(decision -> !decision.isGranted())
.verifyComplete();
}
private JwtAuthenticationToken authentication(String appCode) {
Jwt jwt = Jwt.withTokenValue("token")
.header("alg", "none")
.subject("1")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(60))
.claim("app_code", appCode)
.build();
return new JwtAuthenticationToken(jwt,
java.util.List.of(new SimpleGrantedAuthority("authenticated")));
}
}