diff --git a/src/main/java/com/aida/lanecarford/config/FaceSwapConfig.java b/src/main/java/com/aida/lanecarford/config/FaceSwapConfig.java new file mode 100644 index 0000000..264c9b6 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/config/FaceSwapConfig.java @@ -0,0 +1,32 @@ +package com.aida.lanecarford.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 换脸API配置类 + */ +@Data +@Component +@ConfigurationProperties(prefix = "faceswap.api") +public class FaceSwapConfig { + + /** + * API基础URL + */ + private String baseUrl; + + /** + * 换脸接口端点 + */ + private String refaceEndpoint; + + /** + * 获取完整的换脸API URL + * @return 完整的API URL + */ + public String getRefaceUrl() { + return baseUrl + refaceEndpoint; + } +} \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/config/FileUploadConfig.java b/src/main/java/com/aida/lanecarford/config/FileUploadConfig.java index bea3038..b8322ce 100644 --- a/src/main/java/com/aida/lanecarford/config/FileUploadConfig.java +++ b/src/main/java/com/aida/lanecarford/config/FileUploadConfig.java @@ -1,5 +1,7 @@ package com.aida.lanecarford.config; +import jakarta.validation.constraints.Negative; +import org.checkerframework.checker.units.qual.A; import org.springframework.boot.web.servlet.MultipartConfigFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/com/aida/lanecarford/service/impl/CustomerPhotoServiceImpl.java b/src/main/java/com/aida/lanecarford/service/impl/CustomerPhotoServiceImpl.java index 873b0f5..ad18d1b 100644 --- a/src/main/java/com/aida/lanecarford/service/impl/CustomerPhotoServiceImpl.java +++ b/src/main/java/com/aida/lanecarford/service/impl/CustomerPhotoServiceImpl.java @@ -1,6 +1,7 @@ package com.aida.lanecarford.service.impl; import com.aida.lanecarford.common.constant.MinioFileConstants; +import com.aida.lanecarford.config.MinioConfig; import com.aida.lanecarford.dto.CustomerPhotoDto; import com.aida.lanecarford.entity.CustomerPhoto; import com.aida.lanecarford.mapper.CustomerPhotoMapper; @@ -21,13 +22,15 @@ import org.springframework.stereotype.Service; @RequiredArgsConstructor public class CustomerPhotoServiceImpl extends ServiceImpl implements CustomerPhotoService { private final MinioUtil minioUtil; + private final MinioConfig minioConfig; @Override public CustomerPhoto upload(CustomerPhotoDto customerPhotoDto) { String logicalUrl = minioUtil.uploadFile( customerPhotoDto.getFile(), - MinioFileConstants.FileType.CUSTOMER_PHOTO + MinioFileConstants.FileType.CUSTOMER_PHOTO, + minioConfig.getBucketName() ); CustomerPhoto customerPhoto = CopyUtil.copyObject(customerPhotoDto, CustomerPhoto.class); diff --git a/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java b/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java index ddc8513..5701bd3 100644 --- a/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java +++ b/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java @@ -1,6 +1,7 @@ package com.aida.lanecarford.service.impl; import com.aida.lanecarford.common.constant.MinioFileConstants; +import com.aida.lanecarford.config.MinioConfig; import com.aida.lanecarford.service.ImageCompositionService; import com.aida.lanecarford.util.ImageCompositionUtil; import com.aida.lanecarford.util.MinioUtil; @@ -23,6 +24,7 @@ public class ImageCompositionServiceImpl implements ImageCompositionService { private final ImageCompositionUtil imageCompositionUtil; private final MinioUtil minioUtil; + private final MinioConfig minioConfig; @@ -66,7 +68,8 @@ public class ImageCompositionServiceImpl implements ImageCompositionService { String logicalUrl = minioUtil.uploadBytes( composedImageBytes, MinioFileConstants.FileType.COMPOSED_IMAGE, - "image/jpeg" + "image/jpeg", + minioConfig.getBucketName() ); log.info("图片合成并上传成功,逻辑URL: {}", logicalUrl); @@ -77,4 +80,4 @@ public class ImageCompositionServiceImpl implements ImageCompositionService { throw new RuntimeException("图片合成并上传失败: " + e.getMessage(), e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java b/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java index cbe2bac..9fc54a2 100644 --- a/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java +++ b/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java @@ -2,20 +2,17 @@ package com.aida.lanecarford.service.impl; import cn.hutool.json.JSONObject; import com.aida.lanecarford.common.CommonConstant; +import com.aida.lanecarford.config.MinioConfig; +import com.aida.lanecarford.config.FaceSwapConfig; import com.aida.lanecarford.common.response.ResultEnum; import com.aida.lanecarford.common.constant.MinioFileConstants; -import com.aida.lanecarford.entity.CustomerPhoto; -import com.aida.lanecarford.entity.ModelPhoto; -import com.aida.lanecarford.entity.Style; -import com.aida.lanecarford.entity.TryOnEffect; +import com.aida.lanecarford.entity.*; import com.aida.lanecarford.exception.BusinessException; +import com.aida.lanecarford.mapper.CustomerMapper; import com.aida.lanecarford.mapper.TryOnEffectMapper; -import com.aida.lanecarford.service.CustomerPhotoService; -import com.aida.lanecarford.service.ImageCompositionService; -import com.aida.lanecarford.service.ModelPhotoService; -import com.aida.lanecarford.service.StyleService; -import com.aida.lanecarford.service.TryOnEffectService; +import com.aida.lanecarford.service.*; import com.aida.lanecarford.util.MinioUtil; +import com.aida.lanecarford.util.StringListConverter; import com.aida.lanecarford.vo.TryOnResultVo; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; @@ -25,14 +22,14 @@ import com.google.auth.oauth2.GoogleCredentials; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.concurrent.TimeUnit; /** @@ -40,23 +37,30 @@ import java.util.concurrent.TimeUnit; * * @author AI Assistant * @since 2024-01-01 +/** + * 试穿效果服务实现类 */ -@Slf4j @Service @RequiredArgsConstructor public class TryOnEffectServiceImpl extends ServiceImpl implements TryOnEffectService { + private static final Logger log = LoggerFactory.getLogger(TryOnEffectServiceImpl.class); private final StyleService styleService; private final ModelPhotoService modelPhotoService; private final CustomerPhotoService customerPhotoService; private final ImageCompositionService imageCompositionService; + private final CustomerMapper customerMapper; + private final MinioUtil minioUtil; + private final MinioConfig minioConfig; + private final FaceSwapConfig faceSwapConfig; @Override public TryOnResultVo generateTryOnEffect(TryOnEffect tryOnEffectDto) { Integer isRegenerated = tryOnEffectDto.getIsRegenerated(); String toAIlogicalUrl = null; String prompt = null; + Style style = null; // 收集图片URL List imageUrls = new ArrayList<>(); if (isRegenerated == 1) { @@ -86,59 +90,53 @@ public class TryOnEffectServiceImpl extends ServiceImpl 1) { - log.info("开始合成图片,图片数量: {}", imageUrls.size()); - try { - // 将逻辑URL批量转换为预签名URL,以便图像合成服务可以访问 - List presignedUrls = minioUtil.convertToPresignedUrls(imageUrls, CommonConstant.MINIO_IMAGE_EXPIRE_TIME); - log.debug("批量转换逻辑URL为预签名URL,数量: {}", presignedUrls.size()); - toAIlogicalUrl = imageCompositionService.composeAndUploadImages(presignedUrls); - - log.info("图片合成成功,合成图片URL: {}", toAIlogicalUrl); - } catch (Exception e) { - log.error("图片合成失败: {}", e.getMessage(), e); - throw new RuntimeException("image error " + e.getMessage(), e); + String aiRreultlogicalUrl = null; + if (tryOnEffectDto.getIsRegenerated() == 0 && imageUrls.size() == 1) { + Customer customer = customerMapper.selectById(tryOnEffectDto.getCustomerId()); + List> maps = StringListConverter.jsonToList(style.getItems()); + StringBuffer sb = new StringBuffer(); + for (Map map : maps) { + String category = map.get("category"); + sb.append("a "); + sb.append( category); + sb.append(","); } - } else if (imageUrls.size() == 1) { - toAIlogicalUrl = imageUrls.get(0); - } else { - log.warn("没有找到有效的图片URL进行合成"); - throw BusinessException.parameterRequired("image"); - } - //调用模型生成试穿效果 - log.info("准备调用第三方AI服务,输入图片URL: {}", toAIlogicalUrl); - prompt = "A woman is wearing the outfit (an outerwear, a top, pants, shoes, a handbag), white background, full body, professional portrait photography."+prompt; -// String AIRreultlogicalUrl = AITryOnEffect(prompt, toAIlogicalUrl); - String AIRreultlogicalUrl = "try_on_result/6ecb707a-f541-437a-aef5-9544b5b18164.png"; - tryOnEffectDto.setResultImageUrl(AIRreultlogicalUrl); + prompt = "A full-body, photorealistic professional studio shot of a **young " + customer.getGender() + "**, mid-20s, with **clear facial features and a confident expression**. The model is centered in the frame.They are **standing in a direct, static, full-view pose** on a **clean, light white backdrop** **The entire figure, from head to toe, should be in frame and occupy approximately 80% of the vertical space.**.\n" + + "\n" + + "**CRITICAL COMPOSITION INSTRUCTION:**\n" + + "Generate a single, seamless image of the model with a complete and fully visible head,**wearing ALL distinct items("+sb.toString()+")** from the uploaded image. **ALL items MUST be visible and correctly worn in the final outfit.**" + + "\n" + + "**Placement Detail:** Outerwear must be worn on the outside, and if a bag is present, it must be visible.\n" + + "**Quality:** Ultra-high resolution, The figure should fill the frame as much as possible,studio photography quality. Emphasize **realistic fabric textures and sharp print fidelity**.\n" + + "**Negative Constraints (Exclude):** **NO text, NO borders, NO tables, NO multiple models, NO extra items.** **CRITICAL: NO cropping of the head or face.**"; + aiRreultlogicalUrl = AITryOnEffect(prompt, imageUrls); + + }else if (tryOnEffectDto.getIsRegenerated() == 1 && imageUrls.size() == 1){ + //根据提示词修改图像 + aiRreultlogicalUrl = AITryOnEffect(prompt, imageUrls); + }else if (tryOnEffectDto.getIsRegenerated() == 1 && imageUrls.size() > 1){ + //换脸 + aiRreultlogicalUrl = callFaceSwapAPI(imageUrls); + } + + tryOnEffectDto.setResultImageUrl(aiRreultlogicalUrl); tryOnEffectDto.setGenerationStatus("completed"); this.saveOrUpdate(tryOnEffectDto); TryOnResultVo tryOnResultVo = new TryOnResultVo(); tryOnResultVo.setTryOnId(tryOnEffectDto.getId()); - tryOnResultVo.setTryOnUrl(minioUtil.convertToPresignedUrl(AIRreultlogicalUrl, CommonConstant.MINIO_IMAGE_EXPIRE_TIME)); + tryOnResultVo.setTryOnUrl(minioUtil.convertToPresignedUrl(aiRreultlogicalUrl, CommonConstant.MINIO_IMAGE_EXPIRE_TIME)); return tryOnResultVo; } @@ -164,7 +162,7 @@ public class TryOnEffectServiceImpl extends ServiceImpl styleLambdaQueryWrapper = new LambdaQueryWrapper<>(); styleLambdaQueryWrapper.eq(Style::getId, tryOnEffect.getStyleId()).select(Style::getStyleImageUrl); Style style = styleService.getOne(styleLambdaQueryWrapper); tryOnResultVo.setStyleUrl(minioUtil.convertToPresignedUrl( - style.getStyleImageUrl(), - CommonConstant.MINIO_IMAGE_EXPIRE_TIME - )); + style.getStyleImageUrl(), + CommonConstant.MINIO_IMAGE_EXPIRE_TIME + )); } tryOnResultVo.setIsRegenerated(tryOnEffect.getIsRegenerated()); @@ -243,8 +241,11 @@ public class TryOnEffectServiceImpl extends ServiceImpl urls) { + System.setProperty("https.proxyHost", "127.0.0.1"); + System.setProperty("https.proxyPort", "10809"); + +// log.info("开始执行AITryOnEffect - prompt: {}, url: {}", prompt, url); // 参数验证 if (prompt == null || prompt.trim().isEmpty()) { @@ -252,16 +253,24 @@ public class TryOnEffectServiceImpl extends ServiceImpl base64Images = new ArrayList<>(); + for (String url : urls) { + if (url == null || url.trim().isEmpty()) { + log.error("参数验证失败 - url不能为空"); + throw new BusinessException("URL is required", "url不能为空", ResultEnum.PARAMETER_ERROR.getCode()); + } + String base64Image = getImageAsBase64(url); + base64Images.add(base64Image); + } // 获取图片的base64编码 - String base64Image = getImageAsBase64(url); // 调用谷歌API进行图生图 - String resultImageUrl = callGoogleImageGenerationAPI(prompt, base64Image); + String resultImageUrl = callGoogleImageGenerationAPI(prompt, base64Images); log.info("AITryOnEffect执行成功,结果URL: {}", resultImageUrl); return resultImageUrl; @@ -289,21 +298,21 @@ public class TryOnEffectServiceImpl extends ServiceImpl base64Images) { try { System.setProperty("https.proxyHost", "127.0.0.1"); System.setProperty("https.proxyPort", "10809"); // 谷歌API配置 String projectId = "aida-461108"; String location = "global"; - String model = "gemini-2.0-flash-exp"; // 使用适合的模型 + String model = "gemini-2.5-flash-image"; // 使用适合的模型 String endpoint = String.format( "https://aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent", projectId, location, model ); // 构建请求体 - JSONObject requestBody = buildRequestBody(prompt, base64Image); + JSONObject requestBody = buildRequestBody(prompt, base64Images); // 获取访问令牌 String accessToken = getGoogleAccessToken(); @@ -322,15 +331,29 @@ public class TryOnEffectServiceImpl extends ServiceImpl base64Images) { JSONObject requestBody = new JSONObject(); // 创建图片部分 JSONObject imagePart = new JSONObject(); - JSONObject inlineData = new JSONObject(); - inlineData.set("mimeType", "image/png"); - inlineData.set("data", base64Image.replace("data:image/png;base64,", "")); - imagePart.set("inlineData", inlineData); + JSONObject imagePart2 = new JSONObject(); + if (base64Images.size() == 1) { + JSONObject inlineData = new JSONObject(); + inlineData.set("mimeType", "image/png"); + inlineData.set("data", base64Images.get(0).replace("data:image/png;base64,", "")); + imagePart.set("inlineData", inlineData); + } else if (base64Images.size() == 2) { + JSONObject inlineData = new JSONObject(); + inlineData.set("mimeType", "image/png"); + inlineData.set("data", base64Images.get(0)); + imagePart.set("inlineData", inlineData); + + JSONObject inlineData2 = new JSONObject(); + inlineData2.set("mimeType", "image/jpeg"); + inlineData2.set("data", base64Images.get(1)); + imagePart2.set("inlineData", inlineData2); + } + // 创建文本部分 JSONObject textPart = new JSONObject(); @@ -339,7 +362,11 @@ public class TryOnEffectServiceImpl extends ServiceImpl imageUrls) { + + + try { + log.info("开始调用换脸API - 源图片: {}, 目标图片: {}", imageUrls.get(0), imageUrls.get(1)); + + // 获取图片的预签名URL + String inputFaceUrl = imageUrls.get(0); + + // 构建输入图片列表(从第二张图片开始作为目标图片列表) + JSONArray inputImageList = new JSONArray(); + for (int i = 1; i < imageUrls.size(); i++) { + inputImageList.add(imageUrls.get(i)); + } + + // 构建请求体 + JSONObject requestBody = new JSONObject(); + requestBody.put("input_image_list", inputImageList); + requestBody.put("input_face", inputFaceUrl); + requestBody.put("threshold", 0.2); + + // 调用换脸API + String response = sendFaceSwapRequest(faceSwapConfig.getRefaceUrl(), requestBody.toString()); + + // 处理响应 + return processFaceSwapResponse(response); + + } catch (Exception e) { + log.error("换脸API调用失败: {}", e.getMessage(), e); + throw new BusinessException("Face swap API call failed", "换脸API调用失败: " + e.getMessage(), ResultEnum.ERROR.getCode()); + } + } + + /** + * 发送换脸API请求 + */ + private String sendFaceSwapRequest(String endpoint, String jsonBody) throws IOException { + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(180, TimeUnit.SECONDS) + .writeTimeout(180, TimeUnit.SECONDS) + .callTimeout(300, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build(); + + Request request = new Request.Builder() + .url(endpoint) + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .addHeader("User-Agent", "LaneCarford-Client/1.0") + .post(RequestBody.create(MediaType.parse("application/json"), jsonBody)) + .build(); + + int maxRetries = 3; + int retryDelay = 3000; // 3秒 + + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + log.info("发起换脸API请求 - 尝试次数: {}/{}, URL: {}", attempt, maxRetries, request.url()); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + log.warn("换脸API响应失败,状态码: {}, 响应: {}", response.code(), response.body() != null ? response.body().string() : "null"); + if (attempt < maxRetries) { + Thread.sleep(retryDelay * attempt); + continue; + } else { + throw new IOException("HTTP error code: " + response.code()); + } + } + + String result = response.body().string(); + log.info("换脸API调用成功"); + return result; + } + } catch (IOException e) { + log.warn("换脸API网络连接问题 - 尝试: {}/{}, 错误: {}", attempt, maxRetries, e.getMessage()); + if (attempt < maxRetries) { + try { + Thread.sleep(retryDelay * attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("重试被中断", ie); + } + } else { + throw e; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("请求被中断", e); + } + } + + throw new IOException("所有重试都失败了"); + } + + /** + * 处理换脸API响应 + */ + private String processFaceSwapResponse(String response) { + try { + log.info("开始处理换脸API响应: {}", response); + + JSONObject jsonResponse = new JSONObject(response); + + // 检查响应状态 + if (jsonResponse.containsKey("success") && jsonResponse.getBool("success")) { + // 成功响应,获取结果图片URL + if (jsonResponse.containsKey("result_url")) { + String resultUrl = jsonResponse.getStr("result_url"); + log.info("换脸成功,结果URL: {}", resultUrl); + + // 下载图片并上传到MinIO + return downloadAndUploadToMinio(resultUrl); + } else if (jsonResponse.containsKey("data") && jsonResponse.getJSONObject("data").containsKey("result_url")) { + String resultUrl = jsonResponse.getJSONObject("data").getStr("result_url"); + log.info("换脸成功,结果URL: {}", resultUrl); + + // 下载图片并上传到MinIO + return downloadAndUploadToMinio(resultUrl); + } + } + + // 检查是否有错误信息 + if (jsonResponse.containsKey("error")) { + String error = jsonResponse.getStr("error"); + log.error("换脸API返回错误: {}", error); + throw new BusinessException("Face swap failed", "换脸失败: " + error, ResultEnum.ERROR.getCode()); + } + + log.error("换脸API响应格式不正确: {}", response); + throw new BusinessException("Invalid response format", "响应格式不正确", ResultEnum.ERROR.getCode()); + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("处理换脸API响应失败: {}", e.getMessage(), e); + throw new BusinessException("Response processing failed", "响应处理失败", ResultEnum.ERROR.getCode()); + } + } + + /** + * 下载图片并上传到MinIO + */ + private String downloadAndUploadToMinio(String imageUrl) { + try { + log.info("开始下载换脸结果图片: {}", imageUrl); + + // 使用现有的下载方法 + byte[] imageData = downloadImageFromUrl(imageUrl); + + // 生成对象名称 + String objectName = MinioFileConstants.TRY_ON_RESULT_DIR + "/" + "faceswap_" + System.currentTimeMillis() + ".jpg"; + + // 上传到MinIO + String logicalUrl = minioUtil.uploadBytes(imageData, objectName, "image/jpeg", minioConfig.getBucketName()); + + log.info("换脸结果图片上传成功,逻辑URL: {}", logicalUrl); + return logicalUrl; + + } catch (Exception e) { + log.error("下载并上传换脸结果图片失败: {}", e.getMessage(), e); + throw new BusinessException("Failed to download and upload result image", "下载并上传结果图片失败", ResultEnum.ERROR.getCode()); + } + } + + /** + * 从URL下载图片数据 + */ + private byte[] downloadImageFromUrl(String imageUrl) throws IOException { + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build(); + + Request request = new Request.Builder() + .url(imageUrl) + .addHeader("User-Agent", "LaneCarford-Client/1.0") + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Failed to download image: HTTP " + response.code()); + } + + return response.body().bytes(); + } + } } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/util/MinioUtil.java b/src/main/java/com/aida/lanecarford/util/MinioUtil.java index 94e8033..20410f8 100644 --- a/src/main/java/com/aida/lanecarford/util/MinioUtil.java +++ b/src/main/java/com/aida/lanecarford/util/MinioUtil.java @@ -88,21 +88,24 @@ public class MinioUtil { // ==================== 文件上传操作 ==================== + + /** - * 上传MultipartFile文件 + * 上传文件到指定桶 * - * @param file 文件 - * @param fileType 文件类型 - * @return 逻辑URL(不含桶名) + * @param file 文件 + * @param fileType 文件类型 + * @param bucketName 桶名 + * @return 文件的逻辑路径(包含桶名) */ - public String uploadFile(MultipartFile file, MinioFileConstants.FileType fileType) { + public String uploadFile(MultipartFile file, MinioFileConstants.FileType fileType, String bucketName) { if (file == null || file.isEmpty()) { throw new MinioException("文件不能为空"); } try { - // 确保默认桶存在 - ensureDefaultBucketExists(); + // 确保桶存在 + createBucket(bucketName); // 生成对象名称 String objectName = MinioFileConstants.generateObjectNameByType(fileType); @@ -110,50 +113,53 @@ public class MinioUtil { // 上传文件 minioClient.putObject( PutObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .object(objectName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build() ); - log.info("文件上传成功: {}", objectName); - return objectName; // 返回逻辑URL + log.info("文件上传成功: {}/{}", bucketName, objectName); + return bucketName + "/" + objectName; // 返回包含桶名的逻辑路径 } catch (Exception e) { log.error("文件上传失败: {}", e.getMessage(), e); throw new MinioException("文件上传失败", e); } } + + /** - * 上传字节数组 + * 上传字节数组到指定桶 * * @param bytes 字节数组 - * @param objectName 对象名称(逻辑路径) + * @param objectName 对象名称 * @param contentType 内容类型 - * @return 逻辑URL(不含桶名) + * @param bucketName 桶名 + * @return 文件的逻辑路径(包含桶名) */ - public String uploadBytes(byte[] bytes, String objectName, String contentType) { + public String uploadBytes(byte[] bytes, String objectName, String contentType, String bucketName) { if (bytes == null || bytes.length == 0) { throw new MinioException("文件内容不能为空"); } try { - // 确保默认桶存在 - ensureDefaultBucketExists(); + // 确保桶存在 + createBucket(bucketName); // 上传文件 minioClient.putObject( PutObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .object(objectName) .stream(new ByteArrayInputStream(bytes), bytes.length, -1) .contentType(contentType) .build() ); - log.info("字节数组上传成功: {}", objectName); - return objectName; // 返回逻辑URL + log.info("字节数组上传成功: {}/{}", bucketName, objectName); + return bucketName + "/" + objectName; // 返回包含桶名的逻辑路径 } catch (Exception e) { log.error("字节数组上传失败: {}", e.getMessage(), e); throw new MinioException("字节数组上传失败", e); @@ -166,11 +172,12 @@ public class MinioUtil { * @param bytes 字节数组 * @param fileType 文件类型 * @param contentType 内容类型 - * @return 逻辑URL(不含桶名) + * @param bucketName 桶名 + * @return 逻辑URL(包含桶名) */ - public String uploadBytes(byte[] bytes, MinioFileConstants.FileType fileType, String contentType) { + public String uploadBytes(byte[] bytes, MinioFileConstants.FileType fileType, String contentType, String bucketName) { String objectName = MinioFileConstants.generateObjectNameByType(fileType); - return uploadBytes(bytes, objectName, contentType); + return uploadBytes(bytes, objectName, contentType, bucketName); } // ==================== 文件下载操作 ==================== @@ -178,19 +185,34 @@ public class MinioUtil { /** * 下载文件 * - * @param objectName 对象名称(逻辑路径) + * @param logicalPath 逻辑路径(格式:bucketName/folder/filename) * @return 文件输入流 */ - public InputStream downloadFile(String objectName) { + public InputStream downloadFile(String logicalPath) { + // 解析桶名和对象名:bucketName/folder/filename + int index = logicalPath.indexOf("/"); + if (index <= 0) { + throw new MinioException("逻辑路径格式错误,应包含桶名: " + logicalPath); + } + + String bucketName = logicalPath.substring(0, index); + String objectName = logicalPath.substring(index + 1); + + // 验证桶名是否存在 try { - // 检查文件是否存在 - if (!doesObjectExist(objectName)) { - throw new IOException("文件不存在: " + objectName); + boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + if (!bucketExists) { + throw new MinioException("桶不存在: " + bucketName); } - + } catch (Exception e) { + log.error("验证桶存在性失败: {}", e.getMessage(), e); + throw new MinioException("验证桶存在性失败", e); + } + + try { return minioClient.getObject( GetObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .object(objectName) .build() ); @@ -205,17 +227,37 @@ public class MinioUtil { /** * 删除文件 * - * @param objectName 对象名称(逻辑路径) + * @param logicalPath 逻辑路径(格式:bucketName/folder/filename) */ - public void deleteFile(String objectName) { + public void deleteFile(String logicalPath) { + // 解析桶名和对象名:bucketName/folder/filename + int index = logicalPath.indexOf("/"); + if (index <= 0) { + throw new MinioException("逻辑路径格式错误,应包含桶名: " + logicalPath); + } + + String bucketName = logicalPath.substring(0, index); + String objectName = logicalPath.substring(index + 1); + + // 验证桶名是否存在 + try { + boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + if (!bucketExists) { + throw new MinioException("桶不存在: " + bucketName); + } + } catch (Exception e) { + log.error("验证桶存在性失败: {}", e.getMessage(), e); + throw new MinioException("验证桶存在性失败", e); + } + try { minioClient.removeObject( RemoveObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .object(objectName) .build() ); - log.info("文件删除成功: {}", objectName); + log.info("文件删除成功: {}/{}", bucketName, objectName); } catch (Exception e) { log.error("文件删除失败: {}", e.getMessage(), e); throw new MinioException("文件删除失败", e); @@ -224,25 +266,28 @@ public class MinioUtil { // ==================== 文件查询操作 ==================== + + /** - * 获取文件列表 + * 获取指定桶的文件列表 * - * @param prefix 文件前缀 - * @return 文件列表 + * @param prefix 文件前缀 + * @param bucketName 桶名 + * @return 文件列表(包含桶名) */ - public List listFiles(String prefix) { + public List listFiles(String prefix, String bucketName) { List files = new ArrayList<>(); try { Iterable> results = minioClient.listObjects( ListObjectsArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .prefix(prefix) .build() ); for (Result result : results) { Item item = result.get(); - files.add(item.objectName()); + files.add(bucketName + "/" + item.objectName()); // 返回包含桶名的路径 } } catch (Exception e) { log.error("获取文件列表失败: {}", e.getMessage(), e); @@ -254,14 +299,34 @@ public class MinioUtil { /** * 检查文件是否存在 * - * @param objectName 对象名称(逻辑路径) + * @param logicalPath 逻辑路径(格式:bucketName/folder/filename) * @return 是否存在 */ - public boolean doesObjectExist(String objectName) { + public boolean doesObjectExist(String logicalPath) { + // 解析桶名和对象名:bucketName/folder/filename + int index = logicalPath.indexOf("/"); + if (index <= 0) { + throw new MinioException("逻辑路径格式错误,应包含桶名: " + logicalPath); + } + + String bucketName = logicalPath.substring(0, index); + String objectName = logicalPath.substring(index + 1); + + // 验证桶名是否存在 + try { + boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + if (!bucketExists) { + return false; // 桶不存在,文件肯定不存在 + } + } catch (Exception e) { + log.error("验证桶存在性失败: {}", e.getMessage(), e); + return false; + } + try { minioClient.statObject( StatObjectArgs.builder() - .bucket(minioConfig.getBucketName()) + .bucket(bucketName) .object(objectName) .build() ); @@ -278,13 +343,11 @@ public class MinioUtil { * * @param objectName 对象名称(逻辑路径) * @param expires 过期时间(秒) + * @param bucketName 桶名 * @return 预签名URL */ public String getPresignedUrl(String objectName, int expires, String bucketName) { try { - if (bucketName == null){ - bucketName = minioConfig.getBucketName(); - } return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) @@ -319,15 +382,16 @@ public class MinioUtil { /** * 批量获取预签名URL * - * @param objectNames 对象名称列表(逻辑路径) - * @param expires 过期时间(秒) + * @param logicalPaths 逻辑路径列表(格式:bucketName/folder/filename) + * @param expires 过期时间(秒) * @return 预签名URL列表 */ - public List getPresignedUrls(List objectNames, int expires) { + public List getPresignedUrls(List logicalPaths, int expires) { List presignedUrls = new ArrayList<>(); - for (String objectName : objectNames) { - if (objectName != null && !objectName.trim().isEmpty()) { - presignedUrls.add(getPresignedUrl(objectName, expires, null)); + for (String logicalPath : logicalPaths) { + if (logicalPath != null && !logicalPath.trim().isEmpty()) { + // 所有路径都应该包含桶名 + presignedUrls.add(getPresignedUrl(logicalPath, expires)); } } return presignedUrls; @@ -352,13 +416,14 @@ public class MinioUtil { /** * 将MinIO逻辑URL转换为预签名URL * - * @param logicalUrl 逻辑URL + * @param logicalUrl 逻辑URL(格式:bucketName/folder/filename) * @param expires 过期时间(秒) * @return 预签名URL,如果不是逻辑URL则返回原URL */ public String convertToPresignedUrl(String logicalUrl, int expires) { if (isMinioLogicalUrl(logicalUrl)) { - return getPresignedUrl(logicalUrl, expires, null); + // 所有逻辑URL都应该包含桶名 + return getPresignedUrl(logicalUrl, expires); } return logicalUrl; } @@ -383,17 +448,17 @@ public class MinioUtil { /** * 获取压缩后的图片Base64编码 * - * @param objectName 对象名称(逻辑路径) + * @param logicalPath 逻辑路径(可以包含桶名或不包含) * @param targetWidth 目标宽度 * @param targetHeight 目标高度 * @return 压缩后的图片Base64编码 */ - public String getCompressedImageAsBase64(String objectName, int targetWidth, int targetHeight) throws IOException { - try (InputStream stream = downloadFile(objectName)) { + public String getCompressedImageAsBase64(String logicalPath, int targetWidth, int targetHeight) throws IOException { + try (InputStream stream = downloadFile(logicalPath)) { // 读取原始图片 BufferedImage originalImage = ImageIO.read(stream); if (originalImage == null) { - throw new IOException("无法读取图片: " + objectName); + throw new IOException("无法读取图片: " + logicalPath); } // 计算压缩比例,保持宽高比 @@ -425,7 +490,7 @@ public class MinioUtil { ImageIO.write(compressedImage, "JPEG", baos); byte[] imageBytes = baos.toByteArray(); - log.info("图片压缩完成: {} -> {}x{} (原始: {}x{})", objectName, newWidth, newHeight, originalWidth, originalHeight); + log.info("图片压缩完成: {} -> {}x{} (原始: {}x{})", logicalPath, newWidth, newHeight, originalWidth, originalHeight); return Base64.getEncoder().encodeToString(imageBytes); } catch (Exception e) { @@ -436,26 +501,8 @@ public class MinioUtil { // ==================== 私有辅助方法 ==================== - /** - * 确保默认桶存在 - */ - private void ensureDefaultBucketExists() { - createBucket(minioConfig.getBucketName()); - } - /** - * @deprecated 使用 uploadBytes(byte[], String, String) 替代 - */ - @Deprecated - public String uploadBytes(byte[] bytes, String fileName, String contentType, String bucketName) { - log.warn("使用了已废弃的方法,建议使用新的API"); - if (!bucketName.equals(minioConfig.getBucketName())) { - log.warn("指定的桶名 {} 与配置的桶名 {} 不一致,将使用配置的桶名", bucketName, minioConfig.getBucketName()); - } - return uploadBytes(bytes, fileName, contentType); - } - /** * @deprecated 使用 getPresignedUrl(String, int) 替代 */ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 832c3b2..e429c45 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -70,6 +70,12 @@ minio: # 文件访问URL前缀 url-prefix: ${minio.endpoint}/${minio.bucket-name}/ +# 换脸API配置 +faceswap: + api: + base-url: http://18.167.251.121:10004 + reface-endpoint: /api/v1/reface + # 日志配置 logging: level: