diff --git a/src/main/java/com/youlai/boot/YouLaiBootApplication.java b/src/main/java/com/youlai/boot/YouLaiBootApplication.java index fe69a90..544b788 100644 --- a/src/main/java/com/youlai/boot/YouLaiBootApplication.java +++ b/src/main/java/com/youlai/boot/YouLaiBootApplication.java @@ -14,6 +14,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @SpringBootApplication @MapperScan("com.youlai.boot.**.mapper") +@EnableScheduling public class YouLaiBootApplication { public static void main(String[] args) { diff --git a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java b/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java index 68cff6e..4199f7a 100644 --- a/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java +++ b/src/main/java/com/youlai/boot/codegen/freemarker/MyBatisPlusGenerator.java @@ -126,7 +126,9 @@ public class MyBatisPlusGenerator { ,new TableConfig("mini_content_audit", IdType.AUTO, "admin") ,new TableConfig("mini_content_audit_task", IdType.AUTO, "admin") ,new TableConfig("mini_user_follow", IdType.AUTO, "mini") - + ,new TableConfig("mini_ai_generation_task", IdType.AUTO, "mini") + ,new TableConfig("mini_ai_task_media", IdType.AUTO, "mini") + ,new TableConfig("mini_user_subscribe", IdType.AUTO, "mini") // ,new TableConfig("mini_stray_animal", IdType.AUTO, "mini") // ,new TableConfig("mini_stray_animal", IdType.INPUT, "minitest") diff --git a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java index 35fa752..1f218ca 100644 --- a/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java +++ b/src/main/java/com/youlai/boot/common/enums/LogModuleEnum.java @@ -33,6 +33,9 @@ public enum LogModuleEnum implements IBaseEnum { POINT_RECORD(101, "积分流水"), POINT_RULE(102, "积分规则"), SIGN_RECORD(103, "签到记录"), + AI_TASK_MEDIA(104, "AI任务媒体"), + AI_GENERATION_TASK(105, "AI生成任务"), + USER_SUBSCRIBE(106, "用户订阅模板消息"), STRAY_ANIMAL_NOTE_COMMENT(110, "流浪动物笔记评论"), ADOPTION_DIARY_COMMENT(111, "领养日记评论"), ADOPTION_DIARY_INFO(112, "领养日记信息"), diff --git a/src/main/java/com/youlai/boot/mini/controller/AiGenerationController.java b/src/main/java/com/youlai/boot/mini/controller/AiGenerationController.java new file mode 100644 index 0000000..9210d1b --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/controller/AiGenerationController.java @@ -0,0 +1,200 @@ +package com.youlai.boot.mini.controller; + +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.annotation.RepeatSubmit; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.mini.model.form.*; +import com.youlai.boot.mini.model.vo.AiVideoCallbackVO; +import com.youlai.boot.mini.service.AiGenerationService; +import com.youlai.boot.mini.service.WxSubscribeService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; +import org.springframework.security.access.prepost.PreAuthorize; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.youlai.boot.mini.model.query.AiTaskMediaQuery; +import com.youlai.boot.mini.model.query.DiscoveryPublicWorkQuery; +import com.youlai.boot.mini.model.entity.MiniAiGenerationTask; +import com.youlai.boot.mini.model.vo.AiGenerationTaskVO; +import com.youlai.boot.mini.model.vo.DiscoveryPublicWorkVO; +import cn.hutool.core.util.StrUtil; +import java.util.List; +import com.youlai.boot.mini.model.vo.UserUploadMediaVO; + +@Tag(name = "AI生成相关接口") +@RestController +@RequestMapping("/api/v1/mini/ai/generation") +@RequiredArgsConstructor +@Valid +public class AiGenerationController { + + private final AiGenerationService aiGenerationService; + private final WxSubscribeService wxSubscribeService; + + @Operation(summary = "上传AI生成参考文件") + @PostMapping(value = "/upload/reference", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RepeatSubmit + @Log(module = LogModuleEnum.AI_TASK_MEDIA, value = ActionTypeEnum.INSERT) + public Result> uploadReferenceFile( + @RequestParam(name = "images", required = false) List images, + @RequestParam(name = "videos", required = false) List videos + ) { + Long userId = SecurityUtils.getUserId(); + List urlList = aiGenerationService.uploadReferenceFile(images, videos, userId); + return Result.success(urlList); + } + + @Operation(summary = "提交单图生成任务") + @PostMapping("/generate-single-image") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.INSERT) + public Result generateSingleImage(@Valid @RequestBody AiSingleImageGenerateForm form) { + Long userId = SecurityUtils.getUserId(); + String taskUuid = aiGenerationService.createAndGenerateImage(form, userId); + return Result.success(taskUuid); + } + + @Operation(summary = "单图任务回调接口") + @PostMapping("/single-image/task/callback") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result singleImageTaskCallback(@Valid @RequestBody AiSingleImageCallbackForm form) { + boolean success = aiGenerationService.handleTaskCallback(form); + return Result.success(success); + } + + @Operation(summary = "提交四宫格漫画生成任务") + @PostMapping("/generate-four-panel") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.INSERT) + public Result generateFourPanelImage(@Valid @RequestBody AiFourPanelGenerateForm form) { + Long userId = SecurityUtils.getUserId(); + String taskUuid = aiGenerationService.createAndGenerateFourPanel(form, userId); + return Result.success(taskUuid); + } + + @Operation(summary = "四宫格漫画任务回调接口") + @PostMapping("/four-panel/task/callback") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result fourPanelTaskCallback(@Valid @RequestBody AiFourPanelCallbackForm form) { + boolean success = aiGenerationService.handleFourPanelCallback(form); + return Result.success(success); + } + + @Operation(summary = "提交视频生成任务") + @PostMapping("/generate-video") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.INSERT) + public Result generateVideo(@Valid @RequestBody AiVideoGenerateForm form) { + Long userId = SecurityUtils.getUserId(); + String taskUuid = aiGenerationService.createAndGenerateVideo(form, userId); + return Result.success(taskUuid); + } + + @Operation(summary = "视频任务回调接口") + @PostMapping("/video/task/callback") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result videoTaskCallback(@Valid @RequestBody AiVideoCallbackVO vo) { + boolean success = aiGenerationService.handleVideoTaskCallback(vo); + return Result.success(success); + } + + @Operation(summary = "上报微信订阅消息授权状态") + @PostMapping("/subscribe/auth/report") + @Log(module = LogModuleEnum.USER_SUBSCRIBE, value = ActionTypeEnum.UPDATE) + public Result reportSubscribeAuth(@Valid @RequestBody WxSubscribeAuthForm form) { + form.setUserId(SecurityUtils.getUserId()); + wxSubscribeService.reportAuth(form); + return Result.success(); + } + + @Hidden + @Operation(summary = "发送微信订阅消息") + @PostMapping("/subscribe/send") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.INSERT) + public Result sendSubscribeMessage(@Valid @RequestBody WxSubscribeSendForm form) { + form.setUserId(SecurityUtils.getUserId()); + Boolean result = wxSubscribeService.sendMessage(form); + return Result.success(result); + } + + @Operation(summary = "查询当前用户最近上传的图片视频") + @GetMapping("/my/recent-upload") + public Result> getMyRecentUpload() { + Long userId = SecurityUtils.getUserId(); + List voList = aiGenerationService.getRecentUploadVO(userId); + return Result.success(voList); + } + + @Operation(summary = "删除用户上传的图片/视频") + @PostMapping("/my/upload/delete") + @Log(module = LogModuleEnum.AI_TASK_MEDIA, value = ActionTypeEnum.DELETE) + public Result deleteMyUpload(@RequestParam String uuid) { + Long userId = SecurityUtils.getUserId(); + boolean success = aiGenerationService.deleteUploadMedia(userId, uuid); + return Result.success(success); + } + + @Operation(summary = "查询我的AI生成任务历史") + @GetMapping("/my/history") + @Log(module = LogModuleEnum.AI_TASK_MEDIA, value = ActionTypeEnum.LIST) + public Result> getMyAiGenerateHistory(@Valid AiTaskMediaQuery query) { + Long userId = SecurityUtils.getUserId(); + IPage result = aiGenerationService.getMyAiGenerateHistory(query, userId); + return Result.success(result); + } + + @Operation(summary = "设置微信订阅消息跳转版本") + @PostMapping("/subscribe/mini-program-state/set") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result setMiniProgramState( + @Schema(description = "跳转版本:developer=开发版 trial=体验版 formal=正式版,为空时删除配置") + @Pattern(regexp = "^(developer|trial|formal|)$", message = "参数只能是developer/trial/formal或为空") + @RequestParam(required = false) String miniProgramState + ) { + aiGenerationService.setMiniProgramState(miniProgramState); + return Result.success(); + } + + @Operation(summary = "更新任务可见范围") + @PostMapping("/my/task/visibility") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result updateTaskVisibility(@Valid @RequestBody AiTaskVisibilityForm form) { + Long userId = SecurityUtils.getUserId(); + aiGenerationService.updateTaskVisibility(userId, form); + return Result.success(); + } + + @Operation(summary = "查询公开作品发现页") + @GetMapping("/discovery/feed") + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.LIST) + public Result> getPublicDiscoveryFeed(@Valid DiscoveryPublicWorkQuery query) { + IPage result = aiGenerationService.getPublicDiscoveryFeed(query); + return Result.success(result); + } + + @Operation(summary = "手动同步AI任务状态") + @PostMapping("/task/{taskUuid}/sync-status") + @RepeatSubmit(expire = 60) + @Log(module = LogModuleEnum.AI_GENERATION_TASK, value = ActionTypeEnum.UPDATE) + public Result syncTaskStatus(@PathVariable String taskUuid) { + Long userId = SecurityUtils.getUserId(); + MiniAiGenerationTask task = aiGenerationService.getTaskByUuid(taskUuid); + if (task == null) { + return Result.failed("任务不存在"); + } + if (!task.getMiniUserId().equals(userId)) { + return Result.failed("无权操作该任务"); + } + aiGenerationService.syncTaskStatus(taskUuid); + return Result.success(); + } + +} diff --git a/src/main/java/com/youlai/boot/mini/job/AiTaskTimeoutJob.java b/src/main/java/com/youlai/boot/mini/job/AiTaskTimeoutJob.java new file mode 100644 index 0000000..a164b00 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/job/AiTaskTimeoutJob.java @@ -0,0 +1,28 @@ +package com.youlai.boot.mini.job; + +import com.youlai.boot.mini.service.AiGenerationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class AiTaskTimeoutJob { + + private final AiGenerationService aiGenerationService; + + /** + * 每10分钟扫描一次超时的AI生成任务,标记为超时并退还积分 + */ + @Scheduled(cron = "0 */10 * * * ?") + public void execute() { + log.info("开始扫描超时AI生成任务"); + try { + aiGenerationService.handleTimeoutTasks(); + } catch (Exception e) { + log.error("扫描超时AI生成任务异常", e); + } + } +} diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniAiGenerationTaskMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniAiGenerationTaskMapper.java new file mode 100644 index 0000000..197d131 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/mapper/MiniAiGenerationTaskMapper.java @@ -0,0 +1,14 @@ +package com.youlai.boot.mini.mapper; + +import com.youlai.boot.mini.model.entity.MiniAiGenerationTask; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** +* AI生成任务统一表 Mapper 接口 +* +* @author jwy +* @since +*/ +public interface MiniAiGenerationTaskMapper extends BaseMapper { + +} diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniAiTaskMediaMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniAiTaskMediaMapper.java new file mode 100644 index 0000000..48cdfa0 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/mapper/MiniAiTaskMediaMapper.java @@ -0,0 +1,14 @@ +package com.youlai.boot.mini.mapper; + +import com.youlai.boot.mini.model.entity.MiniAiTaskMedia; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** +* AI任务媒体文件表 Mapper 接口 +* +* @author jwy +* @since +*/ +public interface MiniAiTaskMediaMapper extends BaseMapper { + +} diff --git a/src/main/java/com/youlai/boot/mini/mapper/MiniUserSubscribeMapper.java b/src/main/java/com/youlai/boot/mini/mapper/MiniUserSubscribeMapper.java new file mode 100644 index 0000000..bb7b331 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/mapper/MiniUserSubscribeMapper.java @@ -0,0 +1,17 @@ +package com.youlai.boot.mini.mapper; + +import com.youlai.boot.mini.model.entity.MiniUserSubscribe; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +/** +* 用户订阅状态表 Mapper 接口 +* +* @author jwy +* @since +*/ +public interface MiniUserSubscribeMapper extends BaseMapper { + + String getOpenidByUserId(Long aLong); +} diff --git a/src/main/java/com/youlai/boot/mini/model/dto/GenerateResponse.java b/src/main/java/com/youlai/boot/mini/model/dto/GenerateResponse.java new file mode 100644 index 0000000..388a89c --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/dto/GenerateResponse.java @@ -0,0 +1,31 @@ +package com.youlai.boot.mini.model.dto; + +import lombok.Data; + +/** + * AI生成接口响应DTO + * + * @author youlai + */ +@Data +public class GenerateResponse { + /** + * 响应消息 + */ + private String msg; + + /** + * 响应码,0表示成功 + */ + private Integer code; + + /** + * 生成结果数据 + */ + private GenerationData data; + + /** + * 请求ID + */ + private String request_id; +} diff --git a/src/main/java/com/youlai/boot/mini/model/dto/GenerationData.java b/src/main/java/com/youlai/boot/mini/model/dto/GenerationData.java new file mode 100644 index 0000000..36b4503 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/dto/GenerationData.java @@ -0,0 +1,48 @@ +package com.youlai.boot.mini.model.dto; + +import lombok.Data; + +import java.util.List; + +/** + * AI生成结果数据DTO + * + * @author youlai + */ +@Data +public class GenerationData { + /** + * 使用的模型ID + */ + private String model; + + /** + * 生成的图片列表 + */ + private List data; + + /** + * 错误信息,无错误则为null + */ + private Object error; + + /** + * Token使用统计 + */ + private Object usage; + + /** + * 创建时间戳 + */ + private Long created_at; + + /** + * 使用的工具 + */ + private String tool; + + /** + * 创建时间戳 + */ + private Long created; +} diff --git a/src/main/java/com/youlai/boot/mini/model/dto/ImageData.java b/src/main/java/com/youlai/boot/mini/model/dto/ImageData.java new file mode 100644 index 0000000..ab4e533 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/dto/ImageData.java @@ -0,0 +1,26 @@ +package com.youlai.boot.mini.model.dto; + +import lombok.Data; + +/** + * 生成的图片数据DTO + * + * @author youlai + */ +@Data +public class ImageData { + /** + * 图片URL地址 + */ + private String url; + + /** + * Base64编码的图片JSON + */ + private String b64_json; + + /** + * 图片尺寸,格式如 1664x2496 + */ + private String size; +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniAiGenerationTask.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniAiGenerationTask.java new file mode 100644 index 0000000..1f34d7f --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/entity/MiniAiGenerationTask.java @@ -0,0 +1,94 @@ +package com.youlai.boot.mini.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +@Getter +@Setter +@ToString +@Accessors(chain = true) +@TableName("mini_ai_generation_task") +@Schema(description = "AI生成任务统一表") +public class MiniAiGenerationTask implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "笔记ID") + private Long id; + + + @TableField("uuid") + @Schema(description = "uuid唯一标识,前后端用这个进行数据交互") + private String uuid; + + @TableField("refund_uuid") + @Schema(description = "退积分唯一uuid") + private String refundUuid; + + @TableField("mini_user_id") + @Schema(description = "作者用户ID") + private Long miniUserId; + + @TableField("type") + @Schema(description = "生成类型:img_single, img_grid_4, video") + private String type; + + @TableField("generate_params") + @Schema(description = "生成参数JSON,不同类型参数不同") + private String generateParams; + + @TableField("video_task_uuid") + @Schema(description = "调用视频接口时返回") + private String videoTaskUuid; + + @TableField("status") + @Schema(description = "任务状态:0=生成中 1=成功 2=失败 3=超时 4=已取消") + private Integer status; + + @TableField("visibility") + @Schema(description = "可见范围:public-公开,private-仅自己可见,friends-好友可见") + private String visibility; + + @TableField("points_consumed") + @Schema(description = "消耗积分") + private Integer pointsConsumed; + + @TableField("create_time") + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + @TableField("create_timestamp") + @Schema(description = "创建时间毫秒级时间戳") + private Long createTimestamp; + + @TableField("create_by") + @Schema(description = "创建人ID") + private Long createBy; + + @TableField("update_time") + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; + + @TableField("update_timestamp") + @Schema(description = "更新时间毫秒级时间戳") + private Long updateTimestamp; + + @TableField("update_by") + @Schema(description = "修改人ID") + private Long updateBy; + + @TableField("is_deleted") + @Schema(description = "逻辑删除标识(0-未删除 1-已删除)") + private Boolean deleted; + + +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniAiTaskMedia.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniAiTaskMedia.java new file mode 100644 index 0000000..7537ef5 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/entity/MiniAiTaskMedia.java @@ -0,0 +1,102 @@ +package com.youlai.boot.mini.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +@Getter +@Setter +@ToString +@Accessors(chain = true) +@TableName("mini_ai_task_media") +@Schema(description = "AI任务媒体文件表") +public class MiniAiTaskMedia implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "") + private Long id; + + + @TableField("uuid") + @Schema(description = "uuid唯一标识,前后端用这个进行数据交互") + private String uuid; + + @TableField("mini_user_id") + @Schema(description = "用户id") + private Long miniUserId; + + @TableField("task_id") + @Schema(description = "AI生成任务id") + private Long taskId; + + @TableField("file_source") + @Schema(description = "文件来源:user_upload(用户上传), ai_generated(AI生成)") + private String fileSource; + + @TableField("media_type") + @Schema(description = "媒体类型,image-图片,video-视频") + private String mediaType; + + @TableField("source_url") + @Schema(description = "资源URL") + private String sourceUrl; + + @TableField("storage_key") + @Schema(description = "对象存储中的key") + private String storageKey; + + @TableField("thumbnail_url") + @Schema(description = "缩略图URL(视频需要)") + private String thumbnailUrl; + + @TableField("width") + @Schema(description = "宽度(像素)") + private Integer width; + + @TableField("height") + @Schema(description = "高度(像素)") + private Integer height; + + @TableField("duration") + @Schema(description = "时长(秒,视频用)") + private Integer duration; + + @TableField("create_time") + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + @TableField("create_timestamp") + @Schema(description = "创建时间毫秒级时间戳") + private Long createTimestamp; + + @TableField("create_by") + @Schema(description = "创建人ID") + private Long createBy; + + @TableField("update_time") + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; + + @TableField("update_timestamp") + @Schema(description = "更新时间毫秒级时间戳") + private Long updateTimestamp; + + @TableField("update_by") + @Schema(description = "修改人ID") + private Long updateBy; + + @TableField("is_deleted") + @Schema(description = "逻辑删除标识(0-未删除 1-已删除)") + private Boolean deleted; + + +} diff --git a/src/main/java/com/youlai/boot/mini/model/entity/MiniUserSubscribe.java b/src/main/java/com/youlai/boot/mini/model/entity/MiniUserSubscribe.java new file mode 100644 index 0000000..eaceff0 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/entity/MiniUserSubscribe.java @@ -0,0 +1,74 @@ +package com.youlai.boot.mini.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; + +@Getter +@Setter +@ToString +@Accessors(chain = true) +@TableName("mini_user_subscribe") +@Schema(description = "用户订阅状态表") +public class MiniUserSubscribe implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + @Schema(description = "") + private Long id; + + + @TableField("uuid") + @Schema(description = "uuid唯一标识,前后端用这个进行数据交互") + private String uuid; + + @TableField("user_id") + @Schema(description = "用户id") + private Long userId; + + @TableField("template_id") + @Schema(description = "订阅消息模板ID") + private String templateId; + + @TableField("status") + @Schema(description = "授权状态:0=拒绝 1=同意一次 2=总是同意") + private Integer status; + + @TableField("create_time") + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + @TableField("create_timestamp") + @Schema(description = "创建时间毫秒级时间戳") + private Long createTimestamp; + + @TableField("create_by") + @Schema(description = "创建人ID") + private Long createBy; + + @TableField("update_time") + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; + + @TableField("update_timestamp") + @Schema(description = "更新时间毫秒级时间戳") + private Long updateTimestamp; + + @TableField("update_by") + @Schema(description = "修改人ID") + private Long updateBy; + + @TableField("is_deleted") + @Schema(description = "逻辑删除标识(0-未删除 1-已删除)") + private Boolean deleted; + + +} diff --git a/src/main/java/com/youlai/boot/mini/model/enums/SubscribeStatusEnum.java b/src/main/java/com/youlai/boot/mini/model/enums/SubscribeStatusEnum.java new file mode 100644 index 0000000..28bbd7c --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/enums/SubscribeStatusEnum.java @@ -0,0 +1,46 @@ +package com.youlai.boot.mini.model.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import io.swagger.v3.oas.annotations.media.Schema; + + +@Schema(description = "微信订阅消息授权状态") +public enum SubscribeStatusEnum { + + REJECT(0, "拒绝授权"), + ONCE(1, "同意一次"), + ALWAYS(2, "总是同意"); + + private final Integer value; + private final String desc; + + SubscribeStatusEnum(Integer value, String desc) { + this.value = value; + this.desc = desc; + } + + @JsonValue + public Integer getValue() { + return value; + } + + public String getDesc() { + return desc; + } + + @JsonCreator + public static SubscribeStatusEnum from(Integer value) { + if (value == null) return null; + for (SubscribeStatusEnum e : values()) { + if (e.value.equals(value)) { + return e; + } + } + return null; + } + + public static boolean contains(Integer value) { + return from(value) != null; + } +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiCallbackImage.java b/src/main/java/com/youlai/boot/mini/model/form/AiCallbackImage.java new file mode 100644 index 0000000..e080aa9 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiCallbackImage.java @@ -0,0 +1,25 @@ +package com.youlai.boot.mini.model.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * AI回调图片通用结构 + * + * @author youlai + */ +@Data +@Schema(description = "AI回调图片通用结构") +public class AiCallbackImage { + + @Schema(description = "图片URL地址") + private String url; + + @JsonProperty("b64_json") + @Schema(description = "Base64编码的图片JSON") + private String b64Json; + + @Schema(description = "图片尺寸,格式如 2048x2048") + private String size; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelCallbackForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelCallbackForm.java new file mode 100644 index 0000000..15d0131 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelCallbackForm.java @@ -0,0 +1,35 @@ +package com.youlai.boot.mini.model.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import java.util.List; + +/** + * 四宫格漫画任务回调请求表单 + * + * @author youlai + */ +@Data +@Schema(description = "四宫格漫画任务回调请求表单") +public class AiFourPanelCallbackForm { + + @NotBlank(message = "任务UUID不能为空") + @Schema(description = "任务唯一标识UUID", requiredMode = Schema.RequiredMode.REQUIRED) + private String uuid; + + @Schema(description = "任务状态:succeeded=成功,failed=失败") + private String status; + + @Schema(description = "生成的图片列表") + private List result; + + @JsonProperty("created_at") + @Schema(description = "创建时间戳") + private Long createdAt; + + @JsonProperty("updated_at") + @Schema(description = "更新时间戳") + private Long updatedAt; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelGenerateForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelGenerateForm.java new file mode 100644 index 0000000..899e794 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiFourPanelGenerateForm.java @@ -0,0 +1,57 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.util.List; + +/** + * AI四宫格漫画生成请求表单 + * + * @author youlai + */ +@Data +@Schema(description = "AI四宫格漫画生成请求表单") +public class AiFourPanelGenerateForm { + + @Schema(description = "参考图片URL列表,可选") + private List referenceImage; + + @Schema(description = "使用的模型ID", defaultValue = "doubao-seedream-5-0-260128") + private String model; + + @Schema(description = "画风:1=治愈系水彩,2=Q版卡通,3=日式漫画,4=吉卜力风,5=3D毛绒,6=搞笑夸张", defaultValue = "1") + private Integer style; + + @Schema(description = "比例:1=1:1,2=2:3,3=3:4,4=4:3,5=5:4", defaultValue = "1") + private Integer ratio; + + @Schema(description = "图片尺寸:1=2K,2=4K,3=8K", defaultValue = "1") + private Integer imgSize; + + @Schema(description = "图片格式:1=png,2=jpeg", defaultValue = "1") + private Integer imgType; + + @Schema(description = "宠物物种:如cat、dog") + private String species; + + @Schema(description = "宠物品种:如British Shorthair、Corgi") + private String breed; + + @Schema(description = "宠物毛色:如blue-gray、orange") + private String color; + + @Schema(description = "眼睛颜色:如amber、blue") + private String eyeColor; + + @Schema(description = "体型:如round chubby、slim and elegant") + private String bodyType; + + @Schema(description = "特殊特征:如flat face、thick coat", defaultValue = "") + private String distinctiveFeatures; + + @Schema(description = "故事梗概:AI会据此生成四格漫画脚本") + private String description; + + @Schema(description = "可见范围: public公开 private仅自己", defaultValue = "private") + private String visibility; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageCallbackForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageCallbackForm.java new file mode 100644 index 0000000..9fe2d06 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageCallbackForm.java @@ -0,0 +1,35 @@ +package com.youlai.boot.mini.model.form; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import java.util.List; + +/** + * 单图生成任务回调请求表单 + * + * @author youlai + */ +@Data +@Schema(description = "单图生成任务回调请求表单") +public class AiSingleImageCallbackForm { + + @NotBlank(message = "任务UUID不能为空") + @Schema(description = "任务唯一标识UUID", requiredMode = Schema.RequiredMode.REQUIRED) + private String uuid; + + @Schema(description = "任务状态:succeeded=成功,failed=失败") + private String status; + + @Schema(description = "生成的图片列表") + private List result; + + @JsonProperty("created_at") + @Schema(description = "创建时间戳") + private Long createdAt; + + @JsonProperty("updated_at") + @Schema(description = "更新时间戳") + private Long updatedAt; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageGenerateForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageGenerateForm.java new file mode 100644 index 0000000..bb9f866 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiSingleImageGenerateForm.java @@ -0,0 +1,57 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.util.List; + +/** + * AI单图漫画生成请求表单 + * + * @author youlai + */ +@Data +@Schema(description = "AI单图漫画生成请求表单") +public class AiSingleImageGenerateForm { + + @Schema(description = "参考图片URL列表,可选") + private List imageUrl; + + @Schema(description = "使用的模型ID", defaultValue = "doubao-seedream-5-0-260128") + private String model; + + @Schema(description = "画风:1=治愈系水彩,2=Q版卡通,3=日式漫画,4=吉卜力风,5=3D毛绒,6=搞笑夸张", defaultValue = "1") + private Integer style; + + @Schema(description = "比例:1=1:1,2=2:3,3=3:4,4=4:3,5=5:4", defaultValue = "1") + private Integer ratio; + + @Schema(description = "图片尺寸:1=2K,2=4K,3=8K", defaultValue = "1") + private Integer imgSize; + + @Schema(description = "图片格式:1=png,2=jpeg", defaultValue = "1") + private Integer imgType; + + @Schema(description = "宠物物种:如cat、dog") + private String species; + + @Schema(description = "宠物品种:如British Shorthair、Corgi") + private String breed; + + @Schema(description = "宠物毛色:如blue-gray、orange") + private String color; + + @Schema(description = "眼睛颜色:如amber、blue") + private String eyeColor; + + @Schema(description = "体型:如round chubby、slim and elegant") + private String bodyType; + + @Schema(description = "特殊特征:如flat face、thick coat", defaultValue = "") + private String distinctiveFeatures; + + @Schema(description = "场景描述:用户自定义生成场景") + private String description; + + @Schema(description = "可见范围: public公开 private仅自己", defaultValue = "private") + private String visibility; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiTaskVisibilityForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiTaskVisibilityForm.java new file mode 100644 index 0000000..8c150db --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiTaskVisibilityForm.java @@ -0,0 +1,23 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "更新任务可见范围表单") +public class AiTaskVisibilityForm { + + @NotBlank(message = "任务UUID不能为空") + @Schema(description = "任务UUID") + private String uuid; + + @NotBlank(message = "可见范围不能为空") + @Pattern(regexp = "^(public|private|friends)$", message = "可见范围只能是public/private/friends") + @Schema(description = "可见范围:public-公开,private-仅自己可见") + private String visibility; + +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/AiVideoGenerateForm.java b/src/main/java/com/youlai/boot/mini/model/form/AiVideoGenerateForm.java new file mode 100644 index 0000000..1673eda --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/AiVideoGenerateForm.java @@ -0,0 +1,30 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.util.List; + +/** + * AI视频生成请求表单 + * + * @author youlai + */ +@Data +@Schema(description = "AI视频生成请求表单") +public class AiVideoGenerateForm { + + @Schema(description = "使用的模型ID", defaultValue = "doubao-seedance-2-0-260128") + private String model; + + @Schema(description = "内容数组,包含text、image_url、video_url、audio_url类型") + private List content; + + @Schema(description = "分辨率: 480p, 720p, 1080p, 2K", defaultValue = "720p") + private String resolution = "720p"; + + @Schema(description = "视频时长,单位秒,最大15秒", defaultValue = "5") + private Integer duration = 5; + + @Schema(description = "可见范围: public公开 private仅自己", defaultValue = "private") + private String visibility; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/MediaUrl.java b/src/main/java/com/youlai/boot/mini/model/form/MediaUrl.java new file mode 100644 index 0000000..9b028a1 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/MediaUrl.java @@ -0,0 +1,19 @@ +package com.youlai.boot.mini.model.form; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 媒体URL通用结构 + * + * @author youlai + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "媒体URL通用结构") +public class MediaUrl { + + @Schema(description = "资源URL地址") + private String url; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/VideoContentItem.java b/src/main/java/com/youlai/boot/mini/model/form/VideoContentItem.java new file mode 100644 index 0000000..cddb30a --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/VideoContentItem.java @@ -0,0 +1,34 @@ +package com.youlai.boot.mini.model.form; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 视频生成内容项 + * + * @author youlai + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "视频生成内容项") +public class VideoContentItem { + + @Schema(description = "内容类型:text/image_url/video_url/audio_url", allowableValues = {"text", "image_url", "video_url", "audio_url"}) + private String type; + + @Schema(description = "文本内容,type=text时必填") + private String text; + + @Schema(description = "图片URL结构,type=image_url时必填") + private MediaUrl image_url; + + @Schema(description = "视频URL结构,type=video_url时必填") + private MediaUrl video_url; + + @Schema(description = "音频URL结构,type=audio_url时必填") + private MediaUrl audio_url; + + @Schema(description = "内容角色标识,比如reference_image/reference_video/reference_audio") + private String role; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeAuthForm.java b/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeAuthForm.java new file mode 100644 index 0000000..a7e90e3 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeAuthForm.java @@ -0,0 +1,26 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 上报用户订阅授权请求 + */ +@Data +@Schema(description = "上报用户订阅授权请求") +public class WxSubscribeAuthForm { + +// @NotBlank(message = "用户ID不能为空") + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @NotBlank(message = "模板ID不能为空") + @Schema(description = "订阅消息模板ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String templateId; + + @NotNull(message = "授权状态不能为空") + @Schema(description = "授权状态:0=拒绝 1=同意一次 2=总是同意", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer status; +} diff --git a/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeSendForm.java b/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeSendForm.java new file mode 100644 index 0000000..468861b --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/form/WxSubscribeSendForm.java @@ -0,0 +1,33 @@ +package com.youlai.boot.mini.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.util.Map; + +/** + * 发送订阅消息请求 + */ +@Data +@Schema(description = "发送订阅消息请求") +public class WxSubscribeSendForm { + +// @NotBlank(message = "用户ID不能为空") + @Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @NotBlank(message = "模板ID不能为空") + @Schema(description = "订阅消息模板ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String templateId; + + @Schema(description = "跳转页面,例:/pages/result/index?id=123") + private String page; + + @Schema(description = "跳转版本:developer=开发版 trial=体验版 formal=正式版,默认formal") + private String miniProgramState; + + @NotNull(message = "模板参数不能为空") + @Schema(description = "模板参数,key是模板字段名,value是对应的值", requiredMode = Schema.RequiredMode.REQUIRED) + private Map templateParams; +} diff --git a/src/main/java/com/youlai/boot/mini/model/query/AiTaskMediaQuery.java b/src/main/java/com/youlai/boot/mini/model/query/AiTaskMediaQuery.java new file mode 100644 index 0000000..171b7aa --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/query/AiTaskMediaQuery.java @@ -0,0 +1,21 @@ +package com.youlai.boot.mini.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 用户AI生成任务历史查询参数 + */ +@Getter +@Setter +@Schema(description = "用户AI生成任务历史查询参数") +public class AiTaskMediaQuery extends BaseQuery { + + @Schema(description = "任务状态:0=生成中 1=成功 2=失败 3=超时 4=已取消,不传查全部") + private Integer status; + + @Schema(description = "生成类型:img_single(单图), img_grid_4(四宫格), video(视频),不传查全部") + private String type; +} diff --git a/src/main/java/com/youlai/boot/mini/model/query/DiscoveryPublicWorkQuery.java b/src/main/java/com/youlai/boot/mini/model/query/DiscoveryPublicWorkQuery.java new file mode 100644 index 0000000..3428ff3 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/query/DiscoveryPublicWorkQuery.java @@ -0,0 +1,15 @@ +package com.youlai.boot.mini.model.query; + +import com.youlai.boot.common.base.BaseQuery; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "发现页公开作品查询参数") +public class DiscoveryPublicWorkQuery extends BaseQuery { + + @Schema(description = "生成类型:img_single(单图), img_grid_4(四宫格), video(视频),不传查全部") + private String type; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/AiGenerationTaskVO.java b/src/main/java/com/youlai/boot/mini/model/vo/AiGenerationTaskVO.java new file mode 100644 index 0000000..fd686cd --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/AiGenerationTaskVO.java @@ -0,0 +1,42 @@ +package com.youlai.boot.mini.model.vo; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import java.util.Date; +import java.util.List; + +/** + * AI生成任务列表VO + */ +@Getter +@Setter +@Schema(description = "AI生成任务列表VO") +public class AiGenerationTaskVO { + +// @Hidden +// @Schema(description = "任务ID",hidden = true) +// private Long id; + + @Schema(description = "任务唯一标识") + private String uuid; + + @Schema(description = "生成类型:img_single(单图), img_grid_4(四宫格), video(视频)") + private String type; + + @Schema(description = "任务状态:0=生成中 1=成功 2=失败 3=超时 4=已取消") + private Integer status; + + @Schema(description = "消耗积分") + private Integer pointsConsumed; + + @Schema(description = "可见范围:public(公开) / private(仅自己)") + private String visibility; + + @Schema(description = "创建时间") + private Date createTime; + + @Schema(description = "生成内容列表") + private List generateContent; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/AiVideoCallbackVO.java b/src/main/java/com/youlai/boot/mini/model/vo/AiVideoCallbackVO.java new file mode 100644 index 0000000..63798b8 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/AiVideoCallbackVO.java @@ -0,0 +1,70 @@ +package com.youlai.boot.mini.model.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * AI视频生成任务回调请求 + * + * @author youlai + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AI视频生成任务回调请求") +public class AiVideoCallbackVO { + + @Schema(description = "第三方视频任务唯一ID") + private String id; + + @Schema(description = "使用的模型ID") + private String model; + + @Schema(description = "任务状态:queued排队中/running处理中/succeeded成功/failed失败") + private String status; + + @JsonProperty("created_at") + @Schema(description = "创建时间戳") + private Long createdAt; + + @JsonProperty("updated_at") + @Schema(description = "更新时间戳") + private Long updatedAt; + + @JsonProperty("service_tier") + @Schema(description = "服务等级") + private String serviceTier; + + @JsonProperty("execution_expires_after") + @Schema(description = "执行过期时间") + private Long executionExpiresAfter; + + @JsonProperty("generate_audio") + @Schema(description = "是否生成音频") + private Boolean generateAudio; + + @Schema(description = "是否为草稿") + private Boolean draft; + + @Schema(description = "生成结果,status=succeeded时返回") + private VideoCallbackContent content; + + @Schema(description = "资源消耗统计") + private VideoCallbackUsage usage; + + @Schema(description = "随机种子") + private Long seed; + + @Schema(description = "分辨率,比如720p") + private String resolution; + + @Schema(description = "比例,比如16:9") + private String ratio; + + @Schema(description = "视频时长,单位秒") + private Integer duration; + + @Schema(description = "帧率") + private Integer framespersecond; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/DiscoveryPublicWorkVO.java b/src/main/java/com/youlai/boot/mini/model/vo/DiscoveryPublicWorkVO.java new file mode 100644 index 0000000..e11f0e1 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/DiscoveryPublicWorkVO.java @@ -0,0 +1,38 @@ +package com.youlai.boot.mini.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.util.Date; +import java.util.List; + +@Getter +@Setter +@Schema(description = "发现页公开作品VO") +public class DiscoveryPublicWorkVO { + + @Schema(description = "任务唯一标识") + private String uuid; + + @Schema(description = "生成类型:img_single(单图), img_grid_4(四宫格), video(视频)") + private String type; + + @Schema(description = "消耗积分") + private Integer pointsConsumed; + + @Schema(description = "创建时间") + private Date createTime; + + @Schema(description = "生成内容列表") + private List generateContent; + + @Schema(description = "创作者用户UUID") + private String userUuid; + + @Schema(description = "创作者昵称") + private String nickname; + + @Schema(description = "创作者头像") + private String avatar; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/MiniAiTaskMediaVO.java b/src/main/java/com/youlai/boot/mini/model/vo/MiniAiTaskMediaVO.java new file mode 100644 index 0000000..1ff32ff --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/MiniAiTaskMediaVO.java @@ -0,0 +1,39 @@ +package com.youlai.boot.mini.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import java.util.Date; + +/** + * AI生成媒体内容VO + */ +@Getter +@Setter +@Schema(description = "AI生成媒体内容VO") +public class MiniAiTaskMediaVO { + + @Schema(description = "唯一标识") + private String uuid; + + @Schema(description = "媒体类型,image-图片,video-视频") + private String mediaType; + + @Schema(description = "资源URL") + private String sourceUrl; + + @Schema(description = "缩略图URL(视频需要)") + private String thumbnailUrl; + + @Schema(description = "宽度(像素)") + private Integer width; + + @Schema(description = "高度(像素)") + private Integer height; + + @Schema(description = "时长(秒,视频用)") + private Integer duration; + + @Schema(description = "创建时间") + private Date createTime; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/UserUploadMediaVO.java b/src/main/java/com/youlai/boot/mini/model/vo/UserUploadMediaVO.java new file mode 100644 index 0000000..3ebb0ee --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/UserUploadMediaVO.java @@ -0,0 +1,48 @@ +package com.youlai.boot.mini.model.vo; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 用户上传媒体返回VO + * + * @author youlai + */ +@Data +@Schema(description = "用户上传媒体返回VO") +public class UserUploadMediaVO { + + @Schema(description = "媒体UUID") + private String uuid; + + @Schema(description = "媒体类型:image-图片,video-视频") + private String mediaType; + + @Schema(description = "媒体访问地址") + private String sourceUrl; + + @Schema(description = "缩略图地址(视频才有)") + private String thumbnailUrl; + + @Schema(description = "视频时长(秒,视频才有)") + private Integer duration; + + @Schema(description = "图片宽度") + private Integer width; + + @Schema(description = "图片高度") + private Integer height; + + @Schema(description = "上传时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + @TableField("create_timestamp") + @Schema(description = "创建时间毫秒级时间戳") + private Long createTimestamp; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackContent.java b/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackContent.java new file mode 100644 index 0000000..34a321b --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackContent.java @@ -0,0 +1,21 @@ +package com.youlai.boot.mini.model.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * AI视频生成回调结果内容 + * + * @author youlai + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AI视频生成回调结果内容") +public class VideoCallbackContent { + + @JsonProperty("video_url") + @Schema(description = "生成的视频地址") + private String videoUrl; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackUsage.java b/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackUsage.java new file mode 100644 index 0000000..c6c0d3e --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/VideoCallbackUsage.java @@ -0,0 +1,25 @@ +package com.youlai.boot.mini.model.vo; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 视频生成资源消耗统计 + * + * @author youlai + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "视频生成资源消耗统计") +public class VideoCallbackUsage { + + @JsonProperty("completion_tokens") + @Schema(description = "完成token数") + private Integer completionTokens; + + @JsonProperty("total_tokens") + @Schema(description = "总token数") + private Integer totalTokens; +} diff --git a/src/main/java/com/youlai/boot/mini/model/vo/WxApiResponse.java b/src/main/java/com/youlai/boot/mini/model/vo/WxApiResponse.java new file mode 100644 index 0000000..9b903b4 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/model/vo/WxApiResponse.java @@ -0,0 +1,16 @@ +package com.youlai.boot.mini.model.vo; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 微信接口通用返回 + */ +@Data +public class WxApiResponse { + @JsonProperty("errcode") + private Integer errcode; + + @JsonProperty("errmsg") + private String errmsg; +} diff --git a/src/main/java/com/youlai/boot/mini/service/AiGenerationService.java b/src/main/java/com/youlai/boot/mini/service/AiGenerationService.java new file mode 100644 index 0000000..d3974c1 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/AiGenerationService.java @@ -0,0 +1,56 @@ +package com.youlai.boot.mini.service; + +import com.youlai.boot.mini.model.entity.MiniAiTaskMedia; +import com.youlai.boot.mini.model.form.AiFourPanelGenerateForm; +import com.youlai.boot.mini.model.form.AiSingleImageCallbackForm; +import com.youlai.boot.mini.model.form.AiSingleImageGenerateForm; +import com.youlai.boot.mini.model.form.AiVideoGenerateForm; +import com.youlai.boot.mini.model.entity.MiniAiGenerationTask; +import com.youlai.boot.mini.model.form.AiFourPanelCallbackForm; +import com.youlai.boot.mini.model.form.AiTaskVisibilityForm; +import com.youlai.boot.mini.model.vo.AiVideoCallbackVO; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.youlai.boot.mini.model.query.AiTaskMediaQuery; +import com.youlai.boot.mini.model.query.DiscoveryPublicWorkQuery; +import com.youlai.boot.mini.model.vo.AiGenerationTaskVO; +import com.youlai.boot.mini.model.vo.DiscoveryPublicWorkVO; +import com.youlai.boot.mini.model.vo.UserUploadMediaVO; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +public interface AiGenerationService { + + List uploadReferenceFile(List images, List videos, Long userId); + + String createAndGenerateImage(AiSingleImageGenerateForm form, Long userId); + + String createAndGenerateFourPanel(AiFourPanelGenerateForm form, Long userId); + + boolean handleTaskCallback(AiSingleImageCallbackForm form); + + String createAndGenerateVideo(AiVideoGenerateForm form, Long userId); + + boolean handleVideoTaskCallback(AiVideoCallbackVO vo); + + boolean handleFourPanelCallback(AiFourPanelCallbackForm form); + + MiniAiGenerationTask getTaskByUuid(String uuid); + + List getRecentUploadVO(Long userId); + + boolean deleteUploadMedia(Long userId, String uuid); + + IPage getMyAiGenerateHistory(AiTaskMediaQuery query, Long userId); + + void setMiniProgramState(String miniProgramState); + + void handleTimeoutTasks(); + + IPage getPublicDiscoveryFeed(DiscoveryPublicWorkQuery query); + + void updateTaskVisibility(Long userId, AiTaskVisibilityForm form); + + void syncTaskStatus(String taskUuid); + +} diff --git a/src/main/java/com/youlai/boot/mini/service/WxSubscribeService.java b/src/main/java/com/youlai/boot/mini/service/WxSubscribeService.java new file mode 100644 index 0000000..99945d2 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/WxSubscribeService.java @@ -0,0 +1,14 @@ +package com.youlai.boot.mini.service; + +import com.youlai.boot.mini.model.form.WxSubscribeAuthForm; +import com.youlai.boot.mini.model.form.WxSubscribeSendForm; + +/** + * 微信订阅消息服务 + */ +public interface WxSubscribeService { + + void reportAuth(WxSubscribeAuthForm form); + + Boolean sendMessage(WxSubscribeSendForm form); +} diff --git a/src/main/java/com/youlai/boot/mini/service/impl/AiGenerationServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/AiGenerationServiceImpl.java new file mode 100644 index 0000000..1072025 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/impl/AiGenerationServiceImpl.java @@ -0,0 +1,1286 @@ +package com.youlai.boot.mini.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.json.JSONException; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.exception.MsgException; +import com.youlai.boot.common.util.FileUtils; +import com.youlai.boot.common.util.JavaVCUtils; +import com.youlai.boot.common.util.RandomNumberUtils; +import com.youlai.boot.file.service.FileService; +import com.youlai.boot.file.service.impl.AliyunFileService; +import com.youlai.boot.mini.mapper.MiniAiGenerationTaskMapper; +import com.youlai.boot.mini.mapper.MiniAiTaskMediaMapper; + +import com.youlai.boot.mini.model.entity.MiniAiGenerationTask; +import com.youlai.boot.mini.model.entity.MiniAiTaskMedia; +import com.youlai.boot.mini.model.form.*; +import com.youlai.boot.mini.model.query.AiTaskMediaQuery; +import com.youlai.boot.mini.model.query.DiscoveryPublicWorkQuery; +import com.youlai.boot.mini.model.vo.AiGenerationTaskVO; +import com.youlai.boot.mini.model.vo.AiVideoCallbackVO; +import com.youlai.boot.mini.model.vo.DiscoveryPublicWorkVO; +import com.youlai.boot.mini.model.vo.MiniAiTaskMediaVO; +import com.youlai.boot.mini.model.vo.UserUploadMediaVO; +import com.youlai.boot.system.mapper.UserMapper; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.admin.model.form.AdjustUserPointForm; +import com.youlai.boot.admin.service.PointManageService; +import com.youlai.boot.mini.service.AiGenerationService; +import com.youlai.boot.mini.service.MiniPointRecordService; +import com.youlai.boot.mini.service.WxSubscribeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.InputStream; +import java.net.URL; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiGenerationServiceImpl implements AiGenerationService { + + private final MiniAiGenerationTaskMapper aiGenerationTaskMapper; + private final MiniAiTaskMediaMapper aiTaskMediaMapper; + private final FileService fileService; + private final AliyunFileService aliyunFileService; + private final MiniPointRecordService pointRecordService; + private final WxSubscribeService wxSubscribeService; + private final StringRedisTemplate stringRedisTemplate; + private final PointManageService pointManageService; + private final UserMapper userMapper; + + // Redis key 存储微信订阅消息跳转版本 + private static final String WX_MINI_PROGRAM_STATE_KEY = "wx:subscribe:mini_program_state"; + + //OSS存储目录配置 + private static final String OSS_IMAGE_DIR = "ai/image/"; + private static final String OSS_VIDEO_DIR = "ai/video/"; + private static final String OSS_THUMBNAIL_DIR = "ai/thumbnail/"; + private static final String OSS_GENERATE_IMAGE_DIR = "ai/generate/image"; + private static final String OSS_GENERATE_VIDEO_DIR = "ai/generate/video"; + + //AI单图生成服务地址 + @Value("${ai.generate.single-image-server-url:http://127.0.0.1:8001/api/v1/photo-to-comic}") + private String aiSingleImageServerUrl; + //AI四宫格图片生成服务地址 + @Value("${ai.generate.four-panel-server-url:http://127.0.0.1:8001/api/v1/four-panel-comic}") + private String aiFourPanelServerUrl; + //AI单图/四宫格图片生成任务回调地址 + @Value("${ai.callback.single-image-callback-url:http://127.0.0.1:30101/backend/api/v1/mini/ai/generation/task/callback}") + private String aiSingleImageCallbackUrl; + //四宫格图片生成任务回调地址 + @Value("${ai.callback.four-panel-callback-url:http://192.168.31.197:30101/backend/api/v1/mini/ai/generation/four-panel/task/callback}") + private String aiFourPanelCallbackUrl; + //视频生成服务地址 + @Value("${ai.generate.video-server-url:http://127.0.0.1:8001/api/v1/video/submit}") + private String aiVideoServerUrl; + //视频任务回调地址 + @Value("${ai.callback.video-url:http://127.0.0.1:30101/backend/api/v1/mini/ai/generation/video/task/callback}") + private String aiVideoCallbackUrl; + + //默认图片生成AI模型 + @Value("${ai.default.image-model:doubao-seedream-5-0-260128}") + private String aiDefaultImageModel; + + //默认视频生成AI模型 + @Value("${ai.default.video-model:doubao-seedance-2-0-260128}") + private String aiDefaultVideoModel; + + @Value("${subscribe.template}") + private String subscribeTemplate; + + @Value("${subscribe.miniProgramState}") + private String miniProgramState; + + //AI任务超时时间(分钟),超过此时间的生成中任务将被标记为超时 + @Value("${ai.task.timeout-minutes:300}") + private int taskTimeoutMinutes; + + //AI任务状态查询接口 + @Value("${ai.status.single-image-url}") + private String aiSingleImageStatusUrl; + + @Value("${ai.status.four-panel-url}") + private String aiFourPanelStatusUrl; + + @Value("${ai.status.video-url}") + private String aiVideoStatusUrl; + + @Value("${ai.status.sync-min-interval-minutes:30}") + private int syncMinIntervalMinutes; + + //AI单图生成积分规则编码 + private static final String AI_GENERATE_SINGLE_IMAGE_RULE = "AI_GENERATE_SINGLE_IMAGE"; + //AI四宫格生成积分规则编码 + private static final String AI_GENERATE_QUAD_GRID_RULE = "AI_GENERATE_QUAD_GRID"; + //AI视频生成积分规则编码 + private static final String AI_GENERATE_VIDEO_RULE = "AI_GENERATE_VIDEO"; + //未知类型 + private static final String AI_GENERATE_UNKNOWN_TYPE= "AI_GENERATE_UNKNOWN_TYPE"; + + @Override + public List uploadReferenceFile(List images, List videos, Long userId) { + // 计算本次上传的文件总数 + int uploadCount = (images != null ? images.size() : 0) + (videos != null ? videos.size() : 0); + if (uploadCount == 0) { + throw new BusinessException("请选择要上传的文件"); + } + + // 校验用户当前未关联任务的上传文件数量,最多5个 + Long existCount = aiTaskMediaMapper.selectCount(new LambdaQueryWrapper() + .eq(MiniAiTaskMedia::getCreateBy, userId) + .isNull(MiniAiTaskMedia::getTaskId) + .eq(MiniAiTaskMedia::getFileSource, "user_upload") + .eq(MiniAiTaskMedia::getDeleted, false)); + + if (existCount + uploadCount > 5) { + throw new BusinessException("最多只能上传5个待生成的参考文件"); + } + + List urlList = new ArrayList<>(); + long timestamp = System.currentTimeMillis(); + + // 处理图片 + if (images != null && !images.isEmpty()) { + for (MultipartFile image : images) { + try { + String fileName = timestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); + String ext = FileUtil.extName(image.getOriginalFilename()); + String objectName = OSS_IMAGE_DIR + fileName + "." + ext; + String url = aliyunFileService.uploadFile(objectName, image.getInputStream()); + + // 保存媒体记录 + MiniAiTaskMedia media = new MiniAiTaskMedia(); + String uuid = UUID.randomUUID().toString().replace("-", ""); + media.setUuid(uuid) + .setMiniUserId(userId) + .setFileSource("user_upload") + .setMediaType("image") + .setSourceUrl(url) + .setStorageKey(objectName) + .setCreateBy(userId) + .setCreateTimestamp(timestamp) + .setCreateTime(new Date(timestamp)); + + // 获取图片信息 + BufferedImage imageInfo = ImageIO.read(image.getInputStream()); + media.setWidth(imageInfo.getWidth()) + .setHeight(imageInfo.getHeight()); + + int result = aiTaskMediaMapper.insert(media); + if (result > 0) { + urlList.add(url); + } + + } catch (Exception e) { + log.error("image upload failed", e); + } + } + } + + // 处理视频 + if (videos != null && !videos.isEmpty()) { + String tmpPath = System.getProperty("user.dir") + "/tmp"; + // 确保临时目录存在 + File tmpDir = new File(tmpPath); + if (!tmpDir.exists()) { + tmpDir.mkdirs(); + } + + for (MultipartFile video : videos) { + try { + String fileName = timestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); + String ext = FileUtil.extName(video.getOriginalFilename()); + String objectName = OSS_VIDEO_DIR + fileName + "." + ext; + String url = aliyunFileService.uploadFile(objectName, video.getInputStream()); + + // 保存媒体记录 + MiniAiTaskMedia media = new MiniAiTaskMedia(); + String uuid = UUID.randomUUID().toString().replace("-", ""); + + media.setUuid(uuid) + .setMiniUserId(userId) + .setFileSource("user_upload") + .setMediaType("video") + .setSourceUrl(url) + .setStorageKey(objectName) + .setCreateBy(userId) + .setCreateTimestamp(timestamp) + .setCreateTime(new Date(timestamp)); + + // 获取视频时长 + FileUtils.saveFile(video, tmpPath, fileName); + String videoPath = tmpPath + File.separator + fileName; + double duration = JavaVCUtils.getVideoDuration(videoPath); + media.setDuration((int) Math.ceil(duration)); + + // 生成并上传视频缩略图 + BufferedImage thumbnail = JavaVCUtils.getVideoThumbnail(videoPath, 1); + String thumbnailFileName = timestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8); + String thumbnailObjectName = OSS_THUMBNAIL_DIR + thumbnailFileName + ".png"; + String thumbnailUrl = aliyunFileService.uploadFile(thumbnailObjectName, + FileUtils.bufferedImageToInputStream(thumbnail, "png")); + media.setThumbnailUrl(thumbnailUrl); + + int result = aiTaskMediaMapper.insert(media); + if (result > 0) { + urlList.add(url); + } + + // 删除临时文件 + FileUtils.delete(videoPath); + + } catch (Exception e) { + log.error("video upload failed", e); + } + } + } + + return urlList; + } + + @Override + @Transactional(rollbackFor = Exception.class) // 所有异常都触发事务回滚 + public String createAndGenerateImage(AiSingleImageGenerateForm form, Long userId) { + // 生成任务UUID + String taskUuid = UUID.randomUUID().toString().replace("-", ""); + Date now = new Date(); + long timestamp = System.currentTimeMillis(); + + // 扣减积分(自动加入当前事务,失败会一起回滚) + MiniDeductPointForm deductForm = new MiniDeductPointForm(); + deductForm.setRuleCode(AI_GENERATE_SINGLE_IMAGE_RULE); + deductForm.setBizId(taskUuid); + Integer deductPoint = pointRecordService.deductPoint(userId, deductForm.getRuleCode(), deductForm.getBizId()); + + // 创建生成任务(自动加入当前事务,失败会一起回滚) + MiniAiGenerationTask task = new MiniAiGenerationTask(); + task.setUuid(taskUuid) + .setMiniUserId(userId) + .setVisibility(form.getVisibility()) + .setType("img_single") // 单图 + .setGenerateParams(JSONUtil.toJsonStr(form)) + .setPointsConsumed(Math.abs(deductPoint)) + .setStatus(0) // 生成中 + .setCreateBy(userId) + .setCreateTime(now) + .setCreateTimestamp(timestamp) + .setUpdateTime(now) // 补全update相关字段 + .setUpdateTimestamp(timestamp) + .setDeleted(false); + + aiGenerationTaskMapper.insert(task); + + // 立即关联用户上传的参考文件到当前任务并软删除 + softDeleteUserUploads(userId, task.getId()); + + // 组装第三方AI接口需要的参数 + Map aiRequest = new HashMap<>(); + aiRequest.put("model", form.getModel() == null ? aiDefaultImageModel : form.getModel()); + aiRequest.put("reference_image", form.getImageUrl()); + aiRequest.put("style", form.getStyle()); + aiRequest.put("ratio", form.getRatio()); + aiRequest.put("img_size", form.getImgSize()); + aiRequest.put("img_type", form.getImgType()); + aiRequest.put("species", form.getSpecies()); + aiRequest.put("breed", form.getBreed()); + aiRequest.put("color", form.getColor()); + aiRequest.put("eye_color", form.getEyeColor()); + aiRequest.put("body_type", form.getBodyType()); + aiRequest.put("distinctive_features", form.getDistinctiveFeatures()); + aiRequest.put("description", form.getDescription()); + // 传递任务UUID和回调地址 + aiRequest.put("uuid", taskUuid); + aiRequest.put("callback_url", aiSingleImageCallbackUrl); + + try { + log.info("提交AI生成任务,任务UUID:{},请求参数:{}", taskUuid, JSONUtil.toJsonStr(aiRequest)); + // 同步调用AI接口,超时1秒 + HttpResponse response = HttpRequest.post(aiSingleImageServerUrl) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(aiRequest)) + .timeout(1000) + .execute(); + + // 先判断HTTP状态码 + if (!response.isOk()) { + log.error("AI生成任务提交失败,HTTP状态码:{},响应内容:{}", response.getStatus(), response.body()); + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } + + // 解析返回结果,校验业务状态码:只有code=0才代表提交成功 + String responseBody = response.body(); + JSONObject resJson = JSONUtil.parseObj(responseBody); + Integer code = resJson.getInt("code"); + if (!Integer.valueOf(0).equals(code)) { + String errMsg = resJson.getStr("msg", "服务调用失败"); + + log.error("AI生成任务提交失败,业务错误码:{},错误信息:{},request_id:{},完整响应:{}", + code, errMsg, resJson.getStr("request_id"), responseBody); + throw new MsgException("AI生成失败:" + errMsg); + } + } catch (JSONException e) { + log.error("AI生成任务返回结果解析失败,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("单图任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } catch (Exception e) { + log.error("AI生成任务提交异常,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("单图任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + // 抛出异常触发事务回滚 + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } + + return taskUuid; + } + + @Override + @Transactional(rollbackFor = Exception.class) // 所有异常都触发事务回滚 + public String createAndGenerateFourPanel(AiFourPanelGenerateForm form, Long userId) { + // 生成任务UUID + String taskUuid = UUID.randomUUID().toString().replace("-", ""); + Date now = new Date(); + long timestamp = System.currentTimeMillis(); + + // 扣减积分(自动加入当前事务,失败会一起回滚) + MiniDeductPointForm deductForm = new MiniDeductPointForm(); + deductForm.setRuleCode(AI_GENERATE_QUAD_GRID_RULE); + deductForm.setBizId(taskUuid); + Integer deductPoint = pointRecordService.deductPoint(userId, deductForm.getRuleCode(), deductForm.getBizId()); + + // 创建生成任务(自动加入当前事务,失败会一起回滚) + MiniAiGenerationTask task = new MiniAiGenerationTask(); + task.setUuid(taskUuid) + .setMiniUserId(userId) + .setVisibility(form.getVisibility()) + .setType("img_grid_4") // 四宫格漫画 + .setGenerateParams(JSONUtil.toJsonStr(form)) + .setPointsConsumed(Math.abs(deductPoint)) + .setStatus(0) // 生成中 + .setCreateBy(userId) + .setCreateTime(now) + .setCreateTimestamp(timestamp); + + aiGenerationTaskMapper.insert(task); + + // 立即关联用户上传的参考文件到当前任务并软删除 + softDeleteUserUploads(userId, task.getId()); + + // 组装第三方AI接口需要的参数 + Map aiRequest = new HashMap<>(); + aiRequest.put("model", form.getModel() == null ? aiDefaultImageModel : form.getModel()); + aiRequest.put("reference_image", form.getReferenceImage()); + aiRequest.put("style", form.getStyle()); + aiRequest.put("ratio", form.getRatio()); + aiRequest.put("img_size", form.getImgSize()); + aiRequest.put("img_type", form.getImgType()); + aiRequest.put("species", form.getSpecies()); + aiRequest.put("breed", form.getBreed()); + aiRequest.put("color", form.getColor()); + aiRequest.put("eye_color", form.getEyeColor()); + aiRequest.put("body_type", form.getBodyType()); + aiRequest.put("distinctive_features", form.getDistinctiveFeatures()); + aiRequest.put("description", form.getDescription()); + // 传递任务UUID和回调地址 + aiRequest.put("uuid", taskUuid); + aiRequest.put("callback_url", aiFourPanelCallbackUrl); + + try { + log.info("提交四宫格漫画生成任务,任务UUID:{},请求参数:{}", taskUuid, JSONUtil.toJsonStr(aiRequest)); + // 同步调用AI接口,超时1秒 + HttpResponse response = HttpRequest.post(aiFourPanelServerUrl) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(aiRequest)) + .timeout(1000) + .execute(); + + // 先判断HTTP状态码 + if (!response.isOk()) { + log.error("四宫格漫画生成任务提交失败,HTTP状态码:{},响应内容:{}", response.getStatus(), response.body()); + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } + + // 解析返回结果,校验业务状态码:只有code=0才代表提交成功 + String responseBody = response.body(); + JSONObject resJson = JSONUtil.parseObj(responseBody); + Integer code = resJson.getInt("code"); + if (!Integer.valueOf(0).equals(code)) { + String errMsg = resJson.getStr("msg", "服务调用失败"); + + log.error("四宫格漫画生成任务提交失败,业务错误码:{},错误信息:{},request_id:{},完整响应:{}", + code, errMsg, resJson.getStr("request_id"), responseBody); + throw new MsgException("AI生成失败:" + errMsg); + } + } catch (JSONException e) { + log.error("四宫格漫画生成任务返回结果解析失败,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("四宫格任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } catch (Exception e) { + log.error("四宫格漫画生成任务提交异常,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("四宫格任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + // 抛出异常触发事务回滚 + throw new MsgException("AI生成服务暂时不可用,请稍后重试"); + } + + return taskUuid; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String createAndGenerateVideo(AiVideoGenerateForm form, Long userId) { + // 生成任务UUID + String taskUuid = UUID.randomUUID().toString().replace("-", ""); + Date now = new Date(); + long timestamp = System.currentTimeMillis(); + + // 扣减积分 + MiniDeductPointForm deductForm = new MiniDeductPointForm(); + deductForm.setRuleCode(AI_GENERATE_VIDEO_RULE); + deductForm.setBizId(taskUuid); + Integer deductPoint = pointRecordService.deductPoint(userId, deductForm.getRuleCode(), deductForm.getBizId()); + + // 创建生成任务 + MiniAiGenerationTask task = new MiniAiGenerationTask(); + task.setUuid(taskUuid) + .setVisibility(form.getVisibility()) + .setMiniUserId(userId) + .setType("video") + .setGenerateParams(JSONUtil.toJsonStr(form)) + .setPointsConsumed(Math.abs(deductPoint)) + .setStatus(0) + .setCreateBy(userId) + .setCreateTime(now) + .setCreateTimestamp(timestamp); + + aiGenerationTaskMapper.insert(task); + + // 立即关联用户上传的参考文件到当前任务并软删除 + softDeleteUserUploads(userId, task.getId()); + + // 组装第三方接口参数 + Map aiRequest = new HashMap<>(); + aiRequest.put("model", form.getModel() == null ? aiDefaultVideoModel : form.getModel()); + aiRequest.put("content", form.getContent()); + aiRequest.put("resolution", form.getResolution()); + aiRequest.put("duration", form.getDuration()); + aiRequest.put("uuid", taskUuid); + aiRequest.put("callback_url", aiVideoCallbackUrl); + + try { + log.info("提交视频生成任务,任务UUID:{},请求参数:{}", taskUuid, JSONUtil.toJsonStr(aiRequest)); + HttpResponse response = HttpRequest.post(aiVideoServerUrl) + .header("Content-Type", "application/json") + .body(JSONUtil.toJsonStr(aiRequest)) + .timeout(30000) //超时30秒 + .execute(); + + if (!response.isOk()) { + log.error("视频生成任务提交失败,HTTP状态码:{},响应内容:{}", response.getStatus(), response.body()); + throw new MsgException("AI视频生成服务暂时不可用,请稍后重试"); + } + + String responseBody = response.body(); + JSONObject resJson = JSONUtil.parseObj(responseBody); + Integer code = resJson.getInt("code"); + if (!Integer.valueOf(0).equals(code)) { + String errMsg = resJson.getStr("msg", "服务调用失败"); + log.error("视频生成任务提交失败,业务错误码:{},错误信息:{},完整响应:{}", code, errMsg, responseBody); + throw new MsgException("AI视频生成失败:" + errMsg); + } + + // 提取第三方返回的视频任务uuid,保存到任务表中,后续回调通过这个uuid匹配任务 + JSONObject data = resJson.getJSONObject("data"); + String videoTaskUuid = data.getStr("uuid"); + task.setVideoTaskUuid(videoTaskUuid); + aiGenerationTaskMapper.updateById(task); + log.info("视频任务{}第三方返回uuid:{},已保存", task.getUuid(), videoTaskUuid); + + } catch (JSONException e) { + log.error("视频生成任务返回结果解析失败,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("视频任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + throw new MsgException("AI视频生成服务暂时不可用,请稍后重试"); + } catch (Exception e) { + log.error("视频生成任务提交异常,任务UUID:{},异常信息:{}", taskUuid, e.getMessage(), e); + log.warn("视频任务{}提交异常,事务即将回滚,但AI服务可能已接收请求并在处理中,请关注AI侧状态", taskUuid); + throw new MsgException("AI视频生成服务暂时不可用,请稍后重试"); + } + + return taskUuid; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean handleVideoTaskCallback(AiVideoCallbackVO vo) { + log.info("处理AI视频生成任务回调,请求参数:{}", JSONUtil.toJsonStr(vo)); + try { + String videoTaskUuid = vo.getId(); + // 根据第三方返回的视频任务uuid查询对应的任务 + MiniAiGenerationTask task = aiGenerationTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(MiniAiGenerationTask::getVideoTaskUuid, videoTaskUuid) + .eq(MiniAiGenerationTask::getDeleted, false)); + if (task == null) { + log.error("视频回调任务不存在,第三方视频任务UUID:{}", videoTaskUuid); + return false; + } + + // 终态保护:已成功或已失败的任务不允许再次更新状态 + if (task.getStatus() != null && (task.getStatus() == 1 || task.getStatus() == 2)) { + log.warn("视频任务{}已是终态(status={}),忽略本次回调", task.getUuid(), task.getStatus()); + return true; + } + + // 转换任务状态 + Integer status; + if ("succeeded".equals(vo.getStatus())) { + status = 1; // 成功 + } else if ("failed".equals(vo.getStatus())) { + status = 2; // 失败 + } else { + // queued/running中间状态直接返回成功,不更新任务 + log.info("视频任务{}处于中间状态:{},不处理", task.getUuid(), vo.getStatus()); + return true; + } + + // 如果生成成功,下载外部视频到OSS + if (status == 1 && vo.getContent() != null && vo.getContent().getVideoUrl() != null) { + String externalVideoUrl = vo.getContent().getVideoUrl(); + String ossUrl = downloadExternalUrlToOss(externalVideoUrl, OSS_GENERATE_VIDEO_DIR); + + MiniAiTaskMedia media = new MiniAiTaskMedia(); + String mediaUuid = UUID.randomUUID().toString().replace("-", ""); + media.setUuid(mediaUuid) + .setTaskId(task.getId()) + .setMiniUserId(task.getMiniUserId()) + .setFileSource("ai_generated") + .setMediaType("video") + .setSourceUrl(ossUrl) + .setDuration(vo.getDuration()) + .setCreateBy(task.getCreateBy()) + .setCreateTimestamp(System.currentTimeMillis()) + .setCreateTime(new Date()); + aiTaskMediaMapper.insert(media); + + // 同步发送订阅消息通知 + sendAiGenerateSuccessNotify(task.getMiniUserId(), subscribeTemplate, task.getId()); + } + + // 失败时退还积分 + if (status == 2) { + refundPoints(task); + } + + // 更新任务状态 + if (!updateTaskStatus(task.getUuid(), status)) { + log.error("视频任务{}更新状态失败,可能已被并发更新", task.getUuid()); + return false; + } + + log.info("视频任务{}回调处理完成,状态:{}", task.getUuid(), status); + return true; + } catch (Exception e) { + log.error("视频任务回调处理异常,异常信息:{}", e.getMessage(), e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean handleFourPanelCallback(AiFourPanelCallbackForm form) { + try { + log.info("处理四宫格漫画生成任务回调,任务UUID:{}", form.getUuid()); + String taskUuid = form.getUuid(); + // 查询任务是否存在 + MiniAiGenerationTask task = getTaskByUuid(taskUuid); + if (task == null) { + log.error("四宫格回调任务不存在,UUID:{}", taskUuid); + return false; + } + + // 终态保护:已成功或已失败的任务不允许再次更新状态 + if (task.getStatus() != null && (task.getStatus() == 1 || task.getStatus() == 2)) { + log.warn("四宫格任务{}已是终态(status={}),忽略本次回调", taskUuid, task.getStatus()); + return true; + } + + // 转换任务状态 + Integer status; + if ("succeeded".equals(form.getStatus())) { + status = 1; // 成功 + } else if ("failed".equals(form.getStatus())) { + status = 2; // 失败 + } else { + log.info("四宫格任务{}处于中间状态:{},不处理", taskUuid, form.getStatus()); + return true; // 中间状态直接返回成功,不更新任务 + } + + // 如果生成成功,下载外部URL到OSS,存储所有结果图片 + if (status == 1 && form.getResult() != null && !form.getResult().isEmpty()) { + long now = System.currentTimeMillis(); + + for (AiCallbackImage image : form.getResult()) { + String ossUrl = downloadExternalUrlToOss(image.getUrl(), OSS_GENERATE_IMAGE_DIR); + + // 解析尺寸 + Integer width = null; + Integer height = null; + if (image.getSize() != null && image.getSize().contains("x")) { + String[] sizeArr = image.getSize().split("x"); + try { + width = Integer.parseInt(sizeArr[0]); + height = Integer.parseInt(sizeArr[1]); + } catch (Exception e) { + log.warn("解析四宫格图片尺寸失败,size:{}", image.getSize(), e); + } + } + + MiniAiTaskMedia media = new MiniAiTaskMedia(); + String mediaUuid = UUID.randomUUID().toString().replace("-", ""); + media.setUuid(mediaUuid) + .setTaskId(task.getId()) + .setMiniUserId(task.getMiniUserId()) + .setFileSource("ai_generated") + .setMediaType("image") + .setSourceUrl(ossUrl) + .setWidth(width) + .setHeight(height) + .setCreateBy(task.getCreateBy()) + .setCreateTimestamp(now) + .setCreateTime(new Date(now)); + aiTaskMediaMapper.insert(media); + } + + // 同步发送订阅消息通知 + sendAiGenerateSuccessNotify(task.getMiniUserId(), subscribeTemplate, task.getId()); + } + + // 失败时退还积分 + if (status == 2) { + refundPoints(task); + } + + // 更新任务状态 + if (!updateTaskStatus(taskUuid, status)) { + log.error("四宫格任务{}更新状态失败,可能已被并发更新", taskUuid); + return false; + } + + log.info("四宫格任务{}回调处理完成,状态:{}", taskUuid, status); + return true; + } catch (Exception e) { + log.error("四宫格任务回调处理异常,任务UUID:{},异常信息:{}", form.getUuid(), e.getMessage(), e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean handleTaskCallback(AiSingleImageCallbackForm form) { + log.info("处理单图生成任务回调,任务UUID:{}", form.getUuid()); + try { + String taskUuid = form.getUuid(); + // 查询任务是否存在 + MiniAiGenerationTask task = getTaskByUuid(taskUuid); + if (task == null) { + log.error("单图回调任务不存在,UUID:{}", taskUuid); + return false; + } + + // 终态保护:已成功或已失败的任务不允许再次更新状态 + if (task.getStatus() != null && (task.getStatus() == 1 || task.getStatus() == 2)) { + log.warn("单图任务{}已是终态(status={}),忽略本次回调", taskUuid, task.getStatus()); + return true; + } + + // 转换任务状态 + Integer status; + if ("succeeded".equals(form.getStatus())) { + status = 1; // 成功 + } else if ("failed".equals(form.getStatus())) { + status = 2; // 失败 + } else { + log.error("单图回调任务状态非法,UUID:{},status:{}", taskUuid, form.getStatus()); + return false; + } + + // 如果生成成功,下载外部URL到OSS,存储所有结果图片 + if (status == 1 && form.getResult() != null && !form.getResult().isEmpty()) { + long now = System.currentTimeMillis(); + + for (AiCallbackImage image : form.getResult()) { + String ossUrl = downloadExternalUrlToOss(image.getUrl(), OSS_GENERATE_IMAGE_DIR); + + // 解析尺寸 + Integer width = null; + Integer height = null; + if (image.getSize() != null && image.getSize().contains("x")) { + String[] sizeArr = image.getSize().split("x"); + try { + width = Integer.parseInt(sizeArr[0]); + height = Integer.parseInt(sizeArr[1]); + } catch (Exception e) { + log.warn("解析单图图片尺寸失败,size:{}", image.getSize(), e); + } + } + + MiniAiTaskMedia media = new MiniAiTaskMedia(); + String mediaUuid = UUID.randomUUID().toString().replace("-", ""); + media.setUuid(mediaUuid) + .setMiniUserId(task.getMiniUserId()) + .setTaskId(task.getId()) + .setFileSource("ai_generated") + .setMediaType("image") + .setSourceUrl(ossUrl) + .setWidth(width) + .setHeight(height) + .setCreateBy(task.getCreateBy()) + .setCreateTime(new Date(now)) + .setCreateTimestamp(now) + .setUpdateTime(new Date(now)) + .setUpdateTimestamp(now) + .setDeleted(false); + + aiTaskMediaMapper.insert(media); + } + + // 同步发送订阅消息通知 + sendAiGenerateSuccessNotify(task.getMiniUserId(), subscribeTemplate, task.getId()); + } + + // 失败时退还积分 + if (status == 2) { + refundPoints(task); + } + + // 更新任务状态 + if (!updateTaskStatus(task.getUuid(), status)) { + log.error("单图任务{}更新状态失败,可能已被并发更新", task.getUuid()); + return false; + } + + log.info("单图任务{}回调处理完成,状态:{}", task.getUuid(), status); + return true; + } catch (Exception e) { + log.error("处理AI任务回调异常,UUID:{},异常信息:{}", form.getUuid(), e.getMessage(), e); + return false; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void syncTaskStatus(String taskUuid) { + MiniAiGenerationTask task = getTaskByUuid(taskUuid); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 终态不允许手动同步 + if (task.getStatus() != null && (task.getStatus() == 1 || task.getStatus() == 2)) { + throw new BusinessException("任务已完成或已失败,无需同步"); + } + + // 创建后需等待配置的时间才能手动同步 + long elapsedMinutes = (System.currentTimeMillis() - task.getCreateTimestamp()) / 60000; + if (elapsedMinutes < syncMinIntervalMinutes) { + throw new BusinessException("任务创建不足" + syncMinIntervalMinutes + "分钟,请" + (syncMinIntervalMinutes - elapsedMinutes) + "分钟后再试"); + } + + String statusUrl = buildAiStatusUrl(task); + log.info("手动同步任务状态,taskUuid:{},type:{},statusUrl:{}", taskUuid, task.getType(), statusUrl); + + String responseBody; + try { + HttpResponse response = HttpRequest.get(statusUrl).timeout(10000).execute(); + if (!response.isOk()) { + log.error("AI状态查询返回非200,taskUuid:{},httpStatus:{}", taskUuid, response.getStatus()); + throw new BusinessException("查询AI任务状态失败,HTTP状态码: " + response.getStatus()); + } + responseBody = response.body(); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("调用AI状态查询接口失败,taskUuid:{}", taskUuid, e); + throw new BusinessException("查询AI任务状态失败: " + e.getMessage()); + } + + log.info("AI状态查询响应,taskUuid:{},body:{}", taskUuid, responseBody); + processAiStatusResponse(task.getType(), responseBody); + } + + private String buildAiStatusUrl(MiniAiGenerationTask task) { + switch (task.getType()) { + case "img_single": + return aiSingleImageStatusUrl + task.getUuid(); + case "img_grid_4": + return aiFourPanelStatusUrl + task.getUuid(); + case "video": + if (StrUtil.isBlank(task.getVideoTaskUuid())) { + throw new BusinessException("视频任务尚未获取到第三方任务ID"); + } + return aiVideoStatusUrl + task.getVideoTaskUuid(); + default: + throw new BusinessException("不支持的任务类型: " + task.getType()); + } + } + + private void processAiStatusResponse(String type, String responseBody) { + switch (type) { + case "img_single": + handleTaskCallback(JSONUtil.toBean(responseBody, AiSingleImageCallbackForm.class)); + break; + case "img_grid_4": + handleFourPanelCallback(JSONUtil.toBean(responseBody, AiFourPanelCallbackForm.class)); + break; + case "video": + handleVideoTaskCallback(JSONUtil.toBean(responseBody, AiVideoCallbackVO.class)); + break; + } + } + + @Override + public MiniAiGenerationTask getTaskByUuid(String uuid) { + return aiGenerationTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(MiniAiGenerationTask::getUuid, uuid) + .eq(MiniAiGenerationTask::getDeleted, false)); + } + + /** + * 更新任务状态 + */ + private boolean updateTaskStatus(String uuid, Integer status) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MiniAiGenerationTask::getUuid, uuid) + .eq(MiniAiGenerationTask::getDeleted, false) + .in(MiniAiGenerationTask::getStatus, 0, 3) // 只有生成中或超时状态允许更新,终态(成功/失败)不可覆盖 + .set(MiniAiGenerationTask::getStatus, status) + .set(MiniAiGenerationTask::getUpdateTime, new Date()) + .set(MiniAiGenerationTask::getUpdateTimestamp, System.currentTimeMillis()); + + return aiGenerationTaskMapper.update(null, updateWrapper) > 0; + } + + /** + * 获取微信订阅消息跳转版本 + * 优先级: 配置文件配置 > Redis配置 > 默认formal + */ + private String getMiniProgramState() { + if (StrUtil.isNotBlank(miniProgramState)) { + return miniProgramState; + } + String redisState = stringRedisTemplate.opsForValue().get(WX_MINI_PROGRAM_STATE_KEY); + if (StrUtil.isNotBlank(redisState)) { + return redisState; + } + return "formal"; + } + + @Override + public void setMiniProgramState(String miniProgramState) { + if (StrUtil.isBlank(miniProgramState)) { + stringRedisTemplate.delete(WX_MINI_PROGRAM_STATE_KEY); + } else { + stringRedisTemplate.opsForValue().set(WX_MINI_PROGRAM_STATE_KEY, miniProgramState); + } + } + + /** + * 下载外部URL到指定OSS目录,返回OSS访问地址 + */ + private String downloadExternalUrlToOss(String externalUrl, String dir) { + try ( + // 直接打开URL输入流,自动关闭 + InputStream inputStream = new URL(externalUrl).openStream() + ) { + long currentTimestamp = System.currentTimeMillis(); + + // 生成OSS存储路径:按日期分类 + 唯一ID + 原文件后缀 +// String ext = FileUtil.extName(externalUrl); + String ext = null; + + // 1. 如果URL包含查询参数 '?',则只取问号前面的部分 + String path = externalUrl; + int queryIndex = externalUrl.indexOf('?'); + if (queryIndex > 0) { + path = externalUrl.substring(0, queryIndex); + } + // 2. 从纯净的路径中提取文件后缀 + int lastDotIndex = path.lastIndexOf('.'); + if (lastDotIndex > 0 && lastDotIndex < path.length() - 1) { + ext = path.substring(lastDotIndex + 1); + } + if (ext == null) { + log.error("无法从URL中提取文件后缀,URL:{}", externalUrl); + ext = "jpg"; + } + + String objectName = dir + + currentTimestamp + RandomNumberUtils.createRandomLowerLetterAndNumber(8) + + "." + + ext; + return aliyunFileService.uploadFile(objectName, inputStream); + } catch (Exception e) { + log.error("下载外部URL到OSS失败,URL:{},异常信息:{}", externalUrl, e.getMessage(), e); + throw new MsgException("资源转存失败"); + } + } + + + @Override + public List getRecentUploadVO(Long userId) { + List mediaList = aiTaskMediaMapper.selectList(new LambdaQueryWrapper() + .eq(MiniAiTaskMedia::getMiniUserId, userId) + .eq(MiniAiTaskMedia::getFileSource, "user_upload") + .eq(MiniAiTaskMedia::getDeleted, false) + .orderByDesc(MiniAiTaskMedia::getCreateTime) + .last("limit 20")); + + if (mediaList == null || mediaList.isEmpty()) { + return Collections.emptyList(); + } + + return mediaList.stream() + .map(this::convertToVO) + .toList(); + } + + private UserUploadMediaVO convertToVO(MiniAiTaskMedia media) { + UserUploadMediaVO vo = new UserUploadMediaVO(); + BeanUtil.copyProperties(media, vo); + return vo; + } + + @Override + public void updateTaskVisibility(Long userId, AiTaskVisibilityForm form) { + MiniAiGenerationTask task = aiGenerationTaskMapper.selectOne( + new LambdaQueryWrapper() + .eq(MiniAiGenerationTask::getUuid, form.getUuid()) + .eq(MiniAiGenerationTask::getMiniUserId, userId) + .eq(MiniAiGenerationTask::getDeleted, false)); + + if (task == null) { + throw new MsgException("任务不存在或无权限"); + } + + task.setVisibility(form.getVisibility()); + task.setUpdateTime(new Date()); + task.setUpdateTimestamp(System.currentTimeMillis()); + aiGenerationTaskMapper.updateById(task); + } + + @Override + public boolean deleteUploadMedia(Long userId, String uuid) { + // 校验是否是当前用户的上传媒体 + MiniAiTaskMedia media = aiTaskMediaMapper.selectOne(new LambdaQueryWrapper() + .eq(MiniAiTaskMedia::getUuid, uuid) + .eq(MiniAiTaskMedia::getMiniUserId, userId) + .eq(MiniAiTaskMedia::getFileSource, "user_upload") + .eq(MiniAiTaskMedia::getDeleted, false) + .last("limit 1")); + if (media == null) { + log.warn("用户{}删除媒体失败,媒体{}不存在或无权限", userId, uuid); + return false; + } + // 软删除 + media.setDeleted(true); + media.setUpdateTime(new Date()); + media.setUpdateTimestamp(System.currentTimeMillis()); + return aiTaskMediaMapper.updateById(media) > 0; + } + + + @Override + public IPage getPublicDiscoveryFeed(DiscoveryPublicWorkQuery query) { + // 1. 分页查询公开且已生成成功的任务 + LambdaQueryWrapper taskWrapper = new LambdaQueryWrapper<>(); + taskWrapper.eq(MiniAiGenerationTask::getVisibility, "public") + .eq(MiniAiGenerationTask::getStatus, 1) + .eq(MiniAiGenerationTask::getDeleted, false); + if (query.getType() != null) { + taskWrapper.eq(MiniAiGenerationTask::getType, query.getType()); + } + taskWrapper.orderByDesc(MiniAiGenerationTask::getCreateTimestamp); + + IPage taskPage = aiGenerationTaskMapper.selectPage( + new Page<>(query.getPageNum(), query.getPageSize()), taskWrapper); + + if (taskPage.getRecords().isEmpty()) { + return new Page<>(query.getPageNum(), query.getPageSize()); + } + + // 2. 批量查询媒体和用户 + List taskIds = taskPage.getRecords().stream() + .map(MiniAiGenerationTask::getId) + .toList(); + + List mediaList = aiTaskMediaMapper.selectList( + new LambdaQueryWrapper() + .in(MiniAiTaskMedia::getTaskId, taskIds) + .eq(MiniAiTaskMedia::getFileSource, "ai_generated") + .eq(MiniAiTaskMedia::getDeleted, false)); + + Map> mediaGroupMap = mediaList.stream() + .collect(Collectors.groupingBy( + MiniAiTaskMedia::getTaskId, + Collectors.mapping(media -> { + MiniAiTaskMediaVO vo = new MiniAiTaskMediaVO(); + vo.setUuid(media.getUuid()); + vo.setMediaType(media.getMediaType()); + vo.setSourceUrl(media.getSourceUrl()); + vo.setThumbnailUrl(media.getThumbnailUrl()); + vo.setWidth(media.getWidth()); + vo.setHeight(media.getHeight()); + vo.setDuration(media.getDuration()); + vo.setCreateTime(media.getCreateTime()); + return vo; + }, Collectors.toList()) + )); + + Set userIds = taskPage.getRecords().stream() + .map(MiniAiGenerationTask::getMiniUserId) + .collect(Collectors.toSet()); + + Map userMap = userMapper.selectList( + new LambdaQueryWrapper() + .in(SysUser::getId, userIds) + .eq(SysUser::getIsDeleted, 0)) + .stream() + .collect(Collectors.toMap(SysUser::getId, u -> u)); + + // 3. 组装VO + return taskPage.convert(task -> { + DiscoveryPublicWorkVO vo = new DiscoveryPublicWorkVO(); + vo.setUuid(task.getUuid()); + vo.setType(task.getType()); + vo.setPointsConsumed(task.getPointsConsumed()); + vo.setCreateTime(task.getCreateTime()); + vo.setGenerateContent(mediaGroupMap.getOrDefault(task.getId(), Collections.emptyList())); + + SysUser user = userMap.get(task.getMiniUserId()); + if (user != null) { + vo.setUserUuid(user.getUuid()); + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + } + return vo; + }); + } + + @Override + public IPage getMyAiGenerateHistory(AiTaskMediaQuery query, Long userId) { + // 1. 分页查询当前用户的生成任务 + LambdaQueryWrapper taskQueryWrapper = new LambdaQueryWrapper<>(); + taskQueryWrapper.eq(MiniAiGenerationTask::getMiniUserId, userId); + // 任务状态筛选 + if (query.getStatus() != null) { + taskQueryWrapper.eq(MiniAiGenerationTask::getStatus, query.getStatus()); + } + // 生成类型筛选 + if (query.getType() != null) { + taskQueryWrapper.eq(MiniAiGenerationTask::getType, query.getType()); + } + taskQueryWrapper.orderByDesc(MiniAiGenerationTask::getCreateTimestamp); + IPage taskPage = aiGenerationTaskMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), taskQueryWrapper); + + if (taskPage.getRecords().isEmpty()) { + return taskPage.convert(task -> new AiGenerationTaskVO()); + } + + // 2. 提取所有任务ID + List taskIds = taskPage.getRecords().stream() + .map(MiniAiGenerationTask::getId) + .collect(Collectors.toList()); + + // 3. 批量查询这些任务对应的AI生成媒体内容 + LambdaQueryWrapper mediaQueryWrapper = new LambdaQueryWrapper<>(); + mediaQueryWrapper.in(MiniAiTaskMedia::getTaskId, taskIds); + mediaQueryWrapper.eq(MiniAiTaskMedia::getFileSource, "ai_generated"); + mediaQueryWrapper.eq(MiniAiTaskMedia::getDeleted, false); + + List mediaList = aiTaskMediaMapper.selectList(mediaQueryWrapper); + + // 4. 先按任务ID分组媒体内容,再批量转换为VO + Map> rawMediaGroupMap = mediaList.stream() + .collect(Collectors.groupingBy(MiniAiTaskMedia::getTaskId)); + + // 转换为VO分组 + Map> mediaGroupMap = rawMediaGroupMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().stream().map(media -> { + MiniAiTaskMediaVO mediaVO = new MiniAiTaskMediaVO(); + mediaVO.setUuid(media.getUuid()); + mediaVO.setMediaType(media.getMediaType()); + mediaVO.setSourceUrl(media.getSourceUrl()); + mediaVO.setThumbnailUrl(media.getThumbnailUrl()); + mediaVO.setWidth(media.getWidth()); + mediaVO.setHeight(media.getHeight()); + mediaVO.setDuration(media.getDuration()); + mediaVO.setCreateTime(media.getCreateTime()); + return mediaVO; + }).collect(Collectors.toList()) + )); + + // 5. 组装VO + IPage convert = taskPage.convert(task -> { + AiGenerationTaskVO vo = new AiGenerationTaskVO(); +// vo.setId(task.getId()); + vo.setUuid(task.getUuid()); + vo.setVisibility(task.getVisibility()); + vo.setType(task.getType()); + vo.setStatus(task.getStatus()); + vo.setPointsConsumed(task.getPointsConsumed()); + vo.setCreateTime(task.getCreateTime()); + vo.setGenerateContent(mediaGroupMap.getOrDefault(task.getId(), Collections.emptyList())); + return vo; + }); + log.info("用户{}查询AI生成任务历史成功,结果:{}", userId, convert.getRecords().toString()); + return convert; + } + + /** + * 退还任务消耗的积分。refundUuid 是幂等唯一标识,adjustPoint 内部唯一索引兜底。 + */ + private void refundPoints(MiniAiGenerationTask task) { + if (StrUtil.isNotBlank(task.getRefundUuid())) { + log.info("任务{}已退过款(refundUuid={}),跳过", task.getUuid(), task.getRefundUuid()); + return; + } + if (task.getPointsConsumed() == null || task.getPointsConsumed() <= 0) { + log.warn("任务{}消耗积分为0或null,跳过退款", task.getUuid()); + return; + } + + String refundUuid = UUID.randomUUID().toString().replace("-", ""); + String ruleCode = getRuleCodeByTaskType(task.getType()); + + AdjustUserPointForm adjustForm = new AdjustUserPointForm(); + adjustForm.setUserId(task.getMiniUserId()); + adjustForm.setBizType(ruleCode); + adjustForm.setChangeAmount(task.getPointsConsumed()); + adjustForm.setBizId(refundUuid); + + pointManageService.adjustPoint(adjustForm); + + // adjustPoint 成功后回填 refundUuid,下次直接跳过 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MiniAiGenerationTask::getUuid, task.getUuid()) + .set(MiniAiGenerationTask::getRefundUuid, refundUuid) + .set(MiniAiGenerationTask::getUpdateTime, new Date()) + .set(MiniAiGenerationTask::getUpdateTimestamp, System.currentTimeMillis()); + aiGenerationTaskMapper.update(null, updateWrapper); + + log.info("任务{}退款成功,退还{}积分,refundUuid={}", task.getUuid(), task.getPointsConsumed(), refundUuid); + } + + private String getRuleCodeByTaskType(String type) { + return switch (type) { + case "img_single" -> AI_GENERATE_SINGLE_IMAGE_RULE; + case "img_grid_4" -> AI_GENERATE_QUAD_GRID_RULE; + case "video" -> AI_GENERATE_VIDEO_RULE; + default -> { + log.error("未知任务类型: {}", type); + yield AI_GENERATE_UNKNOWN_TYPE; + } + }; + } + + @Override + public void handleTimeoutTasks() { + LocalDateTime timeoutThreshold = LocalDateTime.now().minusMinutes(taskTimeoutMinutes); + + List timeoutTasks = aiGenerationTaskMapper.selectList( + new LambdaQueryWrapper() + .in(MiniAiGenerationTask::getStatus, 0,3) // 0:生成中 3:超时 + .lt(MiniAiGenerationTask::getCreateTime, timeoutThreshold) + .eq(MiniAiGenerationTask::getDeleted, false)); + + if (timeoutTasks.isEmpty()) { + return; + } + + log.info("发现{}个超时任务,开始处理", timeoutTasks.size()); + + for (MiniAiGenerationTask task : timeoutTasks) { + // 先改状态,再退款; + if (!updateTaskStatus(task.getUuid(), 2)) { + log.warn("超时任务{}更新状态失败,可能已被其他流程处理", task.getUuid()); + continue; + } + try { + refundPoints(task); + } catch (Exception e) { + log.error("超时任务{}退款失败,需人工处理", task.getUuid(), e); + } + } + } + + /** + * 将用户当前未关联任务的上传文件关联到指定任务并软删除。 + * 在创建任务时调用,避免任务生成中用户仍能看到旧上传文件。 + */ + private void softDeleteUserUploads(Long userId, Long taskId) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(MiniAiTaskMedia::getMiniUserId, userId) + .isNull(MiniAiTaskMedia::getTaskId) + .eq(MiniAiTaskMedia::getFileSource, "user_upload") + .eq(MiniAiTaskMedia::getDeleted, false) + .set(MiniAiTaskMedia::getTaskId, taskId) + .set(MiniAiTaskMedia::getDeleted, true) + .set(MiniAiTaskMedia::getUpdateTime, new Date()) + .set(MiniAiTaskMedia::getUpdateTimestamp, System.currentTimeMillis()); + aiTaskMediaMapper.update(null, updateWrapper); + } + + /** + * 发送AI作品完成订阅消息通知 + * @param userId 接收用户ID + * @param templateId 订阅消息模板ID + * @param taskId 任务ID(仅用于日志) + */ + private void sendAiGenerateSuccessNotify(Long userId, String templateId, Long taskId) { + if (StrUtil.isBlank(templateId)) { + return; + } + try { + WxSubscribeSendForm sendForm = new WxSubscribeSendForm(); + sendForm.setUserId(userId); + sendForm.setTemplateId(templateId); + sendForm.setPage("pages/creation/list"); + sendForm.setMiniProgramState(getMiniProgramState()); + sendForm.setTemplateParams(Map.of( + "thing5", "您的AI作品已完成", + "time3", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm")), + "phrase4", "待查看" + )); + wxSubscribeService.sendMessage(sendForm); + } catch (Exception e) { + log.error("AI任务{}发送订阅消息失败", taskId, e); + } + } + +} diff --git a/src/main/java/com/youlai/boot/mini/service/impl/WxSubscribeServiceImpl.java b/src/main/java/com/youlai/boot/mini/service/impl/WxSubscribeServiceImpl.java new file mode 100644 index 0000000..fc7c4d0 --- /dev/null +++ b/src/main/java/com/youlai/boot/mini/service/impl/WxSubscribeServiceImpl.java @@ -0,0 +1,148 @@ +package com.youlai.boot.mini.service.impl; + +import cn.binarywang.wx.miniapp.api.WxMaService; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.exception.MsgException; +import com.youlai.boot.mini.model.enums.SubscribeStatusEnum; +import com.youlai.boot.mini.mapper.MiniUserSubscribeMapper; +import com.youlai.boot.mini.model.entity.MiniUserSubscribe; +import com.youlai.boot.mini.model.form.WxSubscribeAuthForm; +import com.youlai.boot.mini.model.form.WxSubscribeSendForm; +import com.youlai.boot.mini.model.vo.WxApiResponse; +import com.youlai.boot.mini.service.WxSubscribeService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.*; + +/** + * 微信订阅消息服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WxSubscribeServiceImpl implements WxSubscribeService { + + private final WxMaService wxMaService; + private final MiniUserSubscribeMapper userSubscribeMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void reportAuth(WxSubscribeAuthForm form) { + try { + // 查询用户openid + String openid = userSubscribeMapper.getOpenidByUserId(form.getUserId()); + if (StrUtil.isBlank(openid)) { + throw new MsgException("用户未绑定微信"); + } + long currentTime = System.currentTimeMillis(); + + // 查询是否已有授权记录 + MiniUserSubscribe exist = userSubscribeMapper.selectOne(new LambdaQueryWrapper() + .eq(MiniUserSubscribe::getUserId, form.getUserId()) + .eq(MiniUserSubscribe::getTemplateId, form.getTemplateId()) + .eq(MiniUserSubscribe::getDeleted, 0) + .last("limit 1")); + + if (exist != null) { + exist.setStatus(form.getStatus()); + exist.setUpdateTime(new Date()); + exist.setUpdateTimestamp(currentTime); + userSubscribeMapper.updateById(exist); + } else { + MiniUserSubscribe subscribe = new MiniUserSubscribe(); + subscribe.setUuid(IdUtil.fastSimpleUUID()); + subscribe.setUserId(form.getUserId()); + subscribe.setTemplateId(form.getTemplateId()); + subscribe.setStatus(form.getStatus()); + subscribe.setCreateTime(new Date()); + subscribe.setCreateTimestamp(currentTime); + subscribe.setUpdateTime(new Date()); + subscribe.setUpdateTimestamp(currentTime); + subscribe.setDeleted(false); + userSubscribeMapper.insert(subscribe); + } + log.info("用户{}订阅授权上报成功,模板ID:{},状态:{}", form.getUserId(), form.getTemplateId(), form.getStatus()); + } catch (Exception e) { + log.error("上报用户订阅授权失败,请求参数:{}", JSONUtil.toJsonStr(form), e); + throw new MsgException("上报授权状态失败"); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean sendMessage(WxSubscribeSendForm form) { + log.info("开始发送订阅消息,请求参数:{}", JSONUtil.toJsonStr(form)); + try { + // 1. 查询用户openid + String openid = userSubscribeMapper.getOpenidByUserId(form.getUserId()); + if (StrUtil.isBlank(openid)) { + log.error("用户{}未绑定微信openid,无法发送订阅消息", form.getUserId()); + return false; + } + + // 2. 构建微信订阅消息参数(新版小程序订阅消息格式) + Map> dataMap = new HashMap<>(); + for (Map.Entry entry : form.getTemplateParams().entrySet()) { + Map valueMap = new HashMap<>(); + valueMap.put("value", entry.getValue()); + dataMap.put(entry.getKey(), valueMap); + } + + Map messageMap = new HashMap<>(); + messageMap.put("touser", openid); + messageMap.put("template_id", form.getTemplateId()); + messageMap.put("page", form.getPage()); + messageMap.put("miniprogram_state", form.getMiniProgramState()); + messageMap.put("lang", "zh_CN"); + messageMap.put("data", dataMap); + + // 3. 调用微信接口发送 + String responseStr = wxMaService.post("https://api.weixin.qq.com/cgi-bin/message/subscribe/send", JSONUtil.toJsonStr(messageMap)); + WxApiResponse response = JSONUtil.toBean(responseStr, WxApiResponse.class); + log.info("微信订阅消息发送结果,用户:{},返回:{}", form.getUserId(), JSONUtil.toJsonStr(response)); + + // 4. 根据返回结果更新用户订阅状态 + MiniUserSubscribe subscribe = userSubscribeMapper.selectOne(new LambdaQueryWrapper() + .eq(MiniUserSubscribe::getUserId, form.getUserId()) + .eq(MiniUserSubscribe::getTemplateId, form.getTemplateId()) + .eq(MiniUserSubscribe::getDeleted, 0) + .last("limit 1") + ); + + if (response.getErrcode() == 0) { + log.info("订阅消息发送成功,用户:{},模板ID:{}", form.getUserId(), form.getTemplateId()); + // 如果是同意一次的状态,发送后更新为未授权 + if (subscribe != null && SubscribeStatusEnum.ONCE.getValue().equals(subscribe.getStatus())) { + subscribe.setStatus(SubscribeStatusEnum.REJECT.getValue()); + subscribe.setUpdateTime(new Date()); + userSubscribeMapper.updateById(subscribe); + } + return true; + } else if (response.getErrcode() == 43101) { + // 用户未订阅,更新本地状态为拒绝 + log.warn("用户{}未订阅该模板消息,更新本地状态为拒绝", form.getUserId()); + if (subscribe != null) { + subscribe.setStatus(SubscribeStatusEnum.REJECT.getValue()); + subscribe.setUpdateTime(new Date()); + userSubscribeMapper.updateById(subscribe); + } + return false; + } else { + log.error("订阅消息发送失败,错误码:{},错误信息:{}", response.getErrcode(), response.getErrmsg()); + return false; + } + } catch (Exception e) { + log.error("发送订阅消息异常,请求参数:{}", JSONUtil.toJsonStr(form), e); + return false; + } + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cd259e1..0464970 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -92,6 +92,10 @@ security: - /api/v1/mini/homePage/listByBounds - /healthcheck - /api/v1/admin/auditConfig/images/callback #测试OSS图片审核回调 + - /api/v1/mini/ai/generation/single-image/task/callback # AIGeneration 单图任务回调 + - /api/v1/mini/ai/generation/video/task/callback # AIGeneration 视频任务回调 + - /api/v1/mini/ai/generation/four-panel/task/callback # AIGeneration 四宫格任务回调 + - /api/v1/mini/ai/generation/subscribe/send # AIGeneration 发送订阅消息 # 非安全端点路径,完全绕过 Spring Security 的过滤器 unsecured-urls: - ${springdoc.swagger-ui.path} @@ -225,8 +229,8 @@ captcha: # 微信小程序配置 wx: miniapp: - appid: Your_AppId - secret: Your_AppSecret + appid: wx56425c3301f5c6df + secret: 7c4060199f49b0ab872b06b97cef2ac4 # 布隆过滤器配置 bloom: @@ -251,3 +255,37 @@ audit: callbackUid: 1518801682066998 callbackSeed: cbHOfSbeUXrux3uUgnUF1ScU-1DF6Vb timeout-minutes: 30 + + +# AIGeneration 配置 +ai: + generate: + single-image-server-url: http://127.0.0.1:8001/api/v1/photo-to-comic + four-panel-server-url: http://127.0.0.1:8001/api/v1/four-panel-comic + video-server-url: http://127.0.0.1:8001/api/v1/video/submit + callback: + single-image-callback-url: https://0401-153-34-180-144.ngrok-free.app/backend/api/v1/mini/ai/generation/single-image/task/callback + four-panel-callback-url: https://0401-153-34-180-144.ngrok-free.app/backend/api/v1/mini/ai/generation/four-panel/task/callback + #需要内网穿透工具 由火山方舟 api 回调,ngrok http 30101 替换为内网穿透工具地址 + video-url: https://0401-153-34-180-144.ngrok-free.app/backend/api/v1/mini/ai/generation/video/task/callback + default: + image-model: doubao-seedream-5-0-260128 + video-model: doubao-seedance-2-0-260128 + task: + # AI任务超时时间 单位分钟,默认300分钟 + timeout-minutes: 300 + # AI任务状态查询接口 + status: + single-image-url: http://127.0.0.1:8001/api/v1/photo-to-comic/status/ + four-panel-url: http://127.0.0.1:8001/api/v1/four-panel-comic/status/ + video-url: http://127.0.0.1:8001/api/v1/video/status/ + # 手动同步最小间隔(分钟),任务创建后至少等待此时间才能手动同步 + sync-min-interval-minutes: 30 + +# 订阅模板配置 +subscribe: + template: "7m5Vu4gaCo2zY6hzID4yqEv94y1guuOGxdPOJYV_xHE" + # developer=开发版 trial=体验版 formal=正式版 + miniProgramState: "developer" + + diff --git a/src/main/resources/mapper/mini/MiniAiGenerationTaskMapper.xml b/src/main/resources/mapper/mini/MiniAiGenerationTaskMapper.xml new file mode 100644 index 0000000..d01050b --- /dev/null +++ b/src/main/resources/mapper/mini/MiniAiGenerationTaskMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/main/resources/mapper/mini/MiniAiTaskMediaMapper.xml b/src/main/resources/mapper/mini/MiniAiTaskMediaMapper.xml new file mode 100644 index 0000000..2c91340 --- /dev/null +++ b/src/main/resources/mapper/mini/MiniAiTaskMediaMapper.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/src/main/resources/mapper/mini/MiniUserSubscribeMapper.xml b/src/main/resources/mapper/mini/MiniUserSubscribeMapper.xml new file mode 100644 index 0000000..3018fa3 --- /dev/null +++ b/src/main/resources/mapper/mini/MiniUserSubscribeMapper.xml @@ -0,0 +1,15 @@ + + + + + + + +