|
|
|
@ -1,14 +1,18 @@ |
|
|
|
package com.youlai.boot.mini.service.impl; |
|
|
|
|
|
|
|
import cn.hutool.core.date.DateUtil; |
|
|
|
import cn.hutool.core.io.FileUtil; |
|
|
|
import cn.hutool.core.util.IdUtil; |
|
|
|
import cn.hutool.http.HttpRequest; |
|
|
|
import cn.hutool.http.HttpResponse; |
|
|
|
import cn.hutool.http.HttpUtil; |
|
|
|
import cn.hutool.json.JSONException; |
|
|
|
import cn.hutool.json.JSONObject; |
|
|
|
import cn.hutool.json.JSONUtil; |
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
|
|
|
import com.youlai.boot.common.exception.BusinessException; |
|
|
|
import com.youlai.boot.common.exception.MsgException; |
|
|
|
import com.youlai.boot.common.util.FileUtils; |
|
|
|
import com.youlai.boot.common.util.JavaVCUtils; |
|
|
|
import com.youlai.boot.common.util.RandomNumberUtils; |
|
|
|
@ -17,25 +21,30 @@ import com.youlai.boot.file.service.FileService; |
|
|
|
import com.youlai.boot.file.service.impl.AliyunFileService; |
|
|
|
import com.youlai.boot.mini.mapper.MiniAiGenerationTaskMapper; |
|
|
|
import com.youlai.boot.mini.mapper.MiniAiTaskMediaMapper; |
|
|
|
import com.youlai.boot.mini.model.dto.GenerateResponse; |
|
|
|
import com.youlai.boot.mini.model.dto.ImageData; |
|
|
|
import com.youlai.boot.mini.model.dto.PhotoToComicRequest; |
|
|
|
|
|
|
|
import com.youlai.boot.mini.model.entity.MiniAiGenerationTask; |
|
|
|
import com.youlai.boot.mini.model.entity.MiniAiTaskMedia; |
|
|
|
import com.youlai.boot.mini.model.form.AiSingleImageGenerateForm; |
|
|
|
import com.youlai.boot.mini.model.form.MiniDeductPointForm; |
|
|
|
import com.youlai.boot.mini.model.vo.AiTaskCallbackVO; |
|
|
|
import com.youlai.boot.common.exception.BusinessException; |
|
|
|
import com.youlai.boot.mini.service.AiGenerationService; |
|
|
|
import com.youlai.boot.mini.service.MiniPointRecordService; |
|
|
|
import lombok.RequiredArgsConstructor; |
|
|
|
import lombok.extern.slf4j.Slf4j; |
|
|
|
import org.apache.commons.io.FilenameUtils; |
|
|
|
import org.springframework.beans.factory.annotation.Value; |
|
|
|
import org.springframework.stereotype.Service; |
|
|
|
import org.springframework.transaction.annotation.Transactional; |
|
|
|
import org.springframework.web.multipart.MultipartFile; |
|
|
|
|
|
|
|
import javax.imageio.ImageIO; |
|
|
|
import java.awt.image.BufferedImage; |
|
|
|
import java.io.File; |
|
|
|
import java.util.ArrayList; |
|
|
|
import java.util.Date; |
|
|
|
import java.util.List; |
|
|
|
import java.util.UUID; |
|
|
|
import java.io.InputStream; |
|
|
|
import java.net.URL; |
|
|
|
import java.time.LocalDateTime; |
|
|
|
import java.util.*; |
|
|
|
import java.util.concurrent.CompletableFuture; |
|
|
|
|
|
|
|
@Slf4j |
|
|
|
@ -47,11 +56,22 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
private final MiniAiTaskMediaMapper aiTaskMediaMapper; |
|
|
|
private final FileService fileService; |
|
|
|
private final AliyunFileService aliyunFileService; |
|
|
|
private final MiniPointRecordService pointRecordService; |
|
|
|
|
|
|
|
/** |
|
|
|
* AI生成服务地址 |
|
|
|
*/ |
|
|
|
private static final String AI_GENERATE_URL = "http://127.0.0.1:8001/api/v1/photo-to-comic"; |
|
|
|
@Value("${ai.generate.server-url:http://127.0.0.1:8001/api/v1/photo-to-comic}") |
|
|
|
private String aiGenerateServerUrl; |
|
|
|
|
|
|
|
/** |
|
|
|
* AI任务回调地址 |
|
|
|
*/ |
|
|
|
@Value("${ai.callback.url:http://127.0.0.1:30101/backend/api/v1/mini/ai/generation/task/callback}") |
|
|
|
private String aiCallbackUrl; |
|
|
|
|
|
|
|
@Value("${ai.default.model:doubao-seedream-5-0-260128}") |
|
|
|
private String aiDefaultModel; |
|
|
|
|
|
|
|
/** |
|
|
|
* OSS存储目录配置 |
|
|
|
@ -59,6 +79,10 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
private static final String OSS_IMAGE_DIR = "ai/image/"; |
|
|
|
private static final String OSS_VIDEO_DIR = "ai/video/"; |
|
|
|
private static final String OSS_THUMBNAIL_DIR = "ai/thumbnail/"; |
|
|
|
/** |
|
|
|
* AI单图生成积分规则编码 |
|
|
|
*/ |
|
|
|
private static final String AI_GENERATE_SINGLE_IMAGE_RULE = "AI_GENERATE_SINGLE_IMAGE"; |
|
|
|
|
|
|
|
@Override |
|
|
|
public List<String> uploadReferenceFile(List<MultipartFile> images, List<MultipartFile> videos, Long userId) { |
|
|
|
@ -180,97 +204,98 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public String createAndGenerateImage(PhotoToComicRequest request, Long userId) { |
|
|
|
// 创建生成任务
|
|
|
|
MiniAiGenerationTask task = new MiniAiGenerationTask(); |
|
|
|
@Transactional(rollbackFor = Exception.class) // 所有异常都触发事务回滚
|
|
|
|
public String createAndGenerateImage(AiSingleImageGenerateForm form, Long userId) { |
|
|
|
// 生成任务UUID
|
|
|
|
String taskUuid = UUID.randomUUID().toString().replace("-", ""); |
|
|
|
Date now = new Date(); |
|
|
|
long timestamp = System.currentTimeMillis(); |
|
|
|
|
|
|
|
// 扣减积分(自动加入当前事务,失败会一起回滚)
|
|
|
|
MiniDeductPointForm deductForm = new MiniDeductPointForm(); |
|
|
|
deductForm.setRuleCode(AI_GENERATE_SINGLE_IMAGE_RULE); |
|
|
|
deductForm.setBizId(taskUuid); |
|
|
|
Integer deductPoint = pointRecordService.deductPoint(userId, deductForm.getRuleCode(), deductForm.getBizId()); |
|
|
|
|
|
|
|
// 创建生成任务(自动加入当前事务,失败会一起回滚)
|
|
|
|
MiniAiGenerationTask task = new MiniAiGenerationTask(); |
|
|
|
task.setUuid(taskUuid) |
|
|
|
.setMiniUserId(userId) |
|
|
|
.setType("img_single") |
|
|
|
.setGenerateParams(JSONUtil.toJsonStr(request)) |
|
|
|
.setPointsConsumed(1) // 默认消耗1积分
|
|
|
|
.setStatus((byte) 0) |
|
|
|
.setType("img_single") // 单图
|
|
|
|
.setGenerateParams(JSONUtil.toJsonStr(form)) |
|
|
|
.setPointsConsumed(Math.abs(deductPoint)) |
|
|
|
.setStatus(0) // 生成中
|
|
|
|
.setCreateBy(userId) |
|
|
|
.setCreateTime(now) |
|
|
|
.setCreateTimestamp(timestamp) |
|
|
|
.setUpdateTime(now) |
|
|
|
.setUpdateTime(now) // 补全update相关字段
|
|
|
|
.setUpdateTimestamp(timestamp) |
|
|
|
.setDeleted(false); |
|
|
|
|
|
|
|
aiGenerationTaskMapper.insert(task); |
|
|
|
|
|
|
|
// 异步调用AI生成接口
|
|
|
|
CompletableFuture.runAsync(() -> { |
|
|
|
try { |
|
|
|
log.info("开始调用AI生成接口,任务UUID:{},请求参数:{}", taskUuid, JSONUtil.toJsonStr(request)); |
|
|
|
|
|
|
|
// 发送HTTP请求
|
|
|
|
HttpResponse response = HttpRequest.post(AI_GENERATE_URL) |
|
|
|
.header("Content-Type", "application/json") |
|
|
|
.body(JSONUtil.toJsonStr(request)) |
|
|
|
.timeout(30000) // 超时30秒
|
|
|
|
.execute(); |
|
|
|
|
|
|
|
if (!response.isOk()) { |
|
|
|
log.error("AI生成接口调用失败,状态码:{},响应内容:{}", response.getStatus(), response.body()); |
|
|
|
updateTaskStatus(taskUuid, (byte) 2, null); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 解析响应
|
|
|
|
String responseBody = response.body(); |
|
|
|
GenerateResponse generateResponse = JSONUtil.toBean(responseBody, GenerateResponse.class); |
|
|
|
|
|
|
|
if (generateResponse.getCode() != 0 || generateResponse.getData() == null || generateResponse.getData().getData().isEmpty()) { |
|
|
|
log.error("AI生成接口返回错误,响应内容:{}", responseBody); |
|
|
|
updateTaskStatus(taskUuid, (byte) 2, null); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 保存生成的图片
|
|
|
|
ImageData imageData = generateResponse.getData().getData().get(0); |
|
|
|
String externalResultUrl = imageData.getUrl(); |
|
|
|
|
|
|
|
// 下载外部图片到我们的OSS
|
|
|
|
String ossUrl = downloadExternalUrlToOss(externalResultUrl); |
|
|
|
|
|
|
|
// 保存媒体记录
|
|
|
|
MiniAiTaskMedia media = new MiniAiTaskMedia(); |
|
|
|
String mediaUuid = UUID.randomUUID().toString().replace("-", ""); |
|
|
|
// 组装第三方AI接口需要的参数
|
|
|
|
Map<String, Object> aiRequest = new HashMap<>(); |
|
|
|
aiRequest.put("model", form.getModel() == null ? aiDefaultModel : form.getModel()); |
|
|
|
aiRequest.put("reference_image", form.getImageUrl()); |
|
|
|
aiRequest.put("style", form.getStyle()); |
|
|
|
aiRequest.put("ratio", form.getRatio()); |
|
|
|
aiRequest.put("img_size", form.getImgSize()); |
|
|
|
aiRequest.put("img_type", form.getImgType()); |
|
|
|
aiRequest.put("species", form.getSpecies()); |
|
|
|
aiRequest.put("breed", form.getBreed()); |
|
|
|
aiRequest.put("color", form.getColor()); |
|
|
|
aiRequest.put("eye_color", form.getEyeColor()); |
|
|
|
aiRequest.put("body_type", form.getBodyType()); |
|
|
|
aiRequest.put("distinctive_features", form.getDistinctiveFeatures()); |
|
|
|
aiRequest.put("scene_description", form.getSceneDescription()); |
|
|
|
// 传递任务UUID和回调地址
|
|
|
|
aiRequest.put("uuid", taskUuid); |
|
|
|
aiRequest.put("callback_url", aiCallbackUrl); |
|
|
|
|
|
|
|
media.setUuid(mediaUuid) |
|
|
|
.setTaskId(task.getId()) |
|
|
|
.setFileSource("ai_generated") |
|
|
|
.setMediaType("image") |
|
|
|
.setSourceUrl(ossUrl) |
|
|
|
.setCreateBy(userId) |
|
|
|
.setCreateTime(new Date()) |
|
|
|
.setCreateTimestamp(System.currentTimeMillis()) |
|
|
|
.setUpdateTime(new Date()) |
|
|
|
.setUpdateTimestamp(System.currentTimeMillis()) |
|
|
|
.setDeleted(false); |
|
|
|
|
|
|
|
aiTaskMediaMapper.insert(media); |
|
|
|
try { |
|
|
|
log.info("提交AI生成任务,任务UUID:{},请求参数:{}", taskUuid, JSONUtil.toJsonStr(aiRequest)); |
|
|
|
// 同步调用AI接口,超时1秒
|
|
|
|
HttpResponse response = HttpRequest.post(aiGenerateServerUrl) |
|
|
|
.header("Content-Type", "application/json") |
|
|
|
.body(JSONUtil.toJsonStr(aiRequest)) |
|
|
|
.timeout(1000) |
|
|
|
.execute(); |
|
|
|
|
|
|
|
// 先判断HTTP状态码
|
|
|
|
if (!response.isOk()) { |
|
|
|
log.error("AI生成任务提交失败,HTTP状态码:{},响应内容:{}", response.getStatus(), response.body()); |
|
|
|
throw new MsgException("AI生成服务暂时不可用,请稍后重试"); |
|
|
|
} |
|
|
|
|
|
|
|
// 更新任务状态为成功
|
|
|
|
updateTaskStatus(taskUuid, (byte) 1, ossUrl); |
|
|
|
log.info("AI生成任务完成,任务UUID:{},结果URL:{}", taskUuid, ossUrl); |
|
|
|
// 解析返回结果,校验业务状态码:只有code=0才代表提交成功
|
|
|
|
String responseBody = response.body(); |
|
|
|
JSONObject resJson = JSONUtil.parseObj(responseBody); |
|
|
|
Integer code = resJson.getInt("code"); |
|
|
|
if (!Integer.valueOf(0).equals(code)) { |
|
|
|
String errMsg = resJson.getStr("msg", "服务调用失败"); |
|
|
|
|
|
|
|
} catch (Exception e) { |
|
|
|
log.error("AI生成任务异常,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); |
|
|
|
updateTaskStatus(taskUuid, (byte) 2, null); |
|
|
|
log.error("AI生成任务提交失败,业务错误码:{},错误信息:{},request_id:{},完整响应:{}", |
|
|
|
code, errMsg, resJson.getStr("request_id"), responseBody); |
|
|
|
throw new MsgException("AI生成失败:" + errMsg); |
|
|
|
} |
|
|
|
}); |
|
|
|
} catch (JSONException e) { |
|
|
|
log.error("AI生成任务返回结果解析失败,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); |
|
|
|
throw new MsgException("AI生成服务暂时不可用,请稍后重试"); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("AI生成任务提交异常,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); |
|
|
|
// 抛出异常触发事务回滚
|
|
|
|
throw new MsgException("AI生成服务暂时不可用,请稍后重试"); |
|
|
|
} |
|
|
|
|
|
|
|
return taskUuid; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public boolean handleTaskCallback(String taskUuid, Byte status, String externalResultUrl) { |
|
|
|
public boolean handleTaskCallback(AiTaskCallbackVO vo) { |
|
|
|
log.info("处理AI生成任务回调,任务UUID:{}", vo.getUuid()); |
|
|
|
try { |
|
|
|
String taskUuid = vo.getUuid(); |
|
|
|
// 查询任务是否存在
|
|
|
|
MiniAiGenerationTask task = getTaskByUuid(taskUuid); |
|
|
|
if (task == null) { |
|
|
|
@ -278,9 +303,21 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
// 转换任务状态
|
|
|
|
Integer status; |
|
|
|
if ("succeeded".equals(vo.getStatus())) { |
|
|
|
status = 1; // 成功
|
|
|
|
} else if ("failed".equals(vo.getStatus())) { |
|
|
|
status = 2; // 失败
|
|
|
|
} else { |
|
|
|
log.error("回调任务状态非法,UUID:{},status:{}", taskUuid, vo.getStatus()); |
|
|
|
return false; |
|
|
|
} |
|
|
|
|
|
|
|
// 如果生成成功,下载外部URL到OSS
|
|
|
|
String ossUrl = null; |
|
|
|
if (status == 1 && externalResultUrl != null) { |
|
|
|
if (status == 1 && vo.getResult() != null && !vo.getResult().getData().isEmpty()) { |
|
|
|
String externalResultUrl = vo.getResult().getData().get(0).getUrl(); |
|
|
|
ossUrl = downloadExternalUrlToOss(externalResultUrl); |
|
|
|
|
|
|
|
// 保存生成的媒体记录
|
|
|
|
@ -288,6 +325,7 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
String mediaUuid = UUID.randomUUID().toString().replace("-", ""); |
|
|
|
|
|
|
|
media.setUuid(mediaUuid) |
|
|
|
.setMiniUserId(task.getMiniUserId()) |
|
|
|
.setTaskId(task.getId()) |
|
|
|
.setFileSource("ai_generated") |
|
|
|
.setMediaType("image") |
|
|
|
@ -306,7 +344,7 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
return updateTaskStatus(taskUuid, status, ossUrl); |
|
|
|
|
|
|
|
} catch (Exception e) { |
|
|
|
log.error("处理AI任务回调异常,UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); |
|
|
|
log.error("处理AI任务回调异常,UUID:{},异常信息:{}", vo.getUuid(), e.getMessage(), e); |
|
|
|
return false; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -321,7 +359,7 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
/** |
|
|
|
* 更新任务状态 |
|
|
|
*/ |
|
|
|
private boolean updateTaskStatus(String uuid, Byte status, String resultUrl) { |
|
|
|
private boolean updateTaskStatus(String uuid, Integer status, String resultUrl) { |
|
|
|
LambdaUpdateWrapper<MiniAiGenerationTask> updateWrapper = new LambdaUpdateWrapper<>(); |
|
|
|
updateWrapper.eq(MiniAiGenerationTask::getUuid, uuid) |
|
|
|
.eq(MiniAiGenerationTask::getDeleted, false) |
|
|
|
@ -337,61 +375,40 @@ public class AiGenerationServiceImpl implements AiGenerationService { |
|
|
|
* 下载外部URL到OSS,返回OSS访问地址 |
|
|
|
*/ |
|
|
|
private String downloadExternalUrlToOss(String externalUrl) { |
|
|
|
try { |
|
|
|
// 下载文件并获取输入流
|
|
|
|
byte[] fileBytes = HttpUtil.downloadBytes(externalUrl); |
|
|
|
String fileName = IdUtil.fastSimpleUUID() + "." + FileUtil.extName(externalUrl); |
|
|
|
|
|
|
|
// 创建临时的MultipartFile包装类
|
|
|
|
MultipartFile multipartFile = new MultipartFile() { |
|
|
|
@Override |
|
|
|
public String getName() { |
|
|
|
return "file"; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public String getOriginalFilename() { |
|
|
|
return fileName; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public String getContentType() { |
|
|
|
return "image/png"; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public boolean isEmpty() { |
|
|
|
return fileBytes == null || fileBytes.length == 0; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public long getSize() { |
|
|
|
return fileBytes.length; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public byte[] getBytes() { |
|
|
|
return fileBytes; |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public java.io.InputStream getInputStream() { |
|
|
|
return new java.io.ByteArrayInputStream(fileBytes); |
|
|
|
} |
|
|
|
|
|
|
|
@Override |
|
|
|
public void transferTo(java.io.File dest) throws java.io.IOException, IllegalStateException { |
|
|
|
FileUtil.writeBytes(fileBytes, dest); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// 上传到OSS
|
|
|
|
FileInfo fileInfo = fileService.uploadFile(multipartFile); |
|
|
|
return fileInfo.getUrl(); |
|
|
|
try ( |
|
|
|
// 直接打开URL输入流,自动关闭
|
|
|
|
InputStream inputStream = new URL(externalUrl).openStream() |
|
|
|
) { |
|
|
|
long currentTimestamp = System.currentTimeMillis(); |
|
|
|
|
|
|
|
// 生成OSS存储路径:按日期分类 + 唯一ID + 原文件后缀
|
|
|
|
// String ext = FileUtil.extName(externalUrl);
|
|
|
|
String ext = null; |
|
|
|
|
|
|
|
// 1. 如果URL包含查询参数 '?',则只取问号前面的部分
|
|
|
|
String path = externalUrl; |
|
|
|
int queryIndex = externalUrl.indexOf('?'); |
|
|
|
if (queryIndex > 0) { |
|
|
|
path = externalUrl.substring(0, queryIndex); |
|
|
|
} |
|
|
|
// 2. 从纯净的路径中提取文件后缀
|
|
|
|
int lastDotIndex = path.lastIndexOf('.'); |
|
|
|
if (lastDotIndex > 0 && lastDotIndex < path.length() - 1) { |
|
|
|
ext = path.substring(lastDotIndex + 1); |
|
|
|
} |
|
|
|
if (ext == null){ |
|
|
|
log.error("无法从URL中提取文件后缀,URL:{}", externalUrl); |
|
|
|
ext = "jpg"; |
|
|
|
} |
|
|
|
|
|
|
|
String objectName = "ai/generate/" |
|
|
|
+ currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8) |
|
|
|
+ "." |
|
|
|
+ ext; |
|
|
|
return aliyunFileService.uploadFile(objectName, inputStream); |
|
|
|
} catch (Exception e) { |
|
|
|
log.error("下载外部URL到OSS失败,URL:{},异常信息:{}", externalUrl, e.getMessage(), e); |
|
|
|
throw new BusinessException("下载生成结果失败"); |
|
|
|
throw new MsgException("资源转存失败"); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|