feat: 生成视频时,按字节接口扣减用量

This commit is contained in:
yys 2026-04-08 16:56:31 +08:00
parent d807f71677
commit 38b26f9ffb
13 changed files with 303 additions and 9 deletions

View File

@ -1,5 +1,6 @@
package com.ruoyi.api; package com.ruoyi.api;
import cn.hutool.core.util.NumberUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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.ImageUrl;
import com.ruoyi.ai.domain.content; import com.ruoyi.ai.domain.content;
import com.ruoyi.ai.service.IAiOrderService; import com.ruoyi.ai.service.IAiOrderService;
import com.ruoyi.ai.service.IAiUserService;
import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IByteDeptApiKeyService;
import com.ruoyi.ai.service.IByteService; import com.ruoyi.ai.service.IByteService;
import com.ruoyi.api.request.PortalVideoGenRequest; import com.ruoyi.api.request.PortalVideoGenRequest;
import com.ruoyi.common.core.controller.BaseController; 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.exception.ServiceException;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo; 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.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@ -51,6 +55,7 @@ public class PortalVideoController extends BaseController {
private final IAiOrderService aiOrderService; private final IAiOrderService aiOrderService;
private final TencentCosUtil tencentCosUtil; private final TencentCosUtil tencentCosUtil;
private final PortalVideoProperties portalVideoProperties; private final PortalVideoProperties portalVideoProperties;
private final IAiUserService aiUserService;
@Value("${volcengine.ark.callbackUrl:}") @Value("${volcengine.ark.callbackUrl:}")
private String volcCallbackUrl; private String volcCallbackUrl;
@ -256,7 +261,8 @@ public class PortalVideoController extends BaseController {
private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) { private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) {
String functionType = resolveFunctionType(req); String functionType = resolveFunctionType(req);
AiOrder aiOrder = aiOrderService.getAiOrder(functionType); // 判断余额是否足够aimanager里配置了最低限额创建订单
AiOrder aiOrder = aiOrderService.getAiOrder(functionType, false);
if (aiOrder == null) { if (aiOrder == null) {
return AjaxResult.error(-1, "You have a low balance, please recharge"); return AjaxResult.error(-1, "You have a low balance, please recharge");
} }
@ -273,7 +279,29 @@ public class PortalVideoController extends BaseController {
} }
mergeVolcTaskIdIntoVideoParams(aiOrder, id); mergeVolcTaskIdIntoVideoParams(aiOrder, id);
aiOrder.setResult(id); aiOrder.setResult(id);
// 字节订单号与请求ID
aiOrder.setThirdPartyOrderNum(id);
aiOrder.setVideoGenRequestId(byteBodyRes.getRequestId());
aiOrderService.orderSuccess(aiOrder); 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); return AjaxResult.success(byteBodyRes);
} catch (Exception e) { } catch (Exception e) {
aiOrderService.orderFailure(aiOrder); aiOrderService.orderFailure(aiOrder);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,6 +36,10 @@ public class AiOrder extends BaseEntity {
@Excel(name = "订单编号") @Excel(name = "订单编号")
private String orderNum; private String orderNum;
/** 第三方单号 */
@Excel(name = "第三方单号")
private String thirdPartyOrderNum;
/** 用户ID */ /** 用户ID */
@Excel(name = "用户ID") @Excel(name = "用户ID")
private Long userId; private Long userId;
@ -44,10 +48,18 @@ public class AiOrder extends BaseEntity {
@Excel(name = "AI类型") @Excel(name = "AI类型")
private String type; private String type;
/** 预扣金额 */
@Excel(name = "预扣金额")
private BigDecimal preDeductAmount;
/** 金额 */ /** 金额 */
@Excel(name = "金额") @Excel(name = "金额")
private BigDecimal amount; private BigDecimal amount;
/** 生成视频时的请求ID */
@Excel(name = "生成视频时的请求ID")
private String videoGenRequestId;
/** 生成结果 */ /** 生成结果 */
@Excel(name = "生成结果") @Excel(name = "生成结果")
private String result; private String result;

View File

@ -34,6 +34,6 @@ public class ByteBodyRes {
private Integer duration; private Integer duration;
private Integer framespersecond; private Integer framespersecond;
private boolean draft; private boolean draft;
// 火山请求ID在head里需手动设置
private String requestId;
} }

View File

@ -71,6 +71,8 @@ public interface IAiOrderService {
*/ */
int deleteAiOrderById(Long id); int deleteAiOrderById(Long id);
AiOrder getAiOrder(String aiType, boolean isReduceBalance);
AiOrder getAiOrder(String aiType); AiOrder getAiOrder(String aiType);
void orderFailure(AiOrder aiOrder); void orderFailure(AiOrder aiOrder);
@ -81,5 +83,7 @@ public interface IAiOrderService {
AiOrder getAiOrderByPortalVideoTask(String taskId); AiOrder getAiOrderByPortalVideoTask(String taskId);
int getChangerType(String aiType);
BigDecimal getSumAmountByUserId(String userId); BigDecimal getSumAmountByUserId(String userId);
} }

View File

@ -3,6 +3,7 @@ package com.ruoyi.ai.service;
import com.ruoyi.ai.domain.ByteBodyReq; import com.ruoyi.ai.domain.ByteBodyReq;
import com.ruoyi.ai.domain.ByteBodyRes; import com.ruoyi.ai.domain.ByteBodyRes;
import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse;
public interface IByteService { public interface IByteService {
@ -44,4 +45,9 @@ public interface IByteService {
* GET 查询视频生成任务列表火山 list 文档返回原始 JSON 字符串 * GET 查询视频生成任务列表火山 list 文档返回原始 JSON 字符串
*/ */
String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception; String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception;
/**
* GET 查询视频生成任务(单个)
*/
GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception;
} }

View File

@ -136,15 +136,24 @@ public class AiOrderServiceImpl implements IAiOrderService {
return aiOrderMapper.deleteAiOrderById(id); return aiOrderMapper.deleteAiOrderById(id);
} }
/**
* 生成订单
* @param aiType 对应的AI类型
*/
public AiOrder getAiOrder(String aiType) {
return getAiOrder(aiType, true);
}
/** /**
* 生成订单 * 生成订单
* *
* @param aiType 对应的AI类型 * @param aiType 对应的AI类型
* @return * @param isReduceBalance 是否扣减账户
*/ */
@Override @Override
@Transactional @Transactional
public AiOrder getAiOrder(String aiType) { public AiOrder getAiOrder(String aiType, boolean isReduceBalance) {
AiManager aiManager = aiManagerService.selectAiManagerByType(aiType); AiManager aiManager = aiManagerService.selectAiManagerByType(aiType);
if (aiManager == null) { if (aiManager == null) {
throw new ServiceException( throw new ServiceException(
@ -164,12 +173,20 @@ public class AiOrderServiceImpl implements IAiOrderService {
aiOrder.setUserId(SecurityUtils.getAiUserId()); aiOrder.setUserId(SecurityUtils.getAiUserId());
aiOrder.setType(aiType); aiOrder.setType(aiType);
aiOrder.setResult(null); aiOrder.setResult(null);
aiOrder.setAmount(aiManager.getPrice()); if (isReduceBalance) {
aiOrder.setAmount(aiManager.getPrice());
} else {
// 不按aimanager扣减的等提交任务再按实扣减
aiOrder.setAmount(new BigDecimal(0));
}
aiOrder.setStatus(0); aiOrder.setStatus(0);
aiOrder.setSource(aiUser.getSource()); aiOrder.setSource(aiUser.getSource());
aiOrderMapper.insert(aiOrder); 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; return aiOrder;
} }
@ -209,6 +226,7 @@ public class AiOrderServiceImpl implements IAiOrderService {
return aiOrderMapper.getAiOrderByPortalVideoTask(taskId); return aiOrderMapper.getAiOrderByPortalVideoTask(taskId);
} }
@Override
public int getChangerType(String aiType) { public int getChangerType(String aiType) {
switch (aiType) { switch (aiType) {
case "11": case "11":

View File

@ -8,6 +8,7 @@ import com.ruoyi.ai.domain.ByteBodyRes;
import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IByteDeptApiKeyService;
import com.ruoyi.ai.service.IByteService; import com.ruoyi.ai.service.IByteService;
import com.ruoyi.common.core.domain.AjaxResult; 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.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.http.OkHttpUtils; import com.ruoyi.common.utils.http.OkHttpUtils;
@ -121,8 +122,12 @@ public class ByteService implements IByteService {
} }
String responseBody = response.body().string(); String responseBody = response.body().string();
log.info("调用火山接口, response = {}", responseBody); String requestId = response.header("x-request-id");
return objectMapper.readValue(responseBody, ByteBodyRes.class); log.info("调用火山接口, requestId = {}, response = {}", requestId, responseBody);
ByteBodyRes resp = objectMapper.readValue(responseBody, ByteBodyRes.class);
// 从headder拿到requestId便于联调
resp.setRequestId(requestId);
return resp;
} }
@Override @Override
@ -234,4 +239,33 @@ public class ByteService implements IByteService {
private String resolveCurrentAiUserApiKey() { private String resolveCurrentAiUserApiKey() {
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId()); return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
} }
@Override
public GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception {
if (StringUtils.isBlank(arkApiKey)) {
throw new Exception("getVideoGenerationTasks errorapiKey is null");
}
if (StringUtils.isBlank(id)) {
throw new Exception("getVideoGenerationTasks errorid is null");
}
HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id);
if (parsed == null) {
throw new Exception("listVideoGenerationTasks errorinvalid 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);
}
} }