Compare commits
17 Commits
7422418dff
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58951ff9b6 | ||
|
|
9124256f01 | ||
|
|
7f69eebedf | ||
|
|
e00e7f3e5e | ||
|
|
a5c0695488 | ||
|
|
c8d1bc6985 | ||
|
|
3b11905b55 | ||
|
|
94379b9a16 | ||
|
|
e642dbf041 | ||
|
|
e7ef16b8ab | ||
|
|
32bd7c7808 | ||
|
|
a98ba4222c | ||
|
|
472c349220 | ||
|
|
2ebad70036 | ||
|
|
5ed0a0a288 | ||
|
|
ffd45a4f43 | ||
|
|
1b744635d6 |
@@ -4,7 +4,8 @@ on:
|
||||
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: java21
|
||||
|
||||
outputs:
|
||||
build_status: ${{ job.status }}
|
||||
build_url: ${{ gitea.server_url }}/${{ gitea.repository.owner.name }}/${{ gitea.repository.name }}/actions/runs/${{ gitea.run_id }}
|
||||
@@ -26,27 +27,20 @@ jobs:
|
||||
with:
|
||||
ref: master
|
||||
|
||||
- name: 2.设置 JDK 21 + Maven + 依赖缓存
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: 'maven'
|
||||
|
||||
- name: 3.构建jar包
|
||||
- name: 3.缓存 Maven 依赖
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-maven-
|
||||
|
||||
- name: 4.构建项目
|
||||
run: |
|
||||
echo "===== 开始构建JAR包 ====="
|
||||
# 新增:打印当前构建分支(两种方式双重确认)
|
||||
echo "当前工作目录分支:$(git branch --show-current)"
|
||||
echo "Gitea检出分支:${{ github.ref_name }}"
|
||||
echo "预期构建分支: master"
|
||||
echo "========================"
|
||||
mvn -B clean install -DskipTests -Pdev 2>&1
|
||||
# 检查构建是否成功
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "JAR包构建失败!"
|
||||
exit 1
|
||||
fi
|
||||
java -version
|
||||
mvn -v
|
||||
mvn clean package -DskipTests
|
||||
|
||||
- name: 5.生成Dockerfile
|
||||
run: |
|
||||
@@ -78,49 +72,40 @@ jobs:
|
||||
- ./uploads:/temp/uploads
|
||||
ports:
|
||||
- '10094:10094'
|
||||
networks:
|
||||
- aida_java_net
|
||||
restart: always
|
||||
networks:
|
||||
aida_java_net:
|
||||
external: true
|
||||
name: aida_java_net
|
||||
EOF
|
||||
# 验证docker-compose.yml生成
|
||||
echo "docker-compose.yml内容:"
|
||||
cat docker-compose.yml
|
||||
|
||||
- name: 7.安装SSH工具
|
||||
run: |
|
||||
$SUDO apt install -y sshpass openssh-client --no-install-recommends
|
||||
# 配置SSH免密
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
chmod 600 ~/.ssh/id_rsa
|
||||
ssh-keyscan -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
|
||||
- name: 7.上传jar到远程服务器
|
||||
uses: appleboy/scp-action@master
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
port: 22
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
source: "target/*.jar,Dockerfile,docker-compose.yml"
|
||||
target: ${{ env.REMOTE_DEPLOY_PATH }}
|
||||
preserve_host_directory_structure: false
|
||||
|
||||
- name: 8.同步文件到远程服务器
|
||||
run: |
|
||||
echo "===== 同步文件到远程服务器 ====="
|
||||
# 使用scp同步文件
|
||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
./target/*.jar ./Dockerfile ./docker-compose.yml \
|
||||
${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}:${{ env.REMOTE_DEPLOY_PATH }} 2>&1
|
||||
|
||||
- name: 9.部署和运行服务
|
||||
run: |
|
||||
echo "===== 开始部署服务 ====="
|
||||
# SSH执行部署命令
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} << 'EOF_SSH'
|
||||
- name: 8. 重启 Docker 服务
|
||||
uses: appleboy/ssh-action@master # 👈 专门执行命令的 action
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
key_base64: true
|
||||
script: |
|
||||
echo "========= 进入部署目录 ========="
|
||||
cd ${{ env.REMOTE_DEPLOY_PATH }}
|
||||
echo "停止旧容器..."
|
||||
docker compose down || true
|
||||
echo "构建镜像..."
|
||||
docker compose build --no-cache
|
||||
echo "启动服务..."
|
||||
docker compose up -d
|
||||
echo "验证容器状态..."
|
||||
docker compose ps
|
||||
echo "部署完成!"
|
||||
EOF_SSH
|
||||
ls -l
|
||||
|
||||
echo "========= 停止旧服务 ========="
|
||||
docker compose down
|
||||
|
||||
echo "========= 启动新服务 ========="
|
||||
docker compose up -d --build
|
||||
|
||||
echo "========= 查看运行状态 ========="
|
||||
docker compose ps
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/.idea/
|
||||
/target/
|
||||
/log/
|
||||
|
||||
8
pom.xml
8
pom.xml
@@ -100,6 +100,12 @@
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Logging(显式引入,确保 logback 正确初始化) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-logging</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Knife4j Gateway Aggregation -->
|
||||
<dependency>
|
||||
<groupId>com.github.xiaoymin</groupId>
|
||||
@@ -145,6 +151,8 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- 强制工作目录为模块根目录,确保 ./log 指向项目目录而非 Maven 安装目录 -->
|
||||
<workingDirectory>${project.basedir}</workingDirectory>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.aida.gateway.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.config.CorsRegistry;
|
||||
import org.springframework.web.reactive.config.WebFluxConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig implements WebFluxConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowCredentials(true)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE")
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,13 @@ public class GatewayAuthProperties {
|
||||
|
||||
private List<String> ignorePaths;
|
||||
|
||||
/**
|
||||
* 可选认证路径:token 有则解析并写入下游请求头,无则放行。
|
||||
* 与 ignorePaths 的区别:ignorePaths 完全跳过认证逻辑;
|
||||
* optionalAuthPaths 仍然尝试解析 token,有 token 时正常写入 X-User-Id / X-User-Info,
|
||||
* 无 token 时才放行,确保已登录用户的信息能正确传递。
|
||||
*/
|
||||
private List<String> optionalAuthPaths;
|
||||
|
||||
private boolean blacklistEnabled = true;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.data.redis.core.ReactiveRedisTemplate;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
@@ -42,7 +44,12 @@ import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GlobalAuthWebFilter implements WebFilter {
|
||||
public class GlobalAuthWebFilter implements WebFilter, Ordered {
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return Ordered.LOWEST_PRECEDENCE - 1;
|
||||
}
|
||||
|
||||
private final GatewayAuthProperties authProperties;
|
||||
@Qualifier("reactiveRedisTemplate")
|
||||
@@ -54,29 +61,47 @@ public class GlobalAuthWebFilter implements WebFilter {
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
String path = exchange.getRequest().getURI().getPath();
|
||||
|
||||
// 1. 放过 OPTIONS 预检请求,由全局 CORS 配置处理
|
||||
if ("OPTIONS".equalsIgnoreCase(exchange.getRequest().getMethod().name())) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 2. 白名单直接放行
|
||||
// 2. 白名单直接放行(完全跳过认证)
|
||||
if (isIgnoredPath(path)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 3. 提取 token
|
||||
// 3. 可选认证路径:token 有则解析,无则放行
|
||||
if (isOptionalAuthPath(path)) {
|
||||
String rawHeader = exchange.getRequest().getHeaders()
|
||||
.getFirst(authProperties.getJwtTokenHeader());
|
||||
if (StrUtil.isBlank(rawHeader)) {
|
||||
// 无 token,直接放行,不写任何 header
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
// 有 token,正常走解析流程(复用下面的验证逻辑)
|
||||
return processTokenWithAuthCheck(exchange, chain, rawHeader);
|
||||
}
|
||||
|
||||
// 4. 其他路径:必须有 token
|
||||
String rawHeader = exchange.getRequest().getHeaders()
|
||||
.getFirst(authProperties.getJwtTokenHeader());
|
||||
if (StrUtil.isBlank(rawHeader)) {
|
||||
return writeUnauthorized(exchange, AuthConstants.MSG_MISSING_TOKEN);
|
||||
}
|
||||
return processTokenWithAuthCheck(exchange, chain, rawHeader);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 token 解析与认证流程:解析 JWT → 黑名单检查 → 写入下游 header。
|
||||
* 专供可选认证路径中有 token 的情况,以及普通路径的鉴权。
|
||||
*/
|
||||
private Mono<Void> processTokenWithAuthCheck(ServerWebExchange exchange, WebFilterChain chain, String rawHeader) {
|
||||
String token = rawHeader;
|
||||
if (rawHeader.startsWith(authProperties.getJwtTokenPrefix())) {
|
||||
token = rawHeader.substring(authProperties.getJwtTokenPrefix().length());
|
||||
}
|
||||
|
||||
// 4. JWT 签名验证
|
||||
// JWT 签名验证
|
||||
Claims claims;
|
||||
try {
|
||||
claims = parseToken(token);
|
||||
@@ -85,7 +110,7 @@ public class GlobalAuthWebFilter implements WebFilter {
|
||||
return writeUnauthorized(exchange, AuthConstants.MSG_TOKEN_EXPIRED);
|
||||
}
|
||||
|
||||
// 5. 解析用户信息
|
||||
// 解析用户信息
|
||||
AuthPrincipalVo principal;
|
||||
try {
|
||||
principal = objectMapper.readValue(claims.getSubject(), AuthPrincipalVo.class);
|
||||
@@ -98,7 +123,7 @@ public class GlobalAuthWebFilter implements WebFilter {
|
||||
return writeUnauthorized(exchange, AuthConstants.MSG_INVALID_TOKEN);
|
||||
}
|
||||
|
||||
// 6. 黑名单检查(仅当启用时)
|
||||
// 黑名单检查
|
||||
if (authProperties.isBlacklistEnabled()) {
|
||||
String blacklistKey = AuthConstants.BLACKLIST_PREFIX + principal.getId();
|
||||
return redisTemplate.hasKey(blacklistKey).flatMap(isBlacklisted -> {
|
||||
@@ -145,6 +170,18 @@ public class GlobalAuthWebFilter implements WebFilter {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isOptionalAuthPath(String requestUri) {
|
||||
if (authProperties.getOptionalAuthPaths() == null) {
|
||||
return false;
|
||||
}
|
||||
for (String pattern : authProperties.getOptionalAuthPaths()) {
|
||||
if (pathMatcher.match(pattern, requestUri)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Claims parseToken(String token) {
|
||||
SecretKey key = buildSigningKey();
|
||||
return Jwts.parser()
|
||||
@@ -166,6 +203,11 @@ public class GlobalAuthWebFilter implements WebFilter {
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
String origin = exchange.getRequest().getHeaders().getFirst(HttpHeaders.ORIGIN);
|
||||
if (origin != null) {
|
||||
response.getHeaders().set(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin);
|
||||
response.getHeaders().set(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
|
||||
}
|
||||
String body = String.format("{\"code\":401,\"message\":\"%s\"}", message);
|
||||
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
|
||||
return response.writeWith(Mono.just(buffer));
|
||||
|
||||
@@ -11,7 +11,6 @@ spring:
|
||||
name: aida-gateway
|
||||
cloud:
|
||||
gateway:
|
||||
# ---------- 全局 CORS 配置 ----------
|
||||
globalcors:
|
||||
cors-configurations:
|
||||
'[/**]':
|
||||
@@ -24,9 +23,17 @@ spring:
|
||||
- OPTIONS
|
||||
- PATCH
|
||||
allowed-headers: "*"
|
||||
allow-credentials: true
|
||||
max-age: 3600
|
||||
default-filters:
|
||||
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials
|
||||
# ---------- 路由配置 ----------
|
||||
routes:
|
||||
# 多实例部署时推送会失效,升级多实例要注意ws改造
|
||||
- id: aida-back-websocket
|
||||
uri: lb://aida-back
|
||||
predicates:
|
||||
- Path=/notification/**
|
||||
- id: aida-back
|
||||
uri: lb://aida-back
|
||||
predicates:
|
||||
@@ -68,6 +75,9 @@ gateway:
|
||||
jwt-token-header: Authorization
|
||||
jwt-token-prefix: Bearer-
|
||||
blacklist-enabled: true
|
||||
# 可选认证路径:token 有则解析并传递,无则放行(已登录用户身份仍能正确传递)
|
||||
optional-auth-paths:
|
||||
- /seller/listing/shop
|
||||
ignore-paths:
|
||||
# Static resources & docs
|
||||
- /favicon.ico
|
||||
@@ -101,6 +111,7 @@ gateway:
|
||||
- /gateway/healthy
|
||||
# Designer
|
||||
- /aida/api/designer/check
|
||||
- /seller/designer/shop/**
|
||||
# Python (only /aida prefix)
|
||||
- /aida/api/python/saveGeneratePicture
|
||||
- /aida/api/python/getLibraryByUserId
|
||||
@@ -136,8 +147,6 @@ gateway:
|
||||
- /aida/api/alipay-hk/trade/notify
|
||||
- /aida/api/stripe/trade/notify
|
||||
# Notification
|
||||
- /aida/notification/**
|
||||
- /notification/**
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.aida.gateway: debug
|
||||
|
||||
65
src/main/resources/logback-spring.xml
Normal file
65
src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||
|
||||
<!-- 日志存放路径 -->
|
||||
<property name="log.path" value="./log" />
|
||||
<!-- 日志输出格式 -->
|
||||
<property name="log.pattern.console" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
||||
<property name="log.pattern.file" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} ${PID:- } --- [%15t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}" />
|
||||
|
||||
<!-- 控制台输出 -->
|
||||
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${log.pattern.console}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- Info 日志文件 -->
|
||||
<appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${log.path}/aida-gateway-info.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/aida-gateway-info.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>60</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>${log.pattern.file}</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
</appender>
|
||||
|
||||
<!-- Error 日志文件 -->
|
||||
<appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${log.path}/aida-gateway-error.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>${log.path}/aida-gateway-error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>60</maxHistory>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>${log.pattern.file}</pattern>
|
||||
</encoder>
|
||||
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||
<level>ERROR</level>
|
||||
<onMatch>ACCEPT</onMatch>
|
||||
<onMismatch>DENY</onMismatch>
|
||||
</filter>
|
||||
</appender>
|
||||
|
||||
<!-- 服务模块日志级别控制 -->
|
||||
<logger name="com.aida.gateway" level="debug" />
|
||||
<!-- Spring 日志级别控制 -->
|
||||
<logger name="org.springframework" level="warn" />
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="console" />
|
||||
</root>
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="file_info" />
|
||||
<appender-ref ref="file_error" />
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user