diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java index e3aa01f..1a1f40d 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java @@ -1,5 +1,6 @@ package com.ruoyi.api; +import cn.hutool.core.util.NumberUtil; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -12,10 +13,12 @@ import com.ruoyi.ai.domain.ContentItem; import com.ruoyi.ai.domain.ImageUrl; import com.ruoyi.ai.domain.content; import com.ruoyi.ai.service.IAiOrderService; +import com.ruoyi.ai.service.IAiUserService; import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IByteService; import com.ruoyi.api.request.PortalVideoGenRequest; import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; @@ -30,6 +33,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -51,6 +55,7 @@ public class PortalVideoController extends BaseController { private final IAiOrderService aiOrderService; private final TencentCosUtil tencentCosUtil; private final PortalVideoProperties portalVideoProperties; + private final IAiUserService aiUserService; @Value("${volcengine.ark.callbackUrl:}") private String volcCallbackUrl; @@ -256,7 +261,8 @@ public class PortalVideoController extends BaseController { private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) { String functionType = resolveFunctionType(req); - AiOrder aiOrder = aiOrderService.getAiOrder(functionType); + // 判断余额是否足够,aimanager里配置了最低限额。创建订单 + AiOrder aiOrder = aiOrderService.getAiOrder(functionType, false); if (aiOrder == null) { return AjaxResult.error(-1, "You have a low balance, please recharge"); } @@ -273,7 +279,29 @@ public class PortalVideoController extends BaseController { } mergeVolcTaskIdIntoVideoParams(aiOrder, id); aiOrder.setResult(id); + // 字节订单号与请求ID + aiOrder.setThirdPartyOrderNum(id); + aiOrder.setVideoGenRequestId(byteBodyRes.getRequestId()); aiOrderService.orderSuccess(aiOrder); + // 查询任务详情,按字节返回的预扣数量扣减 + GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(id, key); + if (task == null || task.getUsage() == null || task.getUsage().getTotalTokens() == null) { + return AjaxResult.error(-2, "generation failed, byte task's usage is null"); + } + if (task.getUsage().getTotalTokens() <= 0) { + return AjaxResult.error(-2, "generation failed, byte task's totalTokens <= 0"); + } + BigDecimal totalTokens = new BigDecimal(task.getUsage().getTotalTokens()); + // 扣减余额 + aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId() + , NumberUtil.mul(-1, totalTokens), aiOrderService.getChangerType(functionType)); + // 设置订单信息 + AiOrder updAiOrder = new AiOrder(); + updAiOrder.setOrderNum(aiOrder.getOrderNum()); + updAiOrder.setPreDeductAmount(totalTokens); + // 先设置成预扣数量,等收到回调再改过来,这样后续报表会比较准确 + updAiOrder.setAmount(totalTokens); + aiOrderService.updateAiOrder(updAiOrder); return AjaxResult.success(byteBodyRes); } catch (Exception e) { aiOrderService.orderFailure(aiOrder); diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/GetVideoGenerationTaskResponse.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/GetVideoGenerationTaskResponse.java new file mode 100644 index 0000000..40b2eac --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/GetVideoGenerationTaskResponse.java @@ -0,0 +1,91 @@ +package com.ruoyi.common.core.response.video; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.ruoyi.common.core.response.video.dto.VideoGenerationUsage; +import com.ruoyi.common.core.response.video.dto.VideoTaskContent; +import com.ruoyi.common.core.response.video.dto.VideoTaskError; +import com.ruoyi.common.core.response.video.dto.VideoTaskTool; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "查询视频生成任务 API 返回参数") +public class GetVideoGenerationTaskResponse { + @ApiModelProperty(value = "视频生成任务 ID。") + private String id; + + @ApiModelProperty(value = "任务使用的模型名称和版本,模型名称-版本。") + private String model; + + @ApiModelProperty(value = "任务状态:queued 排队中;running 运行中;cancelled 已取消;succeeded 成功;failed 失败;expired 超时。") + private String status; + + @ApiModelProperty(value = "错误提示信息,任务成功时为 null。") + private VideoTaskError error; + + @JsonProperty("created_at") + @ApiModelProperty(value = "任务创建时间的 Unix 时间戳(秒)。") + private Integer createdAt; + + @JsonProperty("updated_at") + @ApiModelProperty(value = "任务当前状态更新时间的 Unix 时间戳(秒)。") + private Integer updatedAt; + + @ApiModelProperty(value = "视频生成任务的输出内容。") + private VideoTaskContent content; + + @ApiModelProperty(value = "本次请求使用的种子整数值。") + private Integer seed; + + @ApiModelProperty(value = "生成视频的分辨率。") + private String resolution; + + @ApiModelProperty(value = "生成视频的宽高比。") + private String ratio; + + @ApiModelProperty(value = "生成视频的时长,单位:秒。与 frames 只会返回其一。") + private Integer duration; + + @ApiModelProperty(value = "生成视频的帧数。与 duration 只会返回其一。") + private Integer frames; + + @JsonProperty("framespersecond") + @ApiModelProperty(value = "生成视频的帧率。") + private Integer framesPerSecond; + + @JsonProperty("generate_audio") + @ApiModelProperty(value = "生成的视频是否包含与画面同步的声音。仅 Seedance 1.5 pro 返回。") + private Boolean generateAudio; + + @ApiModelProperty(value = "本次请求模型实际使用的工具,未使用工具时不返回。") + private List tools; + + @JsonProperty("safety_identifier") + @ApiModelProperty(value = "终端用户的唯一标识符,创建任务时传入则原样返回。") + private String safetyIdentifier; + + @ApiModelProperty(value = "是否为 Draft 视频。仅 Seedance 1.5 pro 返回。") + private Boolean draft; + + @JsonProperty("draft_task_id") + @ApiModelProperty(value = "Draft 视频任务 ID,基于 Draft 生成正式视频时返回。") + private String draftTaskId; + + @JsonProperty("service_tier") + @ApiModelProperty(value = "实际处理任务使用的服务等级。") + private String serviceTier; + + @JsonProperty("execution_expires_after") + @ApiModelProperty(value = "任务超时阈值,单位:秒。") + private Integer executionExpiresAfter; + + @ApiModelProperty(value = "本次请求的 token 用量。") + private VideoGenerationUsage usage; +} diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/UsageToolUsage.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/UsageToolUsage.java new file mode 100644 index 0000000..7a1e895 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/UsageToolUsage.java @@ -0,0 +1,18 @@ +package com.ruoyi.common.core.response.video.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "使用工具的用量信息") +public class UsageToolUsage { + @JsonProperty("web_search") + @ApiModelProperty(value = "实际调用联网搜索工具的次数,仅开启联网搜索时返回。") + private Integer webSearch; +} diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoGenerationUsage.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoGenerationUsage.java new file mode 100644 index 0000000..f758b06 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoGenerationUsage.java @@ -0,0 +1,26 @@ +package com.ruoyi.common.core.response.video.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "本次请求的 token 用量") +public class VideoGenerationUsage { + @JsonProperty("completion_tokens") + @ApiModelProperty(value = "模型输出视频花费的 token 数量。") + private Integer completionTokens; + + @JsonProperty("total_tokens") + @ApiModelProperty(value = "本次请求消耗的总 token 数量。视频生成不统计输入 token,故 total_tokens 与 completion_tokens 一致。") + private Integer totalTokens; + + @JsonProperty("tool_usage") + @ApiModelProperty(value = "使用工具的用量信息。") + private UsageToolUsage toolUsage; +} diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskContent.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskContent.java new file mode 100644 index 0000000..b743d89 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskContent.java @@ -0,0 +1,22 @@ +package com.ruoyi.common.core.response.video.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "视频生成任务的输出内容") +public class VideoTaskContent { + @JsonProperty("video_url") + @ApiModelProperty(value = "生成视频的 URL,格式为 mp4。为保障信息安全,生成的视频会在 24 小时后被清理,请及时转存。") + private String videoUrl; + + @JsonProperty("last_frame_url") + @ApiModelProperty(value = "视频的尾帧图像 URL。有效期为 24 小时,请及时转存。创建任务时设置 return_last_frame: true 时返回。") + private String lastFrameUrl; +} diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskError.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskError.java new file mode 100644 index 0000000..9696c6e --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskError.java @@ -0,0 +1,19 @@ +package com.ruoyi.common.core.response.video.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "视频生成任务错误信息(任务成功时为 null)") +public class VideoTaskError { + @ApiModelProperty(value = "错误码。") + private String code; + + @ApiModelProperty(value = "错误提示信息。") + private String message; +} diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskTool.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskTool.java new file mode 100644 index 0000000..bfbd75e --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskTool.java @@ -0,0 +1,16 @@ +package com.ruoyi.common.core.response.video.dto; + +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +@ApiModel(description = "本次请求模型实际使用的工具") +public class VideoTaskTool { + @ApiModelProperty(value = "实际使用的工具类型,例如 web_search(联网搜索)。") + private String type; +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiOrder.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiOrder.java index 7fec8e0..0a116e7 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiOrder.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/AiOrder.java @@ -36,6 +36,10 @@ public class AiOrder extends BaseEntity { @Excel(name = "订单编号") private String orderNum; + /** 第三方单号 */ + @Excel(name = "第三方单号") + private String thirdPartyOrderNum; + /** 用户ID */ @Excel(name = "用户ID") private Long userId; @@ -44,10 +48,18 @@ public class AiOrder extends BaseEntity { @Excel(name = "AI类型") private String type; + /** 预扣金额 */ + @Excel(name = "预扣金额") + private BigDecimal preDeductAmount; + /** 金额 */ @Excel(name = "金额") private BigDecimal amount; + /** 生成视频时的请求ID */ + @Excel(name = "生成视频时的请求ID") + private String videoGenRequestId; + /** 生成结果 */ @Excel(name = "生成结果") private String result; diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/ByteBodyRes.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/ByteBodyRes.java index d3a305a..410c14f 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/ByteBodyRes.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/domain/ByteBodyRes.java @@ -34,6 +34,6 @@ public class ByteBodyRes { private Integer duration; private Integer framespersecond; private boolean draft; - - + // 火山请求ID,在head里,需手动设置 + private String requestId; } diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiOrderService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiOrderService.java index 1cbe6cd..7942212 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiOrderService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiOrderService.java @@ -71,6 +71,8 @@ public interface IAiOrderService { */ int deleteAiOrderById(Long id); + AiOrder getAiOrder(String aiType, boolean isReduceBalance); + AiOrder getAiOrder(String aiType); void orderFailure(AiOrder aiOrder); @@ -81,5 +83,7 @@ public interface IAiOrderService { AiOrder getAiOrderByPortalVideoTask(String taskId); + int getChangerType(String aiType); + BigDecimal getSumAmountByUserId(String userId); } diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java index ef42e36..c780de6 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java @@ -3,6 +3,7 @@ package com.ruoyi.ai.service; import com.ruoyi.ai.domain.ByteBodyReq; import com.ruoyi.ai.domain.ByteBodyRes; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; public interface IByteService { @@ -44,4 +45,9 @@ public interface IByteService { * GET 查询视频生成任务列表(火山 list 文档),返回原始 JSON 字符串 */ String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception; + + /** + * GET 查询视频生成任务(单个) + */ + GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception; } diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiOrderServiceImpl.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiOrderServiceImpl.java index ff2f117..813f8d1 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiOrderServiceImpl.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiOrderServiceImpl.java @@ -136,15 +136,24 @@ public class AiOrderServiceImpl implements IAiOrderService { return aiOrderMapper.deleteAiOrderById(id); } + + /** + * 生成订单 + * @param aiType 对应的AI类型 + */ + public AiOrder getAiOrder(String aiType) { + return getAiOrder(aiType, true); + } + /** * 生成订单 * * @param aiType 对应的AI类型 - * @return + * @param isReduceBalance 是否扣减账户 */ @Override @Transactional - public AiOrder getAiOrder(String aiType) { + public AiOrder getAiOrder(String aiType, boolean isReduceBalance) { AiManager aiManager = aiManagerService.selectAiManagerByType(aiType); if (aiManager == null) { throw new ServiceException( @@ -164,12 +173,20 @@ public class AiOrderServiceImpl implements IAiOrderService { aiOrder.setUserId(SecurityUtils.getAiUserId()); aiOrder.setType(aiType); aiOrder.setResult(null); - aiOrder.setAmount(aiManager.getPrice()); + if (isReduceBalance) { + aiOrder.setAmount(aiManager.getPrice()); + } else { + // 不按aimanager扣减的,等提交任务再按实扣减 + aiOrder.setAmount(new BigDecimal(0)); + } aiOrder.setStatus(0); aiOrder.setSource(aiUser.getSource()); aiOrderMapper.insert(aiOrder); // 执行余额变更 - aiUserService.addUserBalance(orderno, SecurityUtils.getAiUserId(), NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType)); + if (isReduceBalance) { + aiUserService.addUserBalance(orderno, SecurityUtils.getAiUserId() + , NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType)); + } return aiOrder; } @@ -209,6 +226,7 @@ public class AiOrderServiceImpl implements IAiOrderService { return aiOrderMapper.getAiOrderByPortalVideoTask(taskId); } + @Override public int getChangerType(String aiType) { switch (aiType) { case "11": diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java index e1ffd17..b1e98f7 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java @@ -8,6 +8,7 @@ import com.ruoyi.ai.domain.ByteBodyRes; import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IByteService; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.http.OkHttpUtils; @@ -121,8 +122,12 @@ public class ByteService implements IByteService { } String responseBody = response.body().string(); - log.info("调用火山接口, response = {}", responseBody); - return objectMapper.readValue(responseBody, ByteBodyRes.class); + String requestId = response.header("x-request-id"); + log.info("调用火山接口, requestId = {}, response = {}", requestId, responseBody); + ByteBodyRes resp = objectMapper.readValue(responseBody, ByteBodyRes.class); + // 从headder拿到requestId,便于联调 + resp.setRequestId(requestId); + return resp; } @Override @@ -234,4 +239,33 @@ public class ByteService implements IByteService { private String resolveCurrentAiUserApiKey() { return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId()); } + + @Override + public GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception { + if (StringUtils.isBlank(arkApiKey)) { + throw new Exception("getVideoGenerationTasks error:apiKey is null"); + } + if (StringUtils.isBlank(id)) { + throw new Exception("getVideoGenerationTasks error:id is null"); + } + + HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id); + if (parsed == null) { + throw new Exception("listVideoGenerationTasks error:invalid base url"); + } + Request request = new Request.Builder() + .url(parsed) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + arkApiKey) + .get().build(); + Response response = OkHttpUtils.newCall(request).execute(); + if (response.body() == null) { + throw new Exception("listVideoGenerationTasks response null"); + } + String body = response.body().string(); + if (!response.isSuccessful()) { + throw new Exception("listVideoGenerationTasks error:" + body); + } + return objectMapper.readValue(body, GetVideoGenerationTaskResponse.class); + } }