diff --git a/xianyu-common/src/main/java/top/biwin/xinayu/common/dto/response/FileUploadResponse.java b/xianyu-common/src/main/java/top/biwin/xinayu/common/dto/response/FileUploadResponse.java new file mode 100644 index 0000000..ef4e4cd --- /dev/null +++ b/xianyu-common/src/main/java/top/biwin/xinayu/common/dto/response/FileUploadResponse.java @@ -0,0 +1,21 @@ +package top.biwin.xinayu.common.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * TODO + * + * @author wangli + * @since 2026-01-30 10:48 + */ +@EqualsAndHashCode(callSuper = true) +@Data +@SuperBuilder +public class FileUploadResponse extends BaseResponse{ + + @JsonProperty("image_url") + private String imageUrl; +} diff --git a/xianyu-server/src/main/java/top/biwin/xinayu/server/config/SecurityConfig.java b/xianyu-server/src/main/java/top/biwin/xinayu/server/config/SecurityConfig.java index 7e1d033..b890bb4 100644 --- a/xianyu-server/src/main/java/top/biwin/xinayu/server/config/SecurityConfig.java +++ b/xianyu-server/src/main/java/top/biwin/xinayu/server/config/SecurityConfig.java @@ -42,101 +42,102 @@ import top.biwin.xinayu.server.security.JwtAuthenticationFilter; @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { - @Autowired - private JwtAuthenticationFilter jwtAuthenticationFilter; + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; - @Autowired - private UserDetailsService userDetailsService; + @Autowired + private UserDetailsService userDetailsService; - /** - * 配置安全过滤器链 - *

- * 配置项: - * 1. 禁用 CSRF(JWT 无状态认证不需要) - * 2. 配置授权规则: - * - /auth/** 接口允许匿名访问(登录、刷新令牌) - * - 其他所有接口需要认证 - * 3. 配置无状态会话管理 - * 4. 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前) - * - * @param http HttpSecurity 对象 - * @return SecurityFilterChain - * @throws Exception 配置异常 - */ - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - // 禁用 CSRF(JWT 场景下不需要) - .csrf(AbstractHttpConfigurer::disable) + /** + * 配置安全过滤器链 + *

+ * 配置项: + * 1. 禁用 CSRF(JWT 无状态认证不需要) + * 2. 配置授权规则: + * - /auth/** 接口允许匿名访问(登录、刷新令牌) + * - 其他所有接口需要认证 + * 3. 配置无状态会话管理 + * 4. 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前) + * + * @param http HttpSecurity 对象 + * @return SecurityFilterChain + * @throws Exception 配置异常 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // 禁用 CSRF(JWT 场景下不需要) + .csrf(AbstractHttpConfigurer::disable) - // 配置授权规则 - .authorizeHttpRequests(auth -> auth - // 白名单:允许 /auth/** 接口匿名访问 - .requestMatchers("/auth/**", - "/geetest/**", - "system-settings/public") - .permitAll() + // 配置授权规则 + .authorizeHttpRequests(auth -> auth + // 白名单:允许 /auth/** 接口匿名访问 + .requestMatchers("/auth/**", + "/geetest/**", + "/system-settings/public", + "/static/**") + .permitAll() - // 其他所有接口都需要认证 - .anyRequest().authenticated() - ) + // 其他所有接口都需要认证 + .anyRequest().authenticated() + ) - // 配置无状态会话管理(JWT 无状态认证) - .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) + // 配置无状态会话管理(JWT 无状态认证) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) - // 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前执行) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + // 添加 JWT 认证过滤器(在 UsernamePasswordAuthenticationFilter 之前执行) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - return http.build(); - } + return http.build(); + } - /** - * 密码编码器 Bean - * 使用 BCrypt 算法,强度为 12(2^12 = 4096 次哈希) - *

- * BCrypt 优势: - * - 自动加盐 - * - 计算密集,防暴力破解 - * - 业界标准 - * - * @return PasswordEncoder - */ - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(12); - } + /** + * 密码编码器 Bean + * 使用 BCrypt 算法,强度为 12(2^12 = 4096 次哈希) + *

+ * BCrypt 优势: + * - 自动加盐 + * - 计算密集,防暴力破解 + * - 业界标准 + * + * @return PasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } - /** - * 认证管理器 Bean - * Spring Security 用于处理认证请求 - * - * @param authenticationConfiguration 认证配置 - * @return AuthenticationManager - * @throws Exception 配置异常 - */ - @Bean - public AuthenticationManager authenticationManager( - AuthenticationConfiguration authenticationConfiguration) throws Exception { - return authenticationConfiguration.getAuthenticationManager(); - } + /** + * 认证管理器 Bean + * Spring Security 用于处理认证请求 + * + * @param authenticationConfiguration 认证配置 + * @return AuthenticationManager + * @throws Exception 配置异常 + */ + @Bean + public AuthenticationManager authenticationManager( + AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } - /** - * DAO 认证提供者 Bean - * 连接 UserDetailsService 和 PasswordEncoder - *

- * 工作流程: - * 1. 从 UserDetailsService 加载用户信息 - * 2. 使用 PasswordEncoder 验证密码 - * - * @return DaoAuthenticationProvider - */ - @Bean - public DaoAuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - authProvider.setUserDetailsService(userDetailsService); - authProvider.setPasswordEncoder(passwordEncoder()); - return authProvider; - } + /** + * DAO 认证提供者 Bean + * 连接 UserDetailsService 和 PasswordEncoder + *

+ * 工作流程: + * 1. 从 UserDetailsService 加载用户信息 + * 2. 使用 PasswordEncoder 验证密码 + * + * @return DaoAuthenticationProvider + */ + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } } diff --git a/xianyu-server/src/main/java/top/biwin/xinayu/server/controller/AssistController.java b/xianyu-server/src/main/java/top/biwin/xinayu/server/controller/AssistController.java new file mode 100644 index 0000000..184359e --- /dev/null +++ b/xianyu-server/src/main/java/top/biwin/xinayu/server/controller/AssistController.java @@ -0,0 +1,117 @@ +package top.biwin.xinayu.server.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import top.biwin.xinayu.common.dto.response.FileUploadResponse; +import top.biwin.xinayu.server.util.ImageUtils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +/** + * 辅助功能控制器 + * 提供文件上传等辅助接口哦~ (〃^ω^〃) + * + * @author wangli + * @since 2026-01-30 10:46 + */ +@Slf4j +@RestController +public class AssistController { + + @Value("${assistant.static.uploads.images}") + private String uploadDir; + + /** + * 获取图片内容 + * 会直接返回处理后的 JPEG 二进制流哦!📸✨ + * + * @param filename 文件名 + * @return 图片二进制流 + */ + @GetMapping(value = "${assistant.static.uploads.images}{filename:.+}", produces = MediaType.IMAGE_JPEG_VALUE) + public ResponseEntity getImage(@PathVariable String filename) { + try { + Path path = Paths.get(uploadDir, filename); + if (Files.exists(path)) { + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_JPEG) + .body(Files.readAllBytes(path)); + } else { + log.warn("图片不存在呢... (´;︵;`) : {}", filename); + return ResponseEntity.notFound().build(); + } + } catch (IOException e) { + log.error("读取图片失败: {}", filename, e); + return ResponseEntity.internalServerError().build(); + } + } + + /** + * 上传图片并处理 + * + * @param file 图片文件 + * @return 上传结果,包含图片相对路径 + */ + @PostMapping("/upload-image") + public ResponseEntity uploadImage(@RequestParam("image") MultipartFile file) { + if (file.isEmpty()) { + return ResponseEntity.badRequest().body(FileUploadResponse.builder() + .success(false) + .message("文件不能为空哦!(っ °Д °;)っ") + .build()); + } + + try { + // 1. 调用工具类处理图片 + byte[] processedImage = ImageUtils.processImage(file.getBytes()); + + // 2. 准备保存路径(主动创建目录哦!) + Path path = Paths.get(uploadDir); + if (Files.notExists(path)) { + log.info("目录不存在,小码酱正在为主官主动创建: {}", uploadDir); + Files.createDirectories(path); + } + File directory = path.toFile(); + + // 3. 生成新文件名 + String fileName = UUID.randomUUID().toString() + ".jpg"; + File destFile = new File(directory, fileName); + + // 4. 保存文件 + try (FileOutputStream fos = new FileOutputStream(destFile)) { + fos.write(processedImage); + } + + // 5. 返回相对路径 + String relativePath = uploadDir + fileName; + log.info("图片上传成功: {}", relativePath); + + return ResponseEntity.ok(FileUploadResponse.builder() + .success(true) + .message("上传成功!✨") + .imageUrl(relativePath) + .build()); + + } catch (IOException e) { + log.error("图片上传处理失败", e); + return ResponseEntity.status(500).body(FileUploadResponse.builder() + .success(false) + .message("图片处理失败了呢... " + e.getMessage()) + .build()); + } + } +} diff --git a/xianyu-server/src/main/java/top/biwin/xinayu/server/util/ImageUtils.java b/xianyu-server/src/main/java/top/biwin/xinayu/server/util/ImageUtils.java new file mode 100644 index 0000000..41a5251 --- /dev/null +++ b/xianyu-server/src/main/java/top/biwin/xinayu/server/util/ImageUtils.java @@ -0,0 +1,144 @@ +package top.biwin.xinayu.server.util; + +import lombok.extern.slf4j.Slf4j; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Iterator; + +/** + * 图片处理工具类 + * 完美复现 Master 的 Python 逻辑哦!(。◕‿◕。) + * + * @author wangli + * @since 2026-01-30 11:03 + */ +@Slf4j +public class ImageUtils { + + private static final int MAX_OUTPUT_DIMENSION = 2048; + private static final float JPEG_QUALITY = 0.85f; + + /** + * 处理图片:转换模式、调整尺寸、JPEG 压缩 + * + * @param imageData 原始图片数据 + * @return 处理后的 JPEG 图片数据 + * @throws IOException 如果图片处理失败 + */ + public static byte[] processImage(byte[] imageData) throws IOException { + try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) { + BufferedImage img = ImageIO.read(bais); + if (img == null) { + throw new IOException("无法识别的图片格式 (っ °Д °;)っ"); + } + + // 1. 转换为 RGB 模式(处理透明度/索引颜色) + img = convertToRgb(img); + + // 2. 调整尺寸(如果需要) + img = resizeIfNecessary(img); + + // 3. 保存为 JPEG 并压缩 + return compressToJpeg(img); + } catch (Exception e) { + log.error("图片处理失败: {}", e.getMessage(), e); + // 如果处理失败,根据提示返回原始数据(或者抛出异常) + // 这里我们遵循 Python 逻辑,失败则尝试返回原始数据 + return imageData; + } + } + + private static BufferedImage convertToRgb(BufferedImage source) { + int width = source.getWidth(); + int height = source.getHeight(); + + // 检查是否需要转换:如果有透明通道或者是索引颜色 + // Python: if img.mode in ('RGBA', 'LA', 'P'): + boolean hasAlpha = source.getTransparency() != Transparency.OPAQUE; + + // 创建白色背景的 RGB 图像 + BufferedImage rgbImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = rgbImage.createGraphics(); + + try { + // 设置背景为白色 + g2d.setColor(Color.WHITE); + g2d.fillRect(0, 0, width, height); + + // 绘制原图(自动处理透明混合) + g2d.drawImage(source, 0, 0, null); + } finally { + g2d.dispose(); + } + + return rgbImage; + } + + private static BufferedImage resizeIfNecessary(BufferedImage img) { + int width = img.getWidth(); + int height = img.getHeight(); + + if (width > MAX_OUTPUT_DIMENSION || height > MAX_OUTPUT_DIMENSION) { + double ratio = Math.min((double) MAX_OUTPUT_DIMENSION / width, (double) MAX_OUTPUT_DIMENSION / height); + int newWidth = (int) (width * ratio); + int newHeight = (int) (height * ratio); + + Image scaledImage = img.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH); + BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = resized.createGraphics(); + + try { + // 使用高质量插值算法 + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.drawImage(scaledImage, 0, 0, null); + } finally { + g2d.dispose(); + } + + log.info("图片已调整尺寸: {}x{} -> {}x{}", width, height, newWidth, newHeight); + return resized; + } else { + log.info("图片尺寸合适,无需调整: {}x{}", width, height); + return img; + } + } + + private static byte[] compressToJpeg(BufferedImage img) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + // 获取 JPEG 写入器 + Iterator writers = ImageIO.getImageWritersByFormatName("jpg"); + if (!writers.hasNext()) { + throw new IOException("找不到 JPEG 写入器 (´;︵;`) "); + } + + ImageWriter writer = writers.next(); + try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(ios); + + ImageWriteParam param = writer.getDefaultWriteParam(); + if (param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionType("JPEG"); + param.setCompressionQuality(JPEG_QUALITY); + } + + writer.write(null, new IIOImage(img, null, null), param); + } finally { + writer.dispose(); + } + + return baos.toByteArray(); + } +} diff --git a/xianyu-server/src/main/resources/application.yml b/xianyu-server/src/main/resources/application.yml index db6ee20..176871c 100644 --- a/xianyu-server/src/main/resources/application.yml +++ b/xianyu-server/src/main/resources/application.yml @@ -66,6 +66,11 @@ goofish: hostUrl: https://h5api.m.goofish.com/h5/ appKey: 34839810 +assistant: + static: + uploads: + images : static/uploads/images/ + browser: # chrome 浏览器软件安装位置 location: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome