diff --git a/web-api/nginx_config_optimized.conf b/web-api/nginx_config_optimized.conf new file mode 100644 index 0000000..c4644f3 --- /dev/null +++ b/web-api/nginx_config_optimized.conf @@ -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; + } +} 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 new file mode 100644 index 0000000..7795619 --- /dev/null +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java @@ -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 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 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 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 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 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 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 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 buildTextAndFirstFrame(String text, String firstUrl) { + List 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 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 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 mine = aiOrderService.selectAiOrderList(query); + Set 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; + } +} diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/config/PortalVideoProperties.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/config/PortalVideoProperties.java new file mode 100644 index 0000000..bccebdd --- /dev/null +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/config/PortalVideoProperties.java @@ -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 models = new ArrayList<>(); + + private List ratios = new ArrayList<>(); + + private List durations = new ArrayList<>(); + + private List 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 getModels() { + return models; + } + + public void setModels(List models) { + this.models = models != null ? models : new ArrayList<>(); + } + + public List getRatios() { + return ratios; + } + + public void setRatios(List ratios) { + this.ratios = ratios != null ? ratios : new ArrayList<>(); + } + + public List getDurations() { + return durations; + } + + public void setDurations(List durations) { + this.durations = durations != null ? durations : new ArrayList<>(); + } + + public List getResolutions() { + return resolutions; + } + + public void setResolutions(List 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; + } + } +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java new file mode 100644 index 0000000..e134dbc --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/VmService.java @@ -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 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 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 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 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; + } +} diff --git a/web-api/接口文档.pdf b/web-api/接口文档.pdf new file mode 100644 index 0000000..c949dd1 Binary files /dev/null and b/web-api/接口文档.pdf differ