diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java b/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java index c76a3ed..3895f19 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/ByteApiController.java @@ -93,12 +93,7 @@ public class ByteApiController extends BaseController { return AjaxResult.error(-1, "You have a low balance, please recharge"); } aiOrder.setText(text); - aiOrder.setFunctionType(mode); // 记录生成模式 - - // 文生视频模式下不设置图片 - if ("image-to-video".equals(mode) && firstUrl != null) { - aiOrder.setImg1(firstUrl.toString()); - } + aiOrder.setMode(mode); // 记录生成模式 ByteBodyReq byteBodyReq = new ByteBodyReq(); // model由前端传入,默认为Seedance 2.0 diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java b/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java new file mode 100644 index 0000000..d188e10 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/CosController.java @@ -0,0 +1,38 @@ +package com.ruoyi.api; + +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.utils.TencentCosUtil; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +/** + * COS 上传兼容接口 + */ +@RestController +@RequestMapping("/api/cos") +@Api(tags = "COS文件上传") +@RequiredArgsConstructor(onConstructor_ = @Autowired) +public class CosController { + + private final TencentCosUtil tencentCosUtil; + + @ApiOperation("COS上传接口") + @PostMapping("/upload") + public AjaxResult upload( + @ApiParam(name = "file", value = "文件", required = true) + @RequestParam("file") MultipartFile file) throws Exception { + String uploadUrl = tencentCosUtil.uploadMultipartFile(file, true); + AjaxResult ajax = AjaxResult.success(uploadUrl); + ajax.put("url", uploadUrl); + ajax.put("oldName", file.getOriginalFilename()); + return ajax; + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java b/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java new file mode 100644 index 0000000..df44409 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java @@ -0,0 +1,323 @@ +package com.ruoyi.api; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.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 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.List; +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; + + @Value("${volcengine.ark.callbackUrl:}") + private String volcCallbackUrl; + + private static final String DEFAULT_MODEL = "ep-20260326165811-dlkth"; + private static final ObjectMapper OM = new ObjectMapper(); + + private String apiKey() { + return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId()); + } + + private void applyOptionalParams(ByteBodyReq body, PortalVideoGenRequest req) { + body.setDuration(req.getDuration() != null ? req.getDuration() : 4); + body.setResolution(StringUtils.isNotEmpty(req.getResolution()) ? req.getResolution() : "720p"); + body.setRatio(StringUtils.isNotEmpty(req.getRatio()) ? req.getRatio() : "3:4"); + if (StringUtils.isNotEmpty(volcCallbackUrl)) { + body.setCallback_url(volcCallbackUrl); + } + } + + private ByteBodyReq newVideoBody(PortalVideoGenRequest req, List content) { + ByteBodyReq body = new ByteBodyReq(); + body.setModel(StringUtils.isNotEmpty(req.getModel()) ? req.getModel() : DEFAULT_MODEL); + 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()); + } + } + + private AjaxResult submitOrderAndCreate(PortalVideoGenRequest req, String mode, ByteBodyReq byteBodyReq) { + AiOrder aiOrder = aiOrderService.getAiOrder( + StringUtils.isNotEmpty(req.getFunctionType()) ? req.getFunctionType() : "21"); + if (aiOrder == null) { + return AjaxResult.error(-1, "You have a low balance, please recharge"); + } + try { + aiOrder.setText(req.getText()); + aiOrder.setMode(mode); + applyOrderImages(aiOrder, req); + if (req.getDuration() != null) { + aiOrder.setDuration(req.getDuration()); + } + if (StringUtils.isNotEmpty(byteBodyReq.getResolution())) { + aiOrder.setResolution(byteBodyReq.getResolution()); + } + if (StringUtils.isNotEmpty(byteBodyReq.getRatio())) { + aiOrder.setRatio(byteBodyReq.getRatio()); + } + 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"); + } + 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) { + if (StringUtils.isEmpty(request.getText())) { + return AjaxResult.error("请输入视频描述文本"); + } + List 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) { + if (StringUtils.isEmpty(request.getReferenceUrl())) { + return AjaxResult.error("请上传参考图"); + } + if (StringUtils.isEmpty(request.getText())) { + return AjaxResult.error("请输入视频描述文本"); + } + List 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 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("/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.getAiOrderByResult(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.getAiOrderByResult(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/ruoyi-admin/src/main/java/com/ruoyi/api/request/PortalVideoGenRequest.java b/ruoyi-admin/src/main/java/com/ruoyi/api/request/PortalVideoGenRequest.java new file mode 100644 index 0000000..ea4f9c6 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/api/request/PortalVideoGenRequest.java @@ -0,0 +1,30 @@ +package com.ruoyi.api.request; + +import lombok.Data; + +/** + * 门户视频生成(火山 Seedance)请求体 + */ +@Data +public class PortalVideoGenRequest { + + private String text; + + /** 默认与后台配置的视频计费类型一致 */ + private String functionType = "21"; + + private String model; + + private Integer duration; + + private String resolution; + + private String ratio; + + private String firstUrl; + + private String lastUrl; + + /** 图生视频-参考图模式 */ + private String referenceUrl; +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java new file mode 100644 index 0000000..8593912 --- /dev/null +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiDeptController.java @@ -0,0 +1,120 @@ +package com.ruoyi.web.controller.ai; + +import java.util.List; +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.ai.service.IAiUserService; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.constant.UserConstants; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysDept; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.service.ISysDeptService; + +/** + * AI 业务侧部门管理(数据源与系统部门 sys_dept 一致,权限独立) + */ +@RestController +@RequestMapping("/ai/dept") +public class AiDeptController extends BaseController +{ + @Autowired + private ISysDeptService deptService; + + @Autowired + private IAiUserService aiUserService; + + @PreAuthorize("@ss.hasPermi('ai:dept:list')") + @GetMapping("/list") + public AjaxResult list(SysDept dept) + { + List depts = deptService.selectDeptList(dept); + return success(depts); + } + + @PreAuthorize("@ss.hasPermi('ai:dept:list')") + @GetMapping("/list/exclude/{deptId}") + public AjaxResult excludeChild(@PathVariable(value = "deptId", required = false) Long deptId) + { + List depts = deptService.selectDeptList(new SysDept()); + depts.removeIf(d -> d.getDeptId().intValue() == deptId || ArrayUtils.contains(StringUtils.split(d.getAncestors(), ","), deptId + "")); + return success(depts); + } + + @PreAuthorize("@ss.hasPermi('ai:dept:query')") + @GetMapping(value = "/{deptId}") + public AjaxResult getInfo(@PathVariable Long deptId) + { + deptService.checkDeptDataScope(deptId); + return success(deptService.selectDeptById(deptId)); + } + + @PreAuthorize("@ss.hasPermi('ai:dept:add')") + @Log(title = "AI部门管理", businessType = BusinessType.INSERT) + @PostMapping + public AjaxResult add(@Validated @RequestBody SysDept dept) + { + if (!deptService.checkDeptNameUnique(dept)) + { + return error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在"); + } + dept.setCreateBy(getUsername()); + return toAjax(deptService.insertDept(dept)); + } + + @PreAuthorize("@ss.hasPermi('ai:dept:edit')") + @Log(title = "AI部门管理", businessType = BusinessType.UPDATE) + @PutMapping + public AjaxResult edit(@Validated @RequestBody SysDept dept) + { + Long deptId = dept.getDeptId(); + deptService.checkDeptDataScope(deptId); + if (!deptService.checkDeptNameUnique(dept)) + { + return error("修改部门'" + dept.getDeptName() + "'失败,部门名称已存在"); + } + else if (dept.getParentId().equals(deptId)) + { + return error("修改部门'" + dept.getDeptName() + "'失败,上级部门不能是自己"); + } + else if (StringUtils.equals(UserConstants.DEPT_DISABLE, dept.getStatus()) && deptService.selectNormalChildrenDeptById(deptId) > 0) + { + return error("该部门包含未停用的子部门!"); + } + dept.setUpdateBy(getUsername()); + return toAjax(deptService.updateDept(dept)); + } + + @PreAuthorize("@ss.hasPermi('ai:dept:remove')") + @Log(title = "AI部门管理", businessType = BusinessType.DELETE) + @DeleteMapping("/{deptId}") + public AjaxResult remove(@PathVariable Long deptId) + { + if (deptService.hasChildByDeptId(deptId)) + { + return warn("存在下级部门,不允许删除"); + } + if (deptService.checkDeptExistUser(deptId)) + { + return warn("部门存在系统用户,不允许删除"); + } + if (aiUserService.countAiUserByDeptId(deptId) > 0) + { + return warn("部门存在 AI 用户,不允许删除"); + } + deptService.checkDeptDataScope(deptId); + return toAjax(deptService.deleteDeptById(deptId)); + } +} diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java index 3dd3c4f..8e1d350 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ai/AiUserController.java @@ -93,6 +93,20 @@ public class AiUserController extends BaseController { return toAjax(aiUserService.updateAiUser(aiUser)); } + /** + * 分配归属部门(deptId 为空表示不归属任何部门) + */ + @ApiOperation("分配AI用户归属部门") + @PreAuthorize("@ss.hasPermi('ai:user:edit')") + @Log(title = "ai-用户信息", businessType = BusinessType.UPDATE) + @PutMapping("/dept") + public AjaxResult assignDept(@RequestBody AiUser aiUser) { + if (aiUser.getId() == null) { + return error("用户主键不能为空"); + } + return toAjax(aiUserService.updateAiUserDept(aiUser.getId(), aiUser.getDeptId())); + } + /** * 状态修改 */ diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AiUser.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AiUser.java index 559d070..0cd4948 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AiUser.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/AiUser.java @@ -117,12 +117,19 @@ public class AiUser extends BaseEntity { */ private String country; + /** 归属部门ID(sys_dept.dept_id) */ + private Long deptId; + /** * 上级用户昵称 */ @TableField(exist = false) private String superiorName; + /** 归属部门名称 */ + @TableField(exist = false) + private String deptName; + /** * 上级用户ID */ diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysDept.java b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysDept.java index fb18c5c..f12e4d8 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysDept.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/core/domain/entity/SysDept.java @@ -51,6 +51,9 @@ public class SysDept extends BaseEntity /** 父部门名称 */ private String parentName; + + /** Byte API Key */ + private String byteApiKey; /** 子部门 */ private List children = new ArrayList(); @@ -171,6 +174,16 @@ public class SysDept extends BaseEntity this.parentName = parentName; } + public String getByteApiKey() + { + return byteApiKey; + } + + public void setByteApiKey(String byteApiKey) + { + this.byteApiKey = byteApiKey; + } + public List getChildren() { return children; @@ -192,6 +205,7 @@ public class SysDept extends BaseEntity .append("leader", getLeader()) .append("phone", getPhone()) .append("email", getEmail()) + .append("byteApiKey", getByteApiKey()) .append("status", getStatus()) .append("delFlag", getDelFlag()) .append("createBy", getCreateBy()) diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiUserMapper.java b/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiUserMapper.java index 9cd1763..d5c8c74 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiUserMapper.java +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/mapper/AiUserMapper.java @@ -6,6 +6,7 @@ import java.util.List; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.ruoyi.common.core.domain.entity.AiUser; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; /** * ai-用户信息Mapper接口 @@ -22,4 +23,9 @@ public interface AiUserMapper extends BaseMapper { AiUser selectAiUserById(Long id); AiUser getUserInfo(Long id); + + int countAiUserByDeptId(@Param("deptId") Long deptId); + + @Update("update ai_user set dept_id = #{deptId}, update_time = sysdate() where id = #{userId}") + int updateAiUserDeptId(@Param("userId") Long userId, @Param("deptId") Long deptId); } diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiUserService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiUserService.java index 12ebaed..6ce243e 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiUserService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IAiUserService.java @@ -99,6 +99,16 @@ public interface IAiUserService { int updatePassword(AiUser aiUser); + /** + * 部门下 AI 用户数量(用于删除部门前校验) + */ + int countAiUserByDeptId(Long deptId); + + /** + * 分配 AI 用户归属部门,deptId 为空则清空 + */ + int updateAiUserDept(Long userId, Long deptId); + public void deductSampleAmount(String userId); void handleRebate(String orderNo, Long userId, BigDecimal rechargeAmount); diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteDeptApiKeyService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteDeptApiKeyService.java new file mode 100644 index 0000000..bf2a8ef --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteDeptApiKeyService.java @@ -0,0 +1,12 @@ +package com.ruoyi.ai.service; + +/** + * 门户用户火山方舟 API Key:取自所属二级部门 {@code sys_dept.byte_api_key},带 Redis 缓存。 + */ +public interface IByteDeptApiKeyService { + + /** + * Redis 键:{userId}_byte_api_key,优先读缓存,再读库;均无有效 key 则抛出业务异常。 + */ + String resolveVolcApiKey(Long aiUserId); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java index 4964675..ef42e36 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/IByteService.java @@ -17,17 +17,31 @@ public interface IByteService { ByteBodyRes imgToImg(ByteBodyReq req) throws Exception; /** - * 首尾帧图生视频 + * 首尾帧图生视频(使用全局配置的 Ark API Key) */ ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception; + /** + * 视频生成任务创建(指定 Ark API Key,门户按部门密钥调用) + */ + ByteBodyRes imgToVideo(ByteBodyReq req, String arkApiKey) throws Exception; + /** * 下载视频 */ ByteBodyRes uploadVideo(String id) throws Exception; + ByteBodyRes uploadVideo(String id, String arkApiKey) throws Exception; + /** * 取消视频生成任务 */ AjaxResult cancelVideoTask(String id) throws Exception; + + AjaxResult cancelVideoTask(String id, String arkApiKey) throws Exception; + + /** + * GET 查询视频生成任务列表(火山 list 文档),返回原始 JSON 字符串 + */ + String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception; } diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiUserServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiUserServiceImpl.java index e394ea3..69c2bc4 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiUserServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/AiUserServiceImpl.java @@ -132,6 +132,19 @@ public class AiUserServiceImpl implements IAiUserService { return aiUserMapper.updateById(aiUser); } + @Override + public int countAiUserByDeptId(Long deptId) { + if (deptId == null) { + return 0; + } + return aiUserMapper.countAiUserByDeptId(deptId); + } + + @Override + public int updateAiUserDept(Long userId, Long deptId) { + return aiUserMapper.updateAiUserDeptId(userId, deptId); + } + /** * 批量删除ai-用户信息 * diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java new file mode 100644 index 0000000..b75457d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java @@ -0,0 +1,85 @@ +package com.ruoyi.ai.service.impl; + +import com.ruoyi.ai.service.IByteDeptApiKeyService; +import com.ruoyi.ai.service.IAiUserService; +import com.ruoyi.common.core.domain.entity.AiUser; +import com.ruoyi.common.core.domain.entity.SysDept; +import com.ruoyi.common.core.redis.RedisCache; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.service.ISysDeptService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService { + + private static final String NO_PERM_MSG = "用户没有权限,请联系管理员分配部门"; + private static final int CACHE_HOURS = 1; + + @Autowired + private RedisCache redisCache; + + @Autowired + private IAiUserService aiUserService; + + @Autowired + private ISysDeptService sysDeptService; + + @Override + public String resolveVolcApiKey(Long aiUserId) { + if (aiUserId == null) { + throw new ServiceException(NO_PERM_MSG); + } + String cacheKey = aiUserId + "_byte_api_key"; + String cached = redisCache.getCacheObject(cacheKey); + if (StringUtils.isNotEmpty(cached)) { + return cached; + } + AiUser aiUser = aiUserService.selectAiUserById(aiUserId); + if (aiUser == null || aiUser.getDeptId() == null) { + throw new ServiceException(NO_PERM_MSG); + } + SysDept userDept = sysDeptService.selectDeptById(aiUser.getDeptId()); + if (userDept == null) { + throw new ServiceException(NO_PERM_MSG); + } + Long secondLevelDeptId = resolveSecondLevelDeptId(userDept); + SysDept keyDept = sysDeptService.selectDeptById(secondLevelDeptId); + if (keyDept == null || StringUtils.isEmpty(keyDept.getByteApiKey())) { + throw new ServiceException(NO_PERM_MSG); + } + String apiKey = keyDept.getByteApiKey().trim(); + redisCache.setCacheObject(cacheKey, apiKey, CACHE_HOURS, TimeUnit.HOURS); + return apiKey; + } + + /** + * 二级部门:祖级路径中,紧接在「一级」(ancestors 第二段)之下的部门节点; + * 深度不足时退回用户当前部门。 + */ + private Long resolveSecondLevelDeptId(SysDept userDept) { + String ancestors = userDept.getAncestors(); + if (StringUtils.isEmpty(ancestors)) { + return userDept.getDeptId(); + } + String[] parts = ancestors.split(","); + if (parts.length >= 3) { + try { + return Long.parseLong(parts[2].trim()); + } catch (NumberFormatException ignored) { + return userDept.getDeptId(); + } + } + if (parts.length == 2) { + try { + return Long.parseLong(parts[1].trim()); + } catch (NumberFormatException ignored) { + return userDept.getDeptId(); + } + } + return userDept.getDeptId(); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java index 463e093..120f9eb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteService.java @@ -9,6 +9,7 @@ import com.ruoyi.ai.service.IByteService; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.http.OkHttpUtils; +import okhttp3.HttpUrl; import okhttp3.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -83,17 +84,24 @@ public class ByteService implements IByteService { @Override public ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception { + return imgToVideo(req, volcApiKey); + } + + @Override + public ByteBodyRes imgToVideo(ByteBodyReq req, String arkApiKey) throws Exception { if (req == null) { throw new Exception("imgToVideo error:req is null"); } + if (StringUtils.isBlank(arkApiKey)) { + throw new Exception("imgToVideo error:apiKey is null"); + } - // 使用火山引擎配置 String jsonBody = objectMapper.writeValueAsString(req); Request request = new Request.Builder() .url(volcBaseUrl + "/api/v3/contents/generations/tasks") .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + volcApiKey) + .header("Authorization", "Bearer " + arkApiKey) .post(RequestBody.create( MediaType.parse("application/json; charset=utf-8"), jsonBody @@ -117,15 +125,22 @@ public class ByteService implements IByteService { @Override public ByteBodyRes uploadVideo(String id) throws Exception { + return uploadVideo(id, volcApiKey); + } + + @Override + public ByteBodyRes uploadVideo(String id, String arkApiKey) throws Exception { if (StringUtils.isBlank(id)) { throw new Exception("uploadVideo error:id is null"); } + if (StringUtils.isBlank(arkApiKey)) { + throw new Exception("uploadVideo error:apiKey is null"); + } - // 使用火山引擎配置查询任务状态 Request request = new Request.Builder() .url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id) .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + volcApiKey) + .header("Authorization", "Bearer " + arkApiKey) .get() .build(); @@ -146,16 +161,23 @@ public class ByteService implements IByteService { @Override public AjaxResult cancelVideoTask(String id) throws Exception { + return cancelVideoTask(id, volcApiKey); + } + + @Override + public AjaxResult cancelVideoTask(String id, String arkApiKey) throws Exception { if (StringUtils.isBlank(id)) { return AjaxResult.error("任务ID不能为空"); } + if (StringUtils.isBlank(arkApiKey)) { + return AjaxResult.error("API Key 无效"); + } try { - // 向火山引擎发送 DELETE 请求取消任务 Request request = new Request.Builder() .url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id) .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + volcApiKey) + .header("Authorization", "Bearer " + arkApiKey) .delete() .build(); @@ -171,4 +193,39 @@ public class ByteService implements IByteService { return AjaxResult.error("取消任务异常:" + e.getMessage()); } } + + @Override + public String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception { + if (StringUtils.isBlank(arkApiKey)) { + throw new Exception("listVideoGenerationTasks error:apiKey is null"); + } + int pn = pageNum > 0 ? pageNum : 1; + int ps = pageSize > 0 ? Math.min(pageSize, 500) : 10; + + HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks"); + if (parsed == null) { + throw new Exception("listVideoGenerationTasks error:invalid base url"); + } + HttpUrl url = parsed.newBuilder() + .addQueryParameter("page_num", String.valueOf(pn)) + .addQueryParameter("page_size", String.valueOf(ps)) + .build(); + + Request request = new Request.Builder() + .url(url) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + arkApiKey) + .get() + .build(); + + Response response = OkHttpUtils.newCall(request).execute(); + if (response.body() == null) { + throw new Exception("listVideoGenerationTasks response null"); + } + String body = response.body().string(); + if (!response.isSuccessful()) { + throw new Exception("listVideoGenerationTasks error:" + body); + } + return body; + } } diff --git a/ruoyi-system/src/main/resources/mapper/system/AiUserMapper.xml b/ruoyi-system/src/main/resources/mapper/system/AiUserMapper.xml index 0c0e8e0..5af6704 100644 --- a/ruoyi-system/src/main/resources/mapper/system/AiUserMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/AiUserMapper.xml @@ -33,15 +33,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select id, del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source, ip, country from ai_user + select id, del_flag, create_by, create_time, update_by, update_time, remark, username, nickname, gender, avatar, phone, password, openid, status, email, birthday, invitation_code, payment_url, login_time, balance, superior_id, user_id, source, ip, country, dept_id from ai_user + + @@ -108,6 +118,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" source, ip, country, + dept_id, #{delFlag}, @@ -134,6 +145,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{userId}, #{source}, #{country}, + #{deptId}, @@ -163,8 +175,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" superior_id = #{superiorId}, user_id = #{userId}, source = #{source}, - source = #{ip}, + ip = #{ip}, country = #{country}, + dept_id = #{deptId}, where id = #{id} diff --git a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml b/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml index bd232c8..bcf5ee0 100644 --- a/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml @@ -13,6 +13,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -23,7 +24,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.status, d.del_flag, d.create_by, d.create_time + select d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.phone, d.email, d.byte_api_key, d.status, d.del_flag, d.create_by, d.create_time from sys_dept d @@ -59,7 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"