diff --git a/pom.xml b/pom.xml index 98f4870..4a0ea15 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,12 @@ spring-boot-starter-security + + + io.minio + minio + 8.0.3 + diff --git a/src/main/java/com/aida/lanecarford/common/response/ResultEnum.java b/src/main/java/com/aida/lanecarford/common/response/ResultEnum.java new file mode 100644 index 0000000..d1df838 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/common/response/ResultEnum.java @@ -0,0 +1,67 @@ +package com.aida.lanecarford.common.response; + +/** + * @ClassName ResultEnum + * @Description 响应结果枚举 + * @Author dwjian + * @Date 2019/9/8 21:58 + */ +public enum ResultEnum { + + SUCCESS(true, 0, "SUCCESS", "操作成功"), + FAIL(false, -1, "FAIL", "操作失败"), + ERROR(false, -1, "System error", "系统错误"), + PARAMETER_ERROR(false, -2, "Parameter error", "参数错误"), + + NO_LOGIN(false, -100, "User not logged in", "用户未登录"), + NO_PERMISSION(false, -200, "No permission", "无权限访问"), + ACCOUNT_LOCK(false, -300, "Account locked", "账户已锁定"), + + PROMPT(false, 1, "Prompt", "提示"), + WARNING(false, 2, "Warning", "警告"), + + ; + private int code; + private String msg; // 英文消息,返回给前端 + private String msgCn; // 中文消息,用于日志 + private boolean isOK; + + ResultEnum(boolean isOK, int code, String msg, String msgCn) { + this.isOK = isOK; + this.code = code; + this.msg = msg; + this.msgCn = msgCn; + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMsg() { + return msg; + } + + public void setMsg(String msg) { + this.msg = msg; + } + + public String getMsgCn() { + return msgCn; + } + + public void setMsgCn(String msgCn) { + this.msgCn = msgCn; + } + + public boolean isOK() { + return isOK; + } + + public void setOK(boolean OK) { + isOK = OK; + } +} diff --git a/src/main/java/com/aida/lanecarford/controller/TryOnEffectController.java b/src/main/java/com/aida/lanecarford/controller/TryOnEffectController.java index 128a9be..049c878 100644 --- a/src/main/java/com/aida/lanecarford/controller/TryOnEffectController.java +++ b/src/main/java/com/aida/lanecarford/controller/TryOnEffectController.java @@ -1,7 +1,15 @@ package com.aida.lanecarford.controller; +import com.aida.lanecarford.common.ApiResponse; +import com.aida.lanecarford.entity.TryOnEffect; import com.aida.lanecarford.service.TryOnEffectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -14,8 +22,18 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/try-on-effects") @RequiredArgsConstructor +@Tag(name = "试穿效果管理", description = "试穿效果生成和管理相关API") public class TryOnEffectController { private final TryOnEffectService tryOnEffectService; + @Operation(summary = "生成试穿效果", description = "根据顾客照片和模特照片生成试穿效果") + @PostMapping("/generate") + public ApiResponse generateTryOnEffect( + @Parameter(description = "试穿效果请求参数", required = true) + @Valid @RequestBody TryOnEffect tryOnEffectDto) { + String taskId = tryOnEffectService.generateTryOnEffect(tryOnEffectDto); + return ApiResponse.success(taskId); + } + } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/entity/CustomerPhoto.java b/src/main/java/com/aida/lanecarford/entity/CustomerPhoto.java index e2a0799..d25620b 100644 --- a/src/main/java/com/aida/lanecarford/entity/CustomerPhoto.java +++ b/src/main/java/com/aida/lanecarford/entity/CustomerPhoto.java @@ -64,4 +64,10 @@ public class CustomerPhoto { */ @TableField(value = "created_time", fill = FieldFill.INSERT) private LocalDateTime createdTime; + + /** + * 更新时间 + */ + @TableField(value = "updated_time", fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedTime; } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/entity/Style.java b/src/main/java/com/aida/lanecarford/entity/Style.java index 00d5f8b..e57f640 100644 --- a/src/main/java/com/aida/lanecarford/entity/Style.java +++ b/src/main/java/com/aida/lanecarford/entity/Style.java @@ -60,10 +60,10 @@ public class Style { private String pythonRequestId; /** - * 生成状态(0-处理中,1-已完成,2-失败) + * 生成状态(pending-等待中,processing-处理中,completed-已完成,failed-失败) */ @TableField("generation_status") - private Integer generationStatus; + private String generationStatus; /** * 错误信息 diff --git a/src/main/java/com/aida/lanecarford/entity/TryOnEffect.java b/src/main/java/com/aida/lanecarford/entity/TryOnEffect.java index 5909738..77c4d53 100644 --- a/src/main/java/com/aida/lanecarford/entity/TryOnEffect.java +++ b/src/main/java/com/aida/lanecarford/entity/TryOnEffect.java @@ -41,6 +41,12 @@ public class TryOnEffect { @TableField("visit_record_id") private Long visitRecordId; + /** + * 风格ID + */ + @TableField("style_id") + private Long styleId; + /** * 顾客照片ID */ @@ -53,12 +59,6 @@ public class TryOnEffect { @TableField("model_photo_id") private Long modelPhotoId; - /** - * 试穿效果图URL - */ - @TableField("try_on_image_url") - private String tryOnImageUrl; - /** * 提示词 */ @@ -66,16 +66,34 @@ public class TryOnEffect { private String prompt; /** - * Python请求ID + * 原试穿效果ID,当is_regenerated为1时才会有值 */ - @TableField("python_request_id") - private String pythonRequestId; + @TableField("original_try_on_id") + private Long originalTryOnId; /** - * 生成状态(0-处理中,1-已完成,2-失败) + * 是否由生成结果重新生成(0-否,1-是) + */ + @TableField("is_regenerated") + private Integer isRegenerated; + + /** + * 试穿结果图片URL + */ + @TableField("result_image_url") + private String resultImageUrl; + + /** + * 请求ID + */ + @TableField("request_id") + private String requestId; + + /** + * 生成状态(pending-等待中,processing-处理中,completed-已完成,failed-失败) */ @TableField("generation_status") - private Integer generationStatus; + private String generationStatus; /** * 错误信息 @@ -84,17 +102,11 @@ public class TryOnEffect { private String errorMessage; /** - * 是否收藏(0-否,1-是) + * 是否喜欢的最终造型(0-否,1-是) */ @TableField("is_favorite") private Integer isFavorite; - /** - * 原始试穿效果ID(用于重新生成) - */ - @TableField("original_try_on_id") - private Long originalTryOnId; - /** * 创建时间 */ diff --git a/src/main/java/com/aida/lanecarford/exception/BusinessException.java b/src/main/java/com/aida/lanecarford/exception/BusinessException.java index e39b7f8..31f1508 100644 --- a/src/main/java/com/aida/lanecarford/exception/BusinessException.java +++ b/src/main/java/com/aida/lanecarford/exception/BusinessException.java @@ -1,121 +1,126 @@ package com.aida.lanecarford.exception; +import com.aida.lanecarford.common.response.ResultEnum; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + /** * 业务异常类 * * @author AI Assistant * @since 2024-01-01 */ +@Data +@Slf4j public class BusinessException extends RuntimeException { - private String code; - private String message; + private Integer code; + private String msg; // 英文消息,返回给前端 + private String msgCn; // 中文消息,用于日志 - public BusinessException(String code, String message) { - super(message); + public BusinessException(String msg) { + this.code = ResultEnum.FAIL.getCode(); + this.msg = msg; + this.msgCn = msg; + } + + public BusinessException(String msg, Integer code) { this.code = code; - this.message = message; + this.msg = msg; + this.msgCn = msg; } - public BusinessException(String message) { - super(message); - this.code = "BUSINESS_ERROR"; - this.message = message; - } - - public BusinessException(String code, String message, Throwable cause) { - super(message, cause); + public BusinessException(String msg, String msgCn, Integer code) { this.code = code; - this.message = message; + this.msg = msg; + this.msgCn = msgCn; } - public String getCode() { - return code; + public BusinessException(ResultEnum resultEnum) { + this.code = resultEnum.getCode(); + this.msg = resultEnum.getMsg(); + this.msgCn = resultEnum.getMsgCn(); } - public void setCode(String code) { - this.code = code; + public BusinessException(ResultEnum resultEnum, String customMessage, String customMessageCn) { + this.code = resultEnum.getCode(); + this.msg = customMessage; + this.msgCn = customMessageCn; } - @Override - public String getMessage() { - return message; + /** + * 创建原始试穿ID必填异常 + */ + public static BusinessException originalTryOnIdRequired() { + return new BusinessException(ResultEnum.PARAMETER_ERROR, "Original try-on ID is required", "原始试穿ID不能为空"); } - public void setMessage(String message) { - this.message = message; + /** + * 创建风格ID必填异常 + */ + public static BusinessException styleIdRequired() { + return new BusinessException(ResultEnum.PARAMETER_ERROR, "Style ID is required", "风格ID不能为空"); } - // 常用的业务异常静态方法 - public static BusinessException customerNotFound() { - return new BusinessException("CUSTOMER_NOT_FOUND", "客户不存在"); + /** + * 创建通用参数为空异常 + */ + public static BusinessException parameterRequired(String parameterName) { + return new BusinessException(ResultEnum.PARAMETER_ERROR, + parameterName + " is required", + parameterName + "不能为空"); } - public static BusinessException styleOutfitNotFound() { - return new BusinessException("STYLE_OUTFIT_NOT_FOUND", "风格搭配不存在"); - } - - public static BusinessException modelPhotoNotFound() { - return new BusinessException("MODEL_PHOTO_NOT_FOUND", "模特照片不存在"); - } - - public static BusinessException virtualTryOnNotFound() { - return new BusinessException("VIRTUAL_TRYON_NOT_FOUND", "虚拟试穿记录不存在"); - } - - public static BusinessException favoriteNotFound() { - return new BusinessException("FAVORITE_NOT_FOUND", "收藏记录不存在"); - } - - public static BusinessException fileUploadFailed() { - return new BusinessException("FILE_UPLOAD_FAILED", "文件上传失败"); - } - - public static BusinessException invalidFileFormat() { - return new BusinessException("INVALID_FILE_FORMAT", "文件格式不支持"); - } - - public static BusinessException fileSizeExceeded() { - return new BusinessException("FILE_SIZE_EXCEEDED", "文件大小超过限制"); - } - - public static BusinessException emailAlreadyExists() { - return new BusinessException("EMAIL_ALREADY_EXISTS", "邮箱已存在"); - } - - public static BusinessException phoneAlreadyExists() { - return new BusinessException("PHONE_ALREADY_EXISTS", "手机号已存在"); - } - - public static BusinessException invalidCredentials() { - return new BusinessException("INVALID_CREDENTIALS", "用户名或密码错误"); + /** + * 创建资源不存在异常 + */ + public static BusinessException resourceNotFound(String resourceName) { + return new BusinessException(ResultEnum.FAIL, + resourceName + " not found", + resourceName + "不存在"); } + /** + * 创建权限不足异常 + */ public static BusinessException accessDenied() { - return new BusinessException("ACCESS_DENIED", "访问被拒绝"); + return new BusinessException(ResultEnum.NO_PERMISSION); } - public static BusinessException operationFailed() { - return new BusinessException("OPERATION_FAILED", "操作失败"); + /** + * 创建操作失败异常 + */ + public static BusinessException operationFailed(String operation) { + return new BusinessException(ResultEnum.ERROR, + operation + " operation failed", + operation + "操作失败"); } - public static BusinessException dataNotFound() { - return new BusinessException("DATA_NOT_FOUND", "数据不存在"); + /** + * 创建用户未登录异常 + */ + public static BusinessException notLogin() { + return new BusinessException(ResultEnum.NO_LOGIN); } - public static BusinessException duplicateData() { - return new BusinessException("DUPLICATE_DATA", "数据重复"); + /** + * 创建账户锁定异常 + */ + public static BusinessException accountLocked() { + return new BusinessException(ResultEnum.ACCOUNT_LOCK); } - public static BusinessException invalidParameter(String paramName) { - return new BusinessException("INVALID_PARAMETER", "参数 " + paramName + " 无效"); + /** + * 创建提示异常 + */ + public static BusinessException prompt(String message, String messageCn) { + return new BusinessException(ResultEnum.PROMPT, message, messageCn); } - public static BusinessException serviceUnavailable() { - return new BusinessException("SERVICE_UNAVAILABLE", "服务暂时不可用"); - } - - public static BusinessException externalServiceError() { - return new BusinessException("EXTERNAL_SERVICE_ERROR", "外部服务调用失败"); + /** + * 创建警告异常 + */ + public static BusinessException warning(String message, String messageCn) { + return new BusinessException(ResultEnum.WARNING, message, messageCn); } } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/exception/GlobalExceptionHandler.java b/src/main/java/com/aida/lanecarford/exception/GlobalExceptionHandler.java index f403b64..171c980 100644 --- a/src/main/java/com/aida/lanecarford/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/aida/lanecarford/exception/GlobalExceptionHandler.java @@ -1,37 +1,23 @@ package com.aida.lanecarford.exception; +import com.aida.lanecarford.common.ApiResponse; +import com.aida.lanecarford.common.response.ResultEnum; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.dao.DataAccessException; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; -import org.springframework.web.HttpMediaTypeNotSupportedException; -import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MaxUploadSizeExceededException; -import org.springframework.web.servlet.NoHandlerFoundException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.ConstraintViolationException; import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; /** * 全局异常处理器 * 统一处理应用程序中的各种异常,提供一致的错误响应格式 - * + * * @author AI Assistant * @since 2024-01-01 */ @@ -39,246 +25,102 @@ import java.util.stream.Collectors; public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); - - // 错误代码常量 - private static final String BUSINESS_ERROR = "BUSINESS_ERROR"; - private static final String VALIDATION_ERROR = "VALIDATION_ERROR"; - private static final String BIND_ERROR = "BIND_ERROR"; - private static final String FILE_SIZE_EXCEEDED = "FILE_SIZE_EXCEEDED"; - private static final String ILLEGAL_ARGUMENT = "ILLEGAL_ARGUMENT"; - private static final String NULL_POINTER = "NULL_POINTER"; - private static final String RUNTIME_ERROR = "RUNTIME_ERROR"; - private static final String UNKNOWN_ERROR = "UNKNOWN_ERROR"; - private static final String DATABASE_ERROR = "DATABASE_ERROR"; - private static final String METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"; - private static final String NOT_FOUND = "NOT_FOUND"; - private static final String MEDIA_TYPE_NOT_SUPPORTED = "MEDIA_TYPE_NOT_SUPPORTED"; - private static final String MESSAGE_NOT_READABLE = "MESSAGE_NOT_READABLE"; - private static final String MISSING_PARAMETER = "MISSING_PARAMETER"; - private static final String TYPE_MISMATCH = "TYPE_MISMATCH"; - private static final String CONSTRAINT_VIOLATION = "CONSTRAINT_VIOLATION"; /** * 处理业务异常 */ @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusinessException(BusinessException e, HttpServletRequest request) { - logger.warn("业务异常 [{}]: {} - 请求路径: {}", e.getCode(), e.getMessage(), request.getRequestURI()); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(BUSINESS_ERROR, e.getMessage(), request.getRequestURI(), null)); + public ApiResponse handleBusinessException(BusinessException e) { + logger.warn("业务异常: {}", e.getMsgCn() != null ? e.getMsgCn() : e.getMsg()); + return ApiResponse.error(String.valueOf(e.getCode()), e.getMsg()); } /** * 处理参数验证异常 */ @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) { - logger.warn("参数验证异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - Map errors = e.getBindingResult().getAllErrors().stream() - .collect(Collectors.toMap( - error -> ((FieldError) error).getField(), - error -> error.getDefaultMessage(), - (existing, replacement) -> existing - )); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(VALIDATION_ERROR, "参数验证失败", request.getRequestURI(), errors)); + public ApiResponse handleValidationException(MethodArgumentNotValidException e) { + + logger.warn("参数验证异常: {}", e.getMessage()); + + // 构建英文错误消息返回给前端 + String errorMessageEn = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .reduce((msg1, msg2) -> msg1 + "; " + msg2) + .orElse("Parameter validation failed"); + + return ApiResponse.error(String.valueOf(ResultEnum.PARAMETER_ERROR.getCode()), errorMessageEn); } /** * 处理绑定异常 */ @ExceptionHandler(BindException.class) - public ResponseEntity> handleBindException(BindException e, HttpServletRequest request) { - logger.warn("绑定异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - Map errors = e.getBindingResult().getAllErrors().stream() - .collect(Collectors.toMap( - error -> ((FieldError) error).getField(), - error -> error.getDefaultMessage(), - (existing, replacement) -> existing - )); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(BIND_ERROR, "数据绑定失败", request.getRequestURI(), errors)); - } + public ApiResponse handleBindException(BindException e) { - /** - * 处理约束违反异常 - */ - @ExceptionHandler(ConstraintViolationException.class) - public ResponseEntity> handleConstraintViolationException(ConstraintViolationException e, HttpServletRequest request) { - logger.warn("约束违反异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - Set> violations = e.getConstraintViolations(); - Map errors = violations.stream() - .collect(Collectors.toMap( - violation -> violation.getPropertyPath().toString(), - ConstraintViolation::getMessage, - (existing, replacement) -> existing - )); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(CONSTRAINT_VIOLATION, "约束验证失败", request.getRequestURI(), errors)); + logger.warn("绑定异常: {}", e.getMessage()); + + // 构建英文错误消息返回给前端 + String errorMessageEn = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .reduce((msg1, msg2) -> msg1 + "; " + msg2) + .orElse("Data binding failed"); + + return ApiResponse.error(String.valueOf(ResultEnum.PARAMETER_ERROR.getCode()), errorMessageEn); } /** * 处理文件上传大小超限异常 */ @ExceptionHandler(MaxUploadSizeExceededException.class) - public ResponseEntity> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e, HttpServletRequest request) { - logger.warn("文件上传大小超限: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); + public ApiResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + + logger.warn("文件上传大小超限: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(FILE_SIZE_EXCEEDED, "上传文件大小超过限制", request.getRequestURI(), null)); + return ApiResponse.error(String.valueOf(ResultEnum.PARAMETER_ERROR.getCode()), "File size exceeds limit"); } /** - * 处理非法参数异常 + * 处理MinIO异常 */ - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) { - logger.warn("非法参数异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); + @ExceptionHandler(MinioException.class) + public ApiResponse handleMinioException(MinioException e) { + + logger.error("MinIO异常: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(ILLEGAL_ARGUMENT, "参数错误:" + e.getMessage(), request.getRequestURI(), null)); + return ApiResponse.error(String.valueOf(ResultEnum.ERROR.getCode()), "File storage service error"); } /** - * 处理缺少请求参数异常 + * 处理SQL异常 */ - @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e, HttpServletRequest request) { - logger.warn("缺少请求参数异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - String message = String.format("缺少必需的请求参数: %s", e.getParameterName()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(MISSING_PARAMETER, message, request.getRequestURI(), null)); - } + @ExceptionHandler(SQLException.class) + public ApiResponse handleSQLException(SQLException e) { - /** - * 处理参数类型不匹配异常 - */ - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) { - logger.warn("参数类型不匹配异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - String message = String.format("参数 %s 的值 %s 类型不正确", e.getName(), e.getValue()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(TYPE_MISMATCH, message, request.getRequestURI(), null)); - } + logger.error("数据库异常: {}", e.getMessage(), e); - /** - * 处理HTTP请求方法不支持异常 - */ - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) { - logger.warn("HTTP请求方法不支持异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - String message = String.format("不支持的请求方法: %s", e.getMethod()); - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) - .body(createErrorResponse(METHOD_NOT_ALLOWED, message, request.getRequestURI(), null)); - } - - /** - * 处理媒体类型不支持异常 - */ - @ExceptionHandler(HttpMediaTypeNotSupportedException.class) - public ResponseEntity> handleHttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e, HttpServletRequest request) { - logger.warn("媒体类型不支持异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) - .body(createErrorResponse(MEDIA_TYPE_NOT_SUPPORTED, "不支持的媒体类型", request.getRequestURI(), null)); - } - - /** - * 处理HTTP消息不可读异常 - */ - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e, HttpServletRequest request) { - logger.warn("HTTP消息不可读异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(createErrorResponse(MESSAGE_NOT_READABLE, "请求体格式错误", request.getRequestURI(), null)); - } - - /** - * 处理404异常 - */ - @ExceptionHandler(NoHandlerFoundException.class) - public ResponseEntity> handleNoHandlerFoundException(NoHandlerFoundException e, HttpServletRequest request) { - logger.warn("404异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI()); - - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(createErrorResponse(NOT_FOUND, "请求的资源不存在", request.getRequestURI(), null)); - } - - /** - * 处理数据库相关异常 - */ - @ExceptionHandler({DataAccessException.class, SQLException.class, DataIntegrityViolationException.class}) - public ResponseEntity> handleDatabaseException(Exception e, HttpServletRequest request) { - logger.error("数据库异常: {} - 请求路径: {}", e.getMessage(), request.getRequestURI(), e); - - String message = "数据库操作失败"; - if (e instanceof DataIntegrityViolationException) { - message = "数据完整性约束违反"; - } - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(createErrorResponse(DATABASE_ERROR, message, request.getRequestURI(), null)); - } - - /** - * 处理空指针异常 - */ - @ExceptionHandler(NullPointerException.class) - public ResponseEntity> handleNullPointerException(NullPointerException e, HttpServletRequest request) { - logger.error("空指针异常 - 请求路径: {}", request.getRequestURI(), e); - - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(createErrorResponse(NULL_POINTER, "系统内部错误", request.getRequestURI(), null)); + return ApiResponse.error(String.valueOf(ResultEnum.ERROR.getCode()), "Database operation failed"); } /** * 处理运行时异常 */ @ExceptionHandler(RuntimeException.class) - public ResponseEntity> handleRuntimeException(RuntimeException e, HttpServletRequest request) { - logger.error("运行时异常 - 请求路径: {}", request.getRequestURI(), e); + public ApiResponse handleRuntimeException(RuntimeException e) { + + logger.error("运行时异常: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(createErrorResponse(RUNTIME_ERROR, "系统运行时错误", request.getRequestURI(), null)); + return ApiResponse.error(String.valueOf(ResultEnum.ERROR.getCode()), "System runtime error"); } /** - * 处理所有其他异常 + * 处理其他异常 */ @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception e, HttpServletRequest request) { - logger.error("未知异常 - 请求路径: {}", request.getRequestURI(), e); + public ApiResponse handleException(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(createErrorResponse(UNKNOWN_ERROR, "系统内部错误", request.getRequestURI(), null)); - } - - /** - * 创建统一的错误响应格式 - */ - private Map createErrorResponse(String code, String message, String path, Map errors) { - Map response = new HashMap<>(); - response.put("success", false); - response.put("code", code); - response.put("message", message); - response.put("timestamp", System.currentTimeMillis()); - response.put("path", path); + logger.error("未知异常: {}", e.getMessage(), e); - if (errors != null && !errors.isEmpty()) { - response.put("errors", errors); - } - - return response; + return ApiResponse.error(String.valueOf(ResultEnum.ERROR.getCode()), "System internal error"); } } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/exception/MinioException.java b/src/main/java/com/aida/lanecarford/exception/MinioException.java new file mode 100644 index 0000000..66c9746 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/exception/MinioException.java @@ -0,0 +1,78 @@ +package com.aida.lanecarford.exception; + +/** + * MinIO 操作异常类 + * 用于处理 MinIO 相关的业务异常 + * + * @author Aida + * @since 2024-01-01 + */ +public class MinioException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * 错误码 + */ + private String errorCode; + + /** + * 构造函数 + * + * @param message 异常信息 + */ + public MinioException(String message) { + super(message); + } + + /** + * 构造函数 + * + * @param message 异常信息 + * @param cause 原因 + */ + public MinioException(String message, Throwable cause) { + super(message, cause); + } + + /** + * 构造函数 + * + * @param errorCode 错误码 + * @param message 异常信息 + */ + public MinioException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * 构造函数 + * + * @param errorCode 错误码 + * @param message 异常信息 + * @param cause 原因 + */ + public MinioException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * 获取错误码 + * + * @return 错误码 + */ + public String getErrorCode() { + return errorCode; + } + + /** + * 设置错误码 + * + * @param errorCode 错误码 + */ + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/service/ImageCompositionService.java b/src/main/java/com/aida/lanecarford/service/ImageCompositionService.java new file mode 100644 index 0000000..e3617b0 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/service/ImageCompositionService.java @@ -0,0 +1,29 @@ +package com.aida.lanecarford.service; + +import java.util.List; + +/** + * 图片合成服务接口 + * + * @author Aida + * @since 2024-01-01 + */ +public interface ImageCompositionService { + + /** + * 合成图片并上传到MinIO + * + * @param imageUrls 图片URL列表(1-3张) + * @return 合成后图片的MinIO访问URL + */ + String composeAndUploadImages(List imageUrls); + + /** + * 合成图片并上传到指定存储桶 + * + * @param imageUrls 图片URL列表(1-3张) + * @param bucketName 存储桶名称 + * @return 合成后图片的MinIO访问URL + */ + String composeAndUploadImages(List imageUrls, String bucketName); +} \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/service/TryOnEffectService.java b/src/main/java/com/aida/lanecarford/service/TryOnEffectService.java index 72e8140..8986803 100644 --- a/src/main/java/com/aida/lanecarford/service/TryOnEffectService.java +++ b/src/main/java/com/aida/lanecarford/service/TryOnEffectService.java @@ -2,6 +2,7 @@ package com.aida.lanecarford.service; import com.aida.lanecarford.entity.TryOnEffect; import com.baomidou.mybatisplus.extension.service.IService; +import jakarta.validation.Valid; /** * 试穿效果服务接口 @@ -11,4 +12,5 @@ import com.baomidou.mybatisplus.extension.service.IService; */ public interface TryOnEffectService extends IService { + String generateTryOnEffect(@Valid TryOnEffect tryOnEffectDto); } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java b/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java new file mode 100644 index 0000000..ec086f9 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/service/impl/ImageCompositionServiceImpl.java @@ -0,0 +1,81 @@ +package com.aida.lanecarford.service.impl; + +import com.aida.lanecarford.service.ImageCompositionService; +import com.aida.lanecarford.util.ImageCompositionUtil; +import com.aida.lanecarford.util.MinioUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 图片合成服务实现类 + * + * @author Aida + * @since 2024-01-01 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageCompositionServiceImpl implements ImageCompositionService { + + private final ImageCompositionUtil imageCompositionUtil; + private final MinioUtil minioUtil; + + @Override + public String composeAndUploadImages(List imageUrls) { + return composeAndUploadImages(imageUrls, null); + } + + @Override + public String composeAndUploadImages(List imageUrls, String bucketName) { + try { + log.info("开始合成并上传图片,图片数量: {}, 存储桶: {}", + imageUrls != null ? imageUrls.size() : 0, bucketName); + + // 参数验证 + if (imageUrls == null || imageUrls.isEmpty()) { + throw new IllegalArgumentException("图片URL列表不能为空"); + } + + // 过滤有效的URL + List validUrls = imageUrls.stream() + .filter(url -> url != null && !url.trim().isEmpty()) + .toList(); + + if (validUrls.isEmpty()) { + throw new IllegalArgumentException("没有有效的图片URL"); + } + + log.debug("有效图片URL数量: {}", validUrls.size()); + + // 如果只有一张图片,直接返回原URL + if (validUrls.size() == 1) { + log.info("只有一张图片,直接返回原URL: {}", validUrls.get(0)); + return validUrls.get(0); + } + + // 合成图片 + byte[] composedImageBytes = imageCompositionUtil.composeImages(validUrls); + + // 生成文件名 + String fileName = imageCompositionUtil.generateComposedFileName(validUrls); + + // 上传到MinIO + String uploadedUrl; + if (bucketName != null && !bucketName.trim().isEmpty()) { + uploadedUrl = minioUtil.uploadBytes(composedImageBytes, fileName, "image/jpeg", bucketName); + } else { + uploadedUrl = minioUtil.uploadBytes(composedImageBytes, fileName, "image/jpeg"); + } + + log.info("图片合成并上传成功,访问URL: {}", uploadedUrl); + return uploadedUrl; + + } catch (Exception e) { + log.error("图片合成并上传失败: {}", e.getMessage(), e); + 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 9765358..772a56b 100644 --- a/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java +++ b/src/main/java/com/aida/lanecarford/service/impl/TryOnEffectServiceImpl.java @@ -1,20 +1,117 @@ package com.aida.lanecarford.service.impl; +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.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.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.List; + /** * 试穿效果服务实现类 * * @author AI Assistant * @since 2024-01-01 */ +@Slf4j @Service @RequiredArgsConstructor public class TryOnEffectServiceImpl extends ServiceImpl implements TryOnEffectService { + private final StyleService styleService; + private final ModelPhotoService modelPhotoService; + private final CustomerPhotoService customerPhotoService; + private final TryOnEffectService tryOnEffectService; + private final ImageCompositionService imageCompositionService; + @Override + public String generateTryOnEffect(TryOnEffect tryOnEffectDto) { + Integer isRegenerated = tryOnEffectDto.getIsRegenerated(); + String toAIUrl = null; + // 收集图片URL + List imageUrls = new ArrayList<>(); + if (isRegenerated == 1) { + String prompt = tryOnEffectDto.getPrompt(); + Long originalTryOnId = tryOnEffectDto.getOriginalTryOnId(); + // 验证originalTryOnId不能为空 + if (originalTryOnId == null) { + throw new RuntimeException("originalTryOnId cannot be null"); + } + TryOnEffect originalTryOn = tryOnEffectService.getById(originalTryOnId); + String resultImageUrl = originalTryOn.getResultImageUrl(); + imageUrls.add(resultImageUrl); + } else { + Long styleId = tryOnEffectDto.getStyleId(); + // 验证styleId不能为空 + if (styleId == null) { + throw new RuntimeException("styleId cannot be null"); + } + + + + //根据id查到对应styleurl + Style style = styleService.getById(styleId); + String styleImageUrl = style.getStyleImageUrl(); + if (styleImageUrl != null && !styleImageUrl.trim().isEmpty()) { + imageUrls.add(styleImageUrl); + } + + Long modelPhotoId = tryOnEffectDto.getModelPhotoId(); + if (modelPhotoId != null) { + //根据id查到对应modelurl + ModelPhoto modelPhoto = modelPhotoService.getById(modelPhotoId); + String modelPhotoUrl = modelPhoto.getPhotoUrl(); + if (modelPhotoUrl != null && !modelPhotoUrl.trim().isEmpty()) { + imageUrls.add(modelPhotoUrl); + } + } + + Long customerPhotoId = tryOnEffectDto.getCustomerPhotoId(); + if (customerPhotoId != null) { + //根据id查到对应customerurl + CustomerPhoto customerPhoto = customerPhotoService.getById(customerPhotoId); + String customerPhotoUrl = customerPhoto.getPhotoUrl(); + if (customerPhotoUrl != null && !customerPhotoUrl.trim().isEmpty()) { + imageUrls.add(customerPhotoUrl); + } + } + + // 合成图片 + if (!imageUrls.isEmpty()) { + log.info("开始合成图片,图片数量: {}", imageUrls.size()); + try { + toAIUrl = imageCompositionService.composeAndUploadImages(imageUrls); + log.info("图片合成成功,合成图片URL: {}", toAIUrl); + } catch (Exception e) { + log.error("图片合成失败: {}", e.getMessage(), e); + throw new RuntimeException("image error " + e.getMessage(), e); + } + } else { + log.warn("没有找到有效的图片URL进行合成"); + throw new RuntimeException("image cannot be null"); + } + } + + //调用模型生成试穿效果 + log.info("准备调用第三方AI服务,输入图片URL: {}", toAIUrl); + String AIRreultUrl = AITryOnEffect(tryOnEffectDto.getPrompt(), toAIUrl); + + + return "taskId"; + } + + public String AITryOnEffect(String prompt,String url) { + + //调用模型生成试穿效果 + } } \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/util/FileUtil.java b/src/main/java/com/aida/lanecarford/util/FileUtil.java deleted file mode 100644 index 641e414..0000000 --- a/src/main/java/com/aida/lanecarford/util/FileUtil.java +++ /dev/null @@ -1,262 +0,0 @@ -package com.aida.lanecarford.util; - -import com.aida.lanecarford.exception.BusinessException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; - -/** - * 文件工具类 - * - * @author AI Assistant - * @since 2024-01-01 - */ -public class FileUtil { - - private static final Logger logger = LoggerFactory.getLogger(FileUtil.class); - - /** - * 支持的图片格式 - */ - private static final List SUPPORTED_IMAGE_FORMATS = Arrays.asList( - "jpg", "jpeg", "png", "gif", "bmp", "webp" - ); - - /** - * 最大文件大小(10MB) - */ - private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; - - /** - * 上传根目录 - */ - private static final String UPLOAD_ROOT_DIR = "uploads"; - - /** - * 验证文件是否为图片 - */ - public static boolean isImageFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - return false; - } - - String originalFilename = file.getOriginalFilename(); - if (originalFilename == null) { - return false; - } - - String extension = getFileExtension(originalFilename).toLowerCase(); - return SUPPORTED_IMAGE_FORMATS.contains(extension); - } - - /** - * 验证文件大小 - */ - public static boolean isValidFileSize(MultipartFile file) { - return file != null && file.getSize() <= MAX_FILE_SIZE; - } - - /** - * 获取文件扩展名 - */ - public static String getFileExtension(String filename) { - if (filename == null || filename.isEmpty()) { - return ""; - } - - int lastDotIndex = filename.lastIndexOf('.'); - if (lastDotIndex == -1 || lastDotIndex == filename.length() - 1) { - return ""; - } - - return filename.substring(lastDotIndex + 1); - } - - /** - * 生成唯一文件名 - */ - public static String generateUniqueFileName(String originalFilename) { - String extension = getFileExtension(originalFilename); - String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); - String uuid = UUID.randomUUID().toString().replace("-", ""); - - return timestamp + "_" + uuid + "." + extension; - } - - /** - * 创建目录结构 - */ - public static String createDirectoryStructure(String category) { - String dateDir = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); - String fullPath = UPLOAD_ROOT_DIR + File.separator + category + File.separator + dateDir; - - try { - Path path = Paths.get(fullPath); - Files.createDirectories(path); - return fullPath; - } catch (IOException e) { - logger.error("创建目录失败: {}", fullPath, e); - throw BusinessException.fileUploadFailed(); - } - } - - /** - * 保存文件 - */ - public static String saveFile(MultipartFile file, String category) { - // 验证文件 - validateFile(file); - - // 创建目录 - String directoryPath = createDirectoryStructure(category); - - // 生成文件名 - String filename = generateUniqueFileName(file.getOriginalFilename()); - - // 完整文件路径 - String fullPath = directoryPath + File.separator + filename; - - try { - // 保存文件 - Path filePath = Paths.get(fullPath); - Files.write(filePath, file.getBytes()); - - logger.info("文件保存成功: {}", fullPath); - - // 返回相对路径(用于数据库存储和访问) - return fullPath.replace(File.separator, "/"); - - } catch (IOException e) { - logger.error("文件保存失败: {}", fullPath, e); - throw BusinessException.fileUploadFailed(); - } - } - - /** - * 删除文件 - */ - public static boolean deleteFile(String filePath) { - if (filePath == null || filePath.isEmpty()) { - return false; - } - - try { - Path path = Paths.get(filePath.replace("/", File.separator)); - boolean deleted = Files.deleteIfExists(path); - - if (deleted) { - logger.info("文件删除成功: {}", filePath); - } else { - logger.warn("文件不存在或删除失败: {}", filePath); - } - - return deleted; - } catch (IOException e) { - logger.error("文件删除失败: {}", filePath, e); - return false; - } - } - - /** - * 检查文件是否存在 - */ - public static boolean fileExists(String filePath) { - if (filePath == null || filePath.isEmpty()) { - return false; - } - - Path path = Paths.get(filePath.replace("/", File.separator)); - return Files.exists(path); - } - - /** - * 获取文件大小(字节) - */ - public static long getFileSize(String filePath) { - if (!fileExists(filePath)) { - return 0; - } - - try { - Path path = Paths.get(filePath.replace("/", File.separator)); - return Files.size(path); - } catch (IOException e) { - logger.error("获取文件大小失败: {}", filePath, e); - return 0; - } - } - - /** - * 格式化文件大小 - */ - public static String formatFileSize(long size) { - if (size < 1024) { - return size + " B"; - } else if (size < 1024 * 1024) { - return String.format("%.1f KB", size / 1024.0); - } else if (size < 1024 * 1024 * 1024) { - return String.format("%.1f MB", size / (1024.0 * 1024.0)); - } else { - return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0)); - } - } - - /** - * 验证文件 - */ - private static void validateFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw BusinessException.invalidParameter("file"); - } - - if (!isImageFile(file)) { - throw BusinessException.invalidFileFormat(); - } - - if (!isValidFileSize(file)) { - throw BusinessException.fileSizeExceeded(); - } - } - - /** - * 获取文件的MIME类型 - */ - public static String getContentType(String filename) { - String extension = getFileExtension(filename).toLowerCase(); - - switch (extension) { - case "jpg": - case "jpeg": - return "image/jpeg"; - case "png": - return "image/png"; - case "gif": - return "image/gif"; - case "bmp": - return "image/bmp"; - case "webp": - return "image/webp"; - default: - return "application/octet-stream"; - } - } - - /** - * 清理过期文件(可用于定时任务) - */ - public static void cleanupExpiredFiles(String directoryPath, int daysToKeep) { - // TODO: 实现文件清理逻辑 - logger.info("清理过期文件: {} 天前的文件", daysToKeep); - } -} \ No newline at end of file diff --git a/src/main/java/com/aida/lanecarford/util/ImageCompositionUtil.java b/src/main/java/com/aida/lanecarford/util/ImageCompositionUtil.java new file mode 100644 index 0000000..72835f7 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/util/ImageCompositionUtil.java @@ -0,0 +1,273 @@ +package com.aida.lanecarford.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +/** + * 图片合成工具类 + * 支持1-3张图片的智能合成 + * + * @author Aida + * @since 2024-01-01 + */ +@Slf4j +@Component +public class ImageCompositionUtil { + + /** + * 默认合成图片宽度 + */ + private static final int DEFAULT_WIDTH = 800; + + /** + * 默认合成图片高度 + */ + private static final int DEFAULT_HEIGHT = 600; + + /** + * 图片间距 + */ + private static final int PADDING = 10; + + /** + * 背景颜色 + */ + private static final Color BACKGROUND_COLOR = Color.WHITE; + + /** + * 合成多张图片 + * + * @param imageUrls 图片URL列表(1-3张) + * @return 合成后的图片字节数组 + * @throws IOException 图片处理异常 + */ + public byte[] composeImages(List imageUrls) throws IOException { + if (imageUrls == null || imageUrls.isEmpty()) { + throw new IllegalArgumentException("图片URL列表不能为空"); + } + + if (imageUrls.size() > 3) { + throw new IllegalArgumentException("最多支持3张图片合成"); + } + + // 过滤空URL + List validUrls = imageUrls.stream() + .filter(url -> url != null && !url.trim().isEmpty()) + .toList(); + + if (validUrls.isEmpty()) { + throw new IllegalArgumentException("没有有效的图片URL"); + } + + log.info("开始合成图片,图片数量: {}", validUrls.size()); + + // 下载图片 + List images = downloadImages(validUrls); + + // 根据图片数量选择合成策略 + BufferedImage composedImage = switch (images.size()) { + case 1 -> composeSingleImage(images.get(0)); + case 2 -> composeTwoImages(images.get(0), images.get(1)); + case 3 -> composeThreeImages(images.get(0), images.get(1), images.get(2)); + default -> throw new IllegalArgumentException("不支持的图片数量: " + images.size()); + }; + + // 转换为字节数组 + return imageToBytes(composedImage); + } + + /** + * 下载图片 + * + * @param imageUrls 图片URL列表 + * @return 图片列表 + * @throws IOException 下载异常 + */ + private List downloadImages(List imageUrls) throws IOException { + List images = new ArrayList<>(); + + for (String imageUrl : imageUrls) { + try { + log.debug("下载图片: {}", imageUrl); + URL url = new URL(imageUrl); + BufferedImage image = ImageIO.read(url); + + if (image == null) { + log.warn("无法读取图片: {}", imageUrl); + continue; + } + + images.add(image); + log.debug("成功下载图片: {}, 尺寸: {}x{}", imageUrl, image.getWidth(), image.getHeight()); + } catch (Exception e) { + log.error("下载图片失败: {}, 错误: {}", imageUrl, e.getMessage()); + // 继续处理其他图片,不抛出异常 + } + } + + if (images.isEmpty()) { + throw new IOException("所有图片下载失败"); + } + + return images; + } + + /** + * 单张图片处理(调整尺寸) + * + * @param image 原图片 + * @return 处理后的图片 + */ + private BufferedImage composeSingleImage(BufferedImage image) { + log.debug("处理单张图片"); + return resizeImage(image, DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + /** + * 两张图片合成(左右并排) + * + * @param image1 第一张图片 + * @param image2 第二张图片 + * @return 合成后的图片 + */ + private BufferedImage composeTwoImages(BufferedImage image1, BufferedImage image2) { + log.debug("合成两张图片(左右并排)"); + + int singleWidth = (DEFAULT_WIDTH - PADDING * 3) / 2; + int singleHeight = DEFAULT_HEIGHT - PADDING * 2; + + // 调整图片尺寸 + BufferedImage resized1 = resizeImage(image1, singleWidth, singleHeight); + BufferedImage resized2 = resizeImage(image2, singleWidth, singleHeight); + + // 创建画布 + BufferedImage canvas = new BufferedImage(DEFAULT_WIDTH, DEFAULT_HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = canvas.createGraphics(); + + // 设置抗锯齿 + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + + // 填充背景 + g2d.setColor(BACKGROUND_COLOR); + g2d.fillRect(0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT); + + // 绘制图片 + g2d.drawImage(resized1, PADDING, PADDING, null); + g2d.drawImage(resized2, PADDING * 2 + singleWidth, PADDING, null); + + g2d.dispose(); + return canvas; + } + + /** + * 三张图片合成(三宫格布局) + * + * @param image1 第一张图片 + * @param image2 第二张图片 + * @param image3 第三张图片 + * @return 合成后的图片 + */ + private BufferedImage composeThreeImages(BufferedImage image1, BufferedImage image2, BufferedImage image3) { + log.debug("合成三张图片(三宫格布局)"); + + int singleWidth = (DEFAULT_WIDTH - PADDING * 3) / 2; + int singleHeight = (DEFAULT_HEIGHT - PADDING * 3) / 2; + + // 调整图片尺寸 + BufferedImage resized1 = resizeImage(image1, singleWidth, singleHeight); + BufferedImage resized2 = resizeImage(image2, singleWidth, singleHeight); + BufferedImage resized3 = resizeImage(image3, singleWidth, singleHeight); + + // 创建画布 + BufferedImage canvas = new BufferedImage(DEFAULT_WIDTH, DEFAULT_HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = canvas.createGraphics(); + + // 设置抗锯齿 + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + + // 填充背景 + g2d.setColor(BACKGROUND_COLOR); + g2d.fillRect(0, 0, DEFAULT_WIDTH, DEFAULT_HEIGHT); + + // 绘制图片(左上、右上、左下) + g2d.drawImage(resized1, PADDING, PADDING, null); + g2d.drawImage(resized2, PADDING * 2 + singleWidth, PADDING, null); + g2d.drawImage(resized3, PADDING, PADDING * 2 + singleHeight, null); + + g2d.dispose(); + return canvas; + } + + /** + * 调整图片尺寸 + * + * @param originalImage 原图片 + * @param targetWidth 目标宽度 + * @param targetHeight 目标高度 + * @return 调整后的图片 + */ + private BufferedImage resizeImage(BufferedImage originalImage, int targetWidth, int targetHeight) { + BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = resizedImage.createGraphics(); + + // 设置高质量渲染 + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null); + g2d.dispose(); + + return resizedImage; + } + + /** + * 将图片转换为字节数组 + * + * @param image 图片 + * @return 字节数组 + * @throws IOException 转换异常 + */ + private byte[] imageToBytes(BufferedImage image) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", baos); + byte[] bytes = baos.toByteArray(); + log.debug("图片转换为字节数组,大小: {} bytes", bytes.length); + return bytes; + } + + /** + * 从字节数组创建输入流 + * + * @param imageBytes 图片字节数组 + * @return 输入流 + */ + public InputStream createInputStream(byte[] imageBytes) { + return new ByteArrayInputStream(imageBytes); + } + + /** + * 生成合成图片的文件名 + * + * @param originalUrls 原始图片URL列表 + * @return 文件名 + */ + public String generateComposedFileName(List originalUrls) { + String timestamp = String.valueOf(System.currentTimeMillis()); + String suffix = originalUrls.size() + "_images_composed"; + return String.format("composed_%s_%s.jpg", suffix, timestamp); + } +} \ 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 new file mode 100644 index 0000000..bf23e54 --- /dev/null +++ b/src/main/java/com/aida/lanecarford/util/MinioUtil.java @@ -0,0 +1,401 @@ +package com.aida.lanecarford.util; + +import com.aida.lanecarford.config.MinioConfig; +import com.aida.lanecarford.exception.MinioException; +import io.minio.*; +import io.minio.http.Method; +import io.minio.messages.Bucket; +import io.minio.messages.Item; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 工具类 + * 提供文件上传、下载、删除等操作 + * + * @author Aida + * @since 2024-01-01 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MinioUtil { + + private MinioClient minioClient; + + private MinioConfig minioConfig; + + /** + * 检查存储桶是否存在 + * + * @param bucketName 存储桶名称 + * @return 是否存在 + */ + public boolean bucketExists(String bucketName) { + try { + return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build()); + } catch (Exception e) { + log.error("检查存储桶是否存在失败: {}", e.getMessage(), e); + throw new MinioException("检查存储桶失败", e); + } + } + + /** + * 创建存储桶 + * + * @param bucketName 存储桶名称 + */ + public void createBucket(String bucketName) { + try { + if (!bucketExists(bucketName)) { + minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build()); + log.info("创建存储桶成功: {}", bucketName); + } + } catch (Exception e) { + log.error("创建存储桶失败: {}", e.getMessage(), e); + throw new MinioException("创建存储桶失败", e); + } + } + + /** + * 获取所有存储桶 + * + * @return 存储桶列表 + */ + public List getAllBuckets() { + try { + return minioClient.listBuckets(); + } catch (Exception e) { + log.error("获取存储桶列表失败: {}", e.getMessage(), e); + throw new MinioException("获取存储桶列表失败", e); + } + } + + /** + * 删除存储桶 + * + * @param bucketName 存储桶名称 + */ + public void removeBucket(String bucketName) { + try { + minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build()); + log.info("删除存储桶成功: {}", bucketName); + } catch (Exception e) { + log.error("删除存储桶失败: {}", e.getMessage(), e); + throw new MinioException("删除存储桶失败", e); + } + } + + /** + * 上传文件 + * + * @param file 文件 + * @return 文件访问URL + */ + public String uploadFile(MultipartFile file) { + return uploadFile(file, minioConfig.getBucketName()); + } + + /** + * 上传文件到指定存储桶 + * + * @param file 文件 + * @param bucketName 存储桶名称 + * @return 文件访问URL + */ + public String uploadFile(MultipartFile file, String bucketName) { + if (file == null || file.isEmpty()) { + throw new MinioException("文件不能为空"); + } + + try { + // 确保存储桶存在 + createBucket(bucketName); + + // 生成文件名 + String fileName = generateFileName(file.getOriginalFilename()); + + // 上传文件 + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(fileName) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(file.getContentType()) + .build() + ); + + log.info("文件上传成功: {}", fileName); + return getFileUrl(bucketName, fileName); + } catch (Exception e) { + log.error("文件上传失败: {}", e.getMessage(), e); + throw new MinioException("文件上传失败", e); + } + } + + /** + * 上传字节数组 + * + * @param bytes 字节数组 + * @param fileName 文件名 + * @param contentType 内容类型 + * @return 文件访问URL + */ + public String uploadBytes(byte[] bytes, String fileName, String contentType) { + return uploadBytes(bytes, fileName, contentType, minioConfig.getBucketName()); + } + + /** + * 上传字节数组到指定存储桶 + * + * @param bytes 字节数组 + * @param fileName 文件名 + * @param contentType 内容类型 + * @param bucketName 存储桶名称 + * @return 文件访问URL + */ + public String uploadBytes(byte[] bytes, String fileName, String contentType, String bucketName) { + if (bytes == null || bytes.length == 0) { + throw new MinioException("文件内容不能为空"); + } + + try { + // 确保存储桶存在 + createBucket(bucketName); + + // 生成文件名 + String objectName = generateFileName(fileName); + + // 上传文件 + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucketName) + .object(objectName) + .stream(new ByteArrayInputStream(bytes), bytes.length, -1) + .contentType(contentType) + .build() + ); + + log.info("字节数组上传成功: {}", objectName); + return getFileUrl(bucketName, objectName); + } catch (Exception e) { + log.error("字节数组上传失败: {}", e.getMessage(), e); + throw new MinioException("字节数组上传失败", e); + } + } + + /** + * 下载文件 + * + * @param fileName 文件名 + * @return 文件输入流 + */ + public InputStream downloadFile(String fileName) { + return downloadFile(minioConfig.getBucketName(), fileName); + } + + /** + * 从指定存储桶下载文件 + * + * @param bucketName 存储桶名称 + * @param fileName 文件名 + * @return 文件输入流 + */ + public InputStream downloadFile(String bucketName, String fileName) { + try { + return minioClient.getObject( + GetObjectArgs.builder() + .bucket(bucketName) + .object(fileName) + .build() + ); + } catch (Exception e) { + log.error("文件下载失败: {}", e.getMessage(), e); + throw new MinioException("文件下载失败", e); + } + } + + /** + * 删除文件 + * + * @param fileName 文件名 + */ + public void deleteFile(String fileName) { + deleteFile(minioConfig.getBucketName(), fileName); + } + + /** + * 从指定存储桶删除文件 + * + * @param bucketName 存储桶名称 + * @param fileName 文件名 + */ + public void deleteFile(String bucketName, String fileName) { + try { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(bucketName) + .object(fileName) + .build() + ); + log.info("文件删除成功: {}", fileName); + } catch (Exception e) { + log.error("文件删除失败: {}", e.getMessage(), e); + throw new MinioException("文件删除失败", e); + } + } + + /** + * 获取文件列表 + * + * @param bucketName 存储桶名称 + * @return 文件列表 + */ + public List listFiles(String bucketName) { + return listFiles(bucketName, null); + } + + /** + * 获取文件列表 + * + * @param bucketName 存储桶名称 + * @param prefix 文件前缀 + * @return 文件列表 + */ + public List listFiles(String bucketName, String prefix) { + List files = new ArrayList<>(); + try { + Iterable> results = minioClient.listObjects( + ListObjectsArgs.builder() + .bucket(bucketName) + .prefix(prefix) + .build() + ); + + for (Result result : results) { + Item item = result.get(); + files.add(item.objectName()); + } + } catch (Exception e) { + log.error("获取文件列表失败: {}", e.getMessage(), e); + throw new MinioException("获取文件列表失败", e); + } + return files; + } + + /** + * 检查文件是否存在 + * + * @param fileName 文件名 + * @return 是否存在 + */ + public boolean fileExists(String fileName) { + return fileExists(minioConfig.getBucketName(), fileName); + } + + /** + * 检查文件是否存在 + * + * @param bucketName 存储桶名称 + * @param fileName 文件名 + * @return 是否存在 + */ + public boolean fileExists(String bucketName, String fileName) { + try { + minioClient.statObject( + StatObjectArgs.builder() + .bucket(bucketName) + .object(fileName) + .build() + ); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 获取文件预签名URL(用于临时访问) + * + * @param fileName 文件名 + * @param expires 过期时间(秒) + * @return 预签名URL + */ + public String getPresignedUrl(String fileName, int expires) { + return getPresignedUrl(minioConfig.getBucketName(), fileName, expires); + } + + /** + * 获取文件预签名URL(用于临时访问) + * + * @param bucketName 存储桶名称 + * @param fileName 文件名 + * @param expires 过期时间(秒) + * @return 预签名URL + */ + public String getPresignedUrl(String bucketName, String fileName, int expires) { + try { + return minioClient.getPresignedObjectUrl( + GetPresignedObjectUrlArgs.builder() + .method(Method.GET) + .bucket(bucketName) + .object(fileName) + .expiry(expires, TimeUnit.SECONDS) + .build() + ); + } catch (Exception e) { + log.error("获取预签名URL失败: {}", e.getMessage(), e); + throw new MinioException("获取预签名URL失败", e); + } + } + + /** + * 获取文件访问URL + * + * @param bucketName 存储桶名称 + * @param fileName 文件名 + * @return 文件访问URL + */ + public String getFileUrl(String bucketName, String fileName) { + return minioConfig.getEndpoint() + "/" + bucketName + "/" + fileName; + } + + /** + * 生成唯一文件名 + * + * @param originalFilename 原始文件名 + * @return 生成的文件名 + */ + private String generateFileName(String originalFilename) { + if (originalFilename == null || originalFilename.trim().isEmpty()) { + throw new MinioException("文件名不能为空"); + } + + // 获取文件扩展名 + String extension = ""; + int lastDotIndex = originalFilename.lastIndexOf("."); + if (lastDotIndex > 0) { + extension = originalFilename.substring(lastDotIndex); + } + + // 生成时间戳路径 + String datePath = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); + + // 生成唯一文件名 + String uuid = UUID.randomUUID().toString().replace("-", ""); + + return datePath + "/" + uuid + extension; + } +} \ No newline at end of file diff --git a/src/main/resources/aida-461108-b4afaabebb84.json b/src/main/resources/aida-461108-b4afaabebb84.json new file mode 100644 index 0000000..9948d5e --- /dev/null +++ b/src/main/resources/aida-461108-b4afaabebb84.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "aida-461108", + "private_key_id": "b4afaabebb84da24502b318a5fa175f1dc5c096a", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmk7LKrp8g9yD1\nWmF+mY2qHCEZ/5aIx6QRh0QoVPBL7Yi7ce009QxaE8fu8+QMgg8l3xMreXvgpt56\noFnVwpFusLjSdjgoFluElM2hYxXlO9q8cbBoU2nehOBLLJzGzkodT7xu/BOjNvKC\n//aTbjtJyk8Kj+ENa0/dPaUZs/PCtQqpAu8ag5nXrordVWfO0K25EjeYyoba35zk\nPp2fBi8KALZZI5Xfd2z9++K0K2mWWIMJic30idHvquj0WxlTRK2Pq8BmJXCQpJIi\nQ5E4egue16BfKjrF0Kxkpqd1RmdlEmaSKbbkZXe2z4jg0qknESRFOmRy8C3LnaB2\nHHJWLYM3AgMBAAECggEACUdroOQJSTTQSS/iWRhZ+S0yoC10nTnsZxg527qfiBs7\nOqB7WNqC+Ew8dDsca6CdvLuoaGDkCFJDTQwRn66u8JOM4sG4bxiPuzBEJBv45EQT\n8zCsuvhVNWgBdoPjAnq19jFdixvPnDqQrRYaY4FdxsaA5f24c57pW/xLGMYawLBt\n9RJZSuWmJdzKG1i5W8a8+4f/seNtuo2MtXU3mPJZPqRWPXTAZeaQPM/57ZQ+kzig\nOkAbQZNRmt1yPCjPCQD8vc8yCBMmjus/rlHXD/L7okYUlVZkob5I3FBrLl+ZyIXS\nqxEsBLBwRW3w8WbX+ZSVciQ72JK68W7LnOHSAENmAQKBgQDgBTCqp87KGLWVPb8w\nK+s1Sfh+nM3M4AlbLdcGBs1JCoddF6pAeY4wpf/ow1Tm4rqEuCYzMClPwxvkue+D\nY7lCQgy2FK3ahUzn8oVmvEPD/YPAojDSY3bH0lquHuS6oVKk834JUykButaAU3XY\nvUGNQuKdLKAeQRT8Q6um4m+EYQKBgQC+Wz6nYESKH6GiNnuFTH8hIkThPlbi4wua\nU1kGnPKe3ouE4zRLfPwQ6RRf1slQ/2hFLOatiTLYUgZWZQeBPSWp2EjYcOSzob+7\n11+KqeIRCD5DKxgf0cjJdihK9AM639OKlH2NvZ2507TksdeTPDzdaOMLwLWKexP5\nlYrdob0ulwKBgD81t7Gvf83Ogw4FSjkRa2Cx6ofvPrKcVIeBu7ZbnPkLG37M+qEO\nq2xWqorG8uHi/7YLL9wprr5u0yQKwuZT8SYc9PE7jIKoMjcQW0vNu2FF2zMzkIsM\nvatMU4Hl/awbcPJSMjH3YQ635WZ4Jjxtyl1NjhvDR7rBqmYzwe9o3QaBAoGANhPB\n1tbYYczepDCKIrI6o3US0FJfaJFLqInpDqHjoxJh3FyXbKKTEVLFwPxJsML+IjjB\nR6dkVGPo/P4yhZqTao7REvvvXMCksX5b3A6q9F+9IGPLtK5qNiFlDPYJPN59QC8z\nA+NMPZBRIW8MaP2B5Px5E8upRy/z2sGK86+RCP0CgYATGs75F97q+Zf8q+Pe3Nsb\ngqmhLoI3PZUSWgBcQgNF4nyCZceUrEl72wKO/NWLgxqQPtlra187ce69g7qARHLb\ntHq80nb0f7lil74B6+OlyNNO1htWA90fmGR2s16Mt0BwJRT+/EFuNqbJIUSLxKiW\nqlXBUbmHHzamo5DPYL8S/w==\n-----END PRIVATE KEY-----\n", + "client_email": "aida-239@aida-461108.iam.gserviceaccount.com", + "client_id": "103102077955178349079", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/aida-239%40aida-461108.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a5e064f..a486ebd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,6 +44,15 @@ springdoc: path: /swagger-ui.html enabled: true +# MinIO 对象存储配置 +minio: + endpoint: https://www.minio-api.aida.com.hk + access-key: admin + secret-key: Aidlab123123 + bucket-name: lanecarford + # 文件访问URL前缀 + url-prefix: ${minio.endpoint}/${minio.bucket-name}/ + # 日志配置 logging: level: diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index b708fbc..d92b005 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -116,10 +116,10 @@ CREATE TABLE try_on_effects ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '试穿效果ID', customer_id BIGINT NOT NULL COMMENT '顾客ID', visit_record_id BIGINT NOT NULL COMMENT '进店记录ID', - styles_id BIGINT NOT NULL COMMENT '风格ID', + style_id BIGINT NOT NULL COMMENT '风格ID', model_photo_id BIGINT COMMENT '模特照片ID', customer_photo_id BIGINT COMMENT '顾客照片ID', - prompt VARCHAR(500) COMMENT '提示词', + prompt VARCHAR(500) COMMENT '提示词,当is_regenerated为1时才会有值', original_try_on_id BIGINT COMMENT '原试穿效果ID,当is_regenerated为1时才会有值', is_regenerated TINYINT DEFAULT 0 COMMENT '是否由生成结果重新生成(0-否,1-是)', result_image_url VARCHAR(500) COMMENT '试穿结果图片URL', @@ -131,12 +131,12 @@ CREATE TABLE try_on_effects ( updated_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE, FOREIGN KEY (visit_record_id) REFERENCES visit_records(id) ON DELETE CASCADE, - FOREIGN KEY (styles_id) REFERENCES styles(id) ON DELETE CASCADE, + FOREIGN KEY (style_id) REFERENCES styles(id) ON DELETE CASCADE, FOREIGN KEY (model_photo_id) REFERENCES model_photos(id) ON DELETE SET NULL, FOREIGN KEY (customer_photo_id) REFERENCES customer_photos(id) ON DELETE SET NULL, INDEX idx_customer_id (customer_id), INDEX idx_visit_record_id (visit_record_id), - INDEX idx_styles_id (styles_id), + INDEX idx_style_id (style_id), INDEX idx_request_id (request_id), INDEX idx_generation_status (generation_status), INDEX idx_is_favorite (is_favorite)