diff --git a/src/main/java/com/ai/da/common/config/SecurityConfig.java b/src/main/java/com/ai/da/common/config/SecurityConfig.java new file mode 100644 index 00000000..461f148c --- /dev/null +++ b/src/main/java/com/ai/da/common/config/SecurityConfig.java @@ -0,0 +1,31 @@ +package com.ai.da.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/doc.html", + "/swagger-ui/**", + "/swagger-resources/**", + "/v2/api-docs/**", + "/v3/api-docs/**", + "/webjars/**" + ).permitAll() + .anyRequest().permitAll() // 先全部允许,后续根据业务需要收紧 + ); + return http.build(); + } +} diff --git a/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java b/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java new file mode 100644 index 00000000..a8318d51 --- /dev/null +++ b/src/main/java/com/ai/da/common/interceptor/UserContextInterceptor.java @@ -0,0 +1,51 @@ +package com.ai.da.common.interceptor; + +import com.ai.da.common.context.UserContext; +import com.ai.da.model.vo.AuthPrincipalVo; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 从 Gateway 转发的请求头中读取已鉴权的用户身份,写入 UserContext。 + *

+ * Gateway 验证 JWT 后将 X-User-Id 和 X-User-Info 写入请求头, + * 此拦截器负责将信息填充到 ThreadLocal,供业务代码使用。 + * 不需要 Gateway 鉴权路径(如 login、静态资源)不会有这两个头, + * 此时 UserContext 保持为空,业务代码应自行处理。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserContextInterceptor implements HandlerInterceptor { + + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_INFO_HEADER = "X-User-Info"; + + private final ObjectMapper objectMapper; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String userInfoJson = request.getHeader(USER_INFO_HEADER); + if (userInfoJson != null && !userInfoJson.isBlank()) { + try { + AuthPrincipalVo principal = objectMapper.readValue(userInfoJson, AuthPrincipalVo.class); + UserContext.setUserHolder(principal); + } catch (Exception e) { + log.warn("Failed to parse X-User-Info header: {}", e.getMessage()); + } + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + UserContext.delete(); + } +} diff --git a/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java b/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java new file mode 100644 index 00000000..55ee8634 --- /dev/null +++ b/src/main/java/com/ai/da/common/utils/TokenGenerateUtils.java @@ -0,0 +1,131 @@ +package com.ai.da.common.utils; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.ai.da.common.constant.CommonConstant; +import com.ai.da.model.vo.AuthPrincipalVo; +import com.alibaba.fastjson.JSONObject; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +/** + * Token 生成工具类(仅负责生成,不负责鉴权)。 + * 鉴权逻辑已迁移至 Gateway(GlobalAuthWebFilter)。 + */ +@Slf4j +@Component +public class TokenGenerateUtils { + + private static final String ISSUER = "DWJ"; + + @Value("${spring.security.jwtSecret:JWTSECRET}") + private String jwtSecret; + + @Value("${spring.security.jwtExpiration:8640000000}") + private long jwtExpiration; + + @Value("${spring.security.jwtTokenPrefix:Bearer-}") + private String jwtTokenPrefix; + + /** + * 生成 JWT Token。 + * @param principal 用户信息 + * @return 完整的 token(含 prefix) + */ + public String createToken(AuthPrincipalVo principal) { + SecretKey key = buildSigningKey(); + String token = Jwts.builder() + .id(String.valueOf(principal.getId())) + .subject(JSONObject.toJSONString(principal)) + .issuedAt(new Date()) + .issuer(ISSUER) + .expiration(new Date(System.currentTimeMillis() + jwtExpiration)) + .signWith(key) + .compact(); + return jwtTokenPrefix + token; + } + + /** + * 获取 Token 过期时间(毫秒)。 + */ + public long getJwtExpiration() { + return jwtExpiration; + } + + /** + * 生成用于邮箱变更的简化 Token。 + * @param userId 用户 ID + * @param mailbox 新邮箱 + * @return token(不含 prefix) + */ + public String createMailboxToken(Long userId, String mailbox) { + SecretKey key = buildSigningKey(); + return Jwts.builder() + .id(String.valueOf(userId)) + .subject(mailbox + "_" + userId) + .issuedAt(new Date()) + .issuer(ISSUER) + .expiration(new Date(System.currentTimeMillis() + CommonConstant.CHANGE_MAILBOX_LINK_VALIDITY)) + .signWith(key) + .compact(); + } + + /** + * 验证 Token 是否有效(签名和有效期)。 + */ + public boolean validateToken(String token) { + try { + Claims claims = parseTokenBody(token); + return claims != null; + } catch (Exception e) { + return false; + } + } + + /** + * 从 Token 中解析用户信息。 + */ + public AuthPrincipalVo parserToUser(String token) { + try { + String subject = parseTokenBody(token).getSubject(); + if (StrUtil.isNotEmpty(subject)) { + return JSONObject.parseObject(subject, AuthPrincipalVo.class); + } + } catch (Exception e) { + log.error("JWT解析用户信息失败: {}", e.getMessage()); + } + return null; + } + + /** + * 解析邮箱变更 Token,返回 "email_id" 格式字符串。 + */ + public String parseMailboxToken(String token) { + return parseTokenBody(token).getSubject(); + } + + private Claims parseTokenBody(String token) { + SecretKey key = buildSigningKey(); + return Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey buildSigningKey() { + byte[] raw = jwtSecret.getBytes(StandardCharsets.UTF_8); + if (raw.length < 32) { + raw = DigestUtil.sha256(raw); + } + return Keys.hmacShaKeyFor(raw); + } +} diff --git a/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java b/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java new file mode 100644 index 00000000..03c2dc91 --- /dev/null +++ b/src/main/java/com/ai/da/feign/gateway/GatewayFeignClient.java @@ -0,0 +1,21 @@ +package com.ai.da.feign.gateway; + +import com.ai.da.common.response.Response; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 调用 Gateway 黑名单接口,将指定用户的 token 加入黑名单。 + * 替代原来的 SellerFeignClient.clearTokenCache。 + */ +@FeignClient(name = "aida-gateway", path = "/internal") +public interface GatewayFeignClient { + + /** + * 将用户 token 加入黑名单。 + * 后续 Gateway 会拒绝携带该用户 token 的请求。 + */ + @PostMapping("/logout") + Response logout(@RequestParam("userId") Long userId); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..f127fba0 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,134 @@ +# ============================================================ +# aida-back - 本地配置(不区分环境) +# 公共配置(DB、Redis、RabbitMQ、MinIO、API Keys 等)由 Nacos 统一管理 +# 此文件仅包含 back 服务私有的业务配置 +# ============================================================ + +server: + port: 5567 + +spring: + application: + name: aida-back + + # ---------- 副数据源(back 私有,由 Nacos 统一管理) ---------- + + # ---------- Token 生成参数(由 TokenGenerateUtils 使用) ---------- + security: + jwtSecret: ${JWT_SECRET:JWTSECRET} + jwtTokenHeader: Authorization + jwtTokenPrefix: Bearer- + jwtExpiration: ${JWT_EXPIRATION:8640000000} + +# ---------- Python 服务 ---------- +access: + python: + ip: ${PYTHON_HOST:http://18.167.251.121} + port: 9994 + generate_sr_port: 9994 + address: http://18.167.251.121:9994 + +# ---------- MinIO Buckets ---------- +minio: + bucketName: + clothing: aida-clothing + mannequins: aida-mannequins + results: aida-results + sysImage: aida-sys-image + users: aida-users + collectionElement: aida-collection-element + gradient: aida-gradient + modifiedSketch: aida-modified-sketch + slogan: aida-slogan + partialDesign: aida-partial-design + globalAward: global-award + +# ---------- Redis Keys ---------- +redis: + key: + orderForGenerate: OrderForGenerate + generateCancelSet: GenerateCancelSet + generateExceptionMap: Generate:Exception + resultMap: ResultMap + orderForSR: OrderForSR + SRCancelSet: SRCancelSet + SRExceptionMap: SRExceptionMap + taskList: TaskList + credits: + pre-deduction: Credits:PreDeduction + generateResult: Generate:Result + toProductImageResultKey: ToProductImage:Result + relightResultKey: Relight:Result + newPosted: LastViewNewPostedTime + maximumUserId: CodeCreate:MaximumUserId + +# ---------- RabbitMQ 队列 ---------- +rabbitmq: + queues: + generate: generate-queue + sr: SR-queue + srResult: SuperResolution + generateResult: GenerateImage + toProductImageResult: ToProductImage + relightResult: Relight + poseTransform: PoseTransform + designBatch: DesignBatch + relightBatch: BatchRelight + toProductImageBatch: BatchToProductImage + poseTransformBatch: BatchPoseTransform + emailRetry: emailRetry-business + exchange: + generate: generate-exchange + dead-letter: + exchange: dlx.email-retry + queue: dlx.email-retry.queue + routing-key: dlx.email-retry.key + +# ---------- 第三方服务 ---------- +orderList: + link: ${ORDER_LINK:https://develop.aida.com.hk/home/homePage?order=} + +stripe: + webhook: + fail: + reminder: 0 + paymentMethodConfiguration: pmc_1QIKyq02n1TEydyNKVEYvhW7 + +google: + client: + id: ${GOOGLE_CLIENT_ID:157095842121-kdd1fdf8m8nudvj9sprstb2k2prnf9e4.apps.googleusercontent.com} + secret: ${GOOGLE_CLIENT_SECRET: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} + redirect: + uri: ${GOOGLE_REDIRECT_URI:https://develop.api.aida.com.hk/api/third/party/auth/google_callback} + +design: + callback: + url: ${DESIGN_CALLBACK_URL:https://develop.api.aida.com.hk/api/third/party/receiveDesignResults} + +redirect: + url: ${REDIRECT_URL:http://18.167.251.121:7788} + +global: + award: + link: https://aida-global-design-awards.com.hk/contestants?id= + +# ---------- 文件上传 ---------- +file: + upload: + temp: + dir: temp/uploads + chunk: + size: + pdf: 1048576 + video: 2097152 + max: + size: + pdf: 20971520 + video: 104857600 + task: + expiry: + hours: 24 + +logging: + level: + com.aida: debug