From 38b26f9ffb00e1115f3dee71911d35ba659d09b5 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Wed, 8 Apr 2026 16:56:31 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=97=B6=EF=BC=8C=E6=8C=89=E5=AD=97=E8=8A=82=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=89=A3=E5=87=8F=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ruoyi/api/PortalVideoController.java | 30 +++++- .../video/GetVideoGenerationTaskResponse.java | 91 +++++++++++++++++++ .../response/video/dto/UsageToolUsage.java | 18 ++++ .../video/dto/VideoGenerationUsage.java | 26 ++++++ .../response/video/dto/VideoTaskContent.java | 22 +++++ .../response/video/dto/VideoTaskError.java | 19 ++++ .../response/video/dto/VideoTaskTool.java | 16 ++++ .../java/com/ruoyi/ai/domain/AiOrder.java | 12 +++ .../java/com/ruoyi/ai/domain/ByteBodyRes.java | 4 +- .../com/ruoyi/ai/service/IAiOrderService.java | 4 + .../com/ruoyi/ai/service/IByteService.java | 6 ++ .../ai/service/impl/AiOrderServiceImpl.java | 26 +++++- .../ruoyi/ai/service/impl/ByteService.java | 38 +++++++- 13 files changed, 303 insertions(+), 9 deletions(-) create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/GetVideoGenerationTaskResponse.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/UsageToolUsage.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoGenerationUsage.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskContent.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskError.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/response/video/dto/VideoTaskTool.java 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); + } } From 80c136ad18748ec6b26767603b6fbe5cffe09c0a Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Thu, 9 Apr 2026 16:52:50 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=97=B6=EF=BC=8C=E6=8C=89=E5=AD=97=E8=8A=82=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=89=A3=E5=87=8F=E7=94=A8=E9=87=8F(=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=88=9D=E6=AD=A5=E5=AE=8C=E6=88=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/api/ByteApiController.java | 302 ++++++++++++------ .../resources/i18n/messages_en_US.properties | 3 +- .../video/dto/VideoTaskCallBackRequest.java | 91 ++++++ .../ruoyi/common/enums/AiOrderStatusType.java | 13 + .../common/enums/VideoTaskStatusType.java | 46 +++ web-api/ruoyi-framework/pom.xml | 7 + .../java/com/ruoyi/ai/domain/AiOrder.java | 3 + .../com/ruoyi/ai/mapper/AiOrderMapper.java | 4 + .../com/ruoyi/ai/service/IAiOrderService.java | 6 + .../com/ruoyi/ai/service/IByteService.java | 6 + .../ai/service/impl/AiOrderServiceImpl.java | 22 +- .../ruoyi/ai/service/impl/ByteService.java | 2 +- 12 files changed, 406 insertions(+), 99 deletions(-) create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/video/dto/VideoTaskCallBackRequest.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/AiOrderStatusType.java create mode 100644 web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/VideoTaskStatusType.java diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index 7871511..dcbbf68 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -1,29 +1,33 @@ package com.ruoyi.api; import com.ruoyi.ai.domain.*; -import com.ruoyi.ai.service.IAiManagerService; -import com.ruoyi.ai.service.IAiOrderService; -import com.ruoyi.ai.service.IAiTagService; -import com.ruoyi.ai.service.IByteService; +import com.ruoyi.ai.service.*; import com.ruoyi.api.request.ByteApiRequest; import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.constant.BalanceChangerConstants; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.domain.model.LoginAiUser; +import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; +import com.ruoyi.common.enums.AiOrderStatusType; +import com.ruoyi.common.enums.VideoTaskStatusType; import com.ruoyi.common.utils.RandomStringUtil; -import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.TencentCosUtil; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; /** @@ -34,18 +38,22 @@ import java.util.regex.Pattern; @Api(tags = "生成内容") @RequiredArgsConstructor(onConstructor_ = @Autowired) public class ByteApiController extends BaseController { + // 回调时分布式锁的key前缀 + private static final String VOLC_CALLBACK_LOCK_KEY_PREFIX = "volc:callback:lock:"; + // 锁参数 + private static final int LOCK_WAIT_SECONDS = 10; + private static final int LOCK_LEASE_SECONDS = 20; + // 任务成功时流水表备注 + private static final String TASK_SUCCESS_BALANCE_REMARK = "order.number.generation.successes"; private final IByteService byteService; private final TencentCosUtil tencentCosUtil; private final IAiOrderService aiOrderService; private final IAiManagerService managerService; private final IAiTagService aiTagService; - @Value("${byteapi.callBackUrl}") - private String url; + private final IAiUserService aiUserService; + private final RedissonClient redissonClient; - // 火山引擎配置 - @Value("${volcengine.ark.baseUrl}") - private String volcBaseUrl; @Value("${volcengine.ark.callbackUrl}") private String volcCallbackUrl; @@ -95,7 +103,7 @@ public class ByteApiController extends BaseController { ByteBodyReq byteBodyReq = new ByteBodyReq(); // model由前端传入,默认为Seedance 2.0 - byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ? + byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ? request.getModel() : "ep-20260326165811-dlkth"); byteBodyReq.setPrompt(text); byteBodyReq.setSequential_image_generation("disabled"); @@ -254,13 +262,13 @@ public class ByteApiController extends BaseController { ByteBodyReq byteBodyReq = new ByteBodyReq(); // model由前端传入,默认为Seedance2.0 - byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ? + byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ? request.getModel() : "ep-20260326165811-dlkth"); byteBodyReq.setCallback_url(volcCallbackUrl); // 构建符合火山引擎格式的content List contentList = new ArrayList<>(); - + // 文本提示词 ContentItem textItem = new ContentItem(); textItem.setType("text"); @@ -331,95 +339,200 @@ public class ByteApiController extends BaseController { @PostMapping(value = "/volcCallback") @ApiOperation("火山引擎视频回调") @Anonymous - public AjaxResult volcCallback(@RequestBody ByteBodyRes byteBodyRes) throws Exception { - logger.info("volcCallback 收到回调数据: {}", byteBodyRes); - String id = byteBodyRes.getId(); - if (StringUtils.isEmpty(id)) { - logger.warn("volcCallback 无任务 id,跳过业务处理"); - return AjaxResult.success("callback success"); + public AjaxResult volcCallback(@RequestBody VideoTaskCallBackRequest request) throws Exception { + logger.info("volcCallback 收到回调数据: {}", request); + // 1、基础参数校验 + AjaxResult result = volcCallbackBaseCheck(request); + if (result != null) { + return result; } - - Integer code = byteBodyRes.getCode(); - boolean codeError = code != null && code != 200; - String st = byteBodyRes.getStatus(); - - if (st != null && !st.isEmpty()) { - // 执行中状态 - Integer extStatus = null; - if ("queued".equals(st)) { - extStatus = 0; - } else if ("running".equals(st)) { - extStatus = 1; + // 2、从官方获取任务数据 + String apiKey = byteService.resolveCurrentAiUserApiKey(); + GetVideoGenerationTaskResponse taskResp = byteService.getVideoGenerationTasks(request.getId(), apiKey); + // 3、官方数据校验 + result = volcCallbackByteCheck(request, taskResp); + if (result != null) { + return result; + } + // 4、查询订单(同 taskId 串行:Redisson 分布式锁;步骤 1~3 已在锁外) + String taskId = request.getId(); + String lockKey = VOLC_CALLBACK_LOCK_KEY_PREFIX + taskId; + RLock lock = redissonClient.getLock(lockKey); + boolean locked = false; + try { + locked = lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS); + if (!locked) { + logger.warn("volcCallback skip: concurrent handling for same task, third party order num = {}", taskId); + return AjaxResult.success(); } - if (extStatus != null) { - AiOrder order = findAiOrderByVolcTaskId(id); - if (order == null) { - logger.warn("volcCallback 修改执行中状态,未找到任务对应订单, id={}, {}", id, st); - return AjaxResult.success("callback success"); - } -// if (order.getStatus() != 0) { -// logger.warn("订单状态不为0, 因此不修改ext_status, id = {}, status = {}, order status = {}", id, st, order.getStatus()); -// return AjaxResult.success("callback success"); -// } - AiOrder upd = new AiOrder(); - upd.setId(order.getId()); - upd.setExtStatus(extStatus); - aiOrderService.updateAiOrder(upd); - return AjaxResult.success("callback success"); + AiOrder order = aiOrderService.selectOneByThirdPartyOrderNum(taskId); + if (order == null) { + // 可能是其他环境生成的但回调地址配置成正式的 + logger.warn("volcCallback aiorder is not exist! third party order num = {}", taskId); + return AjaxResult.success(); } - } - - if (codeError) { - markVolcCallbackOrderClearResultFailed(id, "code=" + code); - return AjaxResult.success("callback success"); - } - - if ("succeeded".equals(st)) { - content contentObj = byteBodyRes.getContent(); - if (contentObj != null && StringUtils.isNotEmpty(contentObj.getVideo_url())) { - String videoUrl = contentObj.getVideo_url(); - videoUrl = tencentCosUtil.uploadFileByUrl(videoUrl); - contentObj.setVideo_url(videoUrl); - - AiOrder aiOrderByResult = findAiOrderByVolcTaskId(id); - if (aiOrderByResult != null) { - AiOrder aiOrder = new AiOrder(); - aiOrder.setId(aiOrderByResult.getId()); - aiOrder.setResult(videoUrl); - aiOrder.setStatus(1); - aiOrderService.updateAiOrder(aiOrder); - } + // 5、状态为队列中、执行中,只更新任务状态 + result = volcCallbackRunningTaskProcess(taskResp, order); + if (result != null) { + return result; + } + // 6、订单数据校验 + result = volcCallbackOrderCheck(taskResp, order); + if (result != null) { + return result; + } + // 7、根据状态做不同的处理(加事务) + String status = taskResp.getStatus(); + if (VideoTaskStatusType.SUCCEEDED.getName().equals(status)) { + // 成功,预扣 + return volcCallbackSuccessProcess(request, taskResp, order); } else { - handleVolcCallbackFailure(id, "succeeded但缺少video_url"); + // 前面已判断过status的合法性,并处理了三种非失败的状态,所以可以确定是取消、失败、超时 + return volcCallbackFailProcess(taskResp, order); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.error("volcCallback interrupted while waiting for lock, third party order num = {}", taskId, e); + return AjaxResult.error(); + } catch (Exception ex) { + logger.error("volcCallback error! third party order num = {}, status = {}", + request.getId(), request.getStatus(), ex); + return AjaxResult.error(); + } finally { + if (locked && lock.isHeldByCurrentThread()) { + lock.unlock(); } - return AjaxResult.success("callback success"); } + } - // failed、canceled 等终态,或 status 与成功/进行中均不一致 - if (StringUtils.isNotEmpty(st) || code != null) { - handleVolcCallbackFailure(id, "status=" + st); - } else { - logger.warn("volcCallback 未携带可判定的 status/code,id={}", id); + @Transactional(rollbackFor = Exception.class) + private AjaxResult volcCallbackFailProcess(GetVideoGenerationTaskResponse taskResp, AiOrder order) { + aiOrderService.orderFailure(order); + return null; + } + + @Transactional(rollbackFor = Exception.class) + private AjaxResult volcCallbackSuccessProcess(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp, AiOrder order) { + // 用量是否一致 + Integer requestTotalTokens = request.getUsage() == null || request.getUsage().getTotalTokens() == null + ? 0 : request.getUsage().getTotalTokens(); + Integer officialTotalTokens = taskResp.getUsage() == null || taskResp.getUsage().getTotalTokens() == null + ? 0 : taskResp.getUsage().getTotalTokens(); + if (requestTotalTokens.equals(officialTotalTokens)) { + logger.error("volcCallback request's total tokens != official tokens! third party order num = {}, request's tokens = {}, official tokens = {}", + request.getId(), requestTotalTokens, officialTotalTokens); + return AjaxResult.error(); } + if (officialTotalTokens <= 0) { + // 异常情况,应该不会出现,以防万一 + logger.error("volcCallback official tokens <= 0! third party order num = {}, request's tokens = {}, official tokens = {}", + request.getId(), requestTotalTokens, officialTotalTokens); + return AjaxResult.error(); + } + BigDecimal realAmount = new BigDecimal(officialTotalTokens); + // 用量回补、多退少补 = 预扣量 - 实际用量 + // 有预扣值才回补,没有的是历史单,不处理 + if (order.getPreDeductAmount() != null + && order.getPreDeductAmount().compareTo(new BigDecimal(0)) > 0) { + BigDecimal addAmount = order.getPreDeductAmount().subtract(realAmount); + if (addAmount.compareTo(new BigDecimal(0)) != 0) { + // 回补 + aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), addAmount, + BalanceChangerConstants.REFUND, TASK_SUCCESS_BALANCE_REMARK); + } + } + // 设置视频地址与状态 + if (taskResp.getContent() != null && StringUtils.isNotEmpty(taskResp.getContent().getVideoUrl())) { + order.setResult(taskResp.getContent().getVideoUrl()); + } + // 设置用量 + order.setAmount(realAmount); + // 订单状态 + order.setStatus(AiOrderStatusType.FINISH.ordinal()); + order.setIsBackfilled(1); + aiOrderService.orderSuccess(order); return AjaxResult.success("callback success"); } + private AjaxResult volcCallbackOrderCheck(GetVideoGenerationTaskResponse taskResp, AiOrder order) { + // 订单状态如果不为执行中则不做处理 + if (order.getStatus() != null && order.getStatus() != AiOrderStatusType.RUNNING.ordinal()) { + logger.warn("volcCallback aiorder's status is not running! third party order num = {}, order status = {}" + , taskResp.getId(), order.getStatus()); + return AjaxResult.success(); + } + if (order.getIsBackfilled() != null && order.getIsBackfilled() == 1) { + // 已回补过,不再回补,直接返回成功 + logger.warn("volcCallback is back filled! third party order num = {}, order status = {}" + , taskResp.getId(), order.getStatus()); + return AjaxResult.success(); + } + return null; + } + + private AjaxResult volcCallbackByteCheck(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp) { + String status = taskResp.getStatus(); + // 请求的状态与字节的状态是否一致 + if (!status.equals(taskResp.getStatus().toLowerCase())) { + logger.error("volcCallback request's status != official status! order third party order num = {}, request's status = {}, official status = {}", + request.getId(), status, taskResp.getStatus()); + return AjaxResult.error(); + } + return null; + } + + private AjaxResult volcCallbackBaseCheck(VideoTaskCallBackRequest request) { + // 参数校验 + String status = request.getStatus(); + if (StringUtils.isEmpty(request.getId()) || StringUtils.isEmpty(status)) { + logger.error("volcCallbackBaseCheck id or status is null! third party order num = {}, status = {}" + , request.getId(), status); + return AjaxResult.error("id or status is null!"); + } + // 状态是否正确 + if (!VideoTaskStatusType.isValidName(status)) { + logger.error("volcCallbackBaseCheck invalid status! third party order num = {}, status = {}" + , request.getId(), status); + return AjaxResult.error(); + } + return null; + } + + private AjaxResult volcCallbackRunningTaskProcess(GetVideoGenerationTaskResponse taskResp, AiOrder order) { + // 执行中状态 ,更新到ext_status字段 + Integer extStatus = null; + String status = taskResp.getStatus(); + if (VideoTaskStatusType.QUEUED.getName().equals(status)) { + extStatus = 0; + } else if (VideoTaskStatusType.RUNNING.getName().equals(status)) { + extStatus = 1; + } + if (extStatus != null) { + order.setExtStatus(extStatus); + aiOrderService.updateAiOrder(order); + logger.info("volcCallback order extStatus is updated! third party order num = {}, extStatus = {}" + , taskResp.getId(), order.getExtStatus()); + return AjaxResult.success(); + } + return null; + } + /** * 回调体 code 非 200(HTTP/业务状态码):清空 result,status=2,不退款。 */ - private void markVolcCallbackOrderClearResultFailed(String taskId, String reason) { - AiOrder order = findAiOrderByVolcTaskId(taskId); - if (order == null) { - logger.warn("volcCallback code 非 200:未找到任务对应订单, taskId={}, {}", taskId, reason); - return; - } - AiOrder upd = new AiOrder(); - upd.setId(order.getId()); - upd.setResult(""); - upd.setStatus(2); - aiOrderService.updateAiOrder(upd); - logger.warn("volcCallback code 非 200,已清空 result 并 status=2, orderId={}, {}", order.getId(), reason); - } +// private void markVolcCallbackOrderClearResultFailed(String taskId, String reason) { +// AiOrder order = findAiOrderByVolcTaskId(taskId); +// if (order == null) { +// logger.warn("volcCallback code 非 200:未找到任务对应订单, taskId={}, {}", taskId, reason); +// return; +// } +// AiOrder upd = new AiOrder(); +// upd.setId(order.getId()); +// upd.setResult(""); +// upd.setStatus(2); +// aiOrderService.updateAiOrder(upd); +// logger.warn("volcCallback code 非 200,已清空 result 并 status=2, orderId={}, {}", order.getId(), reason); +// } private AiOrder findAiOrderByVolcTaskId(String taskId) { AiOrder order = aiOrderService.getAiOrderByPortalVideoTask(taskId); @@ -449,10 +562,9 @@ public class ByteApiController extends BaseController { logger.warn("volcCallback 任务失败,已标记订单失败并退款, taskId={}, reason={}", taskId, reason); } - @PostMapping(value = "/{id}/cancel") - @ApiOperation("取消视频生成任务") - public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception { - return byteService.cancelVideoTask(id); - } - +// @PostMapping(value = "/{id}/cancel") +// @ApiOperation("取消视频生成任务") +// public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception { +// return byteService.cancelVideoTask(id); +// } } diff --git a/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties b/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties index b6cc20e..2ed9e69 100644 --- a/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties +++ b/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties @@ -30,4 +30,5 @@ email.verification.code.error=Verification code is incorrect, please try again. # User not found user.not.found=User not found. user.password.incorrect=Password is incorrect, please try again. -order.number.generation.failed=Order number {0} generation failed, please try again later. \ No newline at end of file +order.number.generation.failed=Order number {0} generation failed, please try again later. +order.number.generation.successes=Order number {0} generation successes! \ No newline at end of file diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/video/dto/VideoTaskCallBackRequest.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/video/dto/VideoTaskCallBackRequest.java new file mode 100644 index 0000000..94a5c71 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/core/request/video/dto/VideoTaskCallBackRequest.java @@ -0,0 +1,91 @@ +package com.ruoyi.common.core.request.video.dto; + +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 = "生成视频任务的回调接口") +public class VideoTaskCallBackRequest { + @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/enums/AiOrderStatusType.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/AiOrderStatusType.java new file mode 100644 index 0000000..51bab74 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/AiOrderStatusType.java @@ -0,0 +1,13 @@ +package com.ruoyi.common.enums; + +/** + * AiOrder表中status的类型 + */ +public enum AiOrderStatusType { + // 0-进行中 + RUNNING, + // 1-已完成 + FINISH, + // 2-失败(余额退回) + FAIL; +} \ No newline at end of file diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/VideoTaskStatusType.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/VideoTaskStatusType.java new file mode 100644 index 0000000..cc9ab14 --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/enums/VideoTaskStatusType.java @@ -0,0 +1,46 @@ +package com.ruoyi.common.enums; + +import lombok.Getter; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 生成视频任务中,状态的枚举 + */ +@Getter +public enum VideoTaskStatusType { + QUEUED("queued"), + + RUNNING("running"), + + CANCELLED("cancelled"), + + SUCCEEDED("succeeded"), + + FAILED("failed"), + + EXPIRED("expired"); + + private final String name; + + private static final Set VALID_NAMES = Arrays.stream(VideoTaskStatusType.values()) + .map(status -> status.name) + .collect(Collectors.toSet()); + /** + * 判断名称是否正确, 不区分大小写 + */ + public static boolean isValidName(String name) { + if (name == null) { + return false; + } + String nameLowerCase = name.toLowerCase(); + // 直接判断小写后的入参是否在静态Set中 + return VALID_NAMES.contains(nameLowerCase); + } + + VideoTaskStatusType(String name) { + this.name = name; + } +} diff --git a/web-api/ruoyi-framework/pom.xml b/web-api/ruoyi-framework/pom.xml index e42a12f..e2acc00 100644 --- a/web-api/ruoyi-framework/pom.xml +++ b/web-api/ruoyi-framework/pom.xml @@ -59,6 +59,13 @@ ruoyi-system + + + org.redisson + redisson-spring-boot-starter + 3.17.7 + + \ No newline at end of file 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 0a116e7..c0ad6f6 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 @@ -56,6 +56,9 @@ public class AiOrder extends BaseEntity { @Excel(name = "金额") private BigDecimal amount; + @Excel(name = "是否回补处理过: 0-否 1-是") + private Integer isBackfilled; + /** 生成视频时的请求ID */ @Excel(name = "生成视频时的请求ID") private String videoGenRequestId; diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java index cafa00d..bf1653e 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiOrderMapper.java @@ -6,6 +6,7 @@ import java.util.List; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ruoyi.ai.domain.AiOrder; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; /** * 订单管理Mapper接口 @@ -26,4 +27,7 @@ public interface AiOrderMapper extends BaseMapper { AiOrder getAiOrderByPortalVideoTask(@Param("taskId") String taskId); BigDecimal getSumAmountByUserId(@Param("userId") String userId); + + @Select("SELECT * FROM ai_order WHERE third_party_order_num = #{id} LIMIT 1") + AiOrder selectOneByThirdPartyOrderNum(@Param("id") String id); } 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 7942212..28c3835 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 @@ -6,6 +6,7 @@ import java.util.List; import com.baomidou.mybatisplus.core.metadata.IPage; import com.ruoyi.ai.domain.AiOrder; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.transaction.annotation.Transactional; /** * 订单管理Service接口 @@ -77,6 +78,9 @@ public interface IAiOrderService { void orderFailure(AiOrder aiOrder); + @Transactional + void orderFailure(AiOrder aiOrder, BigDecimal amount); + void orderSuccess(AiOrder aiOrder); AiOrder getAiOrderByResult(String result); @@ -86,4 +90,6 @@ public interface IAiOrderService { int getChangerType(String aiType); BigDecimal getSumAmountByUserId(String userId); + + AiOrder selectOneByThirdPartyOrderNum(String id); } 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 c780de6..6f02655 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 @@ -46,6 +46,12 @@ public interface IByteService { */ String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception; + /** + * 获取当前用户所使用的api key + * @return + */ + String resolveCurrentAiUserApiKey(); + /** * GET 查询视频生成任务(单个) */ 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 813f8d1..5a4efea 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 @@ -190,15 +190,28 @@ public class AiOrderServiceImpl implements IAiOrderService { return aiOrder; } + /** + * 订单失败时,返回订单amount对应数量的用量 + */ @Override - @Transactional public void orderFailure(AiOrder aiOrder) { + orderFailure(aiOrder, aiOrder.getAmount()); + } + + /** + * 订单失败时,返回指定用量的用量 + */ + @Transactional + @Override + public void orderFailure(AiOrder aiOrder, BigDecimal amount) { + aiOrder.setIsBackfilled(1); aiOrder.setStatus(2); String remark = MessageUtils.message("order.number.generation.failed", aiOrder.getOrderNum()); aiOrder.setRemark(remark); aiOrderMapper.updateById(aiOrder); Long userId = aiOrder.getUserId() != null ? aiOrder.getUserId() : SecurityUtils.getAiUserId(); - aiUserService.addUserBalance(aiOrder.getOrderNum(), userId, aiOrder.getAmount(), BalanceChangerConstants.REFUND, remark); + aiUserService.addUserBalance(aiOrder.getOrderNum(), userId, amount, BalanceChangerConstants.REFUND, remark); + } @Override @@ -248,4 +261,9 @@ public class AiOrderServiceImpl implements IAiOrderService { public BigDecimal getSumAmountByUserId(String userId) { return aiOrderMapper.getSumAmountByUserId(userId); } + + @Override + public AiOrder selectOneByThirdPartyOrderNum(String id) { + return aiOrderMapper.selectOneByThirdPartyOrderNum(id); + } } 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 b1e98f7..9496616 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 @@ -236,7 +236,7 @@ public class ByteService implements IByteService { return body; } - private String resolveCurrentAiUserApiKey() { + public String resolveCurrentAiUserApiKey() { return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId()); } From f53f849ec8bc0b8b8432fb27fb071d5ea60902b3 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Fri, 10 Apr 2026 10:03:28 +0800 Subject: [PATCH 03/19] =?UTF-8?q?fix:=20=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E3=80=81=E4=BB=BB=E5=8A=A1=E5=9B=9E=E8=B0=83=20-=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E4=BB=A3=E7=A0=81=E5=B9=B6=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/api/ByteApiController.java | 139 +++++++----------- .../com/ruoyi/api/PortalVideoController.java | 56 +++---- .../src/main/resources/application.yml | 2 +- .../com/ruoyi/ai/service/IAiOrderService.java | 8 + .../com/ruoyi/ai/service/IByteService.java | 4 +- .../ai/service/impl/AiOrderServiceImpl.java | 58 ++++++++ .../ruoyi/ai/service/impl/ByteService.java | 16 +- 7 files changed, 163 insertions(+), 120 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index dcbbf68..0e173ea 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -12,6 +12,7 @@ import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; import com.ruoyi.common.enums.AiOrderStatusType; import com.ruoyi.common.enums.VideoTaskStatusType; import com.ruoyi.common.utils.RandomStringUtil; +import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.TencentCosUtil; import io.swagger.annotations.Api; @@ -43,19 +44,17 @@ public class ByteApiController extends BaseController { // 锁参数 private static final int LOCK_WAIT_SECONDS = 10; private static final int LOCK_LEASE_SECONDS = 20; - // 任务成功时流水表备注 - private static final String TASK_SUCCESS_BALANCE_REMARK = "order.number.generation.successes"; private final IByteService byteService; private final TencentCosUtil tencentCosUtil; private final IAiOrderService aiOrderService; private final IAiManagerService managerService; private final IAiTagService aiTagService; - private final IAiUserService aiUserService; private final RedissonClient redissonClient; @Value("${volcengine.ark.callbackUrl}") private String volcCallbackUrl; + private final IByteDeptApiKeyService byteDeptApiKeyService; @PostMapping("/promptToImg") @ApiOperation("文生图") @@ -346,16 +345,25 @@ public class ByteApiController extends BaseController { if (result != null) { return result; } - // 2、从官方获取任务数据 - String apiKey = byteService.resolveCurrentAiUserApiKey(); + // 2、查询订单 + String taskId = request.getId(); + AiOrder order = aiOrderService.selectOneByThirdPartyOrderNum(taskId); + if (order == null) { + // 可能是其他环境生成的但回调地址配置成正式的 + logger.warn("volcCallback aiorder is not exist! third party order num = {}", taskId); + return AjaxResult.success(); + } + // 3、从官方获取任务数据 + // 根据订单用户ID查询使用的Key + // 严格来讲,按逻辑这块是应放在锁内,但这是调外部接口,如果接口超时整个服务可能会当机,所以不放锁内,即不做强一致 + String apiKey = byteDeptApiKeyService.resolveVolcApiKey(order.getUserId()); GetVideoGenerationTaskResponse taskResp = byteService.getVideoGenerationTasks(request.getId(), apiKey); - // 3、官方数据校验 + // 4、官方数据校验 result = volcCallbackByteCheck(request, taskResp); if (result != null) { return result; } - // 4、查询订单(同 taskId 串行:Redisson 分布式锁;步骤 1~3 已在锁外) - String taskId = request.getId(); + // 5、查询订单(同 taskId 串行:Redisson 分布式锁;步骤 1~3 已在锁外) String lockKey = VOLC_CALLBACK_LOCK_KEY_PREFIX + taskId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; @@ -365,30 +373,32 @@ public class ByteApiController extends BaseController { logger.warn("volcCallback skip: concurrent handling for same task, third party order num = {}", taskId); return AjaxResult.success(); } - AiOrder order = aiOrderService.selectOneByThirdPartyOrderNum(taskId); + // 锁内二次查询,防止并发时状态变更 + order = aiOrderService.selectOneByThirdPartyOrderNum(taskId); if (order == null) { // 可能是其他环境生成的但回调地址配置成正式的 logger.warn("volcCallback aiorder is not exist! third party order num = {}", taskId); return AjaxResult.success(); } - // 5、状态为队列中、执行中,只更新任务状态 + // 6、状态为队列中、执行中,只更新任务状态 result = volcCallbackRunningTaskProcess(taskResp, order); if (result != null) { return result; } - // 6、订单数据校验 + // 7、订单数据校验 result = volcCallbackOrderCheck(taskResp, order); if (result != null) { return result; } - // 7、根据状态做不同的处理(加事务) - String status = taskResp.getStatus(); + // 8、根据状态做不同的处理(加事务) + String status = taskResp.getStatus().toLowerCase(); if (VideoTaskStatusType.SUCCEEDED.getName().equals(status)) { // 成功,预扣 - return volcCallbackSuccessProcess(request, taskResp, order); + return aiOrderService.volcCallbackSuccessProcess(request, taskResp, order); } else { // 前面已判断过status的合法性,并处理了三种非失败的状态,所以可以确定是取消、失败、超时 - return volcCallbackFailProcess(taskResp, order); + aiOrderService.orderFailure(order); + return AjaxResult.success(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -405,55 +415,6 @@ public class ByteApiController extends BaseController { } } - @Transactional(rollbackFor = Exception.class) - private AjaxResult volcCallbackFailProcess(GetVideoGenerationTaskResponse taskResp, AiOrder order) { - aiOrderService.orderFailure(order); - return null; - } - - @Transactional(rollbackFor = Exception.class) - private AjaxResult volcCallbackSuccessProcess(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp, AiOrder order) { - // 用量是否一致 - Integer requestTotalTokens = request.getUsage() == null || request.getUsage().getTotalTokens() == null - ? 0 : request.getUsage().getTotalTokens(); - Integer officialTotalTokens = taskResp.getUsage() == null || taskResp.getUsage().getTotalTokens() == null - ? 0 : taskResp.getUsage().getTotalTokens(); - if (requestTotalTokens.equals(officialTotalTokens)) { - logger.error("volcCallback request's total tokens != official tokens! third party order num = {}, request's tokens = {}, official tokens = {}", - request.getId(), requestTotalTokens, officialTotalTokens); - return AjaxResult.error(); - } - if (officialTotalTokens <= 0) { - // 异常情况,应该不会出现,以防万一 - logger.error("volcCallback official tokens <= 0! third party order num = {}, request's tokens = {}, official tokens = {}", - request.getId(), requestTotalTokens, officialTotalTokens); - return AjaxResult.error(); - } - BigDecimal realAmount = new BigDecimal(officialTotalTokens); - // 用量回补、多退少补 = 预扣量 - 实际用量 - // 有预扣值才回补,没有的是历史单,不处理 - if (order.getPreDeductAmount() != null - && order.getPreDeductAmount().compareTo(new BigDecimal(0)) > 0) { - BigDecimal addAmount = order.getPreDeductAmount().subtract(realAmount); - if (addAmount.compareTo(new BigDecimal(0)) != 0) { - // 回补 - aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), addAmount, - BalanceChangerConstants.REFUND, TASK_SUCCESS_BALANCE_REMARK); - } - } - // 设置视频地址与状态 - if (taskResp.getContent() != null && StringUtils.isNotEmpty(taskResp.getContent().getVideoUrl())) { - order.setResult(taskResp.getContent().getVideoUrl()); - } - // 设置用量 - order.setAmount(realAmount); - // 订单状态 - order.setStatus(AiOrderStatusType.FINISH.ordinal()); - order.setIsBackfilled(1); - aiOrderService.orderSuccess(order); - return AjaxResult.success("callback success"); - } - private AjaxResult volcCallbackOrderCheck(GetVideoGenerationTaskResponse taskResp, AiOrder order) { // 订单状态如果不为执行中则不做处理 if (order.getStatus() != null && order.getStatus() != AiOrderStatusType.RUNNING.ordinal()) { @@ -471,7 +432,7 @@ public class ByteApiController extends BaseController { } private AjaxResult volcCallbackByteCheck(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp) { - String status = taskResp.getStatus(); + String status = request.getStatus().toLowerCase(); // 请求的状态与字节的状态是否一致 if (!status.equals(taskResp.getStatus().toLowerCase())) { logger.error("volcCallback request's status != official status! order third party order num = {}, request's status = {}, official status = {}", @@ -501,7 +462,7 @@ public class ByteApiController extends BaseController { private AjaxResult volcCallbackRunningTaskProcess(GetVideoGenerationTaskResponse taskResp, AiOrder order) { // 执行中状态 ,更新到ext_status字段 Integer extStatus = null; - String status = taskResp.getStatus(); + String status = taskResp.getStatus().toLowerCase(); if (VideoTaskStatusType.QUEUED.getName().equals(status)) { extStatus = 0; } else if (VideoTaskStatusType.RUNNING.getName().equals(status)) { @@ -534,33 +495,33 @@ public class ByteApiController extends BaseController { // logger.warn("volcCallback code 非 200,已清空 result 并 status=2, orderId={}, {}", order.getId(), reason); // } - private AiOrder findAiOrderByVolcTaskId(String taskId) { - AiOrder order = aiOrderService.getAiOrderByPortalVideoTask(taskId); - if (order != null) { - return order; - } - return aiOrderService.getAiOrderByResult(taskId); - } +// private AiOrder findAiOrderByVolcTaskId(String taskId) { +// AiOrder order = aiOrderService.getAiOrderByPortalVideoTask(taskId); +// if (order != null) { +// return order; +// } +// return aiOrderService.getAiOrderByResult(taskId); +// } /** * 回调判定任务失败:订单仍持有火山任务 id、且非已失败时,标记失败并退余额(orderFailure)。 */ - private void handleVolcCallbackFailure(String taskId, String reason) { - AiOrder order = aiOrderService.getAiOrderByResult(taskId); - if (order == null) { - logger.warn("volcCallback 失败:未找到 result={} 的订单, {}", taskId, reason); - return; - } - if (!taskId.equals(order.getResult())) { - logger.info("volcCallback 失败处理跳过:订单结果已更新, taskId={}, {}", taskId, reason); - return; - } - if (Integer.valueOf(2).equals(order.getStatus())) { - return; - } - aiOrderService.orderFailure(order); - logger.warn("volcCallback 任务失败,已标记订单失败并退款, taskId={}, reason={}", taskId, reason); - } +// private void handleVolcCallbackFailure(String taskId, String reason) { +// AiOrder order = aiOrderService.getAiOrderByResult(taskId); +// if (order == null) { +// logger.warn("volcCallback 失败:未找到 result={} 的订单, {}", taskId, reason); +// return; +// } +// if (!taskId.equals(order.getResult())) { +// logger.info("volcCallback 失败处理跳过:订单结果已更新, taskId={}, {}", taskId, reason); +// return; +// } +// if (Integer.valueOf(2).equals(order.getStatus())) { +// return; +// } +// aiOrderService.orderFailure(order); +// logger.warn("volcCallback 任务失败,已标记订单失败并退款, taskId={}, reason={}", taskId, reason); +// } // @PostMapping(value = "/{id}/cancel") // @ApiOperation("取消视频生成任务") 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 1a1f40d..22bce23 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 @@ -272,19 +272,20 @@ public class PortalVideoController extends BaseController { String key = apiKey(); ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq, key); - String id = byteBodyRes.getId(); - if (id == null) { + String thirdPartyOrderNumId = byteBodyRes.getId(); + if (thirdPartyOrderNumId == null) { aiOrderService.orderFailure(aiOrder); return AjaxResult.error(-2, "generation failed, balance has been refunded"); } - mergeVolcTaskIdIntoVideoParams(aiOrder, id); - aiOrder.setResult(id); + mergeVolcTaskIdIntoVideoParams(aiOrder, thirdPartyOrderNumId); + aiOrder.setResult(thirdPartyOrderNumId); // 字节订单号与请求ID - aiOrder.setThirdPartyOrderNum(id); + aiOrder.setThirdPartyOrderNum(thirdPartyOrderNumId); aiOrder.setVideoGenRequestId(byteBodyRes.getRequestId()); - aiOrderService.orderSuccess(aiOrder); +// aiOrderService.orderSuccess(aiOrder); + aiOrderService.updateAiOrder(aiOrder); // 查询任务详情,按字节返回的预扣数量扣减 - GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(id, key); + GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(thirdPartyOrderNumId, key); if (task == null || task.getUsage() == null || task.getUsage().getTotalTokens() == null) { return AjaxResult.error(-2, "generation failed, byte task's usage is null"); } @@ -292,16 +293,21 @@ public class PortalVideoController extends BaseController { 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,以防在抛异常时数值没变 + aiOrder.setPreDeductAmount(totalTokens); + aiOrder.setAmount(totalTokens); + // 设置订单信息 AiOrder updAiOrder = new AiOrder(); + updAiOrder.setId(aiOrder.getId()); updAiOrder.setOrderNum(aiOrder.getOrderNum()); updAiOrder.setPreDeductAmount(totalTokens); // 先设置成预扣数量,等收到回调再改过来,这样后续报表会比较准确 updAiOrder.setAmount(totalTokens); aiOrderService.updateAiOrder(updAiOrder); + // 扣减余额 + aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId() + , NumberUtil.mul(-1, totalTokens), aiOrderService.getChangerType(functionType)); return AjaxResult.success(byteBodyRes); } catch (Exception e) { aiOrderService.orderFailure(aiOrder); @@ -626,19 +632,19 @@ public class PortalVideoController extends BaseController { return AjaxResult.success(byteBodyRes); } - @DeleteMapping("/tasks/{taskId}") - @ApiOperation("删除或取消视频生成任务") - public AjaxResult deleteOrCancelTask(@PathVariable String taskId) throws Exception { - Long uid = SecurityUtils.getAiUserId(); - AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId); - if (owned == null || !uid.equals(owned.getUserId())) { - return AjaxResult.error("无权操作该任务"); - } - String key = apiKey(); - AjaxResult cancelRes = byteService.cancelVideoTask(taskId, key); - if (cancelRes.isSuccess() && owned.getStatus() != null && owned.getStatus() == 0) { - aiOrderService.orderFailure(owned); - } - return cancelRes; - } +// @DeleteMapping("/tasks/{taskId}") +// @ApiOperation("删除或取消视频生成任务") +// public AjaxResult deleteOrCancelTask(@PathVariable String taskId) throws Exception { +// Long uid = SecurityUtils.getAiUserId(); +// AiOrder owned = aiOrderService.getAiOrderByPortalVideoTask(taskId); +// if (owned == null || !uid.equals(owned.getUserId())) { +// return AjaxResult.error("无权操作该任务"); +// } +// String key = apiKey(); +// AjaxResult cancelRes = byteService.cancelVideoTask(taskId, key); +// if (cancelRes.isSuccess() && owned.getStatus() != null && owned.getStatus() == 0) { +// aiOrderService.orderFailure(owned); +// } +// return cancelRes; +// } } diff --git a/web-api/ruoyi-admin/src/main/resources/application.yml b/web-api/ruoyi-admin/src/main/resources/application.yml index 98d5a40..84145cf 100644 --- a/web-api/ruoyi-admin/src/main/resources/application.yml +++ b/web-api/ruoyi-admin/src/main/resources/application.yml @@ -281,8 +281,8 @@ portal: - 14 - 15 resolutions: + - "480p" - "720p" - - "1080p" jinsha: url: https://api.jinshapay.xyz 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 28c3835..5e98731 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 @@ -6,6 +6,9 @@ import java.util.List; import com.baomidou.mybatisplus.core.metadata.IPage; import com.ruoyi.ai.domain.AiOrder; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; import org.springframework.transaction.annotation.Transactional; /** @@ -92,4 +95,9 @@ public interface IAiOrderService { BigDecimal getSumAmountByUserId(String userId); AiOrder selectOneByThirdPartyOrderNum(String id); + + /** + * 火山回调 - 任务成功时处理流程 + */ + AjaxResult volcCallbackSuccessProcess(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp, AiOrder order); } 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 6f02655..64d936f 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 @@ -1,8 +1,10 @@ package com.ruoyi.ai.service; +import com.ruoyi.ai.domain.AiOrder; import com.ruoyi.ai.domain.ByteBodyReq; import com.ruoyi.ai.domain.ByteBodyRes; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; public interface IByteService { @@ -55,5 +57,5 @@ public interface IByteService { /** * GET 查询视频生成任务(单个) */ - GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception; + GetVideoGenerationTaskResponse getVideoGenerationTasks(String thirdPartyOrderNumId, 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 5a4efea..856f279 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 @@ -15,11 +15,17 @@ import com.ruoyi.ai.service.IAiStatisticsService; import com.ruoyi.ai.service.IAiUserService; import com.ruoyi.common.constant.BalanceChangerConstants; import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.domain.entity.AiUser; +import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; +import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; +import com.ruoyi.common.enums.AiOrderStatusType; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.DateUtils; import com.ruoyi.common.utils.MessageUtils; import com.ruoyi.common.utils.SecurityUtils; +import com.ruoyi.common.utils.StringUtils; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +42,7 @@ import java.util.UUID; * @author shi * @date 2025-11-13 */ +@Slf4j @Service public class AiOrderServiceImpl implements IAiOrderService { @@ -51,6 +58,8 @@ public class AiOrderServiceImpl implements IAiOrderService { @Autowired private IAiStatisticsService aiStatisticsService; + // 任务成功时流水表备注 + private static final String TASK_SUCCESS_BALANCE_REMARK = "order.number.generation.successes"; /** * 查询订单管理 @@ -266,4 +275,53 @@ public class AiOrderServiceImpl implements IAiOrderService { public AiOrder selectOneByThirdPartyOrderNum(String id) { return aiOrderMapper.selectOneByThirdPartyOrderNum(id); } + + @Override + @Transactional(rollbackFor = Exception.class) + public AjaxResult volcCallbackSuccessProcess(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp, AiOrder order) { + // 用量是否一致 + Integer requestTotalTokens = request.getUsage() == null || request.getUsage().getTotalTokens() == null + ? 0 : request.getUsage().getTotalTokens(); + Integer officialTotalTokens = taskResp.getUsage() == null || taskResp.getUsage().getTotalTokens() == null + ? 0 : taskResp.getUsage().getTotalTokens(); + // !!!注意!!! + // 以下检查不成立直接返回error,事务不会回退,所以不要在检查前面有存库操作 + // 这两个检查只是以防万一的,防止恶意回调,如果出现允许数据库中存在异常单,后续由程序通过日志检查问题 + if (!requestTotalTokens.equals(officialTotalTokens)) { + log.error("volcCallback request's total tokens != official tokens! third party order num = {}, request's tokens = {}, official tokens = {}", + request.getId(), requestTotalTokens, officialTotalTokens); + return AjaxResult.error(); + } + if (officialTotalTokens <= 0) { + // 异常情况,应该不会出现,以防万一 + log.error("volcCallback official tokens <= 0! third party order num = {}, request's tokens = {}, official tokens = {}", + request.getId(), requestTotalTokens, officialTotalTokens); + return AjaxResult.error(); + } + BigDecimal realAmount = new BigDecimal(officialTotalTokens); + // 先存库再回补,防止订单保存时,会一直回补给用户 + // 设置视频地址与状态 + if (taskResp.getContent() != null && StringUtils.isNotEmpty(taskResp.getContent().getVideoUrl())) { + order.setResult(taskResp.getContent().getVideoUrl()); + } + // 设置用量 + order.setAmount(realAmount); + // 订单状态 + order.setStatus(AiOrderStatusType.FINISH.ordinal()); + order.setIsBackfilled(1); + orderSuccess(order); + + // 用量回补、多退少补 = 预扣量 - 实际用量 + // 有预扣值才回补,没有的是历史单,不处理 + if (order.getPreDeductAmount() != null + && order.getPreDeductAmount().compareTo(new BigDecimal(0)) > 0) { + BigDecimal addAmount = order.getPreDeductAmount().subtract(realAmount); + if (addAmount.compareTo(new BigDecimal(0)) != 0) { + // 回补 + aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), addAmount, + BalanceChangerConstants.REFUND, TASK_SUCCESS_BALANCE_REMARK); + } + } + return AjaxResult.success("callback success"); + } } 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 9496616..d7dd1bf 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 @@ -3,12 +3,17 @@ package com.ruoyi.ai.service.impl; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.ai.domain.AiOrder; import com.ruoyi.ai.domain.ByteBodyReq; import com.ruoyi.ai.domain.ByteBodyRes; +import com.ruoyi.ai.service.IAiUserService; import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IByteService; +import com.ruoyi.common.constant.BalanceChangerConstants; import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; +import com.ruoyi.common.enums.AiOrderStatusType; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.http.OkHttpUtils; @@ -18,6 +23,9 @@ import okhttp3.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; @Slf4j @Service @@ -241,15 +249,15 @@ public class ByteService implements IByteService { } @Override - public GetVideoGenerationTaskResponse getVideoGenerationTasks(String id, String arkApiKey) throws Exception { + public GetVideoGenerationTaskResponse getVideoGenerationTasks(String thirdPartyOrderNumId, 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"); + if (StringUtils.isBlank(thirdPartyOrderNumId)) { + throw new Exception("getVideoGenerationTasks error:thirdPartyOrderNumId is null"); } - HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id); + HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks/" + thirdPartyOrderNumId); if (parsed == null) { throw new Exception("listVideoGenerationTasks error:invalid base url"); } From 992d95992a446714415395ea8311dc02b94954ac Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Fri, 10 Apr 2026 10:39:49 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=E5=8E=BB=E6=8E=89=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E7=AE=80=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal-ui/src/views/VideoGen.vue | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 7d4b582..6cdd421 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -125,13 +125,6 @@ {{ taskStatusText(row) }} - @@ -2014,11 +2007,6 @@ export default { color: rgba(255, 107, 107, 0.95); } -.vg-chat-inline-cancel { - font-size: 12px; - padding: 0; -} - .vg-chat-params { display: flex; flex-wrap: wrap; From bb5daf02be77811c946dcb502aa51b877105823d Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Fri, 10 Apr 2026 12:03:00 +0800 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E3=80=81=E4=BB=BB=E5=8A=A1=E5=9B=9E=E8=B0=83=20-=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E4=BB=A3=E7=A0=81=E5=B9=B6=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/api/ByteApiController.java | 21 ++++++++++++++----- .../com/ruoyi/api/PortalVideoController.java | 1 + 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index 0e173ea..349606b 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -432,12 +432,23 @@ public class ByteApiController extends BaseController { } private AjaxResult volcCallbackByteCheck(VideoTaskCallBackRequest request, GetVideoGenerationTaskResponse taskResp) { - String status = request.getStatus().toLowerCase(); + String requestStatus = request.getStatus().toLowerCase(); + String responseStatus = taskResp.getStatus().toLowerCase(); // 请求的状态与字节的状态是否一致 - if (!status.equals(taskResp.getStatus().toLowerCase())) { - logger.error("volcCallback request's status != official status! order third party order num = {}, request's status = {}, official status = {}", - request.getId(), status, taskResp.getStatus()); - return AjaxResult.error(); + if (!requestStatus.equals(responseStatus)) { + // 如果推送的是队列中、执行中,但官方任务可能已进到下一步的状态,(时间差)此种情况不处理,等待后续推送 + if (requestStatus.equals(VideoTaskStatusType.QUEUED.getName()) + || requestStatus.equals(VideoTaskStatusType.RUNNING.getName())) { + logger.warn("volcCallback request's status != official status, no process! order third party order num = {}, request's status = {}, official status = {}", + request.getId(), requestStatus, responseStatus); + // 防止再次推送 + return AjaxResult.success(); + } else { + logger.error("volcCallback request's status != official status! order third party order num = {}, request's status = {}, official status = {}", + request.getId(), requestStatus, responseStatus); + // 不再让对方二次推送 + return AjaxResult.error(); + } } return null; } 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 22bce23..57fff94 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 @@ -192,6 +192,7 @@ public class PortalVideoController extends BaseController { private void fillVideoOrderRecord(AiOrder aiOrder, PortalVideoGenRequest req, String mode, ByteBodyReq body, String functionTypeResolved) { aiOrder.setText(req.getText()); aiOrder.setMode(mode); + aiOrder.setIsBackfilled(0); applyOrderImages(aiOrder, req); if (req.getDuration() != null) { aiOrder.setDuration(req.getDuration()); From dbc292b0585f0e195a2df5824eaadaa7b81efb32 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Fri, 10 Apr 2026 14:28:54 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat:=20=E5=8E=BB=E6=8E=89=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E9=85=8D=E7=BD=AE=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-api/ruoyi-admin/src/main/resources/application.yml | 3 --- .../src/main/java/com/ruoyi/common/utils/AwsS3Util.java | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/resources/application.yml b/web-api/ruoyi-admin/src/main/resources/application.yml index 84145cf..94c5d37 100644 --- a/web-api/ruoyi-admin/src/main/resources/application.yml +++ b/web-api/ruoyi-admin/src/main/resources/application.yml @@ -242,9 +242,6 @@ volcengine: ak: AKLTNmYyN2VhZTcyMDcxNDNlNzg3OGVlMDVmZjRhNWQwY2M sk: Tm1ZeU1UTmlORFk1WmpKa05HUmpaRGcxTWpjMFpqUmpOVE01TUdJME5URQ== projectAesKeyBase64: "gJajABVfQJ9xA94Q9IvQi68fqqhSIkfcKlG7pjGFt2U=" - url: https://ark.ap-southeast.bytepluses.com/api/v3 - apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97 - callBackUrl: http://47.86.170.114:5173/ # 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举 portal: diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/AwsS3Util.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/AwsS3Util.java index a2b719a..948fdec 100644 --- a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/AwsS3Util.java +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/AwsS3Util.java @@ -23,7 +23,7 @@ import java.util.UUID; /** * AWS S3 文件上传工具类 */ -@Component +//@Component public class AwsS3Util { // -------------------------- 配置参数(需根据实际环境修改)-------------------------- From a67a8e202a41a9841564af015e32d22ebf5e65e9 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Fri, 10 Apr 2026 14:30:56 +0800 Subject: [PATCH 07/19] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E5=90=8E?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E8=B7=B3=E8=BD=AC=E5=88=B0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal-ui/src/layout/components/Login.vue | 1 + portal-ui/src/router/index.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/portal-ui/src/layout/components/Login.vue b/portal-ui/src/layout/components/Login.vue index 1bd02d1..04a6beb 100644 --- a/portal-ui/src/layout/components/Login.vue +++ b/portal-ui/src/layout/components/Login.vue @@ -181,6 +181,7 @@ export default { }) this.username = "" this.password = "" + this.$router.replace({ name: 'video-gen' }) this.$emit('cancel') }) } else { diff --git a/portal-ui/src/router/index.js b/portal-ui/src/router/index.js index c3c96ae..df4bc57 100644 --- a/portal-ui/src/router/index.js +++ b/portal-ui/src/router/index.js @@ -47,7 +47,7 @@ export const constantRoutes = [{ path: '/', component: Layout, redirect: { - name: 'index' + name: 'video-gen' }, children: [{ path: 'index', From 982ce1f179c7776492e9bf2351b4cf346f04ede2 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 09:53:37 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat:=20=E7=A7=AF=E5=88=86=E5=85=88?= =?UTF-8?q?=E6=8C=89aimanager=E9=85=8D=E7=BD=AE=E6=89=A3=E5=87=8F=EF=BC=8C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BB=88=E6=80=81=E6=97=B6=E5=86=8D=E5=9B=9E?= =?UTF-8?q?=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/api/ByteApiController.java | 4 +- .../com/ruoyi/api/PortalVideoController.java | 43 ++++++++++--------- .../ai/service/impl/AiOrderServiceImpl.java | 12 +++++- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index 349606b..cadd665 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -4,7 +4,6 @@ import com.ruoyi.ai.domain.*; import com.ruoyi.ai.service.*; import com.ruoyi.api.request.ByteApiRequest; import com.ruoyi.common.annotation.Anonymous; -import com.ruoyi.common.constant.BalanceChangerConstants; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; @@ -12,7 +11,6 @@ import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; import com.ruoyi.common.enums.AiOrderStatusType; import com.ruoyi.common.enums.VideoTaskStatusType; import com.ruoyi.common.utils.RandomStringUtil; -import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.TencentCosUtil; import io.swagger.annotations.Api; @@ -20,12 +18,12 @@ import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; +import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; 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 57fff94..8d42093 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 @@ -263,7 +263,7 @@ public class PortalVideoController extends BaseController { private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) { String functionType = resolveFunctionType(req); // 判断余额是否足够,aimanager里配置了最低限额。创建订单 - AiOrder aiOrder = aiOrderService.getAiOrder(functionType, false); + AiOrder aiOrder = aiOrderService.getAiOrder(functionType); if (aiOrder == null) { return AjaxResult.error(-1, "You have a low balance, please recharge"); } @@ -285,30 +285,33 @@ public class PortalVideoController extends BaseController { aiOrder.setVideoGenRequestId(byteBodyRes.getRequestId()); // aiOrderService.orderSuccess(aiOrder); aiOrderService.updateAiOrder(aiOrder); + + // !!!!! 逻辑暂时停用,先不预扣 start !!!!! // 查询任务详情,按字节返回的预扣数量扣减 - GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(thirdPartyOrderNumId, 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()); - // 同步设置aiOrder,以防在抛异常时数值没变 - aiOrder.setPreDeductAmount(totalTokens); - aiOrder.setAmount(totalTokens); +// GetVideoGenerationTaskResponse task = byteService.getVideoGenerationTasks(thirdPartyOrderNumId, 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()); +// // 同步设置aiOrder,以防在抛异常时数值没变 +// aiOrder.setPreDeductAmount(totalTokens); +// aiOrder.setAmount(totalTokens); // 设置订单信息 - AiOrder updAiOrder = new AiOrder(); - updAiOrder.setId(aiOrder.getId()); - updAiOrder.setOrderNum(aiOrder.getOrderNum()); - updAiOrder.setPreDeductAmount(totalTokens); +// AiOrder updAiOrder = new AiOrder(); +// updAiOrder.setId(aiOrder.getId()); +// updAiOrder.setOrderNum(aiOrder.getOrderNum()); +// updAiOrder.setPreDeductAmount(totalTokens); // 先设置成预扣数量,等收到回调再改过来,这样后续报表会比较准确 - updAiOrder.setAmount(totalTokens); - aiOrderService.updateAiOrder(updAiOrder); +// updAiOrder.setAmount(totalTokens); +// aiOrderService.updateAiOrder(updAiOrder); // 扣减余额 - aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId() - , NumberUtil.mul(-1, totalTokens), aiOrderService.getChangerType(functionType)); +// aiUserService.addUserBalance(aiOrder.getOrderNum(), SecurityUtils.getAiUserId() +// , NumberUtil.mul(-1, totalTokens), aiOrderService.getChangerType(functionType)); + // !!!!! 逻辑暂时停用,先不预扣 start !!!!! return AjaxResult.success(byteBodyRes); } catch (Exception e) { aiOrderService.orderFailure(aiOrder); 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 856f279..6bbab29 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 @@ -219,8 +219,10 @@ public class AiOrderServiceImpl implements IAiOrderService { aiOrder.setRemark(remark); aiOrderMapper.updateById(aiOrder); Long userId = aiOrder.getUserId() != null ? aiOrder.getUserId() : SecurityUtils.getAiUserId(); - aiUserService.addUserBalance(aiOrder.getOrderNum(), userId, amount, BalanceChangerConstants.REFUND, remark); - + // 变更值为0则不改余额,没有流水 + if (aiOrder.getAmount() != null && !aiOrder.getAmount().equals(new BigDecimal(0))) { + aiUserService.addUserBalance(aiOrder.getOrderNum(), userId, amount, BalanceChangerConstants.REFUND, remark); + } } @Override @@ -308,6 +310,7 @@ public class AiOrderServiceImpl implements IAiOrderService { order.setAmount(realAmount); // 订单状态 order.setStatus(AiOrderStatusType.FINISH.ordinal()); + // 已回补 order.setIsBackfilled(1); orderSuccess(order); @@ -322,6 +325,11 @@ public class AiOrderServiceImpl implements IAiOrderService { BalanceChangerConstants.REFUND, TASK_SUCCESS_BALANCE_REMARK); } } + + // 按实际用量扣减 +// BigDecimal reduceAmount = NumberUtil.mul(-1, realAmount); +// aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), reduceAmount, +// BalanceChangerConstants.QUICK_VIDEO_GENERATION, TASK_SUCCESS_BALANCE_REMARK); return AjaxResult.success("callback success"); } } From 4d428b9d94dcdf2c9a3d00d7648cbb59494de96d Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 09:53:55 +0800 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=B7=B3=E8=BD=AC=E5=88=B0=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal-ui/src/views/VideoGen.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 6cdd421..791f963 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -1201,6 +1201,7 @@ export default { this.$message.warning('任务已完成但未返回视频地址,请稍后重试或联系管理员') } this.destroyInterval() + this.$store.dispatch('user/getInfo').catch(() => {}) this.refreshChatFirstPage() return } From dda43f2de6a223ac09fe86dd5a3298b90b58e8f1 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 10:34:09 +0800 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20=E6=8F=90=E4=BA=A4=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E3=80=81=E6=8C=89F5=E5=88=B7=E6=96=B0=E6=97=B6?= =?UTF-8?q?=E4=BC=9A=E8=AF=BB=E5=8F=96=E6=9C=80=E6=96=B0=E7=9A=84=E4=BD=99?= =?UTF-8?q?=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- portal-ui/src/layout/components/navBar.vue | 4 ++++ portal-ui/src/views/VideoGen.vue | 1 + 2 files changed, 5 insertions(+) diff --git a/portal-ui/src/layout/components/navBar.vue b/portal-ui/src/layout/components/navBar.vue index b63e313..a6591d9 100644 --- a/portal-ui/src/layout/components/navBar.vue +++ b/portal-ui/src/layout/components/navBar.vue @@ -100,6 +100,7 @@ import { constantRoutes } from '@/router/index.js' import Login from './Login.vue' import { LOCALE_NAMES } from '@/lang/i18n' import User from './User.vue' +import { getToken } from '@/utils/auth' export default { name: 'nav-bar', @@ -182,6 +183,9 @@ export default { this.openLogin() } this.getLogo() + if (getToken()) { + this.$store.dispatch('user/getInfo').catch(() => {}) + } }, methods: { openLogin() { diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 791f963..0432086 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -1150,6 +1150,7 @@ export default { this.videoId = res.data.id this.showResult = true this.$refs.videoComposeRef?.clearPromptOnly?.() + this.$store.dispatch('user/getInfo').catch(() => {}) this.getVideo(res.data.id) this.refreshChatFirstPage() } else if (res.code == -1) { From 7068fad278f6f5ad9df6a072d52790d8a86e88d3 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 11:39:16 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=97=B6=E4=BA=A7=E7=94=9F=E7=9A=84=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E9=83=BD=E5=8A=A0=E4=B8=8A=E5=A4=87=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/api/PortalVideoController.java | 1 + .../ruoyi/ai/service/impl/AiOrderServiceImpl.java | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) 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 8d42093..42ed67a 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 @@ -194,6 +194,7 @@ public class PortalVideoController extends BaseController { aiOrder.setMode(mode); aiOrder.setIsBackfilled(0); applyOrderImages(aiOrder, req); + aiOrder.setExtStatus(0); if (req.getDuration() != null) { aiOrder.setDuration(req.getDuration()); } else if (body.getDuration() != null) { 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 6bbab29..6ae72c7 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 @@ -58,8 +58,11 @@ public class AiOrderServiceImpl implements IAiOrderService { @Autowired private IAiStatisticsService aiStatisticsService; - // 任务成功时流水表备注 - private static final String TASK_SUCCESS_BALANCE_REMARK = "order.number.generation.successes"; + // 流水表:任务成功时回补 + private static final String TASK_SUCCESS_BACK_FILL_REMARK = "order.number.generation.successbackfill"; + + // 流水表:提交任务时预扣 + private static final String TASK_SUBMIT_REMARK = "order.number.generation.submit"; /** * 查询订单管理 @@ -183,6 +186,7 @@ public class AiOrderServiceImpl implements IAiOrderService { aiOrder.setType(aiType); aiOrder.setResult(null); if (isReduceBalance) { + aiOrder.setPreDeductAmount(aiManager.getPrice()); aiOrder.setAmount(aiManager.getPrice()); } else { // 不按aimanager扣减的,等提交任务再按实扣减 @@ -194,7 +198,7 @@ public class AiOrderServiceImpl implements IAiOrderService { // 执行余额变更 if (isReduceBalance) { aiUserService.addUserBalance(orderno, SecurityUtils.getAiUserId() - , NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType)); + , NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType), TASK_SUBMIT_REMARK); } return aiOrder; } @@ -322,7 +326,7 @@ public class AiOrderServiceImpl implements IAiOrderService { if (addAmount.compareTo(new BigDecimal(0)) != 0) { // 回补 aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), addAmount, - BalanceChangerConstants.REFUND, TASK_SUCCESS_BALANCE_REMARK); + BalanceChangerConstants.REFUND, TASK_SUCCESS_BACK_FILL_REMARK); } } From f6408400ee87394713c8fdcda0f98a32f950bbfe Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 16:03:52 +0800 Subject: [PATCH 12/19] =?UTF-8?q?feat:=20=E7=81=AB=E5=B1=B1=E6=96=B9?= =?UTF-8?q?=E8=88=9F=E5=9B=9E=E8=B0=83=E5=A2=9E=E5=8A=A0=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/ruoyi/api/ByteApiController.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index cadd665..5446a50 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -13,6 +13,7 @@ import com.ruoyi.common.enums.VideoTaskStatusType; import com.ruoyi.common.utils.RandomStringUtil; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.TencentCosUtil; +import com.ruoyi.common.utils.ip.IpUtils; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.RequiredArgsConstructor; @@ -24,6 +25,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -336,8 +338,9 @@ public class ByteApiController extends BaseController { @PostMapping(value = "/volcCallback") @ApiOperation("火山引擎视频回调") @Anonymous - public AjaxResult volcCallback(@RequestBody VideoTaskCallBackRequest request) throws Exception { - logger.info("volcCallback 收到回调数据: {}", request); + public AjaxResult volcCallback(@RequestBody VideoTaskCallBackRequest request, HttpServletRequest httpRequest) throws Exception { + logger.info("volcCallback 收到回调数据: clientIp = {}, host = {}, request = {}", IpUtils.getIpAddr(httpRequest), + httpRequest.getHeader("Host"), request); // 1、基础参数校验 AjaxResult result = volcCallbackBaseCheck(request); if (result != null) { From f1e09eac1a0bc598abe58eeff6acbb41a660d947 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Mon, 13 Apr 2026 17:05:30 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20=E5=90=8E=E5=8F=B0=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E9=A2=84=E8=A7=88bug=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-ui/src/views/ai/order/index.vue | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/admin-ui/src/views/ai/order/index.vue b/admin-ui/src/views/ai/order/index.vue index 55ebeae..96efc8b 100644 --- a/admin-ui/src/views/ai/order/index.vue +++ b/admin-ui/src/views/ai/order/index.vue @@ -304,23 +304,22 @@ export default { row.isTop = row.isTop === "Y" ? "N" : "Y"; }); }, - // 判断是否为URL + // 判断是否为 http(s) URL(与 portal-ui 生成视频模块一致) isUrl(str) { - const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/; - return urlRegex.test(str); + const value = String(str || "").trim(); + return /^https?:\/\//i.test(value); }, - // 判断是否为图片链接 + // 判断是否为图片结果(与 portal-ui GeneratedAssets 一致) isImage(url) { - console.log(url) - const imageExtensions = /\.(jpg|jpeg|png|gif|bmp|webp)$/i; - var b = imageExtensions.test(url); - console.log(b) - return imageExtensions.test(url); + const value = String(url || "").trim(); + if (!value) return false; + return /\.(jpeg|jpg|png|gif|webp|bmp)(\?.*)?$/i.test(value); }, - // 判断是否为视频链接 + // 判断是否为视频结果(与 portal-ui VideoGen/GeneratedAssets 一致) isVideo(url) { - const videoExtensions = /\.(mp4|avi|mov|wmv|flv|webm)$/i; - return videoExtensions.test(url); + const value = String(url || "").trim(); + if (!value) return false; + return /\.(mp4|mov|webm|ogg|m4v|avi|mkv)(\?.*)?$/i.test(value); }, // 查看图片 viewImage(url) { From 85dc61b07d10ddffd8ed7f3912d4f43a2da4c8e0 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Tue, 14 Apr 2026 11:01:55 +0800 Subject: [PATCH 14/19] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=97=B6=EF=BC=8C=E6=B5=81=E6=B0=B4=E8=A1=A8=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=A4=87=E6=B3=A8=EF=BC=8C=E6=B7=BB=E5=8A=A0tokens?= =?UTF-8?q?=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/i18n/messages.properties | 5 +++++ .../src/main/resources/i18n/messages_zh_HK.properties | 5 ++++- .../src/main/java/com/ruoyi/ai/domain/AiOrder.java | 4 ++++ .../com/ruoyi/ai/service/impl/AiOrderServiceImpl.java | 8 ++++++-- .../src/main/resources/mapper/system/AiOrderMapper.xml | 7 ++++++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/resources/i18n/messages.properties b/web-api/ruoyi-admin/src/main/resources/i18n/messages.properties index 93de005..9490f31 100644 --- a/web-api/ruoyi-admin/src/main/resources/i18n/messages.properties +++ b/web-api/ruoyi-admin/src/main/resources/i18n/messages.properties @@ -36,3 +36,8 @@ no.update.permission=您没有修改数据的权限,请联系管理员添加 no.delete.permission=您没有删除数据的权限,请联系管理员添加权限 [{0}] no.export.permission=您没有导出数据的权限,请联系管理员添加权限 [{0}] no.view.permission=您没有查看数据的权限,请联系管理员添加权限 [{0}] + +# video generation +order.number.generation.failed=订单号 {0} 生成失败,请稍后重试。 +order.number.generation.submit=订单号 {0} 生成任务已提交! +order.number.generation.successbackfill=订单号 {0} 生成成功!金额已回补! \ No newline at end of file diff --git a/web-api/ruoyi-admin/src/main/resources/i18n/messages_zh_HK.properties b/web-api/ruoyi-admin/src/main/resources/i18n/messages_zh_HK.properties index f44388a..7b2abfd 100644 --- a/web-api/ruoyi-admin/src/main/resources/i18n/messages_zh_HK.properties +++ b/web-api/ruoyi-admin/src/main/resources/i18n/messages_zh_HK.properties @@ -29,4 +29,7 @@ email.verification.code.error=驗證碼錯誤,請重新輸入。 user.not.found=用戶不存在。 user.password.incorrect=密碼錯誤,請重新輸入。 -order.number.generation.failed=訂單號 {0} 生成失敗,請稍後重試。 \ No newline at end of file +# video generation +order.number.generation.failed=訂單號 {0} 生成失敗,請稍後重試。 +order.number.generation.submit=訂單號 {0} 生成任務已提交! +order.number.generation.successbackfill=訂單號 {0} 生成成功!金額已回補! \ No newline at end of file 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 c0ad6f6..ee572b1 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 @@ -56,6 +56,10 @@ public class AiOrder extends BaseEntity { @Excel(name = "金额") private BigDecimal amount; + /** 模型Tokens用量 */ + @Excel(name = "模型Tokens用量") + private BigDecimal totalUsage; + @Excel(name = "是否回补处理过: 0-否 1-是") private Integer isBackfilled; 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 6ae72c7..95bc513 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 @@ -197,8 +197,9 @@ public class AiOrderServiceImpl implements IAiOrderService { aiOrderMapper.insert(aiOrder); // 执行余额变更 if (isReduceBalance) { + String remark = MessageUtils.message(TASK_SUBMIT_REMARK, aiOrder.getOrderNum()); aiUserService.addUserBalance(orderno, SecurityUtils.getAiUserId() - , NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType), TASK_SUBMIT_REMARK); + , NumberUtil.mul(-1, aiManager.getPrice()), getChangerType(aiType), remark); } return aiOrder; } @@ -312,6 +313,8 @@ public class AiOrderServiceImpl implements IAiOrderService { } // 设置用量 order.setAmount(realAmount); + // tokens用量 + order.setTotalUsage(realAmount); // 订单状态 order.setStatus(AiOrderStatusType.FINISH.ordinal()); // 已回补 @@ -325,8 +328,9 @@ public class AiOrderServiceImpl implements IAiOrderService { BigDecimal addAmount = order.getPreDeductAmount().subtract(realAmount); if (addAmount.compareTo(new BigDecimal(0)) != 0) { // 回补 + String remark = MessageUtils.message(TASK_SUCCESS_BACK_FILL_REMARK, order.getOrderNum()); aiUserService.addUserBalance(order.getOrderNum(), order.getUserId(), addAmount, - BalanceChangerConstants.REFUND, TASK_SUCCESS_BACK_FILL_REMARK); + BalanceChangerConstants.REFUND, remark); } } diff --git a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml index 293cf2e..5f98aed 100644 --- a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml +++ b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml @@ -12,10 +12,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + @@ -29,10 +32,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select ao.id, ao.del_flag, ao.create_by, ao.create_time, ao.update_by, ao.update_time, ao.remark, ao.order_num, ao.user_id, ao.type, ao.amount, ao.result, ao.status, ao.source, ao.text, ao.is_top, ao.img1, ao.img2, ao.mode, ao.duration, ao.resolution, ao.ratio, ao.model, ao.video_params, au.user_id uuid, ao.ext_status from ai_order ao + select ao.id, ao.del_flag, ao.create_by, ao.create_time, ao.update_by, ao.update_time, ao.remark, ao.order_num, ao.third_party_order_num, ao.user_id, ao.type, ao.pre_deduct_amount, ao.amount, ao.total_usage, ao.result, ao.status, ao.source, ao.text, ao.is_top, ao.img1, ao.img2, ao.mode, ao.duration, ao.resolution, ao.ratio, ao.model, ao.video_params, au.user_id uuid, ao.ext_status, ao.is_backfilled, ao.video_gen_request_id from ai_order ao left join ai_user au on au.id = ao.user_id From b9e08938e05f53b27e636283055e290cb9fd2044 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Tue, 14 Apr 2026 11:18:46 +0800 Subject: [PATCH 15/19] =?UTF-8?q?feat:=20=E7=94=9F=E6=88=90=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E4=BB=BB=E5=8A=A1=E5=A4=B1=E8=B4=A5=E6=97=B6=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=9C=A8=E7=94=A8=E6=88=B7=E5=89=8D=E5=8F=B0?= =?UTF-8?q?-=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BD=95=E3=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0-=E8=AE=A2=E5=8D=95=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-ui/src/views/ai/order/index.vue | 63 +++++++++++++++- portal-ui/src/views/VideoGen.vue | 73 +++++++++++++++++++ .../java/com/ruoyi/api/ByteApiController.java | 5 ++ 3 files changed, 140 insertions(+), 1 deletion(-) diff --git a/admin-ui/src/views/ai/order/index.vue b/admin-ui/src/views/ai/order/index.vue index 96efc8b..77085bb 100644 --- a/admin-ui/src/views/ai/order/index.vue +++ b/admin-ui/src/views/ai/order/index.vue @@ -123,7 +123,21 @@ @click="playVideo(scope.row.result)" /> - + + + {{ scope.row.result }} @@ -309,6 +323,39 @@ export default { const value = String(str || "").trim(); return /^https?:\/\//i.test(value); }, + /** 已知火山错误码后附中文说明(括号) */ + volcFailureCodeWithHint(code) { + const c = String(code || "").trim(); + if (!c) return ""; + const hints = { + OutputVideoSensitiveContentDetected: "输出视频可能包含敏感信息", + InvalidParameter: "请求参数无效" + }; + const hint = hints[c]; + return hint ? `${c}(${hint})` : c; + }, + /** result 为火山回调失败写入的 VideoTaskError JSON 时解析为 { code, message } */ + parseVolcTaskErrorJson(str) { + const s = String(str || "").trim(); + if (!s || s[0] !== "{") return null; + try { + const o = JSON.parse(s); + if ( + o && + typeof o === "object" && + !Array.isArray(o) && + ("code" in o || "message" in o) + ) { + return { + code: o.code != null ? String(o.code) : "", + message: o.message != null ? String(o.message) : "" + }; + } + } catch (_) { + /* ignore */ + } + return null; + }, // 判断是否为图片结果(与 portal-ui GeneratedAssets 一致) isImage(url) { const value = String(url || "").trim(); @@ -333,6 +380,7 @@ export default { }, // 非链接内容点击事件 async handleOtherEvent(row) { + if (this.parseVolcTaskErrorJson(row.result)) return; // 防止重复点击 if (row.isDownloading) return; const originalResult = row.result; @@ -509,6 +557,19 @@ export default { height: auto; } +.order-result-error { + text-align: left; + max-width: 280px; + margin: 0 auto; + font-size: 12px; + line-height: 1.45; + color: #c45656; +} + +.order-result-error-line { + word-break: break-word; +} + .text-ellipsis-two-lines { /* 必须设置宽度(继承表格列宽,也可手动指定) */ width: 100%; diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 0432086..601d169 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -126,6 +126,19 @@ {{ taskStatusText(row) }} + @@ -573,6 +586,51 @@ export default { el.scrollTop = el.scrollHeight }, + /** 解析失败订单存库的 VideoTaskError JSON(兼容旧数据、非 JSON 的 result) */ + parseTaskResultErrorJson(result) { + const s = String(result ?? '').trim() + if (!s || s[0] !== '{') return null + try { + const o = JSON.parse(s) + if ( + o && + typeof o === 'object' && + !Array.isArray(o) && + ('code' in o || 'message' in o) + ) { + return { + code: o.code != null ? String(o.code) : '', + message: o.message != null ? String(o.message) : '' + } + } + } catch (_) { + /* ignore */ + } + return null + }, + + /** status=2 且 result 为错误 JSON 时返回明细,否则 null */ + taskRowFailureError(row) { + const st = row?.status + if (st !== 2 && st !== '2') return null + const parsed = this.parseTaskResultErrorJson(row?.result) + if (!parsed) return null + if (!parsed.code && !parsed.message) return null + return parsed + }, + + /** 已知火山错误码后附中文说明(括号) */ + volcFailureCodeWithHint(code) { + const c = String(code || '').trim() + if (!c) return '' + const hints = { + OutputVideoSensitiveContentDetected: '输出视频可能包含敏感信息', + InvalidParameter: '请求参数无效' + } + const hint = hints[c] + return hint ? `${c}(${hint})` : c + }, + taskRowResultTrim(row) { let r = String(row?.result ?? '').trim() if (r) return r @@ -1969,11 +2027,26 @@ export default { .vg-chat-user-row { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; } +.vg-chat-failure-detail { + flex: 1 1 100%; + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + font-size: 12px; + line-height: 1.45; + color: rgba(255, 180, 180, 0.92); +} + +.vg-chat-failure-line { + word-break: break-word; +} + .vg-chat-user-col-main { flex: 1; min-width: 0; diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index 5446a50..f1f1dcc 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -8,8 +8,10 @@ import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.request.video.dto.VideoTaskCallBackRequest; import com.ruoyi.common.core.response.video.GetVideoGenerationTaskResponse; +import com.ruoyi.common.core.response.video.dto.VideoTaskError; import com.ruoyi.common.enums.AiOrderStatusType; import com.ruoyi.common.enums.VideoTaskStatusType; +import com.ruoyi.common.utils.JsonUtils; import com.ruoyi.common.utils.RandomStringUtil; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.TencentCosUtil; @@ -398,6 +400,9 @@ public class ByteApiController extends BaseController { return aiOrderService.volcCallbackSuccessProcess(request, taskResp, order); } else { // 前面已判断过status的合法性,并处理了三种非失败的状态,所以可以确定是取消、失败、超时 + if (taskResp.getError() != null) { + order.setResult(JsonUtils.toJson(taskResp.getError())); + } aiOrderService.orderFailure(order); return AjaxResult.success(); } From fedaef2ff7b15be381af0169084c659dbb598838 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Tue, 14 Apr 2026 11:31:26 +0800 Subject: [PATCH 16/19] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=EF=BC=8C=E4=BC=98=E5=8C=96=E8=AE=A2=E5=8D=95=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E6=98=BE=E7=A4=BA=E5=88=97=E5=AE=BD=EF=BC=8C=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=BB=93=E6=9E=9C=E5=A6=82=E6=9E=9C=E6=98=AF=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E5=8F=AA=E6=98=BE=E7=A4=BA=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=88=96=E7=BC=96=E5=8F=B7=EF=BC=8C=E6=B5=AE=E7=AA=97=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=AE=8C=E6=95=B4=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin-ui/src/views/ai/order/index.vue | 140 ++++++++++++++++++++------ 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/admin-ui/src/views/ai/order/index.vue b/admin-ui/src/views/ai/order/index.vue index 77085bb..e41693b 100644 --- a/admin-ui/src/views/ai/order/index.vue +++ b/admin-ui/src/views/ai/order/index.vue @@ -93,17 +93,17 @@ - + - - - + + + - - + + - + - - - + + + 32 ? "…" : "") : "—"; + }, + /** 已知火山错误码后附中文说明(括号),导出等场景可用 */ + volcFailureCodeWithHint(code) { + const c = String(code || "").trim(); + if (!c) return ""; + const hint = this.volcTaskErrorHintForCode(c); return hint ? `${c}(${hint})` : c; }, /** result 为火山回调失败写入的 VideoTaskError JSON 时解析为 { code, message } */ @@ -557,19 +594,56 @@ export default { height: auto; } -.order-result-error { - text-align: left; - max-width: 280px; - margin: 0 auto; - font-size: 12px; - line-height: 1.45; - color: #c45656; +.order-result-error-wrap { + display: inline-block; } -.order-result-error-line { +.order-result-error-compact { + display: inline-block; + max-width: 200px; + margin: 0 auto; + font-size: 12px; + line-height: 1.35; + color: #c45656; + cursor: help; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; +} + +.order-result-error-tooltip-body { + max-width: 420px; + text-align: left; + line-height: 1.5; + font-size: 12px; +} + +.order-result-error-tooltip-row { + margin-bottom: 6px; +} + +.order-result-error-tooltip-row:last-child { + margin-bottom: 0; +} + +.order-result-error-tooltip-row--msg .order-result-error-tooltip-v { + display: block; + margin-top: 2px; + white-space: pre-wrap; word-break: break-word; } +.order-result-error-tooltip-k { + font-weight: 600; + color: #fde2e2; +} + +.order-result-error-tooltip-v { + color: #fff; +} + .text-ellipsis-two-lines { /* 必须设置宽度(继承表格列宽,也可手动指定) */ width: 100%; From f31e4c38c657f2a406d832a197423c34b7e924bd Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Tue, 14 Apr 2026 11:48:48 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat:=20=E5=A4=9A=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/i18n/messages_en_US.properties | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties b/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties index 2ed9e69..35e7c9e 100644 --- a/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties +++ b/web-api/ruoyi-admin/src/main/resources/i18n/messages_en_US.properties @@ -30,5 +30,8 @@ email.verification.code.error=Verification code is incorrect, please try again. # User not found user.not.found=User not found. user.password.incorrect=Password is incorrect, please try again. + +# video generation order.number.generation.failed=Order number {0} generation failed, please try again later. -order.number.generation.successes=Order number {0} generation successes! \ No newline at end of file +order.number.generation.submit=Order number {0} generation task submitted! +order.number.generation.successbackfill=Order number {0} sucessed! Amount is back filled! \ No newline at end of file From 1bedde7fa7f73679e5cb7e4d624f8203c67e24cf Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Tue, 14 Apr 2026 11:49:33 +0800 Subject: [PATCH 18/19] =?UTF-8?q?fix=EF=BC=9A=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0-=E8=AE=A2=E5=8D=95=E8=AE=B0=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E8=AE=A2=E5=8D=95=E6=9F=A5=E8=AF=A2=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/mapper/system/AiOrderMapper.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml index 5f98aed..3dcf26b 100644 --- a/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml +++ b/web-api/ruoyi-system/src/main/resources/mapper/system/AiOrderMapper.xml @@ -44,7 +44,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"