fix: 新需求 对接火山seedance
This commit is contained in:
parent
3571bba3ed
commit
592c2595d6
|
|
@ -0,0 +1,64 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
|
||||||
|
server_name undressing.top www.undressing.top;
|
||||||
|
index index.html index.htm;
|
||||||
|
ssl_certificate ssl/undressing.top.crt;
|
||||||
|
ssl_certificate_key ssl/undressing.top.key;
|
||||||
|
ssl_session_timeout 5m;
|
||||||
|
# 优化加密套件配置,移除不安全的算法
|
||||||
|
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!3DES;
|
||||||
|
# 移除不安全的 TLSv1.1,只保留 TLSv1.2 和 TLSv1.3
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
# 启用 SSL session 缓存,提高性能
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
root /data/web/client_web;
|
||||||
|
autoindex off; # 禁用目录列表
|
||||||
|
|
||||||
|
# 安全头部
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
access_log off;
|
||||||
|
log_not_found off;
|
||||||
|
}
|
||||||
|
location ~* (\.git|\.env|composer\.json|\.log|\.sql)$ {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
location ^~ /api/ {
|
||||||
|
proxy_pass http://10.0.0.167:8110;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Real-Port $remote_port;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_set_header REMOTE-HOST $remote_addr;
|
||||||
|
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
# 缓冲配置,提高性能
|
||||||
|
proxy_buffering on;
|
||||||
|
proxy_buffer_size 4k;
|
||||||
|
proxy_buffers 8 4k;
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
root /data/web/client_web;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,519 @@
|
||||||
|
package com.ruoyi.api;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.ruoyi.ai.domain.AiOrder;
|
||||||
|
import com.ruoyi.ai.domain.ByteBodyReq;
|
||||||
|
import com.ruoyi.ai.domain.ByteBodyRes;
|
||||||
|
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.IByteDeptApiKeyService;
|
||||||
|
import com.ruoyi.ai.service.IByteService;
|
||||||
|
import com.ruoyi.api.request.PortalVideoGenRequest;
|
||||||
|
import com.ruoyi.common.core.controller.BaseController;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.core.page.TableDataInfo;
|
||||||
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
|
import com.ruoyi.common.utils.TencentCosUtil;
|
||||||
|
import com.ruoyi.config.PortalVideoProperties;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户视频生成:按用户二级部门 byte_api_key 调用火山;任务列表含库表与火山过滤列表。
|
||||||
|
*/
|
||||||
|
@Api(tags = "门户-视频生成")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/portal/video")
|
||||||
|
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||||
|
public class PortalVideoController extends BaseController {
|
||||||
|
|
||||||
|
private final IByteService byteService;
|
||||||
|
private final IByteDeptApiKeyService byteDeptApiKeyService;
|
||||||
|
private final IAiOrderService aiOrderService;
|
||||||
|
private final TencentCosUtil tencentCosUtil;
|
||||||
|
private final PortalVideoProperties portalVideoProperties;
|
||||||
|
|
||||||
|
@Value("${volcengine.ark.callbackUrl:}")
|
||||||
|
private String volcCallbackUrl;
|
||||||
|
|
||||||
|
private static final ObjectMapper OM = new ObjectMapper();
|
||||||
|
|
||||||
|
private String apiKey() {
|
||||||
|
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 ai_manager.type、portal.video.function-type 对齐,用于扣费 */
|
||||||
|
private String resolveFunctionType(PortalVideoGenRequest req) {
|
||||||
|
if (StringUtils.isNotEmpty(req.getFunctionType())) {
|
||||||
|
return req.getFunctionType();
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(portalVideoProperties.getFunctionType())) {
|
||||||
|
return portalVideoProperties.getFunctionType();
|
||||||
|
}
|
||||||
|
return "21";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyOptionalParams(ByteBodyReq body, PortalVideoGenRequest req) {
|
||||||
|
PortalVideoProperties.Defaults d = portalVideoProperties.getDefaults();
|
||||||
|
body.setDuration(req.getDuration() != null ? req.getDuration() : d.getDuration());
|
||||||
|
body.setResolution(StringUtils.isNotEmpty(req.getResolution()) ? req.getResolution() : d.getResolution());
|
||||||
|
body.setRatio(StringUtils.isNotEmpty(req.getRatio()) ? req.getRatio() : d.getRatio());
|
||||||
|
if (StringUtils.isNotEmpty(volcCallbackUrl)) {
|
||||||
|
body.setCallback_url(volcCallbackUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ByteBodyReq newVideoBody(PortalVideoGenRequest req, List<ContentItem> content) {
|
||||||
|
String modelId = StringUtils.isNotEmpty(req.getModel()) ? req.getModel() : portalVideoProperties.resolveDefaultModelId();
|
||||||
|
if (StringUtils.isEmpty(modelId)) {
|
||||||
|
throw new ServiceException("未配置门户视频模型,请在 application.yml 的 portal.video 中配置 models 与 defaults.model");
|
||||||
|
}
|
||||||
|
ByteBodyReq body = new ByteBodyReq();
|
||||||
|
body.setModel(modelId);
|
||||||
|
body.setContent(content);
|
||||||
|
applyOptionalParams(body, req);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyOrderImages(AiOrder aiOrder, PortalVideoGenRequest req) {
|
||||||
|
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
|
||||||
|
aiOrder.setImg1(req.getFirstUrl());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(req.getLastUrl())) {
|
||||||
|
aiOrder.setImg2(req.getLastUrl());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
|
||||||
|
aiOrder.setImg1(req.getReferenceUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入订单:提示词、生成模式、图床 URL,以及模型/时长/分辨率/比例与完整 JSON 参数(便于对账与审计)。
|
||||||
|
*/
|
||||||
|
private void fillVideoOrderRecord(AiOrder aiOrder, PortalVideoGenRequest req, String mode, ByteBodyReq body, String functionTypeResolved) {
|
||||||
|
aiOrder.setText(req.getText());
|
||||||
|
aiOrder.setMode(mode);
|
||||||
|
applyOrderImages(aiOrder, req);
|
||||||
|
if (req.getDuration() != null) {
|
||||||
|
aiOrder.setDuration(req.getDuration());
|
||||||
|
} else if (body.getDuration() != null) {
|
||||||
|
aiOrder.setDuration(body.getDuration());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(body.getResolution())) {
|
||||||
|
aiOrder.setResolution(body.getResolution());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(body.getRatio())) {
|
||||||
|
aiOrder.setRatio(body.getRatio());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(body.getModel())) {
|
||||||
|
aiOrder.setModel(body.getModel());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> snap = new LinkedHashMap<>();
|
||||||
|
snap.put("generationMode", mode);
|
||||||
|
snap.put("prompt", req.getText());
|
||||||
|
snap.put("functionType", functionTypeResolved);
|
||||||
|
snap.put("model", body.getModel());
|
||||||
|
snap.put("duration", body.getDuration());
|
||||||
|
snap.put("resolution", body.getResolution());
|
||||||
|
snap.put("ratio", body.getRatio());
|
||||||
|
if (StringUtils.isNotEmpty(req.getFirstUrl())) {
|
||||||
|
snap.put("firstUrl", req.getFirstUrl());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(req.getLastUrl())) {
|
||||||
|
snap.put("lastUrl", req.getLastUrl());
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotEmpty(req.getReferenceUrl())) {
|
||||||
|
snap.put("referenceUrl", req.getReferenceUrl());
|
||||||
|
}
|
||||||
|
if (req.getContent() != null && !req.getContent().isEmpty()) {
|
||||||
|
snap.put("content", req.getContent());
|
||||||
|
}
|
||||||
|
aiOrder.setVideoParams(OM.writeValueAsString(snap));
|
||||||
|
} catch (Exception e) {
|
||||||
|
aiOrder.setVideoParams("{\"error\":\"video_params_serialize_failed\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入 video_params.volcTaskId,任务成功后 result 会改为成品 URL,仍应用此 id 校验归属与轮询 */
|
||||||
|
private void mergeVolcTaskIdIntoVideoParams(AiOrder aiOrder, String volcTaskId) {
|
||||||
|
if (StringUtils.isEmpty(volcTaskId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ObjectNode node;
|
||||||
|
if (StringUtils.isNotEmpty(aiOrder.getVideoParams())) {
|
||||||
|
JsonNode existing = OM.readTree(aiOrder.getVideoParams());
|
||||||
|
if (existing instanceof ObjectNode) {
|
||||||
|
node = (ObjectNode) existing;
|
||||||
|
} else {
|
||||||
|
node = OM.createObjectNode();
|
||||||
|
node.set("snapshotBeforeVolcId", existing);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
node = OM.createObjectNode();
|
||||||
|
}
|
||||||
|
node.put("volcTaskId", volcTaskId);
|
||||||
|
aiOrder.setVideoParams(OM.writeValueAsString(node));
|
||||||
|
} catch (Exception e) {
|
||||||
|
aiOrder.setVideoParams("{\"volcTaskId\":\"" + volcTaskId.replace("\"", "") + "\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) {
|
||||||
|
String functionType = resolveFunctionType(req);
|
||||||
|
AiOrder aiOrder = aiOrderService.getAiOrder(functionType);
|
||||||
|
if (aiOrder == null) {
|
||||||
|
return AjaxResult.error(-1, "You have a low balance, please recharge");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fillVideoOrderRecord(aiOrder, req, mode, byteBodyReq, functionType);
|
||||||
|
aiOrderService.updateAiOrder(aiOrder);
|
||||||
|
|
||||||
|
String key = apiKey();
|
||||||
|
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq, key);
|
||||||
|
String id = byteBodyRes.getId();
|
||||||
|
if (id == null) {
|
||||||
|
aiOrderService.orderFailure(aiOrder);
|
||||||
|
return AjaxResult.error(-2, "generation failed, balance has been refunded");
|
||||||
|
}
|
||||||
|
mergeVolcTaskIdIntoVideoParams(aiOrder, id);
|
||||||
|
aiOrder.setResult(id);
|
||||||
|
aiOrderService.orderSuccess(aiOrder);
|
||||||
|
return AjaxResult.success(byteBodyRes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
aiOrderService.orderFailure(aiOrder);
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/text-to-video")
|
||||||
|
@ApiOperation("文生视频")
|
||||||
|
public AjaxResult textToVideo(@RequestBody PortalVideoGenRequest request) {
|
||||||
|
List<ContentItem> contentList;
|
||||||
|
if (request.getContent() != null && !request.getContent().isEmpty()) {
|
||||||
|
contentList = request.getContent();
|
||||||
|
} else {
|
||||||
|
if (StringUtils.isEmpty(request.getText())) {
|
||||||
|
return AjaxResult.error("请输入视频描述文本");
|
||||||
|
}
|
||||||
|
contentList = new ArrayList<>();
|
||||||
|
ContentItem textItem = new ContentItem();
|
||||||
|
textItem.setType("text");
|
||||||
|
textItem.setText(request.getText());
|
||||||
|
contentList.add(textItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBodyReq body = newVideoBody(request, contentList);
|
||||||
|
return submitOrderAndCreate(request, "text-to-video", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/image-first-frame")
|
||||||
|
@ApiOperation("图生视频-基于首帧")
|
||||||
|
public AjaxResult imageFirstFrame(@RequestBody PortalVideoGenRequest request) {
|
||||||
|
if (StringUtils.isEmpty(request.getFirstUrl())) {
|
||||||
|
return AjaxResult.error("请上传首帧图片");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(request.getText())) {
|
||||||
|
return AjaxResult.error("请输入视频描述文本");
|
||||||
|
}
|
||||||
|
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
|
||||||
|
ByteBodyReq body = newVideoBody(request, contentList);
|
||||||
|
return submitOrderAndCreate(request, "image-first-frame", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/image-first-last-frame")
|
||||||
|
@ApiOperation("图生视频-基于首尾帧")
|
||||||
|
public AjaxResult imageFirstLastFrame(@RequestBody PortalVideoGenRequest request) {
|
||||||
|
if (StringUtils.isEmpty(request.getFirstUrl()) || StringUtils.isEmpty(request.getLastUrl())) {
|
||||||
|
return AjaxResult.error("请同时上传首帧与尾帧图片");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(request.getText())) {
|
||||||
|
return AjaxResult.error("请输入视频描述文本");
|
||||||
|
}
|
||||||
|
List<ContentItem> contentList = buildTextAndFirstFrame(request.getText(), request.getFirstUrl());
|
||||||
|
ContentItem lastFrameItem = new ContentItem();
|
||||||
|
lastFrameItem.setType("image_url");
|
||||||
|
lastFrameItem.setRole("last_frame");
|
||||||
|
ImageUrl lastImageUrl = new ImageUrl();
|
||||||
|
lastImageUrl.setUrl(request.getLastUrl());
|
||||||
|
lastFrameItem.setImageUrl(lastImageUrl);
|
||||||
|
contentList.add(lastFrameItem);
|
||||||
|
|
||||||
|
ByteBodyReq body = newVideoBody(request, contentList);
|
||||||
|
return submitOrderAndCreate(request, "image-first-last-frame", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/image-reference")
|
||||||
|
@ApiOperation("图生视频-基于参考图")
|
||||||
|
public AjaxResult imageReference(@RequestBody PortalVideoGenRequest request) {
|
||||||
|
List<ContentItem> contentList;
|
||||||
|
if (request.getContent() != null && !request.getContent().isEmpty()) {
|
||||||
|
contentList = new ArrayList<>(request.getContent());
|
||||||
|
ContentItem head = contentList.get(0);
|
||||||
|
if (head == null || !"text".equals(head.getType()) || StringUtils.isEmpty(head.getText())) {
|
||||||
|
return AjaxResult.error("请输入视频描述文本(首条须为 type=text,可含 [图片n]/[视频n]/[音频n] 占位)");
|
||||||
|
}
|
||||||
|
// 保留 text + 合法 reference_*(图/音/视频);允许“只有 text 没有参考素材”
|
||||||
|
List<ContentItem> filtered = new ArrayList<>();
|
||||||
|
filtered.add(head);
|
||||||
|
for (int i = 1; i < contentList.size(); i++) {
|
||||||
|
ContentItem it = contentList.get(i);
|
||||||
|
if (isReferenceImageContentItem(it)
|
||||||
|
|| isReferenceAudioContentItem(it)
|
||||||
|
|| isReferenceVideoContentItem(it)) {
|
||||||
|
filtered.add(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentList = filtered;
|
||||||
|
|
||||||
|
String firstRef = contentList.stream()
|
||||||
|
.skip(1)
|
||||||
|
.map(PortalVideoController::firstReferenceUrlFromItem)
|
||||||
|
.filter(StringUtils::isNotEmpty)
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
// 无参考图也允许,仅 text 提示词
|
||||||
|
if (StringUtils.isNotEmpty(firstRef) && StringUtils.isEmpty(request.getReferenceUrl())) {
|
||||||
|
request.setReferenceUrl(firstRef);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (StringUtils.isEmpty(request.getReferenceUrl())) {
|
||||||
|
return AjaxResult.error("请上传参考图");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(request.getText())) {
|
||||||
|
return AjaxResult.error("请输入视频描述文本");
|
||||||
|
}
|
||||||
|
contentList = new ArrayList<>();
|
||||||
|
ContentItem textItem = new ContentItem();
|
||||||
|
textItem.setType("text");
|
||||||
|
textItem.setText(request.getText());
|
||||||
|
contentList.add(textItem);
|
||||||
|
|
||||||
|
ContentItem refItem = new ContentItem();
|
||||||
|
refItem.setType("image_url");
|
||||||
|
refItem.setRole("reference_image");
|
||||||
|
ImageUrl refUrl = new ImageUrl();
|
||||||
|
refUrl.setUrl(request.getReferenceUrl());
|
||||||
|
refItem.setImageUrl(refUrl);
|
||||||
|
contentList.add(refItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteBodyReq body = newVideoBody(request, contentList);
|
||||||
|
return submitOrderAndCreate(request, "image-reference", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstReferenceUrlFromItem(ContentItem item) {
|
||||||
|
if (isReferenceImageContentItem(item)) {
|
||||||
|
return item.getImageUrl().getUrl();
|
||||||
|
}
|
||||||
|
if (isReferenceVideoContentItem(item)) {
|
||||||
|
return item.getVideoUrl().getUrl();
|
||||||
|
}
|
||||||
|
if (isReferenceAudioContentItem(item)) {
|
||||||
|
return item.getAudioUrl().getUrl();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidReferenceAssetOrHttpUrl(String raw) {
|
||||||
|
if (StringUtils.isEmpty(raw)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String url = raw.trim().toLowerCase();
|
||||||
|
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("asset://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isReferenceImageContentItem(ContentItem item) {
|
||||||
|
if (item == null || !"image_url".equals(item.getType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!"reference_image".equals(item.getRole())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ImageUrl iu = item.getImageUrl();
|
||||||
|
if (iu == null || StringUtils.isEmpty(iu.getUrl())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isValidReferenceAssetOrHttpUrl(iu.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isReferenceVideoContentItem(ContentItem item) {
|
||||||
|
if (item == null || !"video_url".equals(item.getType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!"reference_video".equals(item.getRole())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ImageUrl vu = item.getVideoUrl();
|
||||||
|
if (vu == null || StringUtils.isEmpty(vu.getUrl())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isValidReferenceAssetOrHttpUrl(vu.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isReferenceAudioContentItem(ContentItem item) {
|
||||||
|
if (item == null || !"audio_url".equals(item.getType())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!"reference_audio".equals(item.getRole())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ImageUrl au = item.getAudioUrl();
|
||||||
|
if (au == null || StringUtils.isEmpty(au.getUrl())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isValidReferenceAssetOrHttpUrl(au.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ContentItem> buildTextAndFirstFrame(String text, String firstUrl) {
|
||||||
|
List<ContentItem> contentList = new ArrayList<>();
|
||||||
|
ContentItem textItem = new ContentItem();
|
||||||
|
textItem.setType("text");
|
||||||
|
textItem.setText(text);
|
||||||
|
contentList.add(textItem);
|
||||||
|
|
||||||
|
ContentItem firstFrameItem = new ContentItem();
|
||||||
|
firstFrameItem.setType("image_url");
|
||||||
|
firstFrameItem.setRole("first_frame");
|
||||||
|
ImageUrl firstImageUrl = new ImageUrl();
|
||||||
|
firstImageUrl.setUrl(firstUrl);
|
||||||
|
firstFrameItem.setImageUrl(firstImageUrl);
|
||||||
|
contentList.add(firstFrameItem);
|
||||||
|
return contentList;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/options")
|
||||||
|
@ApiOperation("门户视频生成可选参数(模型/比例/时长等,来自配置)")
|
||||||
|
public AjaxResult videoParamOptions() {
|
||||||
|
Map<String, Object> data = new LinkedHashMap<>();
|
||||||
|
data.put("defaults", portalVideoProperties.getDefaults());
|
||||||
|
data.put("models", portalVideoProperties.getModels());
|
||||||
|
data.put("ratios", portalVideoProperties.getRatios());
|
||||||
|
data.put("durations", portalVideoProperties.getDurations());
|
||||||
|
data.put("resolutions", portalVideoProperties.getResolutions());
|
||||||
|
return AjaxResult.success(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks")
|
||||||
|
@ApiOperation("查询视频生成任务列表(本用户库表分页)")
|
||||||
|
public TableDataInfo listMyVideoTasks(AiOrder aiOrder) {
|
||||||
|
aiOrder.setUserId(SecurityUtils.getAiUserId());
|
||||||
|
aiOrder.setType("21");
|
||||||
|
startPage();
|
||||||
|
List<AiOrder> list = aiOrderService.selectAiOrderList(aiOrder);
|
||||||
|
return getDataTable(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/volc-tasks")
|
||||||
|
@ApiOperation("查询视频生成任务列表(火山平台,按本用户在库中的任务 id 过滤)")
|
||||||
|
public AjaxResult listVolcTasks(
|
||||||
|
@RequestParam(defaultValue = "1") int pageNum,
|
||||||
|
@RequestParam(defaultValue = "20") int pageSize) throws Exception {
|
||||||
|
Long uid = SecurityUtils.getAiUserId();
|
||||||
|
String key = apiKey();
|
||||||
|
String raw = byteService.listVideoGenerationTasks(pageNum, pageSize, key);
|
||||||
|
String filtered = filterVolcTasksJsonForUser(raw, uid);
|
||||||
|
return AjaxResult.success(OM.readTree(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String filterVolcTasksJsonForUser(String raw, Long userId) throws Exception {
|
||||||
|
AiOrder query = new AiOrder();
|
||||||
|
query.setUserId(userId);
|
||||||
|
query.setType("21");
|
||||||
|
List<AiOrder> mine = aiOrderService.selectAiOrderList(query);
|
||||||
|
Set<String> allowed = mine.stream()
|
||||||
|
.map(AiOrder::getResult)
|
||||||
|
.filter(StringUtils::isNotEmpty)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
JsonNode root = OM.readTree(raw);
|
||||||
|
if (!(root instanceof ObjectNode)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
ArrayNode arr = null;
|
||||||
|
String arrayKey = null;
|
||||||
|
if (root.has("items") && root.get("items").isArray()) {
|
||||||
|
arr = (ArrayNode) root.get("items");
|
||||||
|
arrayKey = "items";
|
||||||
|
} else if (root.has("data") && root.get("data").isArray()) {
|
||||||
|
arr = (ArrayNode) root.get("data");
|
||||||
|
arrayKey = "data";
|
||||||
|
}
|
||||||
|
if (arr == null) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
ArrayNode out = OM.createArrayNode();
|
||||||
|
for (JsonNode n : arr) {
|
||||||
|
String tid = n.has("id") ? n.get("id").asText() : null;
|
||||||
|
if (tid != null && allowed.contains(tid)) {
|
||||||
|
out.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ObjectNode result = ((ObjectNode) root).deepCopy();
|
||||||
|
result.set(arrayKey, out);
|
||||||
|
result.put("filtered_total", out.size());
|
||||||
|
return OM.writeValueAsString(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tasks/{taskId}")
|
||||||
|
@ApiOperation("查询单个视频生成任务(火山)")
|
||||||
|
public AjaxResult getVolcTask(@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();
|
||||||
|
ByteBodyRes byteBodyRes = byteService.uploadVideo(taskId, key);
|
||||||
|
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
||||||
|
content contentObj = byteBodyRes.getContent();
|
||||||
|
if (contentObj != null && StringUtils.isNotEmpty(contentObj.getVideo_url())) {
|
||||||
|
String videoUrl = tencentCosUtil.uploadFileByUrl(contentObj.getVideo_url());
|
||||||
|
if (videoUrl != null) {
|
||||||
|
contentObj.setVideo_url(videoUrl);
|
||||||
|
AiOrder aiOrder = new AiOrder();
|
||||||
|
aiOrder.setId(owned.getId());
|
||||||
|
aiOrder.setResult(videoUrl);
|
||||||
|
aiOrderService.updateAiOrder(aiOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
package com.ruoyi.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门户视频生成参数(模型、比例、时长、分辨率等从配置读取,不在代码中写死业务默认值)。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "portal.video")
|
||||||
|
public class PortalVideoProperties {
|
||||||
|
|
||||||
|
private Defaults defaults = new Defaults();
|
||||||
|
|
||||||
|
private List<ModelOption> models = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<String> ratios = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<Integer> durations = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<String> resolutions = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 ai_manager.type 一致,用于扣费与订单;库中需存在对应记录且 status=0、del_flag=0
|
||||||
|
*/
|
||||||
|
private String functionType = "21";
|
||||||
|
|
||||||
|
public String getFunctionType() {
|
||||||
|
return functionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFunctionType(String functionType) {
|
||||||
|
this.functionType = functionType != null ? functionType : "21";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Defaults getDefaults() {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDefaults(Defaults defaults) {
|
||||||
|
if (defaults != null) {
|
||||||
|
this.defaults = defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelOption> getModels() {
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModels(List<ModelOption> models) {
|
||||||
|
this.models = models != null ? models : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getRatios() {
|
||||||
|
return ratios;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRatios(List<String> ratios) {
|
||||||
|
this.ratios = ratios != null ? ratios : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Integer> getDurations() {
|
||||||
|
return durations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDurations(List<Integer> durations) {
|
||||||
|
this.durations = durations != null ? durations : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getResolutions() {
|
||||||
|
return resolutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResolutions(List<String> resolutions) {
|
||||||
|
this.resolutions = resolutions != null ? resolutions : new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* defaults.model 为空时,取 models 第一项的 value。
|
||||||
|
*/
|
||||||
|
public String resolveDefaultModelId() {
|
||||||
|
if (defaults.getModel() != null && !defaults.getModel().isEmpty()) {
|
||||||
|
return defaults.getModel();
|
||||||
|
}
|
||||||
|
if (!models.isEmpty() && models.get(0).getValue() != null) {
|
||||||
|
return models.get(0).getValue();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Defaults {
|
||||||
|
private String model;
|
||||||
|
private Integer duration;
|
||||||
|
private String resolution;
|
||||||
|
private String ratio;
|
||||||
|
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModel(String model) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDuration() {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDuration(Integer duration) {
|
||||||
|
this.duration = duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getResolution() {
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setResolution(String resolution) {
|
||||||
|
this.resolution = resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRatio() {
|
||||||
|
return ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRatio(String ratio) {
|
||||||
|
this.ratio = ratio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ModelOption {
|
||||||
|
private String label;
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
public String getLabel() {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLabel(String label) {
|
||||||
|
this.label = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setValue(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
package com.ruoyi.ai.service.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ruoyi.ai.domain.*;
|
||||||
|
import com.ruoyi.ai.service.*;
|
||||||
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
|
import com.ruoyi.common.core.domain.entity.AiUser;
|
||||||
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
|
import com.ruoyi.common.utils.SecurityUtils;
|
||||||
|
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||||
|
import com.ruoyi.common.utils.sign.Md5Utils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
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;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VM支付服务实现
|
||||||
|
*
|
||||||
|
* @author system
|
||||||
|
* @date 2025-01-XX
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class VmService implements IVmService {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||||
|
|
||||||
|
@Value("${vm.url:http://payment-api.togame.top}")
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
@Value("${vm.mchNo}")
|
||||||
|
private String mchNo;
|
||||||
|
|
||||||
|
@Value("${vm.appId}")
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
@Value("${vm.secret}")
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Value("${vm.notifyUrl}")
|
||||||
|
private String notifyUrl;
|
||||||
|
|
||||||
|
@Value("${vm.wayCode:BUZHI_VM}")
|
||||||
|
private String wayCode;
|
||||||
|
|
||||||
|
@Value("${vm.currency:USD}")
|
||||||
|
private String currency;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAiRechargeService aiRechargeService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAiUserService aiUserService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAiRechargeGiftGearService aiRechargeGiftGearService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IAiRechargeGiftService aiRechargeGiftService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IExchangeRateService exchangeRateService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PayResVO vmPay(Long gearId, VmCardInfo vmCardInfo, String clientIp) throws Exception {
|
||||||
|
// 充值挡位查询
|
||||||
|
AiRechargeGiftGear aiRechargeGiftGear = aiRechargeGiftGearService.selectAiRechargeGiftGearById(gearId);
|
||||||
|
if (aiRechargeGiftGear == null) {
|
||||||
|
throw new ServiceException("The gear position does not exist.", -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal amount = aiRechargeGiftGear.getRechargeAmount();
|
||||||
|
// 使用汇率服务转换金额(从USD转换为目标货币,默认USD)
|
||||||
|
// 注意:VM支付使用USD作为基础货币
|
||||||
|
if (!"USD".equalsIgnoreCase(currency)) {
|
||||||
|
amount = exchangeRateService.convertAmount(amount, "USD", currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成订单号
|
||||||
|
String uuid = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8);
|
||||||
|
String dateTime = new SimpleDateFormat("yyyyMMdd").format(new Date());
|
||||||
|
String orderNo = dateTime + "vm" + uuid;
|
||||||
|
|
||||||
|
// 金额转换为分(整数)
|
||||||
|
int amountInCents = amount.multiply(new BigDecimal(100)).intValue();
|
||||||
|
|
||||||
|
// 构建extParam(VM卡信息)
|
||||||
|
String extParam = null;
|
||||||
|
if (vmCardInfo != null) {
|
||||||
|
extParam = objectMapper.writeValueAsString(vmCardInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建统一下单请求
|
||||||
|
VmUnifiedOrderReq req = new VmUnifiedOrderReq();
|
||||||
|
req.setMchNo(mchNo);
|
||||||
|
req.setAppId(appId);
|
||||||
|
req.setMchOrderNo(orderNo);
|
||||||
|
req.setWayCode(wayCode);
|
||||||
|
req.setAmount(amountInCents);
|
||||||
|
req.setCurrency(currency.toLowerCase());
|
||||||
|
req.setSubject("游戏充值");
|
||||||
|
req.setBody("游戏充值");
|
||||||
|
req.setNotifyUrl(notifyUrl + "/api/pay/vm-callBack");
|
||||||
|
req.setExpiredTime(7200); // 默认2小时
|
||||||
|
req.setExtParam(extParam);
|
||||||
|
req.setReqTime(System.currentTimeMillis());
|
||||||
|
req.setVersion("1.0");
|
||||||
|
req.setSignType("MD5");
|
||||||
|
// 设置客户端IP
|
||||||
|
if (clientIp != null && !clientIp.trim().isEmpty()) {
|
||||||
|
req.setClientIp(clientIp);
|
||||||
|
log.debug("VM支付设置客户端IP: {}", clientIp);
|
||||||
|
} else {
|
||||||
|
log.warn("VM支付客户端IP为空,订单号: {}", orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
String sign = generateSign(req);
|
||||||
|
req.setSign(sign);
|
||||||
|
|
||||||
|
// 构建请求体JSON
|
||||||
|
String jsonBody = objectMapper.writeValueAsString(req);
|
||||||
|
log.info("VM支付请求参数: {}", jsonBody);
|
||||||
|
|
||||||
|
// 构建POST请求
|
||||||
|
Request request = new Request.Builder()
|
||||||
|
.url(url + "/api/pay/unifiedOrder")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.post(RequestBody.create(MediaType.parse("application/json"), jsonBody))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
Response response = OkHttpUtils.newCall(request).execute();
|
||||||
|
ResponseBody body = response.body();
|
||||||
|
|
||||||
|
if (body == null) {
|
||||||
|
log.error("VM支付请求的响应异常");
|
||||||
|
throw new Exception("vm pay response body is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
String responseBody = body.string();
|
||||||
|
log.info("VM支付请求响应: {}", responseBody);
|
||||||
|
|
||||||
|
VmUnifiedOrderRes res = objectMapper.readValue(responseBody, VmUnifiedOrderRes.class);
|
||||||
|
|
||||||
|
// 检查响应code,0表示成功
|
||||||
|
if (res.getCode() == null || res.getCode() != 0) {
|
||||||
|
String msg = res.getMsg();
|
||||||
|
log.error("VM支付请求失败,code: {}, msg: {}", res.getCode(), msg);
|
||||||
|
throw new ServiceException(msg != null ? msg : "支付请求失败", res.getCode() != null ? res.getCode() : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查返回数据
|
||||||
|
if (res.getData() == null) {
|
||||||
|
log.error("VM支付返回的data为空,订单号: {}", orderNo);
|
||||||
|
throw new ServiceException("支付返回的数据为空", -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
VmUnifiedOrderRes.VmUnifiedOrderData data = res.getData();
|
||||||
|
|
||||||
|
// 记录关键字段信息
|
||||||
|
String payOrderId = data.getPayOrderId();
|
||||||
|
String mchOrderNo = data.getMchOrderNo();
|
||||||
|
log.info("VM支付返回数据,商户订单号: {}, 支付订单号: {}, 订单状态: {}, payDataType: {}",
|
||||||
|
mchOrderNo, payOrderId, data.getOrderState(), data.getPayDataType());
|
||||||
|
|
||||||
|
// 检查订单状态
|
||||||
|
Integer orderState = data.getOrderState();
|
||||||
|
if (orderState == null) {
|
||||||
|
log.error("VM支付返回的订单状态为空,订单号: {}", orderNo);
|
||||||
|
throw new ServiceException("支付返回的订单状态为空", -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单状态说明:
|
||||||
|
// 0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||||
|
String payUrl = null;
|
||||||
|
String payDataType = data.getPayDataType();
|
||||||
|
String payData = data.getPayData();
|
||||||
|
|
||||||
|
// 如果订单状态是支付失败(3),抛出异常
|
||||||
|
if (orderState == 3) {
|
||||||
|
String errCode = data.getErrCode();
|
||||||
|
String errMsg = data.getErrMsg();
|
||||||
|
String errorMessage = "支付失败";
|
||||||
|
if (errMsg != null && !errMsg.trim().isEmpty()) {
|
||||||
|
errorMessage = errMsg;
|
||||||
|
} else if (errCode != null && !errCode.trim().isEmpty()) {
|
||||||
|
errorMessage = String.format("支付失败,错误码: %s", errCode);
|
||||||
|
}
|
||||||
|
log.error("VM支付失败,订单号: {}, 订单状态: {}, 错误码: {}, 错误消息: {}",
|
||||||
|
orderNo, orderState, errCode, errMsg);
|
||||||
|
throw new ServiceException(errorMessage, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果订单状态是支付中(1),需要获取支付URL让用户去支付
|
||||||
|
// 如果订单状态是支付成功(2),不需要payUrl(用户说"订单状态只要是支付成功就不用管订单状态")
|
||||||
|
if (orderState == 1) {
|
||||||
|
// 根据payDataType获取支付URL
|
||||||
|
if (payDataType != null && payData != null && !payData.trim().isEmpty()) {
|
||||||
|
// payurl类型(注意:实际返回是小写payurl):跳转链接的方式
|
||||||
|
if ("payurl".equalsIgnoreCase(payDataType) || "payUrl".equalsIgnoreCase(payDataType)) {
|
||||||
|
payUrl = payData;
|
||||||
|
log.info("VM支付使用payData作为支付URL(payDataType={}),订单号: {}", payDataType, orderNo);
|
||||||
|
}
|
||||||
|
// codeUrl类型:二维码地址
|
||||||
|
else if ("codeUrl".equalsIgnoreCase(payDataType)) {
|
||||||
|
payUrl = payData;
|
||||||
|
log.info("VM支付使用payData作为支付URL(payDataType=codeUrl),订单号: {}", orderNo);
|
||||||
|
}
|
||||||
|
// codeImgUrl类型:二维码图片地址
|
||||||
|
else if ("codeImgUrl".equalsIgnoreCase(payDataType)) {
|
||||||
|
payUrl = payData;
|
||||||
|
log.info("VM支付使用payData作为支付URL(payDataType=codeImgUrl),订单号: {}", orderNo);
|
||||||
|
}
|
||||||
|
// 其他类型(form, wxapp, aliapp, ysfapp, none)暂不处理payUrl
|
||||||
|
else if (!"none".equalsIgnoreCase(payDataType)) {
|
||||||
|
log.info("VM支付payDataType为: {},暂不支持作为支付URL,订单号: {}", payDataType, orderNo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧版本:如果payUrl字段有值,优先使用
|
||||||
|
if ((payUrl == null || payUrl.trim().isEmpty()) &&
|
||||||
|
data.getPayUrl() != null && !data.getPayUrl().trim().isEmpty()) {
|
||||||
|
payUrl = data.getPayUrl();
|
||||||
|
log.info("VM支付使用payUrl字段,订单号: {}", orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有payUrl,记录警告
|
||||||
|
if (payUrl == null || payUrl.trim().isEmpty()) {
|
||||||
|
log.warn("VM支付返回的payUrl为空,订单状态为支付中,订单号: {}, payDataType: {}",
|
||||||
|
orderNo, payDataType);
|
||||||
|
payUrl = "";
|
||||||
|
}
|
||||||
|
} else if (orderState == 2) {
|
||||||
|
// 支付成功,不需要payUrl
|
||||||
|
log.info("VM支付订单状态为支付成功,不需要返回payUrl,订单号: {}", orderNo);
|
||||||
|
payUrl = "";
|
||||||
|
} else {
|
||||||
|
// 其他状态(0-订单生成, 4-已撤销, 5-已退款, 6-订单关闭)
|
||||||
|
log.info("VM支付订单状态: {},订单号: {}", orderState, orderNo);
|
||||||
|
payUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建充值管理
|
||||||
|
AiUser userInfo = aiUserService.getUserInfo(SecurityUtils.getAiUserId());
|
||||||
|
AiRecharge aiRecharge = new AiRecharge();
|
||||||
|
aiRecharge.setOrderNum(orderNo);
|
||||||
|
aiRecharge.setUserId(SecurityUtils.getAiUserId());
|
||||||
|
aiRecharge.setAmount(amount);
|
||||||
|
aiRecharge.setGearId(gearId);
|
||||||
|
aiRecharge.setGearAmount(aiRechargeGiftGear.getRechargeAmount());
|
||||||
|
aiRecharge.setSource(userInfo.getSource());
|
||||||
|
AiRechargeGift aiRechargeGift = aiRechargeGiftService.selectAiRechargeGiftById(aiRechargeGiftGear.getRechargeId());
|
||||||
|
aiRecharge.setPayType(aiRechargeGift.getPayType());
|
||||||
|
aiRecharge.setPayUrl(payUrl);
|
||||||
|
aiRechargeService.insertAiRecharge(aiRecharge);
|
||||||
|
|
||||||
|
PayResVO payResVO = new PayResVO();
|
||||||
|
payResVO.setOrderNo(orderNo);
|
||||||
|
payResVO.setPayUrl(payUrl);
|
||||||
|
return payResVO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public String vmCallBack(VmCallBackReq req) {
|
||||||
|
log.info("VM支付回调,订单号: {}, 状态: {}", req.getMchOrderNo(), req.getState());
|
||||||
|
|
||||||
|
log.debug("VM支付回调请求对象: mchNo={}, appId={}, mchOrderNo={}, payOrderId={}, ifCode={}, wayCode={}, amount={}, currency={}, state={}, subject={}, body={}, createdAt={}, reqTime={}, sign={}",
|
||||||
|
req.getMchNo(), req.getAppId(), req.getMchOrderNo(), req.getPayOrderId(), req.getIfCode(), req.getWayCode(),
|
||||||
|
req.getAmount(), req.getCurrency(), req.getState(), req.getSubject(), req.getBody(),
|
||||||
|
req.getCreatedAt(), req.getReqTime(), req.getSign());
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
String sign = generateCallBackSign(req);
|
||||||
|
if (!sign.equals(req.getSign())) {
|
||||||
|
log.error("VM支付回调签名错误,订单号: {}, 期望签名: {}, 实际签名: {}",
|
||||||
|
req.getMchOrderNo(), sign, req.getSign());
|
||||||
|
return "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理订单状态(文档字段名:state)
|
||||||
|
// 0-订单生成, 1-支付中, 2-支付成功, 3-支付失败, 4-已撤销, 5-已退款, 6-订单关闭
|
||||||
|
Integer state = req.getState();
|
||||||
|
if (state == null) {
|
||||||
|
log.warn("VM支付回调state为空,订单号: {},返回 success 等待后续回调", req.getMchOrderNo());
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == 2) {
|
||||||
|
log.info("VM支付成功,订单号: {}", req.getMchOrderNo());
|
||||||
|
aiRechargeService.addRecharge(req.getMchOrderNo());
|
||||||
|
return "success";
|
||||||
|
} else if (state == 3) {
|
||||||
|
log.warn("VM支付失败,订单号: {}", req.getMchOrderNo());
|
||||||
|
return "success";
|
||||||
|
} else {
|
||||||
|
log.info("VM支付状态: {}, 订单号: {}", state, req.getMchOrderNo());
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成签名(统一下单)
|
||||||
|
* 第一步:将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序),使用URL键值对的格式拼接成字符串stringA
|
||||||
|
* 第二步:在stringA最后拼接上key[即 StringA +"&key=" + 私钥 ] 得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写
|
||||||
|
*/
|
||||||
|
private String generateSign(VmUnifiedOrderReq req) {
|
||||||
|
TreeMap<String, Object> params = new TreeMap<>();
|
||||||
|
|
||||||
|
// 添加所有非空参数(sign不参与签名)
|
||||||
|
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
|
||||||
|
params.put("mchNo", req.getMchNo());
|
||||||
|
}
|
||||||
|
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
|
||||||
|
params.put("appId", req.getAppId());
|
||||||
|
}
|
||||||
|
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
|
||||||
|
params.put("mchOrderNo", req.getMchOrderNo());
|
||||||
|
}
|
||||||
|
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
|
||||||
|
params.put("wayCode", req.getWayCode());
|
||||||
|
}
|
||||||
|
if (req.getAmount() != null) {
|
||||||
|
params.put("amount", req.getAmount());
|
||||||
|
}
|
||||||
|
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
|
||||||
|
params.put("currency", req.getCurrency());
|
||||||
|
}
|
||||||
|
if (req.getClientIp() != null && !req.getClientIp().isEmpty()) {
|
||||||
|
params.put("clientIp", req.getClientIp());
|
||||||
|
}
|
||||||
|
if (req.getSubject() != null && !req.getSubject().isEmpty()) {
|
||||||
|
params.put("subject", req.getSubject());
|
||||||
|
}
|
||||||
|
if (req.getBody() != null && !req.getBody().isEmpty()) {
|
||||||
|
params.put("body", req.getBody());
|
||||||
|
}
|
||||||
|
if (req.getNotifyUrl() != null && !req.getNotifyUrl().isEmpty()) {
|
||||||
|
params.put("notifyUrl", req.getNotifyUrl());
|
||||||
|
}
|
||||||
|
if (req.getExpiredTime() != null) {
|
||||||
|
params.put("expiredTime", req.getExpiredTime());
|
||||||
|
}
|
||||||
|
if (req.getChannelExtra() != null && !req.getChannelExtra().isEmpty()) {
|
||||||
|
params.put("channelExtra", req.getChannelExtra());
|
||||||
|
}
|
||||||
|
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
|
||||||
|
params.put("extParam", req.getExtParam());
|
||||||
|
}
|
||||||
|
if (req.getReqTime() != null) {
|
||||||
|
params.put("reqTime", req.getReqTime());
|
||||||
|
}
|
||||||
|
if (req.getVersion() != null && !req.getVersion().isEmpty()) {
|
||||||
|
params.put("version", req.getVersion());
|
||||||
|
}
|
||||||
|
if (req.getSignType() != null && !req.getSignType().isEmpty()) {
|
||||||
|
params.put("signType", req.getSignType());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接参数
|
||||||
|
StringBuilder stringA = new StringBuilder();
|
||||||
|
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||||
|
if (stringA.length() > 0) {
|
||||||
|
stringA.append("&");
|
||||||
|
}
|
||||||
|
stringA.append(entry.getKey()).append("=").append(entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼接key
|
||||||
|
String stringSignTemp = stringA.toString() + "&key=" + secret;
|
||||||
|
log.debug("VM签名源字符串: {}", stringSignTemp);
|
||||||
|
|
||||||
|
// MD5并转大写
|
||||||
|
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
|
||||||
|
return sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成回调签名。按文档字段名:state、reqTime;sign、signType 不参与签名。
|
||||||
|
* 参与签名的非空参数按 key 字典序排序,key=value 用 & 拼接,最后 &key=secret,MD5 后转大写。
|
||||||
|
*/
|
||||||
|
private String generateCallBackSign(VmCallBackReq req) {
|
||||||
|
TreeMap<String, Object> params = new TreeMap<>();
|
||||||
|
|
||||||
|
if (req.getMchNo() != null && !req.getMchNo().isEmpty()) {
|
||||||
|
params.put("mchNo", req.getMchNo());
|
||||||
|
}
|
||||||
|
if (req.getAppId() != null && !req.getAppId().isEmpty()) {
|
||||||
|
params.put("appId", req.getAppId());
|
||||||
|
}
|
||||||
|
if (req.getMchOrderNo() != null && !req.getMchOrderNo().isEmpty()) {
|
||||||
|
params.put("mchOrderNo", req.getMchOrderNo());
|
||||||
|
}
|
||||||
|
if (req.getPayOrderId() != null && !req.getPayOrderId().isEmpty()) {
|
||||||
|
params.put("payOrderId", req.getPayOrderId());
|
||||||
|
}
|
||||||
|
if (req.getIfCode() != null && !req.getIfCode().isEmpty()) {
|
||||||
|
params.put("ifCode", req.getIfCode());
|
||||||
|
}
|
||||||
|
if (req.getWayCode() != null && !req.getWayCode().isEmpty()) {
|
||||||
|
params.put("wayCode", req.getWayCode());
|
||||||
|
}
|
||||||
|
if (req.getAmount() != null) {
|
||||||
|
params.put("amount", req.getAmount());
|
||||||
|
}
|
||||||
|
if (req.getCurrency() != null && !req.getCurrency().isEmpty()) {
|
||||||
|
params.put("currency", req.getCurrency());
|
||||||
|
}
|
||||||
|
if (req.getState() != null) {
|
||||||
|
params.put("state", req.getState());
|
||||||
|
}
|
||||||
|
if (req.getClientIp() != null && !req.getClientIp().isEmpty()) {
|
||||||
|
params.put("clientIp", req.getClientIp());
|
||||||
|
}
|
||||||
|
if (req.getSubject() != null && !req.getSubject().isEmpty()) {
|
||||||
|
params.put("subject", req.getSubject());
|
||||||
|
}
|
||||||
|
if (req.getBody() != null && !req.getBody().isEmpty()) {
|
||||||
|
params.put("body", req.getBody());
|
||||||
|
}
|
||||||
|
if (req.getChannelOrderNo() != null && !req.getChannelOrderNo().isEmpty()) {
|
||||||
|
params.put("channelOrderNo", req.getChannelOrderNo());
|
||||||
|
}
|
||||||
|
if (req.getErrCode() != null && !req.getErrCode().isEmpty()) {
|
||||||
|
params.put("errCode", req.getErrCode());
|
||||||
|
}
|
||||||
|
if (req.getErrMsg() != null && !req.getErrMsg().isEmpty()) {
|
||||||
|
params.put("errMsg", req.getErrMsg());
|
||||||
|
}
|
||||||
|
if (req.getExtParam() != null && !req.getExtParam().isEmpty()) {
|
||||||
|
params.put("extParam", req.getExtParam());
|
||||||
|
}
|
||||||
|
if (req.getCreatedAt() != null) {
|
||||||
|
params.put("createdAt", req.getCreatedAt());
|
||||||
|
}
|
||||||
|
if (req.getSuccessTime() != null) {
|
||||||
|
params.put("successTime", req.getSuccessTime());
|
||||||
|
}
|
||||||
|
if (req.getReqTime() != null) {
|
||||||
|
params.put("reqTime", req.getReqTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder stringA = new StringBuilder();
|
||||||
|
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||||
|
if (stringA.length() > 0) {
|
||||||
|
stringA.append("&");
|
||||||
|
}
|
||||||
|
stringA.append(entry.getKey()).append("=").append(entry.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
String stringSignTemp = stringA.toString() + "&key=" + secret;
|
||||||
|
log.debug("VM回调签名源字符串: {}", stringSignTemp);
|
||||||
|
|
||||||
|
String sign = Md5Utils.hash(stringSignTemp).toUpperCase();
|
||||||
|
log.debug("VM回调计算出的签名: {}", sign);
|
||||||
|
return sign;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Loading…
Reference in New Issue