This commit is contained in:
wangli 2026-01-30 14:04:41 +08:00
parent 16bb8fac3f
commit d15443d0e3
5 changed files with 375 additions and 87 deletions

View File

@ -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;
}

View File

@ -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;
/**
* 配置安全过滤器链
* <p>
* 配置项
* 1. 禁用 CSRFJWT 无状态认证不需要
* 2. 配置授权规则
* - /auth/** 接口允许匿名访问登录刷新令牌
* - 其他所有接口需要认证
* 3. 配置无状态会话管理
* 4. 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前
*
* @param http HttpSecurity 对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRFJWT 场景下不需要
.csrf(AbstractHttpConfigurer::disable)
/**
* 配置安全过滤器链
* <p>
* 配置项
* 1. 禁用 CSRFJWT 无状态认证不需要
* 2. 配置授权规则
* - /auth/** 接口允许匿名访问登录刷新令牌
* - 其他所有接口需要认证
* 3. 配置无状态会话管理
* 4. 添加 JWT 认证过滤器 UsernamePasswordAuthenticationFilter 之前
*
* @param http HttpSecurity 对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 禁用 CSRFJWT 场景下不需要
.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 算法强度为 122^12 = 4096 次哈希
* <p>
* BCrypt 优势
* - 自动加盐
* - 计算密集防暴力破解
* - 业界标准
*
* @return PasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* 密码编码器 Bean
* 使用 BCrypt 算法强度为 122^12 = 4096 次哈希
* <p>
* 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
* <p>
* 工作流程
* 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
* <p>
* 工作流程
* 1. UserDetailsService 加载用户信息
* 2. 使用 PasswordEncoder 验证密码
*
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
}

View File

@ -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<byte[]> 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<FileUploadResponse> 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());
}
}
}

View File

@ -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<ImageWriter> 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();
}
}

View File

@ -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