fix: 新需求 对接火山seedance

This commit is contained in:
old burden 2026-04-01 13:17:58 +08:00
parent 3571bba3ed
commit 592c2595d6
5 changed files with 1206 additions and 0 deletions

View File

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

View File

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

View File

@ -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=0del_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;
}
}
}

View File

@ -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();
// 构建extParamVM卡信息
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);
// 检查响应code0表示成功
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作为支付URLpayDataType={}),订单号: {}", payDataType, orderNo);
}
// codeUrl类型二维码地址
else if ("codeUrl".equalsIgnoreCase(payDataType)) {
payUrl = payData;
log.info("VM支付使用payData作为支付URLpayDataType=codeUrl订单号: {}", orderNo);
}
// codeImgUrl类型二维码图片地址
else if ("codeImgUrl".equalsIgnoreCase(payDataType)) {
payUrl = payData;
log.info("VM支付使用payData作为支付URLpayDataType=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;
}
/**
* 生成回调签名按文档字段名statereqTimesignsignType 不参与签名
* 参与签名的非空参数按 key 字典序排序key=value & 拼接最后 &key=secretMD5 后转大写
*/
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;
}
}

BIN
web-api/接口文档.pdf Normal file

Binary file not shown.