commit cace369da2bd11c76f02a5898814ee8ec07111bd Author: wangys <3401275564@qq.com> Date: Fri Feb 13 17:15:00 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b81fe1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ + +# Logs +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Build +build/ +out/ + +# Temp +*.tmp +*.bak +*.swp + +# 敏感配置文件(本地环境配置不要提交) +application-local.yml +application-local.properties +*-local.yml +*-local.properties diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3501ac --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# 1818AI 微信小程序后端服务 + +## 项目简介 + +基于 Spring Boot 的微信小程序后端服务。 + +## 技术栈 + +- Java +- Spring Boot +- Maven + +## 快速开始 + +```bash +# 安装依赖 +mvn install + +# 启动服务 +mvn spring-boot:run +``` + +## 目录结构 + +``` +├── src/ # 源代码 +├── docs/ # 文档 +├── pom.xml # Maven 配置 +└── README.md # 项目说明 +``` + +## License + +MIT diff --git a/docs/CODE_STANDARDS.md b/docs/CODE_STANDARDS.md new file mode 100644 index 0000000..8756949 --- /dev/null +++ b/docs/CODE_STANDARDS.md @@ -0,0 +1,427 @@ +# 代码开发规范 + +## 一、项目结构规范 + +``` +src/main/java/com/dora/ +├── WeixinApplication.java # 启动类 +├── common/ # 公共模块 +│ ├── constant/ # 常量定义 +│ ├── enums/ # 枚举类 +│ ├── exception/ # 异常处理 +│ └── result/ # 统一响应 +├── config/ # 配置类 +├── controller/ # 控制器层(接收请求) +├── service/ # 业务服务层 +│ └── impl/ # 服务实现 +├── mapper/ # 数据访问层 +├── entity/ # 数据库实体 +├── dto/ # 数据传输对象(入参) +├── vo/ # 视图对象(出参) +├── util/ # 工具类 +└── aspect/ # 切面 +``` + +## 二、命名规范 + +### 2.1 类命名 +| 类型 | 规范 | 示例 | +|------|------|------| +| Controller | XxxController | UserController | +| Service接口 | XxxService | UserService | +| Service实现 | XxxServiceImpl | UserServiceImpl | +| Mapper | XxxMapper | UserMapper | +| Entity | 与表名对应,驼峰 | User, AiWork | +| DTO | XxxDTO / XxxRequest | UserLoginDTO | +| VO | XxxVO | UserInfoVO | +| 枚举 | XxxEnum | UserStatusEnum | +| 常量 | XxxConstant | RedisConstant | +| 工具类 | XxxUtil | DateUtil | +| 配置类 | XxxConfig | RedisConfig | + +### 2.2 方法命名 +| 操作 | 命名规范 | 示例 | +|------|----------|------| +| 查询单个 | getXxx / findXxx | getUserById | +| 查询列表 | listXxx | listUserByStatus | +| 分页查询 | pageXxx | pageUser | +| 新增 | save / add / create | saveUser | +| 修改 | update / modify | updateUser | +| 删除 | delete / remove | deleteUser | +| 统计 | countXxx | countUserByVip | +| 判断 | isXxx / hasXxx | isVipUser | + +### 2.3 变量命名 +- 使用小驼峰:`userId`, `orderNo` +- 布尔类型:`isXxx`, `hasXxx`, `canXxx` +- 集合类型:复数形式 `users`, `orderList` +- 常量:全大写下划线分隔 `MAX_RETRY_COUNT` + +## 三、Controller规范 + +```java +@Slf4j +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + /** + * 获取用户信息 + * + * @param id 用户ID + * @return 用户信息 + */ + @GetMapping("/{id}") + public Result getById(@PathVariable Long id) { + return Result.success(userService.getById(id)); + } + + /** + * 分页查询用户 + */ + @GetMapping("/page") + public Result> page(UserPageDTO dto) { + return Result.success(userService.page(dto)); + } + + /** + * 新增用户 + */ + @PostMapping + public Result save(@RequestBody @Valid UserSaveDTO dto) { + return Result.success(userService.save(dto)); + } + + /** + * 更新用户 + */ + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody @Valid UserUpdateDTO dto) { + userService.update(id, dto); + return Result.success(); + } + + /** + * 删除用户 + */ + @DeleteMapping("/{id}") + public Result delete(@PathVariable Long id) { + userService.delete(id); + return Result.success(); + } +} +``` + +### Controller规则 +1. 使用 `@RequiredArgsConstructor` 构造器注入,禁止 `@Autowired` +2. 方法必须有注释说明 +3. 入参使用 DTO,出参使用 VO +4. 参数校验使用 `@Valid` +5. 统一返回 `Result` +6. 不写业务逻辑,只做参数接收和结果返回 + +## 四、Service规范 + +```java +public interface UserService { + + UserVO getById(Long id); + + IPage page(UserPageDTO dto); + + Long save(UserSaveDTO dto); + + void update(Long id, UserUpdateDTO dto); + + void delete(Long id); +} +``` + +```java +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserMapper userMapper; + private final RedisUtil redisUtil; + + @Override + public UserVO getById(Long id) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + return convertToVO(user); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long save(UserSaveDTO dto) { + // 1. 参数校验 + checkPhoneExists(dto.getPhone()); + + // 2. 构建实体 + User user = new User(); + BeanUtils.copyProperties(dto, user); + + // 3. 保存数据 + userMapper.insert(user); + + // 4. 返回结果 + return user.getId(); + } +} +``` + +### Service规则 +1. 接口与实现分离 +2. 事务注解加在实现类方法上:`@Transactional(rollbackFor = Exception.class)` +3. 复杂业务逻辑拆分为私有方法 +4. 异常使用 `BusinessException` 抛出 +5. 日志记录关键操作 + +## 五、Entity规范 + +```java +@Data +@TableName("user") +public class User { + + @TableId(type = IdType.AUTO) + private Long id; + + private String openid; + + private String nickname; + + private String phone; + + private Integer vipLevel; + + private LocalDateTime vipExpireTime; + + private Integer points; + + private Integer status; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createdAt; + + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} +``` + +### Entity规则 +1. 使用 `@Data` 简化代码 +2. 字段类型与数据库对应 +3. 时间类型使用 `LocalDateTime` +4. 逻辑删除字段使用 `@TableLogic` +5. 禁止在Entity中写业务方法 + +## 六、DTO/VO规范 + +```java +// 入参DTO +@Data +public class UserSaveDTO { + + @NotBlank(message = "手机号不能为空") + @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") + private String phone; + + @NotBlank(message = "昵称不能为空") + @Size(max = 64, message = "昵称最长64个字符") + private String nickname; +} + +// 出参VO +@Data +public class UserVO { + + private Long id; + + private String nickname; + + private String avatar; + + private Integer vipLevel; + + private String vipLevelName; + + private Integer points; + + private LocalDateTime createdAt; +} +``` + +### DTO/VO规则 +1. DTO用于接收入参,必须加校验注解 +2. VO用于返回数据,可添加格式化字段 +3. 禁止直接返回Entity +4. 敏感字段(密码、token)不放入VO + +## 七、Mapper规范 + +```java +@Mapper +public interface UserMapper extends BaseMapper { + + /** + * 根据手机号查询用户 + */ + User selectByPhone(@Param("phone") String phone); + + /** + * 批量更新状态 + */ + int batchUpdateStatus(@Param("ids") List ids, @Param("status") Integer status); +} +``` + +### Mapper规则 +1. 继承 `BaseMapper` 使用通用方法 +2. 复杂SQL写在XML文件中 +3. 参数使用 `@Param` 注解 +4. 禁止在Mapper中写业务逻辑 + +## 八、异常处理规范 + +```java +// 抛出业务异常 +throw new BusinessException(ResultCode.USER_NOT_FOUND); +throw new BusinessException("自定义错误信息"); +throw new BusinessException(4001, "自定义错误码和信息"); + +// 参数校验 +if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); +} + +// 使用断言(推荐) +Assert.notNull(user, "用户不存在"); +``` + +## 九、日志规范 + +```java +@Slf4j +public class UserServiceImpl { + + public void doSomething() { + // DEBUG:调试信息 + log.debug("查询用户参数: {}", dto); + + // INFO:关键业务操作 + log.info("用户注册成功, userId={}, phone={}", user.getId(), user.getPhone()); + + // WARN:警告信息 + log.warn("用户登录失败次数过多, userId={}", userId); + + // ERROR:异常错误(带异常堆栈) + log.error("调用第三方接口失败, url={}", url, e); + } +} +``` + +### 日志规则 +1. 使用 `@Slf4j` 注解 +2. 使用占位符 `{}`,禁止字符串拼接 +3. ERROR日志必须带异常对象 +4. 敏感信息脱敏处理 + +## 十、数据库规范 + +### 10.1 连接池配置 +项目使用 HikariCP 连接池,已优化配置: +- 最小空闲连接:10 +- 最大连接数:50 +- 空闲超时:10分钟 +- 连接最大存活:30分钟 +- 泄漏检测:60秒 + +### 10.2 SQL规范 +1. 禁止 `SELECT *`,明确指定字段 +2. 大表查询必须走索引 +3. 禁止在循环中执行SQL +4. 批量操作使用 `saveBatch` +5. 分页查询必须有排序字段 + +```java +// 错误示例 +for (Long id : ids) { + userMapper.selectById(id); // N+1问题 +} + +// 正确示例 +List users = userMapper.selectBatchIds(ids); +``` + +### 10.3 事务规范 +```java +// 只读事务 +@Transactional(readOnly = true) +public UserVO getById(Long id) { } + +// 写事务 +@Transactional(rollbackFor = Exception.class) +public void save(UserDTO dto) { } +``` + +## 十一、接口规范 + +### 11.1 RESTful规范 +| 操作 | 方法 | 路径 | 示例 | +|------|------|------|------| +| 查询 | GET | /资源 | GET /users | +| 详情 | GET | /资源/{id} | GET /users/1 | +| 新增 | POST | /资源 | POST /users | +| 修改 | PUT | /资源/{id} | PUT /users/1 | +| 删除 | DELETE | /资源/{id} | DELETE /users/1 | + +### 11.2 响应格式 +```json +{ + "code": 200, + "message": "操作成功", + "data": {}, + "timestamp": 1704067200000 +} +``` + +## 十二、安全规范 + +1. 敏感配置使用环境变量或配置中心 +2. 密码使用 BCrypt 加密存储 +3. 接口做权限校验 +4. 防止SQL注入,使用参数化查询 +5. 防止XSS,对输出内容转义 +6. 日志脱敏处理敏感信息 + +## 十三、Git提交规范 + +``` +(): + +feat: 新功能 +fix: 修复bug +docs: 文档更新 +style: 代码格式 +refactor: 重构 +test: 测试 +chore: 构建/工具 +``` + +示例: +``` +feat(user): 添加用户注册功能 +fix(order): 修复订单金额计算错误 +docs(readme): 更新部署文档 +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..952132a --- /dev/null +++ b/pom.xml @@ -0,0 +1,166 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + + com.dora + weixin-backend + 1.0.0-SNAPSHOT + jar + weixin-backend + 1818AIGC微信后端服务 + + + 17 + UTF-8 + UTF-8 + 3.5.5 + 5.6.191 + 0.12.5 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + com.qcloud + cos_api + ${cos.version} + + + + + com.tencentcloudapi + tencentcloud-sdk-java-vod + 3.1.1411 + + + + + org.projectlombok + lombok + true + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + + org.springframework.security + spring-security-crypto + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework.boot + spring-boot-starter-mail + + + + + com.github.binarywang + weixin-java-pay + 4.6.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/dora/WeixinApplication.java b/src/main/java/com/dora/WeixinApplication.java new file mode 100644 index 0000000..13ed622 --- /dev/null +++ b/src/main/java/com/dora/WeixinApplication.java @@ -0,0 +1,19 @@ +package com.dora; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 微信后端服务启动类 + * + * @author dora + */ +@SpringBootApplication +@EnableScheduling +public class WeixinApplication { + + public static void main(String[] args) { + SpringApplication.run(WeixinApplication.class, args); + } +} diff --git a/src/main/java/com/dora/annotation/RequirePermission.java b/src/main/java/com/dora/annotation/RequirePermission.java new file mode 100644 index 0000000..c4f6821 --- /dev/null +++ b/src/main/java/com/dora/annotation/RequirePermission.java @@ -0,0 +1,22 @@ +package com.dora.annotation; + +import java.lang.annotation.*; + +/** + * 权限校验注解 + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequirePermission { + + /** + * 需要的权限码 + */ + String[] value() default {}; + + /** + * 是否需要全部权限(AND逻辑),默认false(OR逻辑) + */ + boolean requireAll() default false; +} diff --git a/src/main/java/com/dora/aspect/PermissionAspect.java b/src/main/java/com/dora/aspect/PermissionAspect.java new file mode 100644 index 0000000..4494f0a --- /dev/null +++ b/src/main/java/com/dora/aspect/PermissionAspect.java @@ -0,0 +1,78 @@ +package com.dora.aspect; + +import com.dora.annotation.RequirePermission; +import com.dora.common.context.AdminContext; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.mapper.AdminMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +/** + * 权限校验切面 + */ +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class PermissionAspect { + + private final AdminMapper adminMapper; + + @Around("@annotation(com.dora.annotation.RequirePermission)") + public Object checkPermission(ProceedingJoinPoint joinPoint) throws Throwable { + // 获取注解 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RequirePermission annotation = method.getAnnotation(RequirePermission.class); + + if (annotation == null || annotation.value().length == 0) { + return joinPoint.proceed(); + } + + // 获取当前管理员ID + Long adminId = AdminContext.getAdminId(); + if (adminId == null) { + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + // 获取管理员权限 + List permissions = adminMapper.selectPermissionCodesByAdminId(adminId); + + // 超级管理员拥有所有权限 + List roles = adminMapper.selectRoleCodesByAdminId(adminId); + if (roles.contains("SUPER_ADMIN")) { + return joinPoint.proceed(); + } + + // 校验权限 + String[] requiredPermissions = annotation.value(); + boolean hasPermission; + + if (annotation.requireAll()) { + // 需要全部权限 + hasPermission = permissions.containsAll(Arrays.asList(requiredPermissions)); + } else { + // 只需要其中一个权限 + hasPermission = Arrays.stream(requiredPermissions) + .anyMatch(permissions::contains); + } + + if (!hasPermission) { + log.warn("权限不足: adminId={}, required={}, has={}", + adminId, Arrays.toString(requiredPermissions), permissions); + throw new BusinessException(ResultCode.FORBIDDEN); + } + + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/dora/common/context/AdminContext.java b/src/main/java/com/dora/common/context/AdminContext.java new file mode 100644 index 0000000..8428fca --- /dev/null +++ b/src/main/java/com/dora/common/context/AdminContext.java @@ -0,0 +1,31 @@ +package com.dora.common.context; + +/** + * 管理员上下文 + */ +public class AdminContext { + + private static final ThreadLocal ADMIN_ID = new ThreadLocal<>(); + private static final ThreadLocal USERNAME = new ThreadLocal<>(); + + public static void setAdminId(Long adminId) { + ADMIN_ID.set(adminId); + } + + public static Long getAdminId() { + return ADMIN_ID.get(); + } + + public static void setUsername(String username) { + USERNAME.set(username); + } + + public static String getUsername() { + return USERNAME.get(); + } + + public static void clear() { + ADMIN_ID.remove(); + USERNAME.remove(); + } +} diff --git a/src/main/java/com/dora/common/context/UserContext.java b/src/main/java/com/dora/common/context/UserContext.java new file mode 100644 index 0000000..b4a6a3e --- /dev/null +++ b/src/main/java/com/dora/common/context/UserContext.java @@ -0,0 +1,46 @@ +package com.dora.common.context; + +/** + * 用户上下文,存储当前请求的用户信息 + */ +public class UserContext { + + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal OPENID = new ThreadLocal<>(); + + /** + * 设置用户ID + */ + public static void setUserId(Long userId) { + USER_ID.set(userId); + } + + /** + * 获取用户ID + */ + public static Long getUserId() { + return USER_ID.get(); + } + + /** + * 设置openid + */ + public static void setOpenid(String openid) { + OPENID.set(openid); + } + + /** + * 获取openid + */ + public static String getOpenid() { + return OPENID.get(); + } + + /** + * 清理上下文 + */ + public static void clear() { + USER_ID.remove(); + OPENID.remove(); + } +} diff --git a/src/main/java/com/dora/common/exception/BusinessException.java b/src/main/java/com/dora/common/exception/BusinessException.java new file mode 100644 index 0000000..5ce930b --- /dev/null +++ b/src/main/java/com/dora/common/exception/BusinessException.java @@ -0,0 +1,35 @@ +package com.dora.common.exception; + +import com.dora.common.result.ResultCode; +import lombok.Getter; + +/** + * 业务异常 + * + * @author dora + */ +@Getter +public class BusinessException extends RuntimeException { + + private final Integer code; + + public BusinessException(String message) { + super(message); + this.code = ResultCode.FAIL.getCode(); + } + + public BusinessException(Integer code, String message) { + super(message); + this.code = code; + } + + public BusinessException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.code = resultCode.getCode(); + } + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.code = resultCode.getCode(); + } +} diff --git a/src/main/java/com/dora/common/exception/GlobalExceptionHandler.java b/src/main/java/com/dora/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d9f278f --- /dev/null +++ b/src/main/java/com/dora/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,78 @@ +package com.dora.common.exception; + +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理器 + * + * @author dora + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 业务异常 + */ + @ExceptionHandler(BusinessException.class) + @ResponseStatus(HttpStatus.OK) + public Result handleBusinessException(BusinessException e) { + log.warn("业务异常: {}", e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } + + /** + * 参数校验异常 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldError() != null + ? e.getBindingResult().getFieldError().getDefaultMessage() + : "参数校验失败"; + log.warn("参数校验异常: {}", message); + return Result.fail(ResultCode.PARAM_ERROR.getCode(), message); + } + + /** + * 绑定异常 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBindException(BindException e) { + String message = e.getFieldError() != null + ? e.getFieldError().getDefaultMessage() + : "参数绑定失败"; + log.warn("参数绑定异常: {}", message); + return Result.fail(ResultCode.PARAM_ERROR.getCode(), message); + } + + /** + * 约束违反异常 + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleConstraintViolationException(ConstraintViolationException e) { + log.warn("约束违反异常: {}", e.getMessage()); + return Result.fail(ResultCode.PARAM_ERROR.getCode(), e.getMessage()); + } + + /** + * 其他异常 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) { + log.error("系统异常: ", e); + return Result.fail(ResultCode.INTERNAL_ERROR); + } +} diff --git a/src/main/java/com/dora/common/result/Result.java b/src/main/java/com/dora/common/result/Result.java new file mode 100644 index 0000000..177b7a2 --- /dev/null +++ b/src/main/java/com/dora/common/result/Result.java @@ -0,0 +1,59 @@ +package com.dora.common.result; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 统一响应结果 + * + * @param 数据类型 + * @author dora + */ +@Data +public class Result implements Serializable { + + private static final long serialVersionUID = 1L; + + private Integer code; + private String message; + private T data; + private Long timestamp; + + public Result() { + this.timestamp = System.currentTimeMillis(); + } + + public static Result success() { + return success(null); + } + + public static Result success(T data) { + Result result = new Result<>(); + result.setCode(ResultCode.SUCCESS.getCode()); + result.setMessage(ResultCode.SUCCESS.getMessage()); + result.setData(data); + return result; + } + + public static Result fail(String message) { + Result result = new Result<>(); + result.setCode(ResultCode.FAIL.getCode()); + result.setMessage(message); + return result; + } + + public static Result fail(ResultCode resultCode) { + Result result = new Result<>(); + result.setCode(resultCode.getCode()); + result.setMessage(resultCode.getMessage()); + return result; + } + + public static Result fail(Integer code, String message) { + Result result = new Result<>(); + result.setCode(code); + result.setMessage(message); + return result; + } +} diff --git a/src/main/java/com/dora/common/result/ResultCode.java b/src/main/java/com/dora/common/result/ResultCode.java new file mode 100644 index 0000000..9c6fcfe --- /dev/null +++ b/src/main/java/com/dora/common/result/ResultCode.java @@ -0,0 +1,81 @@ +package com.dora.common.result; + +import lombok.Getter; + +/** + * 响应状态码枚举 + * + * @author dora + */ +@Getter +public enum ResultCode { + + // ========== 成功 ========== + SUCCESS(0, "操作成功"), + + // ========== 通用失败 ========== + FAIL(1, "操作失败"), + + // ========== 客户端错误 1xxx ========== + PARAM_ERROR(1001, "参数错误"), + PARAM_MISSING(1002, "参数缺失"), + PARAM_TYPE_ERROR(1003, "参数类型错误"), + PARAM_VALID_ERROR(1004, "参数校验失败"), + + // ========== 认证授权 2xxx ========== + UNAUTHORIZED(2001, "未登录或登录已过期"), + TOKEN_INVALID(2002, "Token无效"), + TOKEN_EXPIRED(2003, "Token已过期"), + FORBIDDEN(2004, "无权限访问"), + ACCOUNT_DISABLED(2005, "账号已被禁用"), + ACCOUNT_LOCKED(2006, "账号已被锁定"), + LOGIN_FAILED(2007, "用户名或密码错误"), + + // ========== 用户相关 3xxx ========== + USER_NOT_FOUND(3001, "用户不存在"), + USER_ALREADY_EXISTS(3002, "用户已存在"), + USER_PASSWORD_ERROR(3003, "密码错误"), + USER_PHONE_EXISTS(3004, "手机号已被注册"), + USER_EMAIL_EXISTS(3005, "邮箱已被注册"), + + // ========== 业务错误 4xxx ========== + DATA_NOT_FOUND(4001, "数据不存在"), + DATA_ALREADY_EXISTS(4002, "数据已存在"), + DATA_SAVE_FAILED(4003, "数据保存失败"), + DATA_UPDATE_FAILED(4004, "数据更新失败"), + DATA_DELETE_FAILED(4005, "数据删除失败"), + OPERATION_FAILED(4006, "操作失败"), + OPERATION_NOT_ALLOWED(4007, "操作不允许"), + OPERATION_TOO_FREQUENT(4008, "操作过于频繁,请稍后再试"), + VERIFICATION_CODE_ERROR(4009, "验证码错误或已过期"), + EMAIL_SEND_FAILED(4010, "邮件发送失败"), + + // ========== 微信相关 5xxx ========== + WECHAT_AUTH_FAILED(5001, "微信授权失败"), + WECHAT_CODE_INVALID(5002, "微信code无效"), + WECHAT_USER_NOT_BOUND(5003, "微信用户未绑定"), + WECHAT_API_ERROR(5004, "微信接口调用失败"), + + // ========== 文件相关 6xxx ========== + FILE_NOT_FOUND(6001, "文件不存在"), + FILE_UPLOAD_FAILED(6002, "文件上传失败"), + FILE_TYPE_NOT_ALLOWED(6003, "文件类型不允许"), + FILE_SIZE_EXCEEDED(6004, "文件大小超出限制"), + + // ========== 系统错误 9xxx ========== + INTERNAL_ERROR(9000, "系统内部错误"), + SYSTEM_ERROR(9001, "系统错误"), + SERVICE_UNAVAILABLE(9002, "服务不可用"), + NETWORK_ERROR(9003, "网络异常"), + DATABASE_ERROR(9004, "数据库异常"), + THIRD_PARTY_ERROR(9005, "第三方服务异常"), + RATE_LIMIT_EXCEEDED(9006, "请求过于频繁"); + + private final Integer code; + private final String message; + + ResultCode(Integer code, String message) { + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/dora/config/CosConfig.java b/src/main/java/com/dora/config/CosConfig.java new file mode 100644 index 0000000..5aaad80 --- /dev/null +++ b/src/main/java/com/dora/config/CosConfig.java @@ -0,0 +1,47 @@ +package com.dora.config; + +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.region.Region; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 腾讯云 COS 配置 + * + * @author dora + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "tencent.cos") +public class CosConfig { + + private String secretId; + private String secretKey; + private String region; + private String bucketName; + private String userImgFolder; + private Integer expirationSeconds; + private String customDomain; + + @Bean + public COSClient cosClient() { + COSCredentials credentials = new BasicCOSCredentials(secretId, secretKey); + ClientConfig clientConfig = new ClientConfig(new Region(region)); + return new COSClient(credentials, clientConfig); + } + + /** + * 获取文件访问URL + */ + public String getFileUrl(String key) { + if (customDomain != null && !customDomain.isEmpty()) { + return customDomain + "/" + key; + } + return String.format("https://%s.cos.%s.myqcloud.com/%s", bucketName, region, key); + } +} diff --git a/src/main/java/com/dora/config/DatabaseInitConfig.java b/src/main/java/com/dora/config/DatabaseInitConfig.java new file mode 100644 index 0000000..135754e --- /dev/null +++ b/src/main/java/com/dora/config/DatabaseInitConfig.java @@ -0,0 +1,63 @@ +package com.dora.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; + +import javax.sql.DataSource; + +/** + * 数据库初始化配置 + * 每次启动时执行 schema.sql 和 data.sql + */ +@Slf4j +@Configuration +public class DatabaseInitConfig { + + @Value("${spring.sql.init.mode:never}") + private String initMode; + + @Bean + public CommandLineRunner databaseInitializer(DataSource dataSource) { + return args -> { + if (!"always".equalsIgnoreCase(initMode)) { + log.info("数据库初始化已禁用 (mode={})", initMode); + return; + } + + log.info("========== 开始初始化数据库 =========="); + + try { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); + populator.setSqlScriptEncoding("UTF-8"); + populator.setSeparator(";"); + populator.setContinueOnError(false); + + // 执行 schema.sql(删除并重建表) + ClassPathResource schemaResource = new ClassPathResource("db/schema.sql"); + if (schemaResource.exists()) { + log.info("执行 schema.sql ..."); + populator.addScript(schemaResource); + } + + // 执行 data.sql(插入初始数据) + ClassPathResource dataResource = new ClassPathResource("db/data.sql"); + if (dataResource.exists()) { + log.info("执行 data.sql ..."); + populator.addScript(dataResource); + } + + populator.execute(dataSource); + + log.info("========== 数据库初始化完成 =========="); + } catch (Exception e) { + log.error("数据库初始化失败: {}", e.getMessage(), e); + throw new RuntimeException("数据库初始化失败", e); + } + }; + } +} diff --git a/src/main/java/com/dora/config/MyBatisPlusConfig.java b/src/main/java/com/dora/config/MyBatisPlusConfig.java new file mode 100644 index 0000000..a67df3e --- /dev/null +++ b/src/main/java/com/dora/config/MyBatisPlusConfig.java @@ -0,0 +1,28 @@ +package com.dora.config; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MyBatis Plus 配置 + * + * @author dora + */ +@Configuration +@MapperScan("com.dora.mapper") +public class MyBatisPlusConfig { + + /** + * 分页插件 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); + return interceptor; + } +} diff --git a/src/main/java/com/dora/config/RedisConfig.java b/src/main/java/com/dora/config/RedisConfig.java new file mode 100644 index 0000000..dba8f78 --- /dev/null +++ b/src/main/java/com/dora/config/RedisConfig.java @@ -0,0 +1,46 @@ +package com.dora.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置 + * + * @author dora + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + // JSON 序列化配置 + ObjectMapper mapper = new ObjectMapper(); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); + mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL); + + Jackson2JsonRedisSerializer jsonSerializer = new Jackson2JsonRedisSerializer<>(mapper, Object.class); + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + + // key 使用 String 序列化 + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + // value 使用 JSON 序列化 + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/main/java/com/dora/config/RestTemplateConfig.java b/src/main/java/com/dora/config/RestTemplateConfig.java new file mode 100644 index 0000000..5b93844 --- /dev/null +++ b/src/main/java/com/dora/config/RestTemplateConfig.java @@ -0,0 +1,21 @@ +package com.dora.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +/** + * RestTemplate配置 + */ +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(30000); // 连接超时30秒 + factory.setReadTimeout(120000); // 读取超时120秒(AI生成需要较长时间) + return new RestTemplate(factory); + } +} diff --git a/src/main/java/com/dora/config/SwaggerConfig.java b/src/main/java/com/dora/config/SwaggerConfig.java new file mode 100644 index 0000000..4e210bd --- /dev/null +++ b/src/main/java/com/dora/config/SwaggerConfig.java @@ -0,0 +1,63 @@ +package com.dora.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +/** + * Swagger/OpenAPI 配置 + */ +@Configuration +public class SwaggerConfig { + + @Value("${swagger.server-url:}") + private String serverUrl; + + @Bean + public OpenAPI openAPI() { + OpenAPI openAPI = new OpenAPI() + .info(new Info() + .title("1818AIGC 微信小程序后端 API 文档") + .description("1818AIGC 微信小程序后端接口文档,包含用户认证、作品管理、分类管理等模块") + .version("1.0.0") + .contact(new Contact() + .name("1818AIGC 技术团队") + .email("support@1818aigc.com")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0"))) + .components(new Components() + .addSecuritySchemes("Bearer认证", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT令牌认证,请在值中填入: Bearer {你的token}"))) + .addSecurityItem(new SecurityRequirement().addList("Bearer认证")); + + // 配置服务器地址,解决HTTPS环境下Swagger请求HTTP的问题 + if (serverUrl != null && !serverUrl.isEmpty()) { + openAPI.servers(List.of( + new Server().url(serverUrl).description("生产环境 (HTTPS)"), + new Server().url("http://localhost:8080/api").description("本地开发环境") + )); + } else { + // 默认配置,防止未设置时使用错误的协议 + openAPI.servers(List.of( + new Server().url("https://api.1818ai.com/api").description("生产环境 (HTTPS)"), + new Server().url("http://localhost:8080/api").description("本地开发环境") + )); + } + + return openAPI; + } +} diff --git a/src/main/java/com/dora/config/WebConfig.java b/src/main/java/com/dora/config/WebConfig.java new file mode 100644 index 0000000..227c407 --- /dev/null +++ b/src/main/java/com/dora/config/WebConfig.java @@ -0,0 +1,155 @@ +package com.dora.config; + +import com.dora.interceptor.AdminAuthInterceptor; +import com.dora.interceptor.JwtAuthInterceptor; +import com.dora.interceptor.SwaggerAuthInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.boot.web.servlet.MultipartConfigFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.util.unit.DataSize; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.multipart.MultipartResolver; +import org.springframework.web.multipart.support.StandardServletMultipartResolver; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import jakarta.servlet.MultipartConfigElement; +import java.util.Arrays; + +/** + * Web配置 + * + * @author dora + */ +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final JwtAuthInterceptor jwtAuthInterceptor; + private final SwaggerAuthInterceptor swaggerAuthInterceptor; + private final AdminAuthInterceptor adminAuthInterceptor; + + /** + * 配置文件上传解析器 + */ + @Bean + public MultipartResolver multipartResolver() { + return new StandardServletMultipartResolver(); + } + + /** + * CORS 过滤器 - 优先级最高,确保在拦截器之前处理跨域 + */ + @Bean + public FilterRegistrationBean corsFilterRegistration() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(Arrays.asList("*")); + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + config.setAllowedHeaders(Arrays.asList("*")); + config.setExposedHeaders(Arrays.asList("*")); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source)); + // 设置最高优先级,确保在所有拦截器之前执行 + bean.setOrder(Ordered.HIGHEST_PRECEDENCE); + return bean; + } + + /** + * 配置文件上传大小限制 + */ + @Bean + public MultipartConfigElement multipartConfigElement() { + MultipartConfigFactory factory = new MultipartConfigFactory(); + // 设置单个文件最大大小为10MB + factory.setMaxFileSize(DataSize.ofMegabytes(10)); + // 设置总上传数据最大大小为10MB + factory.setMaxRequestSize(DataSize.ofMegabytes(10)); + return factory.createMultipartConfig(); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .exposedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + // 确保路径匹配不使用后缀模式 + configurer.setUseTrailingSlashMatch(false); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // Swagger 访问密码拦截器(优先级最高) + registry.addInterceptor(swaggerAuthInterceptor) + .addPathPatterns("/swagger-ui.html", "/swagger-ui/**", "/swagger-ui/index.html") + .excludePathPatterns("/v3/api-docs/**", "/swagger-resources/**", "/webjars/**") + .order(0); + + // 管理员认证拦截器 + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/admin/**") + .excludePathPatterns( + "/admin/auth/login", + "/admin/auth/login/email", + "/admin/auth/send-code", + "/admin/auth/register" + ) + .order(2); + + // JWT 认证拦截器(小程序用户) + registry.addInterceptor(jwtAuthInterceptor) + .addPathPatterns("/**") + // 排除不需要认证的接口 + .excludePathPatterns( + // 管理端接口(由AdminAuthInterceptor处理) + "/admin/**", + // 登录相关 + "/user/check", + "/user/wx-login", + "/user/refresh-token", + // 公开接口 - 作品相关(/work/*匹配/work/{id}和/work/list,不匹配/work/{id}/like) + "/work/*", + // 分类和Banner + "/category/**", + "/banner/**", + // AI模型列表(公开) + "/ai/models", + "/ai/models/home", + "/ai/tasks/public/**", + "/ai/tasks/no/**", // 支持公开访问已完成的任务 + // 积分套餐(公开)和支付回调 + "/points/packages", + "/points/notify/**", + // 头像上传(登录流程中使用) + "/user/upload-avatar", + // Swagger文档 + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + // 错误页面 + "/error" + ) + .order(3); + } +} diff --git a/src/main/java/com/dora/config/WechatConfig.java b/src/main/java/com/dora/config/WechatConfig.java new file mode 100644 index 0000000..40dfc98 --- /dev/null +++ b/src/main/java/com/dora/config/WechatConfig.java @@ -0,0 +1,46 @@ +package com.dora.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 微信小程序配置 + * + * @author dora + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "wechat.miniapp") +public class WechatConfig { + + /** + * 小程序AppID + */ + private String appid; + + /** + * 小程序密钥 + */ + private String secret; + + /** + * 获取code2session接口URL + */ + public String getCode2SessionUrl(String code) { + return String.format( + "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + appid, secret, code + ); + } + + /** + * 获取access_token接口URL + */ + public String getAccessTokenUrl() { + return String.format( + "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", + appid, secret + ); + } +} diff --git a/src/main/java/com/dora/config/WxPayConfig.java b/src/main/java/com/dora/config/WxPayConfig.java new file mode 100644 index 0000000..a7a5f54 --- /dev/null +++ b/src/main/java/com/dora/config/WxPayConfig.java @@ -0,0 +1,36 @@ +package com.dora.config; + +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "wx.pay") +public class WxPayConfig { + private String appId; + private String mchId; + private String mchKey; + private String tradeType; + private String notifyUrl; + private String certPath; + + @Bean + public WxPayService wxPayService() { + com.github.binarywang.wxpay.config.WxPayConfig payConfig = + new com.github.binarywang.wxpay.config.WxPayConfig(); + payConfig.setAppId(appId); + payConfig.setMchId(mchId); + payConfig.setMchKey(mchKey); + payConfig.setKeyPath(certPath); + payConfig.setTradeType(tradeType); + payConfig.setNotifyUrl(notifyUrl); + + WxPayService wxPayService = new WxPayServiceImpl(); + wxPayService.setConfig(payConfig); + return wxPayService; + } +} diff --git a/src/main/java/com/dora/controller/AiController.java b/src/main/java/com/dora/controller/AiController.java new file mode 100644 index 0000000..c5e22ec --- /dev/null +++ b/src/main/java/com/dora/controller/AiController.java @@ -0,0 +1,172 @@ +package com.dora.controller; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.dto.AiTaskDTO; +import com.dora.service.AiModelService; +import com.dora.service.AiTaskService; +import com.dora.util.JwtUtil; +import com.dora.vo.AiTaskVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; + +@Tag(name = "AI功能") +@RestController +@RequestMapping("/ai") +@RequiredArgsConstructor +@Slf4j +public class AiController { + + private final AiModelService aiModelService; + private final AiTaskService aiTaskService; + private final JwtUtil jwtUtil; + + @Operation(summary = "获取可用的AI模型列表") + @GetMapping("/models") + public Result getModels(@RequestParam(required = false) String type) { + // 用户端使用简化VO,不返回敏感信息 + return Result.success(aiModelService.getUserModels(type)); + } + + @Operation(summary = "获取模型分类列表(用于资产页面筛选)") + @GetMapping("/models/categories") + public Result getModelCategories() { + // 返回启用状态的模型列表,用于资产页面的分类筛选 + return Result.success(aiModelService.getUserModels(null)); + } + + @Operation(summary = "获取首页展示的模型列表") + @GetMapping("/models/home") + public Result getHomeModels(@RequestParam(defaultValue = "4") Integer limit) { + return Result.success(aiModelService.getHomeModels(limit)); + } + + @Operation(summary = "获取模型详情") + @GetMapping("/models/{id}") + public Result getModel(@PathVariable Long id) { + // 用户端使用简化VO,不返回敏感信息 + try { + return Result.success(aiModelService.getModelSimpleById(id)); + } catch (Exception e) { + return Result.success(null); + } + } + + @Operation(summary = "根据编码获取模型详情") + @GetMapping("/models/code/{code}") + public Result getModelByCode(@PathVariable String code) { + // 用户端使用简化VO,不返回敏感信息 + // 模型不存在时返回null,避免前端批量加载模型时弹错误提示 + try { + return Result.success(aiModelService.getModelSimpleByCode(code)); + } catch (Exception e) { + return Result.success(null); + } + } + + @Operation(summary = "创建AI任务") + @PostMapping("/tasks") + public Result createTask(@Valid @RequestBody AiTaskDTO dto, HttpServletRequest request) { + Long userId = jwtUtil.getUserIdFromRequest(request); + String taskNo = aiTaskService.createTask(userId, dto); + return Result.success(taskNo); + } + + @Operation(summary = "获取我的任务列表") + @GetMapping("/tasks") + public Result getMyTasks(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) Long modelId, + HttpServletRequest request) { + Long userId = jwtUtil.getUserIdFromRequest(request); + Page pageParam = new Page<>(page, size); + return Result.success(aiTaskService.getTaskPage(pageParam, userId, status, modelId)); + } + + @Operation(summary = "获取任务详情") + @GetMapping("/tasks/{id}") + public Result getTask(@PathVariable Long id, HttpServletRequest request) { + Long userId = jwtUtil.getUserIdFromRequest(request); + AiTaskVO task = aiTaskService.getTaskById(id); + + // 检查权限 + if (!task.getUserId().equals(userId)) { + return Result.fail("无权限查看"); + } + + return Result.success(task); + } + + @Operation(summary = "根据任务编号获取任务详情") + @GetMapping("/tasks/no/{taskNo}") + public Result getTaskByNo(@PathVariable String taskNo, HttpServletRequest request) { + log.info("获取任务详情: taskNo={}", taskNo); + + try { + AiTaskVO task = aiTaskService.getTaskByNo(taskNo); + + // 尝试获取用户ID(可能为null,表示未登录用户) + Long userId = null; + try { + userId = jwtUtil.getUserIdFromRequest(request); + } catch (Exception e) { + // 用户未登录,userId保持为null + log.debug("用户未登录访问任务: taskNo={}", taskNo); + } + + // 如果用户已登录且是任务创建者,返回完整信息 + if (userId != null && task.getUserId() != null && task.getUserId().equals(userId)) { + log.info("任务创建者访问: taskNo={}, userId={}", taskNo, userId); + return Result.success(task); + } + + // 如果是未登录用户或其他用户,检查任务是否可以公开访问 + // 只有已完成的任务才能公开访问 + if (task.getStatus() != null && task.getStatus() == 2) { + log.info("公开访问已完成任务: taskNo={}", taskNo); + return Result.success(task); + } else { + log.warn("尝试访问未完成的任务: taskNo={}, status={}", taskNo, task.getStatus()); + return Result.fail("无权限查看"); + } + + } catch (BusinessException e) { + log.warn("业务异常: taskNo={}, message={}", taskNo, e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } catch (Exception e) { + log.error("获取任务详情失败: taskNo={}", taskNo, e); + return Result.fail("系统错误,请稍后重试"); + } + } + + @Operation(summary = "获取任务详情(公开接口,不需要认证)") + @GetMapping("/tasks/public/{id}") + public Result getTaskPublic(@PathVariable Long id) { + AiTaskVO task = aiTaskService.getTaskById(id); + return Result.success(task); + } + + @Operation(summary = "取消任务") + @PostMapping("/tasks/{id}/cancel") + public Result cancelTask(@PathVariable Long id, HttpServletRequest request) { + Long userId = jwtUtil.getUserIdFromRequest(request); + aiTaskService.cancelTask(userId, id); + return Result.success(); + } + + @Operation(summary = "删除任务") + @DeleteMapping("/tasks/{id}") + public Result deleteTask(@PathVariable Long id, HttpServletRequest request) { + Long userId = jwtUtil.getUserIdFromRequest(request); + aiTaskService.deleteTask(userId, id); + return Result.success(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dora/controller/BannerController.java b/src/main/java/com/dora/controller/BannerController.java new file mode 100644 index 0000000..17fd39b --- /dev/null +++ b/src/main/java/com/dora/controller/BannerController.java @@ -0,0 +1,37 @@ +package com.dora.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.dora.common.result.Result; +import com.dora.entity.Banner; +import com.dora.mapper.BannerMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +@Tag(name = "Banner模块") +@RestController +@RequestMapping("/banner") +@RequiredArgsConstructor +public class BannerController { + + private final BannerMapper bannerMapper; + + @Operation(summary = "获取Banner列表") + @GetMapping("/list") + public Result> getBannerList(@RequestParam(defaultValue = "home") String position) { + LocalDateTime now = LocalDateTime.now(); + List banners = bannerMapper.selectList( + new LambdaQueryWrapper() + .eq(Banner::getStatus, 1) + .eq(Banner::getPosition, position) + .and(w -> w.isNull(Banner::getStartTime).or().le(Banner::getStartTime, now)) + .and(w -> w.isNull(Banner::getEndTime).or().ge(Banner::getEndTime, now)) + .orderByDesc(Banner::getSort) + ); + return Result.success(banners); + } +} diff --git a/src/main/java/com/dora/controller/CategoryController.java b/src/main/java/com/dora/controller/CategoryController.java new file mode 100644 index 0000000..79839c9 --- /dev/null +++ b/src/main/java/com/dora/controller/CategoryController.java @@ -0,0 +1,32 @@ +package com.dora.controller; + +import com.dora.common.result.Result; +import com.dora.service.WorkCategoryService; +import com.dora.vo.CategoryVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 分类控制器 + */ +@Tag(name = "分类模块", description = "作品分类相关接口,公开接口无需Token验证") +@RestController +@RequestMapping("/category") +@RequiredArgsConstructor +public class CategoryController { + + private final WorkCategoryService workCategoryService; + + @Operation(summary = "获取分类列表", description = "获取所有启用的作品分类") + @GetMapping("/list") + public Result> list() { + List categories = workCategoryService.listCategories(); + return Result.success(categories); + } +} diff --git a/src/main/java/com/dora/controller/HealthController.java b/src/main/java/com/dora/controller/HealthController.java new file mode 100644 index 0000000..3431cd8 --- /dev/null +++ b/src/main/java/com/dora/controller/HealthController.java @@ -0,0 +1,27 @@ +package com.dora.controller; + +import com.dora.common.result.Result; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + * + * @author dora + */ +@RestController +@RequestMapping("/health") +public class HealthController { + + @GetMapping + public Result> health() { + Map info = new HashMap<>(); + info.put("status", "UP"); + info.put("timestamp", System.currentTimeMillis()); + return Result.success(info); + } +} diff --git a/src/main/java/com/dora/controller/NoticeController.java b/src/main/java/com/dora/controller/NoticeController.java new file mode 100644 index 0000000..131b048 --- /dev/null +++ b/src/main/java/com/dora/controller/NoticeController.java @@ -0,0 +1,225 @@ +package com.dora.controller; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import com.dora.entity.Notice; +import com.dora.entity.NoticeRead; +import com.dora.mapper.NoticeMapper; +import com.dora.mapper.NoticeReadMapper; +import com.dora.common.context.UserContext; +import com.dora.vo.PageVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 用户端公告控制器 + */ +@Tag(name = "公告模块") +@RestController +@RequestMapping("/notice") +@RequiredArgsConstructor +public class NoticeController { + + private final NoticeMapper noticeMapper; + private final NoticeReadMapper noticeReadMapper; + + @Operation(summary = "获取公告列表") + @GetMapping("/list") + public Result> getNoticeList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) Integer type) { + + LocalDateTime now = LocalDateTime.now(); + Page pageParam = new Page<>(page, pageSize); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(Notice::getStatus, 1) + .and(w -> w.isNull(Notice::getStartTime).or().le(Notice::getStartTime, now)) + .and(w -> w.isNull(Notice::getEndTime).or().ge(Notice::getEndTime, now)); + + if (type != null) { + wrapper.eq(Notice::getType, type); + } + + wrapper.orderByDesc(Notice::getIsTop) + .orderByDesc(Notice::getCreatedAt); + + Page result = noticeMapper.selectPage(pageParam, wrapper); + return Result.success(PageVO.of(result.getRecords(), result.getTotal(), page, pageSize)); + } + + @Operation(summary = "获取公告详情") + @GetMapping("/{id}") + public Result getNoticeDetail(@PathVariable Long id) { + Notice notice = noticeMapper.selectById(id); + if (notice == null || notice.getStatus() != 1) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "公告不存在"); + } + + // 增加浏览次数 + notice.setViewCount(notice.getViewCount() == null ? 1 : notice.getViewCount() + 1); + noticeMapper.updateById(notice); + + return Result.success(notice); + } + + @Operation(summary = "获取未读公告数量") + @GetMapping("/unread/count") + public Result getUnreadCount() { + Long userId = UserContext.getUserId(); + LocalDateTime now = LocalDateTime.now(); + + // 获取有效公告ID列表 + List validNotices = noticeMapper.selectList( + new LambdaQueryWrapper() + .select(Notice::getId) + .eq(Notice::getStatus, 1) + .and(w -> w.isNull(Notice::getStartTime).or().le(Notice::getStartTime, now)) + .and(w -> w.isNull(Notice::getEndTime).or().ge(Notice::getEndTime, now)) + ); + + if (validNotices.isEmpty()) { + return Result.success(0L); + } + + Set validNoticeIds = validNotices.stream() + .map(Notice::getId) + .collect(Collectors.toSet()); + + // 获取用户已读公告ID列表 + List readRecords = noticeReadMapper.selectList( + new LambdaQueryWrapper() + .eq(NoticeRead::getUserId, userId) + .in(NoticeRead::getNoticeId, validNoticeIds) + ); + + Set readNoticeIds = readRecords.stream() + .map(NoticeRead::getNoticeId) + .collect(Collectors.toSet()); + + long unreadCount = validNoticeIds.size() - readNoticeIds.size(); + return Result.success(Math.max(0, unreadCount)); + } + + @Operation(summary = "标记公告已读") + @PostMapping("/{id}/read") + public Result markAsRead(@PathVariable Long id) { + Long userId = UserContext.getUserId(); + + // 检查公告是否存在 + Notice notice = noticeMapper.selectById(id); + if (notice == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "公告不存在"); + } + + // 检查是否已读 + NoticeRead existing = noticeReadMapper.selectOne( + new LambdaQueryWrapper() + .eq(NoticeRead::getNoticeId, id) + .eq(NoticeRead::getUserId, userId) + ); + + if (existing == null) { + NoticeRead readRecord = new NoticeRead(); + readRecord.setNoticeId(id); + readRecord.setUserId(userId); + readRecord.setCreatedAt(LocalDateTime.now()); + noticeReadMapper.insert(readRecord); + } + + return Result.success(); + } + + @Operation(summary = "标记所有公告已读") + @PostMapping("/read/all") + public Result markAllAsRead() { + Long userId = UserContext.getUserId(); + LocalDateTime now = LocalDateTime.now(); + + // 获取所有有效公告 + List validNotices = noticeMapper.selectList( + new LambdaQueryWrapper() + .select(Notice::getId) + .eq(Notice::getStatus, 1) + .and(w -> w.isNull(Notice::getStartTime).or().le(Notice::getStartTime, now)) + .and(w -> w.isNull(Notice::getEndTime).or().ge(Notice::getEndTime, now)) + ); + + // 获取用户已读公告 + List readRecords = noticeReadMapper.selectList( + new LambdaQueryWrapper() + .eq(NoticeRead::getUserId, userId) + ); + + Set readNoticeIds = readRecords.stream() + .map(NoticeRead::getNoticeId) + .collect(Collectors.toSet()); + + // 标记未读公告为已读 + for (Notice notice : validNotices) { + if (!readNoticeIds.contains(notice.getId())) { + NoticeRead readRecord = new NoticeRead(); + readRecord.setNoticeId(notice.getId()); + readRecord.setUserId(userId); + readRecord.setCreatedAt(LocalDateTime.now()); + noticeReadMapper.insert(readRecord); + } + } + + return Result.success(); + } + + @Operation(summary = "获取弹窗公告") + @GetMapping("/popup") + public Result> getPopupNotices() { + Long userId = UserContext.getUserId(); + LocalDateTime now = LocalDateTime.now(); + + // 获取需要弹窗且未读的公告 + List popupNotices = noticeMapper.selectList( + new LambdaQueryWrapper() + .eq(Notice::getStatus, 1) + .eq(Notice::getIsPopup, 1) + .and(w -> w.isNull(Notice::getStartTime).or().le(Notice::getStartTime, now)) + .and(w -> w.isNull(Notice::getEndTime).or().ge(Notice::getEndTime, now)) + .orderByDesc(Notice::getLevel) + .orderByDesc(Notice::getCreatedAt) + ); + + if (popupNotices.isEmpty()) { + return Result.success(popupNotices); + } + + // 过滤已读的公告 + Set noticeIds = popupNotices.stream() + .map(Notice::getId) + .collect(Collectors.toSet()); + + List readRecords = noticeReadMapper.selectList( + new LambdaQueryWrapper() + .eq(NoticeRead::getUserId, userId) + .in(NoticeRead::getNoticeId, noticeIds) + ); + + Set readNoticeIds = readRecords.stream() + .map(NoticeRead::getNoticeId) + .collect(Collectors.toSet()); + + List unreadPopupNotices = popupNotices.stream() + .filter(n -> !readNoticeIds.contains(n.getId())) + .collect(Collectors.toList()); + + return Result.success(unreadPopupNotices); + } +} diff --git a/src/main/java/com/dora/controller/PointsController.java b/src/main/java/com/dora/controller/PointsController.java new file mode 100644 index 0000000..5efa8d0 --- /dev/null +++ b/src/main/java/com/dora/controller/PointsController.java @@ -0,0 +1,53 @@ +package com.dora.controller; + +import com.dora.common.result.Result; +import com.dora.dto.CreatePointsOrderDTO; +import com.dora.service.PointsService; +import com.dora.vo.PointsPackageVO; +import com.dora.vo.WxPayOrderVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "积分模块") +@RestController +@RequestMapping("/points") +@RequiredArgsConstructor +public class PointsController { + + private final PointsService pointsService; + + @Operation(summary = "获取积分套餐列表") + @GetMapping("/packages") + public Result> getPackages() { + return Result.success(pointsService.getPackageList()); + } + + @Operation(summary = "创建积分订单") + @PostMapping("/order") + public Result createOrder( + @RequestAttribute("userId") Long userId, + @Valid @RequestBody CreatePointsOrderDTO dto) { + return Result.success(pointsService.createOrder(userId, dto)); + } + + @Operation(summary = "取消订单") + @PostMapping("/order/cancel") + public Result cancelOrder( + @RequestAttribute("userId") Long userId, + @RequestParam String orderNo) { + pointsService.cancelOrder(userId, orderNo); + return Result.success(); + } + + @Operation(summary = "微信支付回调") + @PostMapping("/notify/wechat") + public String wechatNotify(HttpServletRequest request) { + return pointsService.handlePayNotify(request); + } +} diff --git a/src/main/java/com/dora/controller/UploadController.java b/src/main/java/com/dora/controller/UploadController.java new file mode 100644 index 0000000..ca12103 --- /dev/null +++ b/src/main/java/com/dora/controller/UploadController.java @@ -0,0 +1,163 @@ +package com.dora.controller; + +import com.dora.common.result.Result; +import com.dora.config.CosConfig; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Tag(name = "文件上传") +@RestController +@RequestMapping("/upload") +@RequiredArgsConstructor +public class UploadController { + + private final COSClient cosClient; + private final CosConfig cosConfig; + + @Operation(summary = "上传单个图片") + @PostMapping("/image") + public Result uploadImage(@RequestParam("file") MultipartFile file) { + try { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return Result.fail("只能上传图片文件"); + } + + // 验证文件大小(10MB) + if (file.getSize() > 10 * 1024 * 1024) { + return Result.fail("图片大小不能超过10MB"); + } + + String url = uploadToCos(file); + return Result.success(url); + + } catch (Exception e) { + log.error("上传图片失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "上传多个图片") + @PostMapping("/images") + public Result> uploadImages(@RequestParam("files") MultipartFile[] files) { + try { + List urls = new ArrayList<>(); + + for (MultipartFile file : files) { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return Result.fail("只能上传图片文件"); + } + + // 验证文件大小(10MB) + if (file.getSize() > 10 * 1024 * 1024) { + return Result.fail("图片大小不能超过10MB"); + } + + String url = uploadToCos(file); + urls.add(url); + } + + return Result.success(urls); + + } catch (Exception e) { + log.error("上传图片失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "上传视频") + @PostMapping("/video") + public Result uploadVideo(@RequestParam("file") MultipartFile file) { + try { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("video/")) { + return Result.fail("只能上传视频文件"); + } + + // 验证文件大小(100MB) + if (file.getSize() > 100 * 1024 * 1024) { + return Result.fail("视频大小不能超过100MB"); + } + + String url = uploadVideoToCos(file); + return Result.success(url); + + } catch (Exception e) { + log.error("上传视频失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + private String uploadToCos(MultipartFile file) throws IOException { + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String fileName = "user-upload/" + UUID.randomUUID().toString() + extension; + + // 设置元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + + cosClient.putObject(putObjectRequest); + + // 返回文件URL + return cosConfig.getFileUrl(fileName); + } + + private String uploadVideoToCos(MultipartFile file) throws IOException { + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String extension = ".mp4"; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String fileName = "user-video/" + UUID.randomUUID().toString() + extension; + + // 设置元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + + cosClient.putObject(putObjectRequest); + + // 返回文件URL + return cosConfig.getFileUrl(fileName); + } +} diff --git a/src/main/java/com/dora/controller/UserController.java b/src/main/java/com/dora/controller/UserController.java new file mode 100644 index 0000000..bbd11ee --- /dev/null +++ b/src/main/java/com/dora/controller/UserController.java @@ -0,0 +1,261 @@ +package com.dora.controller; + +import com.dora.common.context.UserContext; +import com.dora.common.result.Result; +import com.dora.dto.RefreshTokenDTO; +import com.dora.dto.UpdateProfileDTO; +import com.dora.dto.WxLoginDTO; +import com.dora.service.AiWorkService; +import com.dora.service.UserService; +import com.dora.vo.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +/** + * 用户控制器 + */ +@Tag(name = "用户模块", description = "用户登录、注册、信息管理相关接口") +@RestController +@RequestMapping("/user") +@RequiredArgsConstructor +@Slf4j +public class UserController { + + private final UserService userService; + private final AiWorkService aiWorkService; + + @Operation(summary = "检查用户状态", description = "检查用户是否存在及信息完整度,用于登录前判断") + @PostMapping("/check") + public Result checkUser(@RequestBody Map params) { + String code = params.get("code"); + if (code == null || code.isEmpty()) { + return Result.fail(4001, "code不能为空"); + } + UserCheckVO result = userService.checkUser(code); + return Result.success(result); + } + + @Operation(summary = "微信登录", description = "微信小程序授权登录,支持新用户注册和老用户登录") + @PostMapping("/wx-login") + public Result wxLogin(@RequestBody WxLoginDTO dto) { + LoginVO loginVO = userService.wxLogin(dto); + return Result.success(loginVO); + } + + @Operation(summary = "刷新Token", description = "使用refreshToken刷新accessToken,避免用户重新登录") + @PostMapping("/refresh-token") + public Result refreshToken(@RequestBody RefreshTokenDTO dto) { + if (dto.getRefreshToken() == null || dto.getRefreshToken().isEmpty()) { + return Result.fail(4001, "refreshToken不能为空"); + } + try { + TokenVO tokenVO = userService.refreshToken(dto.getRefreshToken()); + return Result.success(tokenVO); + } catch (RuntimeException e) { + return Result.fail(2001, e.getMessage()); + } + } + + @Operation(summary = "上传头像", description = "将微信临时头像持久化到COS存储") + @PostMapping("/upload-avatar") + public Result uploadAvatar( + @Parameter(description = "头像文件") @RequestParam("file") MultipartFile file) { + + // 获取当前用户信息用于日志 + String currentUserInfo = "未知用户"; + try { + Long userId = com.dora.common.context.UserContext.getUserId(); + if (userId != null) { + currentUserInfo = "用户ID:" + userId; + } + } catch (Exception e) { + log.warn("获取当前用户ID失败", e); + } + + // 记录请求信息 + log.info("=== 头像上传请求开始 ==="); + log.info("当前用户: {}", currentUserInfo); + log.info("文件名: {}", file.getOriginalFilename()); + log.info("文件大小: {} bytes", file.getSize()); + log.info("文件类型: {}", file.getContentType()); + log.info("是否为空: {}", file.isEmpty()); + + if (file.isEmpty()) { + log.warn("头像上传失败: 文件为空"); + return Result.fail(4001, "请选择要上传的文件"); + } + + // 检查文件大小(限制10MB) + if (file.getSize() > 10 * 1024 * 1024) { + log.warn("头像上传失败: 文件过大,大小: {} bytes", file.getSize()); + return Result.fail(4002, "文件大小不能超过10MB"); + } + + // 检查文件类型 + String contentType = file.getContentType(); + if (contentType == null || (!contentType.startsWith("image/"))) { + log.warn("头像上传失败: 文件类型不支持,类型: {}", contentType); + return Result.fail(4003, "只支持图片文件"); + } + + try { + String avatarUrl = userService.uploadAvatar(file); + log.info("=== 头像上传请求成功 ==="); + log.info("用户: {}", currentUserInfo); + log.info("返回URL: {}", avatarUrl); + return Result.success(avatarUrl); + } catch (Exception e) { + log.error("=== 头像上传请求失败 ==="); + log.error("用户: {}", currentUserInfo); + log.error("异常详情:", e); + return Result.fail(5001, "头像上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取用户信息", description = "获取当前登录用户的详细信息") + @GetMapping("/info") + public Result getUserInfo() { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + LoginVO userInfo = userService.getUserInfo(userId); + if (userInfo == null) { + return Result.fail(2002, "用户不存在"); + } + return Result.success(userInfo); + } + + @Operation(summary = "获取用户主页信息", description = "获取用户主页展示的信息,包含统计数据") + @GetMapping("/profile") + public Result getUserProfile() { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + UserProfileVO profile = userService.getUserProfile(userId); + if (profile == null) { + return Result.fail(2002, "用户不存在"); + } + return Result.success(profile); + } + + @Operation(summary = "获取用户发布的作品", description = "分页获取当前用户发布的作品列表") + @GetMapping("/works") + public Result> getUserWorks( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + PageVO page = aiWorkService.getUserWorks(userId, pageNum, pageSize); + return Result.success(page); + } + + @Operation(summary = "获取用户点赞的作品", description = "分页获取当前用户点赞的作品列表") + @GetMapping("/liked-works") + public Result> getUserLikedWorks( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer pageSize) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + PageVO page = aiWorkService.getUserLikedWorks(userId, pageNum, pageSize); + return Result.success(page); + } + + @Operation(summary = "更新用户资料", description = "更新当前用户的昵称和头像") + @PostMapping("/update-profile") + public Result updateProfile(@RequestBody UpdateProfileDTO dto) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + userService.updateProfile(userId, dto.getNickname(), dto.getAvatar()); + return Result.success(); + } + + @Operation(summary = "获取订阅状态", description = "获取当前用户是否已订阅消息通知") + @GetMapping("/subscribed") + public Result isSubscribed() { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + boolean subscribed = userService.isSubscribed(userId); + return Result.success(subscribed); + } + + @Operation(summary = "更新订阅状态", description = "更新当前用户的订阅消息通知状态") + @PostMapping("/subscribed") + public Result updateSubscribed(@RequestBody Map params) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + Boolean subscribed = params.get("subscribed"); + if (subscribed == null) { + return Result.fail(4001, "参数错误"); + } + userService.updateSubscribed(userId, subscribed); + return Result.success(); + } + + @Operation(summary = "获取邀请统计", description = "获取当前用户的邀请统计信息") + @GetMapping("/invite-stats") + public Result getInviteStats() { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + InviteStatsVO stats = userService.getInviteStats(userId); + return Result.success(stats); + } + + @Operation(summary = "获取积分统计", description = "获取当前用户的积分统计信息(订阅积分和赠送积分)") + @GetMapping("/points-stats") + public Result getPointsStats() { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + PointsStatsVO stats = userService.getPointsStats(userId); + return Result.success(stats); + } + + @Operation(summary = "获取邀请记录", description = "分页获取当前用户的邀请记录列表") + @GetMapping("/invite-records") + public Result> getInviteRecords( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + PageVO page = userService.getInviteRecords(userId, pageNum, pageSize); + return Result.success(page); + } + + @Operation(summary = "获取积分记录", description = "分页获取当前用户的积分记录列表") + @GetMapping("/points-records") + public Result> getPointsRecords( + @Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNum, + @Parameter(description = "每页数量") @RequestParam(defaultValue = "20") Integer pageSize, + @Parameter(description = "类型筛选: 1充值 2消费 3赠送 4推广奖励 5签到 6退款,不传则查询全部") @RequestParam(required = false) Integer type) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(2001, "请先登录"); + } + PageVO page = userService.getPointsRecords(userId, pageNum, pageSize, type); + return Result.success(page); + } +} diff --git a/src/main/java/com/dora/controller/VideoProjectController.java b/src/main/java/com/dora/controller/VideoProjectController.java new file mode 100644 index 0000000..815d4ff --- /dev/null +++ b/src/main/java/com/dora/controller/VideoProjectController.java @@ -0,0 +1,265 @@ +package com.dora.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.result.Result; +import com.dora.dto.video.*; +import com.dora.service.VideoProjectService; +import com.dora.common.context.UserContext; +import com.dora.vo.*; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/video-project") +@RequiredArgsConstructor +public class VideoProjectController { + + private final VideoProjectService projectService; + + /** + * 创建项目 + */ + @PostMapping("/create") + public Result createProject() { + Long userId = UserContext.getUserId(); + return Result.success(projectService.createProject(userId)); + } + + /** + * 获取项目详情 + */ + @GetMapping("/{projectId}") + public Result getProject(@PathVariable Long projectId) { + return Result.success(projectService.getProjectById(projectId)); + } + + /** + * 获取项目列表 + */ + @GetMapping("/list") + public Result> getProjectList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) Integer status) { + Long userId = UserContext.getUserId(); + Page pageParam = new Page<>(page, size); + return Result.success(projectService.getProjectList(pageParam, userId, status)); + } + + /** + * 更新项目设置 + */ + @PutMapping("/{projectId}/settings") + public Result updateSettings(@PathVariable Long projectId, + @RequestBody VideoProjectDTO dto) { + projectService.updateProjectSettings(projectId, dto); + return Result.success(); + } + + /** + * 删除项目 + */ + @DeleteMapping("/{projectId}") + public Result deleteProject(@PathVariable Long projectId) { + Long userId = UserContext.getUserId(); + projectService.deleteProject(userId, projectId); + return Result.success(); + } + + /** + * 生成剧本 + */ + @PostMapping("/{projectId}/generate-script") + public Result generateScript( + @PathVariable Long projectId, + @RequestBody @Valid GenerateScriptDTO dto) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.generateScript(projectId, dto.getIdea(), userId)); + } + + // ========== 角色管理 ========== + + /** + * 获取项目角色列表 + */ + @GetMapping("/{projectId}/characters") + public Result> getCharacters(@PathVariable Long projectId) { + return Result.success(projectService.getProjectCharacters(projectId)); + } + + /** + * 保存角色 + */ + @PostMapping("/{projectId}/character") + public Result saveCharacter(@PathVariable Long projectId, + @RequestBody ProjectCharacterDTO dto) { + return Result.success(projectService.saveCharacter(projectId, dto)); + } + + /** + * 删除角色 + */ + @DeleteMapping("/{projectId}/character/{characterId}") + public Result deleteCharacter(@PathVariable Long projectId, + @PathVariable Long characterId) { + projectService.deleteCharacter(projectId, characterId); + return Result.success(); + } + + /** + * 生成角色形象 + */ + @PostMapping("/{projectId}/character/{characterId}/generate-image") + public Result generateCharacterImage(@PathVariable Long projectId, + @PathVariable Long characterId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.generateCharacterImage(projectId, characterId, userId)); + } + + /** + * 获取用户角色库(跨项目,已生成形象的角色) + */ + @GetMapping("/character-library") + public Result> getUserCharacterLibrary() { + Long userId = UserContext.getUserId(); + return Result.success(projectService.getUserCharacterLibrary(userId)); + } + + /** + * 获取角色模板库 + */ + @GetMapping("/character-templates") + public Result> getCharacterTemplates( + @RequestParam(required = false) String category) { + return Result.success(projectService.getCharacterTemplates(category)); + } + + // ========== 场次管理 ========== + + /** + * 获取场次列表 + */ + @GetMapping("/{projectId}/scenes") + public Result> getScenes(@PathVariable Long projectId) { + return Result.success(projectService.getProjectScenes(projectId)); + } + + /** + * 保存场次 + */ + @PostMapping("/{projectId}/scene") + public Result saveScene(@PathVariable Long projectId, + @RequestBody ProjectSceneDTO dto) { + return Result.success(projectService.saveScene(projectId, dto)); + } + + /** + * 删除场次 + */ + @DeleteMapping("/{projectId}/scene/{sceneId}") + public Result deleteScene(@PathVariable Long projectId, + @PathVariable Long sceneId) { + projectService.deleteScene(projectId, sceneId); + return Result.success(); + } + + // ========== 分镜管理 ========== + + /** + * 获取分镜列表 + */ + @GetMapping("/{projectId}/scene/{sceneId}/storyboards") + public Result> getStoryboards(@PathVariable Long projectId, + @PathVariable Long sceneId) { + return Result.success(projectService.getSceneStoryboards(sceneId)); + } + + /** + * 生成分镜 + */ + @PostMapping("/{projectId}/scene/{sceneId}/generate-storyboards") + public Result generateStoryboards( + @PathVariable Long projectId, + @PathVariable Long sceneId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.generateStoryboards(projectId, sceneId, userId)); + } + + /** + * 更新分镜 + */ + @PutMapping("/{projectId}/storyboard/{storyboardId}") + public Result updateStoryboard(@PathVariable Long projectId, + @PathVariable Long storyboardId, + @RequestBody SceneStoryboardDTO dto) { + projectService.updateStoryboard(projectId, storyboardId, dto); + return Result.success(); + } + + /** + * 删除分镜 + */ + @DeleteMapping("/{projectId}/storyboard/{storyboardId}") + public Result deleteStoryboard(@PathVariable Long projectId, + @PathVariable Long storyboardId) { + projectService.deleteStoryboard(projectId, storyboardId); + return Result.success(); + } + + /** + * 新增分镜 + */ + @PostMapping("/{projectId}/scene/{sceneId}/storyboard") + public Result addStoryboard(@PathVariable Long projectId, + @PathVariable Long sceneId, + @RequestParam(required = false) Integer afterIndex) { + return Result.success(projectService.addStoryboard(projectId, sceneId, afterIndex)); + } + + /** + * 生成分镜画面 + */ + @PostMapping("/{projectId}/storyboard/{storyboardId}/generate-image") + public Result generateStoryboardImage(@PathVariable Long projectId, + @PathVariable Long storyboardId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.generateStoryboardImage(projectId, storyboardId, userId)); + } + + /** + * 智能优化画面描述 + */ + @PostMapping("/{projectId}/storyboard/{storyboardId}/optimize-description") + public Result optimizeDescription(@PathVariable Long projectId, + @PathVariable Long storyboardId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.optimizeDescription(projectId, storyboardId, userId)); + } + + // ========== 视频生成 ========== + + /** + * 生成场次视频 + * 流程:拼接分镜图 -> 上传COS -> 调用Grok生成10秒视频 + */ + @PostMapping("/{projectId}/scene/{sceneId}/generate-video") + public Result> generateSceneVideo(@PathVariable Long projectId, + @PathVariable Long sceneId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.generateSceneVideo(projectId, sceneId, userId)); + } + + /** + * 合成最终视频(将所有场次视频拼接) + */ + @PostMapping("/{projectId}/composite-final-video") + public Result> compositeFinalVideo(@PathVariable Long projectId) { + Long userId = UserContext.getUserId(); + return Result.success(projectService.compositeFinalVideo(projectId, userId)); + } +} diff --git a/src/main/java/com/dora/controller/WorkController.java b/src/main/java/com/dora/controller/WorkController.java new file mode 100644 index 0000000..4c93db1 --- /dev/null +++ b/src/main/java/com/dora/controller/WorkController.java @@ -0,0 +1,70 @@ +package com.dora.controller; + +import com.dora.common.context.UserContext; +import com.dora.common.result.Result; +import com.dora.dto.PublishWorkDTO; +import com.dora.dto.WorkQueryDTO; +import com.dora.service.AiWorkService; +import com.dora.vo.LikeResultVO; +import com.dora.vo.PageVO; +import com.dora.vo.WorkDetailVO; +import com.dora.vo.WorkVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 作品控制器 + */ +@Tag(name = "作品模块", description = "AI作品广场相关接口,包含作品列表、详情、点赞等功能") +@RestController +@RequestMapping("/work") +@RequiredArgsConstructor +public class WorkController { + + private final AiWorkService aiWorkService; + + @Operation(summary = "获取作品列表", description = "分页获取公开的AI作品列表,支持分类筛选") + @GetMapping("/list") + public Result> list(WorkQueryDTO query) { + Long currentUserId = UserContext.getUserId(); + PageVO page = aiWorkService.listWorks(query, currentUserId); + return Result.success(page); + } + + @Operation(summary = "获取作品详情", description = "根据作品ID获取作品详细信息") + @GetMapping("/{id}") + public Result detail( + @Parameter(description = "作品ID") @PathVariable("id") Long id) { + Long currentUserId = UserContext.getUserId(); + WorkDetailVO detail = aiWorkService.getWorkDetail(id, currentUserId); + if (detail == null) { + return Result.fail(4004, "作品不存在"); + } + return Result.success(detail); + } + + @Operation(summary = "点赞/取消点赞", description = "切换作品的点赞状态,已点赞则取消,未点赞则点赞") + @PostMapping("/{id}/like") + public Result toggleLike( + @Parameter(description = "作品ID") @PathVariable("id") Long workId) { + Long userId = UserContext.getUserId(); + LikeResultVO result = aiWorkService.toggleLike(workId, userId); + return Result.success(result); + } + + @Operation(summary = "发布作品", description = "将AI任务结果发布到广场,需要管理员审核") + @PostMapping("/publish") + public Result publishWork(@Valid @RequestBody PublishWorkDTO dto) { + Long userId = UserContext.getUserId(); + if (userId == null) { + return Result.fail(401, "请先登录"); + } + aiWorkService.publishWork(dto, userId); + return Result.success(); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminAiController.java b/src/main/java/com/dora/controller/admin/AdminAiController.java new file mode 100644 index 0000000..cff9472 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminAiController.java @@ -0,0 +1,263 @@ +package com.dora.controller.admin; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.result.Result; +import com.dora.config.CosConfig; +import com.dora.dto.AiModelDTO; +import com.dora.dto.AiProviderDTO; +import com.dora.service.AiModelService; +import com.dora.service.AiProviderService; +import com.dora.service.AiTaskService; +import com.dora.vo.AiModelVO; +import com.dora.vo.AiProviderVO; +import com.dora.vo.AiTaskVO; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import java.util.UUID; + +@Slf4j +@Tag(name = "管理端-AI管理") +@RestController +@RequestMapping("/admin/ai") +@RequiredArgsConstructor +public class AdminAiController { + + private final AiProviderService aiProviderService; + private final AiModelService aiModelService; + private final AiTaskService aiTaskService; + private final COSClient cosClient; + private final CosConfig cosConfig; + + // ==================== AI厂商管理 ==================== + + @Operation(summary = "分页查询AI厂商") + @GetMapping("/providers") + public Result getProviders(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String name, + @RequestParam(required = false) Integer status) { + Page pageParam = new Page<>(page, size); + return Result.success(aiProviderService.getProviderPage(pageParam, name, status)); + } + + @Operation(summary = "获取所有启用的厂商") + @GetMapping("/providers/active") + public Result getActiveProviders() { + return Result.success(aiProviderService.getActiveProviders()); + } + + @Operation(summary = "获取厂商详情") + @GetMapping("/providers/{id}") + public Result getProvider(@PathVariable Long id) { + return Result.success(aiProviderService.getProviderById(id)); + } + + @Operation(summary = "创建AI厂商") + @PostMapping("/providers") + public Result createProvider(@Valid @RequestBody AiProviderDTO dto) { + aiProviderService.createProvider(dto); + return Result.success(); + } + + @Operation(summary = "更新AI厂商") + @PutMapping("/providers") + public Result updateProvider(@Valid @RequestBody AiProviderDTO dto) { + aiProviderService.updateProvider(dto); + return Result.success(); + } + + @Operation(summary = "删除AI厂商") + @DeleteMapping("/providers/{id}") + public Result deleteProvider(@PathVariable Long id) { + aiProviderService.deleteProvider(id); + return Result.success(); + } + + @Operation(summary = "更新厂商状态") + @PutMapping("/providers/{id}/status") + public Result updateProviderStatus(@PathVariable Long id, @RequestParam Integer status) { + aiProviderService.updateProviderStatus(id, status); + return Result.success(); + } + + // ==================== AI模型管理 ==================== + + @Operation(summary = "分页查询AI模型") + @GetMapping("/models") + public Result getModels(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) String type, + @RequestParam(required = false) String category, + @RequestParam(required = false) Integer status) { + Page pageParam = new Page<>(page, size); + return Result.success(aiModelService.getModelPage(pageParam, type, category, status)); + } + + @Operation(summary = "获取启用的模型列表") + @GetMapping("/models/active") + public Result getActiveModels(@RequestParam(required = false) String type) { + return Result.success(aiModelService.getActiveModels(type)); + } + + @Operation(summary = "获取模型详情") + @GetMapping("/models/{id}") + public Result getModel(@PathVariable Long id) { + return Result.success(aiModelService.getModelById(id)); + } + + @Operation(summary = "创建AI模型") + @PostMapping("/models") + public Result createModel(@Valid @RequestBody AiModelDTO dto) { + aiModelService.createModel(dto); + return Result.success(); + } + + @Operation(summary = "更新AI模型") + @PutMapping("/models") + public Result updateModel(@Valid @RequestBody AiModelDTO dto) { + aiModelService.updateModel(dto); + return Result.success(); + } + + @Operation(summary = "删除AI模型") + @DeleteMapping("/models/{id}") + public Result deleteModel(@PathVariable Long id) { + aiModelService.deleteModel(id); + return Result.success(); + } + + @Operation(summary = "更新模型状态") + @PutMapping("/models/{id}/status") + public Result updateModelStatus(@PathVariable Long id, @RequestParam Integer status) { + aiModelService.updateModelStatus(id, status); + return Result.success(); + } + + @Operation(summary = "上传模型图片") + @PostMapping("/models/upload") + public Result uploadModelImage(@RequestParam("file") MultipartFile file) { + try { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return Result.fail("只能上传图片文件"); + } + + // 验证文件大小(10MB) + if (file.getSize() > 10 * 1024 * 1024) { + return Result.fail("图片大小不能超过10MB"); + } + + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String fileName = "model/" + UUID.randomUUID().toString() + extension; + + // 设置元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + + cosClient.putObject(putObjectRequest); + + // 返回文件URL + String url = cosConfig.getFileUrl(fileName); + return Result.success(url); + + } catch (Exception e) { + log.error("上传模型图片失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "获取AI模型类型列表") + @GetMapping("/models/types") + public Result getModelTypes() { + java.util.List> types = new java.util.ArrayList<>(); + + // 文本生成类 + types.add(java.util.Map.of("value", "text_generation", "label", "文本生成", "category", "文本")); + + // 图片生成类 + types.add(java.util.Map.of("value", "image_generation", "label", "图片生成", "category", "图片")); + types.add(java.util.Map.of("value", "text2img", "label", "文生图", "category", "图片")); + types.add(java.util.Map.of("value", "img2img", "label", "图生图", "category", "图片")); + types.add(java.util.Map.of("value", "img_enhance", "label", "图像增强", "category", "图片")); + types.add(java.util.Map.of("value", "img_upscale", "label", "图像放大", "category", "图片")); + types.add(java.util.Map.of("value", "img_colorize", "label", "图像上色", "category", "图片")); + types.add(java.util.Map.of("value", "img_remove_bg", "label", "背景移除", "category", "图片")); + + // 视频生成类 + types.add(java.util.Map.of("value", "video_generation", "label", "视频生成", "category", "视频")); + types.add(java.util.Map.of("value", "text2video", "label", "文生视频", "category", "视频")); + types.add(java.util.Map.of("value", "img2video", "label", "图生视频", "category", "视频")); + types.add(java.util.Map.of("value", "video_enhance", "label", "视频增强", "category", "视频")); + + // 音频类 + types.add(java.util.Map.of("value", "text2audio", "label", "文本转语音", "category", "音频")); + types.add(java.util.Map.of("value", "audio2text", "label", "语音转文本", "category", "音频")); + types.add(java.util.Map.of("value", "voice_clone", "label", "声音克隆", "category", "音频")); + types.add(java.util.Map.of("value", "music_gen", "label", "音乐生成", "category", "音频")); + + // 其他类 + types.add(java.util.Map.of("value", "face_swap", "label", "换脸", "category", "其他")); + types.add(java.util.Map.of("value", "code_gen", "label", "代码生成", "category", "其他")); + types.add(java.util.Map.of("value", "other", "label", "其他", "category", "其他")); + + return Result.success(types); + } + + // ==================== AI任务管理 ==================== + + @Operation(summary = "分页查询AI任务") + @GetMapping("/tasks") + public Result getTasks(@RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer size, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) Integer status, + @RequestParam(required = false) Long modelId) { + Page pageParam = new Page<>(page, size); + return Result.success(aiTaskService.getTaskPage(pageParam, userId, status, modelId)); + } + + @Operation(summary = "获取任务详情") + @GetMapping("/tasks/{id}") + public Result getTask(@PathVariable Long id) { + return Result.success(aiTaskService.getTaskById(id)); + } + + @Operation(summary = "处理任务队列") + @PostMapping("/tasks/process") + public Result processTaskQueue() { + aiTaskService.processTaskQueue(); + return Result.success(); + } + + // ==================== AI模型调试 ==================== + + @Operation(summary = "调试AI模型") + @PostMapping("/models/{id}/debug") + public Result debugModel(@PathVariable Long id, @RequestBody java.util.Map params) { + return Result.success(aiModelService.debugModel(id, params)); + } +} \ No newline at end of file diff --git a/src/main/java/com/dora/controller/admin/AdminAuthController.java b/src/main/java/com/dora/controller/admin/AdminAuthController.java new file mode 100644 index 0000000..e7ce266 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminAuthController.java @@ -0,0 +1,90 @@ +package com.dora.controller.admin; + +import com.dora.common.context.AdminContext; +import com.dora.common.result.Result; +import com.dora.dto.admin.AdminLoginDTO; +import com.dora.dto.admin.AdminRegisterDTO; +import com.dora.dto.admin.EmailCodeDTO; +import com.dora.dto.admin.EmailLoginDTO; +import com.dora.service.AdminService; +import com.dora.service.EmailService; +import com.dora.vo.admin.AdminInfoVO; +import com.dora.vo.admin.AdminLoginVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 管理员认证控制器 + */ +@Tag(name = "管理员认证", description = "管理员登录、注册、获取信息") +@RestController +@RequestMapping("/admin/auth") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AdminService adminService; + private final EmailService emailService; + + @Operation(summary = "管理员登录(用户名密码)") + @PostMapping("/login") + public Result login(@Valid @RequestBody AdminLoginDTO dto, HttpServletRequest request) { + String ip = getClientIp(request); + return Result.success(adminService.login(dto, ip)); + } + + @Operation(summary = "发送邮箱验证码") + @PostMapping("/send-code") + public Result sendEmailCode(@Valid @RequestBody EmailCodeDTO dto) { + // 先校验邮箱是否存在 + adminService.checkEmailExists(dto.getEmail()); + // 发送验证码 + emailService.sendVerificationCode(dto.getEmail()); + return Result.success(); + } + + @Operation(summary = "管理员邮箱验证码登录") + @PostMapping("/login/email") + public Result loginByEmail(@Valid @RequestBody EmailLoginDTO dto, HttpServletRequest request) { + String ip = getClientIp(request); + return Result.success(adminService.loginByEmail(dto, ip)); + } + + @Operation(summary = "管理员注册(仅用于测试,无需认证)") + @PostMapping("/register") + public Result register(@Valid @RequestBody AdminRegisterDTO dto) { + adminService.register(dto); + return Result.success(); + } + + @Operation(summary = "获取当前管理员信息") + @GetMapping("/info") + public Result getInfo() { + Long adminId = AdminContext.getAdminId(); + return Result.success(adminService.getAdminInfo(adminId)); + } + + @Operation(summary = "退出登录") + @PostMapping("/logout") + public Result logout() { + // 客户端清除Token即可,服务端可以加入黑名单机制 + return Result.success(); + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminCategoryController.java b/src/main/java/com/dora/controller/admin/AdminCategoryController.java new file mode 100644 index 0000000..3fdba53 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminCategoryController.java @@ -0,0 +1,99 @@ +package com.dora.controller.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import com.dora.entity.WorkCategory; +import com.dora.mapper.WorkCategoryMapper; +import com.dora.service.impl.WorkCategoryServiceImpl; +import com.dora.vo.CategoryVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 管理后台分类控制器 + */ +@Tag(name = "分类管理", description = "作品分类的查询、新增、修改、删除") +@RestController +@RequestMapping("/admin/category") +@RequiredArgsConstructor +public class AdminCategoryController { + + private final WorkCategoryServiceImpl workCategoryService; + private final WorkCategoryMapper workCategoryMapper; + + @Operation(summary = "获取分类树(仅启用)") + @GetMapping("/tree") + public Result> getCategoryTree() { + return Result.success(workCategoryService.listCategories()); + } + + @Operation(summary = "获取分类列表(全部)") + @GetMapping("/list") + public Result> getCategoryList() { + return Result.success(workCategoryService.listAllCategories()); + } + + @Operation(summary = "创建分类") + @PostMapping + public Result createCategory(@RequestBody CategoryDTO dto) { + WorkCategory category = new WorkCategory(); + category.setParentId(dto.getParentId() != null ? dto.getParentId() : 0L); + category.setName(dto.getName()); + category.setIcon(dto.getIcon()); + category.setSort(dto.getSort() != null ? dto.getSort() : 0); + category.setStatus(dto.getStatus() != null ? dto.getStatus() : 1); + category.setCreatedAt(LocalDateTime.now()); + category.setUpdatedAt(LocalDateTime.now()); + category.setDeleted(0); + workCategoryMapper.insert(category); + return Result.success(); + } + + @Operation(summary = "更新分类") + @PutMapping("/{id}") + public Result updateCategory(@PathVariable Long id, @RequestBody CategoryDTO dto) { + WorkCategory category = workCategoryMapper.selectById(id); + if (category == null || category.getDeleted() == 1) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "分类不存在"); + } + if (dto.getName() != null) category.setName(dto.getName()); + if (dto.getIcon() != null) category.setIcon(dto.getIcon()); + if (dto.getSort() != null) category.setSort(dto.getSort()); + if (dto.getStatus() != null) category.setStatus(dto.getStatus()); + if (dto.getParentId() != null) category.setParentId(dto.getParentId()); + category.setUpdatedAt(LocalDateTime.now()); + workCategoryMapper.updateById(category); + return Result.success(); + } + + @Operation(summary = "删除分类") + @DeleteMapping("/{id}") + public Result deleteCategory(@PathVariable Long id) { + WorkCategory category = workCategoryMapper.selectById(id); + if (category == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "分类不存在"); + } + // 逻辑删除 + category.setDeleted(1); + category.setUpdatedAt(LocalDateTime.now()); + workCategoryMapper.updateById(category); + return Result.success(); + } + + @Data + public static class CategoryDTO { + private Long parentId; + private String name; + private String icon; + private Integer sort; + private Integer status; + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminConfigController.java b/src/main/java/com/dora/controller/admin/AdminConfigController.java new file mode 100644 index 0000000..40bef60 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminConfigController.java @@ -0,0 +1,425 @@ +package com.dora.controller.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.annotation.RequirePermission; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import com.dora.config.CosConfig; +import com.dora.entity.*; +import com.dora.mapper.*; +import com.dora.vo.PageVO; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 系统配置控制器 + */ +@Slf4j +@Tag(name = "系统配置", description = "奖励语句、VIP套餐、积分套餐、Banner、公告管理") +@RestController +@RequestMapping("/admin/config") +@RequiredArgsConstructor +public class AdminConfigController { + + private final RewardMessageMapper rewardMessageMapper; + private final PromotionConfigMapper promotionConfigMapper; + private final VipPackageMapper vipPackageMapper; + private final PointsPackageMapper pointsPackageMapper; + private final BannerMapper bannerMapper; + private final NoticeMapper noticeMapper; + private final COSClient cosClient; + private final CosConfig cosConfig; + + // ==================== 奖励配置(综合) ==================== + + @Operation(summary = "获取奖励配置") + @GetMapping("/reward") + @RequirePermission("config:reward") + public Result> getRewardConfig() { + Map result = new HashMap<>(); + + // 从推广配置中获取奖励积分 + List promotions = promotionConfigMapper.selectList( + new LambdaQueryWrapper().orderByAsc(PromotionConfig::getType) + ); + + for (PromotionConfig config : promotions) { + // type: 1=注册奖励, 2=首充奖励, 3=邀请奖励 + if (config.getType() == 1) { + result.put("registerReward", config.getRewardPoints() != null ? config.getRewardPoints() : 0); + } else if (config.getType() == 2) { + result.put("firstChargeReward", config.getRewardPoints() != null ? config.getRewardPoints() : 0); + } else if (config.getType() == 3) { + result.put("inviteReward", config.getRewardPoints() != null ? config.getRewardPoints() : 0); + } + } + + // 设置默认值 + result.putIfAbsent("registerReward", 100); + result.putIfAbsent("firstChargeReward", 200); + result.putIfAbsent("inviteReward", 50); + + return Result.success(result); + } + + @Operation(summary = "更新奖励配置") + @PutMapping("/reward") + @RequirePermission("config:reward") + public Result updateRewardConfig(@RequestBody Map config) { + // 更新注册奖励 + if (config.containsKey("registerReward")) { + updatePromotionReward(1, config.get("registerReward"), "新用户注册奖励"); + } + + // 更新首充奖励 + if (config.containsKey("firstChargeReward")) { + updatePromotionReward(2, config.get("firstChargeReward"), "首次充值奖励"); + } + + // 更新邀请奖励 + if (config.containsKey("inviteReward")) { + updatePromotionReward(3, config.get("inviteReward"), "邀请好友奖励"); + } + + return Result.success(); + } + + private void updatePromotionReward(Integer type, Integer points, String description) { + PromotionConfig existing = promotionConfigMapper.selectOne( + new LambdaQueryWrapper().eq(PromotionConfig::getType, type) + ); + + if (existing != null) { + existing.setRewardPoints(points); + existing.setUpdatedAt(LocalDateTime.now()); + promotionConfigMapper.updateById(existing); + } else { + PromotionConfig config = new PromotionConfig(); + config.setType(type); + config.setRewardPoints(points); + config.setDescription(description); + config.setStatus(1); + config.setCreatedAt(LocalDateTime.now()); + config.setUpdatedAt(LocalDateTime.now()); + promotionConfigMapper.insert(config); + } + } + + // ==================== 奖励语句配置 ==================== + + @Operation(summary = "获取奖励语句配置") + @GetMapping("/reward-messages") + @RequirePermission("config:reward") + public Result> getRewardMessages() { + List list = rewardMessageMapper.selectList(null); + Map result = new HashMap<>(); + for (RewardMessage msg : list) { + result.put(msg.getConfigKey(), msg.getConfigValue()); + } + return Result.success(result); + } + + @Operation(summary = "更新奖励语句配置") + @PutMapping("/reward-messages") + @RequirePermission("config:reward") + public Result updateRewardMessages(@RequestBody Map messages) { + for (Map.Entry entry : messages.entrySet()) { + RewardMessage existing = rewardMessageMapper.selectByKey(entry.getKey()); + if (existing != null) { + existing.setConfigValue(entry.getValue()); + existing.setUpdatedAt(LocalDateTime.now()); + rewardMessageMapper.updateById(existing); + } else { + RewardMessage msg = new RewardMessage(); + msg.setConfigKey(entry.getKey()); + msg.setConfigValue(entry.getValue()); + msg.setCreatedAt(LocalDateTime.now()); + msg.setUpdatedAt(LocalDateTime.now()); + rewardMessageMapper.insert(msg); + } + } + return Result.success(); + } + + // ==================== 推广配置 ==================== + + @Operation(summary = "获取推广配置") + @GetMapping("/promotion") + @RequirePermission("config:reward") + public Result> getPromotionConfig() { + List list = promotionConfigMapper.selectList( + new LambdaQueryWrapper().orderByAsc(PromotionConfig::getType) + ); + return Result.success(list); + } + + @Operation(summary = "更新推广配置") + @PutMapping("/promotion") + @RequirePermission("config:reward") + public Result updatePromotionConfig(@RequestBody List configs) { + for (PromotionConfig config : configs) { + config.setUpdatedAt(LocalDateTime.now()); + if (config.getId() != null) { + promotionConfigMapper.updateById(config); + } else { + config.setCreatedAt(LocalDateTime.now()); + promotionConfigMapper.insert(config); + } + } + return Result.success(); + } + + // ==================== VIP套餐 ==================== + + @Operation(summary = "VIP套餐列表") + @GetMapping("/vip-package/list") + @RequirePermission("config:vip") + public Result> getVipPackageList() { + List list = vipPackageMapper.selectList( + new LambdaQueryWrapper().orderByAsc(VipPackage::getSort) + ); + return Result.success(list); + } + + @Operation(summary = "创建VIP套餐") + @PostMapping("/vip-package") + @RequirePermission("config:vip") + public Result createVipPackage(@RequestBody VipPackage pkg) { + pkg.setCreatedAt(LocalDateTime.now()); + pkg.setUpdatedAt(LocalDateTime.now()); + vipPackageMapper.insert(pkg); + return Result.success(); + } + + @Operation(summary = "更新VIP套餐") + @PutMapping("/vip-package/{id}") + @RequirePermission("config:vip") + public Result updateVipPackage(@PathVariable Long id, @RequestBody VipPackage pkg) { + VipPackage existing = vipPackageMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + pkg.setId(id); + pkg.setUpdatedAt(LocalDateTime.now()); + vipPackageMapper.updateById(pkg); + return Result.success(); + } + + @Operation(summary = "删除VIP套餐") + @DeleteMapping("/vip-package/{id}") + @RequirePermission("config:vip") + public Result deleteVipPackage(@PathVariable Long id) { + vipPackageMapper.deleteById(id); + return Result.success(); + } + + // ==================== 积分套餐 ==================== + + @Operation(summary = "积分套餐列表") + @GetMapping("/points-package/list") + @RequirePermission("config:points") + public Result> getPointsPackageList() { + List list = pointsPackageMapper.selectList( + new LambdaQueryWrapper().orderByAsc(PointsPackage::getSort) + ); + return Result.success(list); + } + + @Operation(summary = "创建积分套餐") + @PostMapping("/points-package") + @RequirePermission("config:points") + public Result createPointsPackage(@RequestBody PointsPackage pkg) { + pkg.setCreatedAt(LocalDateTime.now()); + pkg.setUpdatedAt(LocalDateTime.now()); + pointsPackageMapper.insert(pkg); + return Result.success(); + } + + @Operation(summary = "更新积分套餐") + @PutMapping("/points-package/{id}") + @RequirePermission("config:points") + public Result updatePointsPackage(@PathVariable Long id, @RequestBody PointsPackage pkg) { + PointsPackage existing = pointsPackageMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + pkg.setId(id); + pkg.setUpdatedAt(LocalDateTime.now()); + pointsPackageMapper.updateById(pkg); + return Result.success(); + } + + @Operation(summary = "删除积分套餐") + @DeleteMapping("/points-package/{id}") + @RequirePermission("config:points") + public Result deletePointsPackage(@PathVariable Long id) { + pointsPackageMapper.deleteById(id); + return Result.success(); + } + + // ==================== Banner管理 ==================== + + @Operation(summary = "Banner列表") + @GetMapping("/banner/list") + @RequirePermission("config:banner") + public Result> getBannerList() { + List list = bannerMapper.selectList( + new LambdaQueryWrapper().orderByAsc(Banner::getSort) + ); + return Result.success(list); + } + + @Operation(summary = "创建Banner") + @PostMapping("/banner") + @RequirePermission("config:banner") + public Result createBanner(@RequestBody Banner banner) { + banner.setId(null); + banner.setCreatedAt(LocalDateTime.now()); + banner.setUpdatedAt(LocalDateTime.now()); + bannerMapper.insert(banner); + return Result.success(); + } + + @Operation(summary = "更新Banner") + @PutMapping("/banner/{id}") + @RequirePermission("config:banner") + public Result updateBanner(@PathVariable Long id, @RequestBody Banner banner) { + Banner existing = bannerMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + banner.setId(id); + banner.setUpdatedAt(LocalDateTime.now()); + bannerMapper.updateById(banner); + return Result.success(); + } + + @Operation(summary = "删除Banner") + @DeleteMapping("/banner/{id}") + @RequirePermission("config:banner") + public Result deleteBanner(@PathVariable Long id) { + bannerMapper.deleteById(id); + return Result.success(); + } + + @Operation(summary = "上传Banner图片") + @PostMapping("/banner/upload") + @RequirePermission("config:banner") + public Result uploadBannerImage(@RequestParam("file") MultipartFile file) { + try { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BusinessException(ResultCode.PARAM_ERROR, "只能上传图片文件"); + } + + // 验证文件大小(限制10MB) + if (file.getSize() > 10 * 1024 * 1024) { + throw new BusinessException(ResultCode.PARAM_ERROR, "图片大小不能超过10MB"); + } + + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String suffix = ""; + if (originalFilename != null && originalFilename.contains(".")) { + suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); + } else { + // 默认使用jpg + suffix = ".jpg"; + } + String fileName = "banner/" + UUID.randomUUID().toString().replace("-", "") + suffix; + + // 设置文件元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + cosClient.putObject(putObjectRequest); + + // 返回文件访问URL + String fileUrl = cosConfig.getFileUrl(fileName); + log.info("Banner图片上传成功: {}", fileUrl); + return Result.success(fileUrl); + } catch (IOException e) { + log.error("Banner图片上传失败", e); + throw new BusinessException(ResultCode.SYSTEM_ERROR, "图片上传失败"); + } + } + + // ==================== 公告管理 ==================== + + @Operation(summary = "公告列表") + @GetMapping("/notice/list") + @RequirePermission("config:notice") + public Result> getNoticeList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize) { + + Page pageParam = new Page<>(page, pageSize); + Page result = noticeMapper.selectPage(pageParam, + new LambdaQueryWrapper().orderByDesc(Notice::getCreatedAt) + ); + return Result.success(PageVO.of(result.getRecords(), result.getTotal(), page, pageSize)); + } + + @Operation(summary = "创建公告") + @PostMapping("/notice") + @RequirePermission("config:notice") + public Result createNotice(@RequestBody Notice notice) { + notice.setCreatedAt(LocalDateTime.now()); + notice.setUpdatedAt(LocalDateTime.now()); + noticeMapper.insert(notice); + return Result.success(); + } + + @Operation(summary = "更新公告") + @PutMapping("/notice/{id}") + @RequirePermission("config:notice") + public Result updateNotice(@PathVariable Long id, @RequestBody Notice notice) { + Notice existing = noticeMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + notice.setId(id); + notice.setUpdatedAt(LocalDateTime.now()); + noticeMapper.updateById(notice); + return Result.success(); + } + + @Operation(summary = "删除公告") + @DeleteMapping("/notice/{id}") + @RequirePermission("config:notice") + public Result deleteNotice(@PathVariable Long id) { + noticeMapper.deleteById(id); + return Result.success(); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminDashboardController.java b/src/main/java/com/dora/controller/admin/AdminDashboardController.java new file mode 100644 index 0000000..c40da18 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminDashboardController.java @@ -0,0 +1,43 @@ +package com.dora.controller.admin; + +import com.dora.common.result.Result; +import com.dora.vo.admin.DashboardStatsVO; +import com.dora.vo.admin.DashboardTrendVO; +import com.dora.vo.admin.RecentOrderVO; +import com.dora.service.AdminDashboardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理后台Dashboard控制器 + */ +@Tag(name = "Dashboard", description = "控制台数据统计") +@RestController +@RequestMapping("/admin/dashboard") +@RequiredArgsConstructor +public class AdminDashboardController { + + private final AdminDashboardService dashboardService; + + @Operation(summary = "获取统计数据") + @GetMapping("/stats") + public Result getStats() { + return Result.success(dashboardService.getStats()); + } + + @Operation(summary = "获取近7天趋势数据") + @GetMapping("/trend") + public Result> getTrend() { + return Result.success(dashboardService.getTrend()); + } + + @Operation(summary = "获取最近订单") + @GetMapping("/recent-orders") + public Result> getRecentOrders() { + return Result.success(dashboardService.getRecentOrders()); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminOrderController.java b/src/main/java/com/dora/controller/admin/AdminOrderController.java new file mode 100644 index 0000000..b2b8032 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminOrderController.java @@ -0,0 +1,44 @@ +package com.dora.controller.admin; + +import com.dora.common.result.Result; +import com.dora.dto.admin.OrderQueryDTO; +import com.dora.service.AdminOrderService; +import com.dora.vo.admin.OrderListVO; +import com.dora.vo.admin.OrderVO; +import com.dora.vo.admin.RecentOrderVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理后台订单控制器 + */ +@Tag(name = "订单管理", description = "订单查询与管理") +@RestController +@RequestMapping("/admin/order") +@RequiredArgsConstructor +public class AdminOrderController { + + private final AdminOrderService orderService; + + @Operation(summary = "订单列表") + @GetMapping("/list") + public Result getOrderList(OrderQueryDTO dto) { + return Result.success(orderService.getOrderList(dto)); + } + + @Operation(summary = "订单详情") + @GetMapping("/{id}") + public Result getOrderDetail(@PathVariable Long id, @RequestParam Integer type) { + return Result.success(orderService.getOrderDetail(id, type)); + } + + @Operation(summary = "最近订单") + @GetMapping("/recent") + public Result> getRecentOrders() { + return Result.success(orderService.getRecentOrders()); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminPointsController.java b/src/main/java/com/dora/controller/admin/AdminPointsController.java new file mode 100644 index 0000000..608fab7 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminPointsController.java @@ -0,0 +1,69 @@ +package com.dora.controller.admin; + +import com.dora.annotation.RequirePermission; +import com.dora.common.result.Result; +import com.dora.dto.admin.PointsPackageDTO; +import com.dora.entity.PointsPackage; +import com.dora.service.AdminPointsService; +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.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "管理端-积分套餐管理") +@RestController +@RequestMapping("/admin/points") +@RequiredArgsConstructor +public class AdminPointsController { + + private final AdminPointsService adminPointsService; + + @Operation(summary = "获取套餐列表") + @GetMapping("/packages") + @RequirePermission("config:points:list") + public Result> getPackages() { + return Result.success(adminPointsService.getPackageList()); + } + + @Operation(summary = "获取套餐详情") + @GetMapping("/packages/{id}") + @RequirePermission("config:points:list") + public Result getPackage(@PathVariable Long id) { + return Result.success(adminPointsService.getPackageById(id)); + } + + @Operation(summary = "创建套餐") + @PostMapping("/packages") + @RequirePermission("config:points:create") + public Result createPackage(@Valid @RequestBody PointsPackageDTO dto) { + adminPointsService.createPackage(dto); + return Result.success(); + } + + @Operation(summary = "更新套餐") + @PutMapping("/packages") + @RequirePermission("config:points:update") + public Result updatePackage(@Valid @RequestBody PointsPackageDTO dto) { + adminPointsService.updatePackage(dto); + return Result.success(); + } + + @Operation(summary = "删除套餐") + @DeleteMapping("/packages/{id}") + @RequirePermission("config:points:delete") + public Result deletePackage(@PathVariable Long id) { + adminPointsService.deletePackage(id); + return Result.success(); + } + + @Operation(summary = "更新套餐状态") + @PutMapping("/packages/{id}/status") + @RequirePermission("config:points:update") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + adminPointsService.updateStatus(id, status); + return Result.success(); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminRedeemCodeController.java b/src/main/java/com/dora/controller/admin/AdminRedeemCodeController.java new file mode 100644 index 0000000..a40b99c --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminRedeemCodeController.java @@ -0,0 +1,79 @@ +package com.dora.controller.admin; + +import com.dora.annotation.RequirePermission; +import com.dora.common.result.Result; +import com.dora.dto.admin.RedeemCodeCreateDTO; +import com.dora.dto.admin.RedeemCodeQueryDTO; +import com.dora.entity.RedeemCode; +import com.dora.service.RedeemCodeService; +import com.dora.vo.PageVO; +import com.dora.vo.admin.RedeemCodeVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 管理后台兑换码控制器 + */ +@Tag(name = "兑换码管理", description = "兑换码的生成、查询、管理") +@RestController +@RequestMapping("/admin/redeem-code") +@RequiredArgsConstructor +public class AdminRedeemCodeController { + + private final RedeemCodeService redeemCodeService; + + @Operation(summary = "兑换码列表") + @GetMapping("/list") + @RequirePermission("config:redeem") + public Result> getCodeList(RedeemCodeQueryDTO dto) { + return Result.success(redeemCodeService.getCodeList(dto)); + } + + @Operation(summary = "兑换码详情") + @GetMapping("/{id}") + @RequirePermission("config:redeem") + public Result getCodeDetail(@PathVariable Long id) { + return Result.success(redeemCodeService.getCodeDetail(id)); + } + + @Operation(summary = "批量生成兑换码") + @PostMapping("/generate") + @RequirePermission("config:redeem") + public Result> generateCodes(@RequestBody RedeemCodeCreateDTO dto) { + return Result.success(redeemCodeService.generateCodes(dto)); + } + + @Operation(summary = "更新兑换码") + @PutMapping("/{id}") + @RequirePermission("config:redeem") + public Result updateCode(@PathVariable Long id, @RequestBody RedeemCode code) { + redeemCodeService.updateCode(id, code); + return Result.success(); + } + + @Operation(summary = "删除兑换码") + @DeleteMapping("/{id}") + @RequirePermission("config:redeem") + public Result deleteCode(@PathVariable Long id) { + redeemCodeService.deleteCode(id); + return Result.success(); + } + + @Operation(summary = "启用/禁用兑换码") + @PutMapping("/{id}/status") + @RequirePermission("config:redeem") + public Result toggleStatus(@PathVariable Long id, @RequestBody StatusDTO dto) { + redeemCodeService.toggleStatus(id, dto.getStatus()); + return Result.success(); + } + + @Data + public static class StatusDTO { + private Integer status; + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminSystemController.java b/src/main/java/com/dora/controller/admin/AdminSystemController.java new file mode 100644 index 0000000..04816a8 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminSystemController.java @@ -0,0 +1,250 @@ +package com.dora.controller.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.annotation.RequirePermission; +import com.dora.common.context.AdminContext; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import com.dora.dto.admin.*; +import com.dora.entity.*; +import com.dora.mapper.*; +import com.dora.service.AdminService; +import com.dora.vo.PageVO; +import com.dora.vo.admin.*; +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.beans.BeanUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 系统管理控制器 + */ +@Tag(name = "系统管理", description = "管理员、角色、权限管理") +@RestController +@RequestMapping("/admin/system") +@RequiredArgsConstructor +public class AdminSystemController { + + private final AdminService adminService; + private final AdminMapper adminMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final AdminRoleMapper adminRoleMapper; + private final RolePermissionMapper rolePermissionMapper; + + // ==================== 管理员管理 ==================== + + @Operation(summary = "管理员列表") + @GetMapping("/admin/list") + @RequirePermission("system:admin:view") + public Result> getAdminList(AdminQueryDTO dto) { + return Result.success(adminService.getAdminList(dto)); + } + + @Operation(summary = "创建管理员") + @PostMapping("/admin") + @RequirePermission("system:admin:add") + public Result createAdmin(@Valid @RequestBody AdminCreateDTO dto) { + adminService.createAdmin(dto); + return Result.success(); + } + + @Operation(summary = "更新管理员") + @PutMapping("/admin/{id}") + @RequirePermission("system:admin:edit") + public Result updateAdmin(@PathVariable Long id, @Valid @RequestBody AdminUpdateDTO dto) { + adminService.updateAdmin(id, dto); + return Result.success(); + } + + @Operation(summary = "删除管理员") + @DeleteMapping("/admin/{id}") + @RequirePermission("system:admin:delete") + public Result deleteAdmin(@PathVariable Long id) { + // 不能删除自己 + if (id.equals(AdminContext.getAdminId())) { + throw new BusinessException(ResultCode.OPERATION_NOT_ALLOWED, "不能删除自己"); + } + adminService.deleteAdmin(id); + return Result.success(); + } + + // ==================== 角色管理 ==================== + + @Operation(summary = "角色列表(分页)") + @GetMapping("/role/list") + @RequirePermission("system:role:view") + public Result> getRoleList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String keyword) { + + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(keyword)) { + wrapper.like(Role::getName, keyword).or().like(Role::getCode, keyword); + } + wrapper.orderByAsc(Role::getSort); + + Page result = roleMapper.selectPage(pageParam, wrapper); + List list = result.getRecords().stream().map(r -> { + RoleVO vo = new RoleVO(); + BeanUtils.copyProperties(r, vo); + return vo; + }).collect(Collectors.toList()); + + return Result.success(PageVO.of(list, result.getTotal(), page, pageSize)); + } + + @Operation(summary = "所有角色(下拉选择用)") + @GetMapping("/role/all") + public Result> getAllRoles() { + List roles = roleMapper.selectList( + new LambdaQueryWrapper() + .eq(Role::getStatus, 1) + .orderByAsc(Role::getSort) + ); + List list = roles.stream().map(r -> { + RoleVO vo = new RoleVO(); + BeanUtils.copyProperties(r, vo); + return vo; + }).collect(Collectors.toList()); + return Result.success(list); + } + + @Operation(summary = "创建角色") + @PostMapping("/role") + @RequirePermission("system:role:add") + public Result createRole(@RequestBody Role role) { + // 检查编码唯一性 + Role existing = roleMapper.selectByCode(role.getCode()); + if (existing != null) { + throw new BusinessException(ResultCode.DATA_ALREADY_EXISTS, "角色编码已存在"); + } + role.setCreatedAt(LocalDateTime.now()); + role.setUpdatedAt(LocalDateTime.now()); + roleMapper.insert(role); + return Result.success(); + } + + @Operation(summary = "更新角色") + @PutMapping("/role/{id}") + @RequirePermission("system:role:edit") + public Result updateRole(@PathVariable Long id, @RequestBody Role role) { + Role existing = roleMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + role.setId(id); + role.setCode(existing.getCode()); // 编码不允许修改 + role.setUpdatedAt(LocalDateTime.now()); + roleMapper.updateById(role); + return Result.success(); + } + + @Operation(summary = "删除角色") + @DeleteMapping("/role/{id}") + @RequirePermission("system:role:delete") + public Result deleteRole(@PathVariable Long id) { + // 检查是否有管理员使用该角色 + Long count = adminRoleMapper.selectCount( + new LambdaQueryWrapper().eq(AdminRole::getRoleId, id) + ); + if (count > 0) { + throw new BusinessException(ResultCode.OPERATION_NOT_ALLOWED, "该角色下有管理员,无法删除"); + } + roleMapper.deleteById(id); + rolePermissionMapper.deleteByRoleId(id); + return Result.success(); + } + + @Operation(summary = "获取角色权限") + @GetMapping("/role/{id}/permissions") + @RequirePermission("system:role:view") + public Result> getRolePermissions(@PathVariable Long id) { + List permissionIds = permissionMapper.selectPermissionIdsByRoleId(id); + return Result.success(permissionIds); + } + + @Operation(summary = "更新角色权限") + @PutMapping("/role/{id}/permissions") + @RequirePermission("system:role:permission") + public Result updateRolePermissions(@PathVariable Long id, @RequestBody List permissionIds) { + // 删除原有权限 + rolePermissionMapper.deleteByRoleId(id); + // 添加新权限 + for (Long permissionId : permissionIds) { + RolePermission rp = new RolePermission(); + rp.setRoleId(id); + rp.setPermissionId(permissionId); + rp.setCreatedAt(LocalDateTime.now()); + rolePermissionMapper.insert(rp); + } + return Result.success(); + } + + // ==================== 权限管理 ==================== + + @Operation(summary = "权限树") + @GetMapping("/permission/tree") + @RequirePermission("system:permission:view") + public Result> getPermissionTree() { + return Result.success(adminService.getPermissionTree()); + } + + @Operation(summary = "创建权限") + @PostMapping("/permission") + @RequirePermission("system:permission:add") + public Result createPermission(@RequestBody Permission permission) { + // 检查编码唯一性 + Permission existing = permissionMapper.selectOne( + new LambdaQueryWrapper().eq(Permission::getCode, permission.getCode()) + ); + if (existing != null) { + throw new BusinessException(ResultCode.DATA_ALREADY_EXISTS, "权限编码已存在"); + } + permission.setCreatedAt(LocalDateTime.now()); + permission.setUpdatedAt(LocalDateTime.now()); + permissionMapper.insert(permission); + return Result.success(); + } + + @Operation(summary = "更新权限") + @PutMapping("/permission/{id}") + @RequirePermission("system:permission:edit") + public Result updatePermission(@PathVariable Long id, @RequestBody Permission permission) { + Permission existing = permissionMapper.selectById(id); + if (existing == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND); + } + permission.setId(id); + permission.setCode(existing.getCode()); // 编码不允许修改 + permission.setUpdatedAt(LocalDateTime.now()); + permissionMapper.updateById(permission); + return Result.success(); + } + + @Operation(summary = "删除权限") + @DeleteMapping("/permission/{id}") + @RequirePermission("system:permission:delete") + public Result deletePermission(@PathVariable Long id) { + // 检查是否有子权限 + Long childCount = permissionMapper.selectCount( + new LambdaQueryWrapper().eq(Permission::getParentId, id) + ); + if (childCount > 0) { + throw new BusinessException(ResultCode.OPERATION_NOT_ALLOWED, "存在子权限,无法删除"); + } + permissionMapper.deleteById(id); + return Result.success(); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminUploadController.java b/src/main/java/com/dora/controller/admin/AdminUploadController.java new file mode 100644 index 0000000..8848a62 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminUploadController.java @@ -0,0 +1,111 @@ +package com.dora.controller.admin; + +import com.dora.common.result.Result; +import com.dora.config.CosConfig; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Tag(name = "管理端-文件上传") +@RestController +@RequestMapping("/admin/upload") +@RequiredArgsConstructor +public class AdminUploadController { + + private final COSClient cosClient; + private final CosConfig cosConfig; + + @Operation(summary = "上传单个图片") + @PostMapping("/image") + public Result uploadImage(@RequestParam("file") MultipartFile file) { + try { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return Result.fail("只能上传图片文件"); + } + + // 验证文件大小(10MB) + if (file.getSize() > 10 * 1024 * 1024) { + return Result.fail("图片大小不能超过10MB"); + } + + String url = uploadToCos(file); + return Result.success(url); + + } catch (Exception e) { + log.error("上传图片失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + @Operation(summary = "上传多个图片") + @PostMapping("/images") + public Result> uploadImages(@RequestParam("files") MultipartFile[] files) { + try { + List urls = new ArrayList<>(); + + for (MultipartFile file : files) { + // 验证文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return Result.fail("只能上传图片文件"); + } + + // 验证文件大小(10MB) + if (file.getSize() > 10 * 1024 * 1024) { + return Result.fail("图片大小不能超过10MB"); + } + + String url = uploadToCos(file); + urls.add(url); + } + + return Result.success(urls); + + } catch (Exception e) { + log.error("上传图片失败", e); + return Result.fail("上传失败: " + e.getMessage()); + } + } + + private String uploadToCos(MultipartFile file) throws IOException { + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } + String fileName = "ai-debug/" + UUID.randomUUID().toString() + extension; + + // 设置元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + + cosClient.putObject(putObjectRequest); + + // 返回文件URL + return cosConfig.getFileUrl(fileName); + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminUserController.java b/src/main/java/com/dora/controller/admin/AdminUserController.java new file mode 100644 index 0000000..81da2b4 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminUserController.java @@ -0,0 +1,155 @@ +package com.dora.controller.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.annotation.RequirePermission; +import com.dora.common.context.AdminContext; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.Result; +import com.dora.common.result.ResultCode; +import com.dora.entity.PointsRecord; +import com.dora.entity.User; +import com.dora.mapper.PointsRecordMapper; +import com.dora.mapper.UserMapper; +import com.dora.vo.PageVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 用户管理控制器 + */ +@Tag(name = "用户管理", description = "用户列表、状态管理、积分调整") +@RestController +@RequestMapping("/admin/user") +@RequiredArgsConstructor +public class AdminUserController { + + private final UserMapper userMapper; + private final PointsRecordMapper pointsRecordMapper; + + @Operation(summary = "用户列表") + @GetMapping("/list") + @RequirePermission("user:view") + public Result> getUserList( + @RequestParam(defaultValue = "1") Integer page, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) Integer vipLevel, + @RequestParam(required = false) Integer status) { + + Page pageParam = new Page<>(page, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + if (StringUtils.hasText(keyword)) { + wrapper.and(w -> w.like(User::getNickname, keyword) + .or().like(User::getPhone, keyword)); + } + if (vipLevel != null) { + wrapper.eq(User::getVipLevel, vipLevel); + } + if (status != null) { + wrapper.eq(User::getStatus, status); + } + wrapper.orderByDesc(User::getCreatedAt); + + Page result = userMapper.selectPage(pageParam, wrapper); + return Result.success(PageVO.of(result.getRecords(), result.getTotal(), page, pageSize)); + } + + @Operation(summary = "用户详情") + @GetMapping("/{id}") + @RequirePermission("user:view") + public Result getUserDetail(@PathVariable Long id) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + return Result.success(user); + } + + @Operation(summary = "更新用户状态") + @PutMapping("/{id}/status") + @RequirePermission("user:edit") + public Result updateUserStatus(@PathVariable Long id, @RequestBody StatusDTO dto) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + user.setStatus(dto.getStatus()); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + return Result.success(); + } + + @Operation(summary = "调整用户积分") + @PostMapping("/{id}/points") + @RequirePermission("user:points") + public Result adjustUserPoints(@PathVariable Long id, @RequestBody PointsAdjustDTO dto) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + + int newPoints = user.getPoints() + dto.getPoints(); + if (newPoints < 0) { + throw new BusinessException(ResultCode.OPERATION_NOT_ALLOWED, "积分不足"); + } + + // 更新用户积分 + user.setPoints(newPoints); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + + // 记录积分流水 + PointsRecord record = new PointsRecord(); + record.setUserId(id); + record.setType(dto.getPoints() > 0 ? 3 : 2); // 3赠送 2消费 + record.setPoints(dto.getPoints()); + record.setBalance(newPoints); + record.setBizType("admin_adjust"); + record.setRemark(dto.getRemark() != null ? dto.getRemark() : "管理员调整"); + record.setCreatedAt(LocalDateTime.now()); + pointsRecordMapper.insert(record); + + return Result.success(); + } + + @Operation(summary = "更新用户VIP") + @PutMapping("/{id}/vip") + @RequirePermission("user:vip") + public Result updateUserVip(@PathVariable Long id, @RequestBody VipUpdateDTO dto) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + user.setVipLevel(dto.getVipLevel()); + user.setVipExpireTime(dto.getVipExpireTime()); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + return Result.success(); + } + + @Data + public static class StatusDTO { + private Integer status; + } + + @Data + public static class PointsAdjustDTO { + private Integer points; + private String remark; + } + + @Data + public static class VipUpdateDTO { + private Integer vipLevel; + private LocalDateTime vipExpireTime; + } +} diff --git a/src/main/java/com/dora/controller/admin/AdminWorkController.java b/src/main/java/com/dora/controller/admin/AdminWorkController.java new file mode 100644 index 0000000..07e4b92 --- /dev/null +++ b/src/main/java/com/dora/controller/admin/AdminWorkController.java @@ -0,0 +1,87 @@ +package com.dora.controller.admin; + +import com.dora.common.result.Result; +import com.dora.dto.admin.AdminWorkQueryDTO; +import com.dora.service.AdminWorkService; +import com.dora.vo.PageVO; +import com.dora.vo.admin.AdminWorkVO; +import com.dora.vo.admin.AdminWorkStatsVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +/** + * 管理后台广场作品控制器 + */ +@Tag(name = "广场作品管理", description = "广场作品的查询、审核、精选等管理") +@RestController +@RequestMapping("/admin/work") +@RequiredArgsConstructor +public class AdminWorkController { + + private final AdminWorkService adminWorkService; + + @Operation(summary = "广场作品列表") + @GetMapping("/list") + public Result> getWorkList(AdminWorkQueryDTO dto) { + return Result.success(adminWorkService.getWorkList(dto)); + } + + @Operation(summary = "广场作品统计") + @GetMapping("/stats") + public Result getWorkStats() { + return Result.success(adminWorkService.getWorkStats()); + } + + @Operation(summary = "作品详情") + @GetMapping("/{id}") + public Result getWorkDetail(@PathVariable Long id) { + return Result.success(adminWorkService.getWorkDetail(id)); + } + + @Operation(summary = "审核作品") + @PutMapping("/{id}/audit") + public Result auditWork(@PathVariable Long id, @RequestBody AuditDTO dto) { + adminWorkService.auditWork(id, dto.getAuditStatus(), dto.getAuditRemark()); + return Result.success(); + } + + @Operation(summary = "设置/取消精选") + @PutMapping("/{id}/featured") + public Result setFeatured(@PathVariable Long id, @RequestBody FeaturedDTO dto) { + adminWorkService.setFeatured(id, dto.getIsFeatured()); + return Result.success(); + } + + @Operation(summary = "下架/上架作品") + @PutMapping("/{id}/status") + public Result setStatus(@PathVariable Long id, @RequestBody StatusDTO dto) { + adminWorkService.setWorkStatus(id, dto.getStatus()); + return Result.success(); + } + + @Operation(summary = "删除作品") + @DeleteMapping("/{id}") + public Result deleteWork(@PathVariable Long id) { + adminWorkService.deleteWork(id); + return Result.success(); + } + + // 内部DTO类 + @lombok.Data + public static class AuditDTO { + private Integer auditStatus; + private String auditRemark; + } + + @lombok.Data + public static class FeaturedDTO { + private Integer isFeatured; + } + + @lombok.Data + public static class StatusDTO { + private Integer status; + } +} diff --git a/src/main/java/com/dora/dto/AiModelDTO.java b/src/main/java/com/dora/dto/AiModelDTO.java new file mode 100644 index 0000000..80441c8 --- /dev/null +++ b/src/main/java/com/dora/dto/AiModelDTO.java @@ -0,0 +1,61 @@ +package com.dora.dto; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Data +public class AiModelDTO { + private Long id; + + @NotNull(message = "厂商ID不能为空") + private Long providerId; + + @NotBlank(message = "模型名称不能为空") + private String name; + + @NotBlank(message = "模型编码不能为空") + private String code; + + @NotBlank(message = "模型类型不能为空") + private String type; + + private String category; + private String description; + private String icon; + /** 封面图URL */ + private String coverImage; + + @NotBlank(message = "API端点不能为空") + private String apiEndpoint; + + private String requestMethod; + private String requestHeaders; + + @NotBlank(message = "请求模板不能为空") + private String requestTemplate; + + @NotBlank(message = "响应映射不能为空") + private String responseMapping; + + @NotBlank(message = "输入参数配置不能为空") + private String inputParams; + + @NotNull(message = "积分消耗不能为空") + private Integer pointsCost; + + private Integer maxConcurrent; + private Integer timeout; + private String workflowType; + private String workflowId; + private String workflowConfig; + private Integer isAsync; + + /** 是否显示在AI功能列表:0不显示 1显示 */ + private Integer showInList; + + @NotNull(message = "状态不能为空") + private Integer status; + + private Integer sort; +} \ No newline at end of file diff --git a/src/main/java/com/dora/dto/AiProviderDTO.java b/src/main/java/com/dora/dto/AiProviderDTO.java new file mode 100644 index 0000000..a4f8177 --- /dev/null +++ b/src/main/java/com/dora/dto/AiProviderDTO.java @@ -0,0 +1,27 @@ +package com.dora.dto; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Data +public class AiProviderDTO { + private Long id; + + @NotBlank(message = "厂商名称不能为空") + private String name; + + @NotBlank(message = "厂商编码不能为空") + private String code; + + private String description; + private String baseUrl; + private String apiKey; + private String secretKey; + private String extraConfig; + + @NotNull(message = "状态不能为空") + private Integer status; + + private Integer sort; +} \ No newline at end of file diff --git a/src/main/java/com/dora/dto/AiTaskDTO.java b/src/main/java/com/dora/dto/AiTaskDTO.java new file mode 100644 index 0000000..5c5c2ca --- /dev/null +++ b/src/main/java/com/dora/dto/AiTaskDTO.java @@ -0,0 +1,19 @@ +package com.dora.dto; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Data +public class AiTaskDTO { + @NotNull(message = "模型ID不能为空") + private Long modelId; + + @NotBlank(message = "输入参数不能为空") + private String inputParams; + + private Integer priority; + + /** 是否订阅完成通知 */ + private Boolean subscribeNotify; +} \ No newline at end of file diff --git a/src/main/java/com/dora/dto/CreatePointsOrderDTO.java b/src/main/java/com/dora/dto/CreatePointsOrderDTO.java new file mode 100644 index 0000000..e5c17bf --- /dev/null +++ b/src/main/java/com/dora/dto/CreatePointsOrderDTO.java @@ -0,0 +1,10 @@ +package com.dora.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CreatePointsOrderDTO { + @NotNull(message = "套餐ID不能为空") + private Long packageId; +} diff --git a/src/main/java/com/dora/dto/PublishWorkDTO.java b/src/main/java/com/dora/dto/PublishWorkDTO.java new file mode 100644 index 0000000..ca4d28e --- /dev/null +++ b/src/main/java/com/dora/dto/PublishWorkDTO.java @@ -0,0 +1,27 @@ +package com.dora.dto; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 发布作品DTO + */ +@Data +public class PublishWorkDTO { + + @NotNull(message = "任务ID不能为空") + private Long taskId; + + @NotBlank(message = "作品标题不能为空") + @Size(max = 20, message = "标题长度不能超过20个字符") + private String title; + + @Size(max = 5000, message = "描述长度不能超过5000个字符") + private String description; + + @NotNull(message = "分类ID不能为空") + private Long categoryId; +} diff --git a/src/main/java/com/dora/dto/RefreshTokenDTO.java b/src/main/java/com/dora/dto/RefreshTokenDTO.java new file mode 100644 index 0000000..79bf2fe --- /dev/null +++ b/src/main/java/com/dora/dto/RefreshTokenDTO.java @@ -0,0 +1,13 @@ +package com.dora.dto; + +import lombok.Data; + +/** + * 刷新Token请求DTO + */ +@Data +public class RefreshTokenDTO { + + /** 刷新令牌 */ + private String refreshToken; +} diff --git a/src/main/java/com/dora/dto/UpdateProfileDTO.java b/src/main/java/com/dora/dto/UpdateProfileDTO.java new file mode 100644 index 0000000..7b1b422 --- /dev/null +++ b/src/main/java/com/dora/dto/UpdateProfileDTO.java @@ -0,0 +1,18 @@ +package com.dora.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 更新用户资料DTO + */ +@Data +@Schema(description = "更新用户资料请求") +public class UpdateProfileDTO { + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像URL") + private String avatar; +} diff --git a/src/main/java/com/dora/dto/WorkQueryDTO.java b/src/main/java/com/dora/dto/WorkQueryDTO.java new file mode 100644 index 0000000..848544b --- /dev/null +++ b/src/main/java/com/dora/dto/WorkQueryDTO.java @@ -0,0 +1,25 @@ +package com.dora.dto; + +import lombok.Data; + +/** + * 作品查询DTO + */ +@Data +public class WorkQueryDTO { + + /** 搜索关键词(匹配标题、描述、prompt) */ + private String keyword; + + /** 排序类型:hot最热 new最新 */ + private String sortType = "hot"; + + /** 分类ID */ + private Long categoryId; + + /** 页码 */ + private Integer pageNum = 1; + + /** 每页数量 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/dora/dto/WxLoginDTO.java b/src/main/java/com/dora/dto/WxLoginDTO.java new file mode 100644 index 0000000..df4b43b --- /dev/null +++ b/src/main/java/com/dora/dto/WxLoginDTO.java @@ -0,0 +1,28 @@ +package com.dora.dto; + +import lombok.Data; + +/** + * 微信登录请求DTO + */ +@Data +public class WxLoginDTO { + + /** 微信登录code(首次登录时使用) */ + private String code; + + /** 微信openid(从/user/check接口获取,避免code重复使用) */ + private String openid; + + /** 用户昵称 */ + private String nickname; + + /** 用户头像 */ + private String avatar; + + /** 手机号授权code(用于获取手机号) */ + private String phoneCode; + + /** 邀请码(注册时填写,用于推广奖励) */ + private String inviteCode; +} diff --git a/src/main/java/com/dora/dto/admin/AdminCreateDTO.java b/src/main/java/com/dora/dto/admin/AdminCreateDTO.java new file mode 100644 index 0000000..5d58852 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminCreateDTO.java @@ -0,0 +1,43 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +/** + * 创建管理员DTO + */ +@Data +@Schema(description = "创建管理员请求") +public class AdminCreateDTO { + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 20, message = "用户名长度3-20位") + @Schema(description = "用户名", required = true) + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 20, message = "密码长度6-20位") + @Schema(description = "密码", required = true) + private String password; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @NotEmpty(message = "角色不能为空") + @Schema(description = "角色ID列表", required = true) + private List roleIds; + + @Schema(description = "状态:0禁用 1正常", defaultValue = "1") + private Integer status = 1; +} diff --git a/src/main/java/com/dora/dto/admin/AdminLoginDTO.java b/src/main/java/com/dora/dto/admin/AdminLoginDTO.java new file mode 100644 index 0000000..831aa8e --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminLoginDTO.java @@ -0,0 +1,21 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 管理员登录DTO + */ +@Data +@Schema(description = "管理员登录请求") +public class AdminLoginDTO { + + @NotBlank(message = "用户名不能为空") + @Schema(description = "用户名", required = true) + private String username; + + @NotBlank(message = "密码不能为空") + @Schema(description = "密码", required = true) + private String password; +} diff --git a/src/main/java/com/dora/dto/admin/AdminQueryDTO.java b/src/main/java/com/dora/dto/admin/AdminQueryDTO.java new file mode 100644 index 0000000..b212e2d --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminQueryDTO.java @@ -0,0 +1,24 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 管理员查询DTO + */ +@Data +@Schema(description = "管理员查询请求") +public class AdminQueryDTO { + + @Schema(description = "关键词(用户名/姓名)") + private String keyword; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "页码", defaultValue = "1") + private Integer page = 1; + + @Schema(description = "每页数量", defaultValue = "10") + private Integer pageSize = 10; +} diff --git a/src/main/java/com/dora/dto/admin/AdminRegisterDTO.java b/src/main/java/com/dora/dto/admin/AdminRegisterDTO.java new file mode 100644 index 0000000..bf34a58 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminRegisterDTO.java @@ -0,0 +1,33 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 管理员注册DTO(仅用于测试) + */ +@Data +@Schema(description = "管理员注册请求") +public class AdminRegisterDTO { + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 20, message = "用户名长度3-20位") + @Schema(description = "用户名", required = true) + private String username; + + @NotBlank(message = "密码不能为空") + @Size(min = 6, max = 20, message = "密码长度6-20位") + @Schema(description = "密码", required = true) + private String password; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; +} diff --git a/src/main/java/com/dora/dto/admin/AdminUpdateDTO.java b/src/main/java/com/dora/dto/admin/AdminUpdateDTO.java new file mode 100644 index 0000000..36918f9 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminUpdateDTO.java @@ -0,0 +1,34 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +/** + * 更新管理员DTO + */ +@Data +@Schema(description = "更新管理员请求") +public class AdminUpdateDTO { + + @Schema(description = "密码(留空则不修改)") + private String password; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @NotEmpty(message = "角色不能为空") + @Schema(description = "角色ID列表", required = true) + private List roleIds; + + @Schema(description = "状态:0禁用 1正常") + private Integer status; +} diff --git a/src/main/java/com/dora/dto/admin/AdminWorkQueryDTO.java b/src/main/java/com/dora/dto/admin/AdminWorkQueryDTO.java new file mode 100644 index 0000000..9317eed --- /dev/null +++ b/src/main/java/com/dora/dto/admin/AdminWorkQueryDTO.java @@ -0,0 +1,33 @@ +package com.dora.dto.admin; + +import lombok.Data; + +/** + * 管理端作品查询DTO + */ +@Data +public class AdminWorkQueryDTO { + /** 关键词(标题/描述) */ + private String keyword; + + /** 分类ID */ + private Long categoryId; + + /** 任务类型 */ + private String taskType; + + /** 审核状态 */ + private Integer auditStatus; + + /** 是否精选 */ + private Integer isFeatured; + + /** 用户ID */ + private Long userId; + + /** 页码 */ + private Integer page = 1; + + /** 每页数量 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/dora/dto/admin/EmailCodeDTO.java b/src/main/java/com/dora/dto/admin/EmailCodeDTO.java new file mode 100644 index 0000000..559d316 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/EmailCodeDTO.java @@ -0,0 +1,19 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 发送邮箱验证码DTO + */ +@Data +@Schema(description = "发送邮箱验证码请求") +public class EmailCodeDTO { + + @Schema(description = "邮箱地址", required = true) + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; +} diff --git a/src/main/java/com/dora/dto/admin/EmailLoginDTO.java b/src/main/java/com/dora/dto/admin/EmailLoginDTO.java new file mode 100644 index 0000000..d082ba4 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/EmailLoginDTO.java @@ -0,0 +1,23 @@ +package com.dora.dto.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 邮箱验证码登录DTO + */ +@Data +@Schema(description = "邮箱验证码登录请求") +public class EmailLoginDTO { + + @Schema(description = "邮箱地址", required = true) + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + private String email; + + @Schema(description = "验证码", required = true) + @NotBlank(message = "验证码不能为空") + private String code; +} diff --git a/src/main/java/com/dora/dto/admin/OrderQueryDTO.java b/src/main/java/com/dora/dto/admin/OrderQueryDTO.java new file mode 100644 index 0000000..982a8ff --- /dev/null +++ b/src/main/java/com/dora/dto/admin/OrderQueryDTO.java @@ -0,0 +1,33 @@ +package com.dora.dto.admin; + +import lombok.Data; + +/** + * 订单查询DTO + */ +@Data +public class OrderQueryDTO { + /** 订单号 */ + private String orderNo; + + /** 用户名 */ + private String username; + + /** 订单类型:1VIP充值 2积分充值 */ + private Integer type; + + /** 订单状态 */ + private Integer status; + + /** 开始日期 */ + private String startDate; + + /** 结束日期 */ + private String endDate; + + /** 页码 */ + private Integer page = 1; + + /** 每页数量 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/dora/dto/admin/PointsPackageDTO.java b/src/main/java/com/dora/dto/admin/PointsPackageDTO.java new file mode 100644 index 0000000..a916abf --- /dev/null +++ b/src/main/java/com/dora/dto/admin/PointsPackageDTO.java @@ -0,0 +1,30 @@ +package com.dora.dto.admin; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.math.BigDecimal; + +@Data +public class PointsPackageDTO { + private Long id; + + @NotBlank(message = "套餐名称不能为空") + private String name; + + @NotNull(message = "积分数量不能为空") + private Integer points; + + @NotNull(message = "价格不能为空") + private BigDecimal price; + + private BigDecimal originalPrice; + private Integer bonusPoints; + private Integer validDays; + private String description; + private String bgImage; + private String cardStyle; + private String btnStyle; + private Integer sort; + private Integer status; +} diff --git a/src/main/java/com/dora/dto/admin/RedeemCodeCreateDTO.java b/src/main/java/com/dora/dto/admin/RedeemCodeCreateDTO.java new file mode 100644 index 0000000..c544117 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/RedeemCodeCreateDTO.java @@ -0,0 +1,38 @@ +package com.dora.dto.admin; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 兑换码创建DTO + */ +@Data +public class RedeemCodeCreateDTO { + /** 生成数量 */ + private Integer count = 1; + + /** 类型:1积分 2VIP会员 */ + private Integer type; + + /** 奖励值(积分数量或VIP天数) */ + private Integer rewardValue; + + /** VIP等级(type=2时有效) */ + private Integer vipLevel; + + /** 每个码可使用次数 */ + private Integer totalCount = 1; + + /** 生效开始时间 */ + private LocalDateTime startTime; + + /** 过期时间 */ + private LocalDateTime expireTime; + + /** 备注 */ + private String remark; + + /** 自定义兑换码前缀 */ + private String prefix; +} diff --git a/src/main/java/com/dora/dto/admin/RedeemCodeQueryDTO.java b/src/main/java/com/dora/dto/admin/RedeemCodeQueryDTO.java new file mode 100644 index 0000000..2207607 --- /dev/null +++ b/src/main/java/com/dora/dto/admin/RedeemCodeQueryDTO.java @@ -0,0 +1,30 @@ +package com.dora.dto.admin; + +import lombok.Data; + +/** + * 兑换码查询DTO + */ +@Data +public class RedeemCodeQueryDTO { + /** 兑换码 */ + private String code; + + /** 批次号 */ + private String batchNo; + + /** 类型:1积分 2VIP会员 */ + private Integer type; + + /** 状态 */ + private Integer status; + + /** 是否已用完:0否 1是 */ + private Integer exhausted; + + /** 页码 */ + private Integer page = 1; + + /** 每页数量 */ + private Integer pageSize = 10; +} diff --git a/src/main/java/com/dora/dto/package-info.java b/src/main/java/com/dora/dto/package-info.java new file mode 100644 index 0000000..a993050 --- /dev/null +++ b/src/main/java/com/dora/dto/package-info.java @@ -0,0 +1,4 @@ +/** + * 数据传输对象层 + */ +package com.dora.dto; diff --git a/src/main/java/com/dora/dto/video/DialogueItem.java b/src/main/java/com/dora/dto/video/DialogueItem.java new file mode 100644 index 0000000..c990bef --- /dev/null +++ b/src/main/java/com/dora/dto/video/DialogueItem.java @@ -0,0 +1,15 @@ +package com.dora.dto.video; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DialogueItem { + private Long characterId; + private String characterName; + private String characterAvatar; + private String content; +} diff --git a/src/main/java/com/dora/dto/video/GenerateScriptDTO.java b/src/main/java/com/dora/dto/video/GenerateScriptDTO.java new file mode 100644 index 0000000..a66c6f0 --- /dev/null +++ b/src/main/java/com/dora/dto/video/GenerateScriptDTO.java @@ -0,0 +1,10 @@ +package com.dora.dto.video; + +import lombok.Data; +import jakarta.validation.constraints.NotBlank; + +@Data +public class GenerateScriptDTO { + @NotBlank(message = "创意内容不能为空") + private String idea; +} diff --git a/src/main/java/com/dora/dto/video/ProjectCharacterDTO.java b/src/main/java/com/dora/dto/video/ProjectCharacterDTO.java new file mode 100644 index 0000000..53206b1 --- /dev/null +++ b/src/main/java/com/dora/dto/video/ProjectCharacterDTO.java @@ -0,0 +1,18 @@ +package com.dora.dto.video; + +import lombok.Data; + +@Data +public class ProjectCharacterDTO { + private Long id; + private Long templateId; + private String name; + private String age; + private String gender; + private String voiceType; + private String appearance; + private String clothing; + private String description; + private String imageUrl; + private String referenceImageUrl; +} diff --git a/src/main/java/com/dora/dto/video/ProjectSceneDTO.java b/src/main/java/com/dora/dto/video/ProjectSceneDTO.java new file mode 100644 index 0000000..ca83bd8 --- /dev/null +++ b/src/main/java/com/dora/dto/video/ProjectSceneDTO.java @@ -0,0 +1,13 @@ +package com.dora.dto.video; + +import lombok.Data; + +@Data +public class ProjectSceneDTO { + private Long id; + private String sceneName; + private String sceneTitle; + private String sceneDescription; + private String sceneStory; + private Integer storyboardCount; +} diff --git a/src/main/java/com/dora/dto/video/SceneStoryboardDTO.java b/src/main/java/com/dora/dto/video/SceneStoryboardDTO.java new file mode 100644 index 0000000..0173511 --- /dev/null +++ b/src/main/java/com/dora/dto/video/SceneStoryboardDTO.java @@ -0,0 +1,20 @@ +package com.dora.dto.video; + +import lombok.Data; +import java.util.List; + +@Data +public class SceneStoryboardDTO { + private Long id; + private Integer storyboardIndex; + private String shotType; + private String cameraAngle; + private String cameraMove; + private String description; + private String narration; + private String dialogue; + private Long dialogueCharacterId; + private List dialogues; + private String imageUrl; + private Integer duration; +} diff --git a/src/main/java/com/dora/dto/video/ScriptGenerationResult.java b/src/main/java/com/dora/dto/video/ScriptGenerationResult.java new file mode 100644 index 0000000..d05662e --- /dev/null +++ b/src/main/java/com/dora/dto/video/ScriptGenerationResult.java @@ -0,0 +1,32 @@ +package com.dora.dto.video; + +import lombok.Data; +import java.util.List; + +@Data +public class ScriptGenerationResult { + private String title; + private String summary; + private String content; + private List characters; + private List scenes; + + @Data + public static class CharacterInfo { + private String name; + private String description; + private String voiceType; + private String age; + private String gender; + private String appearance; + private String clothing; + } + + @Data + public static class SceneInfo { + private String name; + private String title; + private String description; + private String story; + } +} diff --git a/src/main/java/com/dora/dto/video/StoryboardGenerationResult.java b/src/main/java/com/dora/dto/video/StoryboardGenerationResult.java new file mode 100644 index 0000000..bd25e85 --- /dev/null +++ b/src/main/java/com/dora/dto/video/StoryboardGenerationResult.java @@ -0,0 +1,22 @@ +package com.dora.dto.video; + +import lombok.Data; +import java.util.List; + +@Data +public class StoryboardGenerationResult { + private List storyboards; + + @Data + public static class StoryboardInfo { + private Integer index; + private String shotType; + private String cameraAngle; + private String cameraMove; + private String description; + private String narration; + private String dialogue; + private String dialogueCharacter; + private Integer duration; + } +} diff --git a/src/main/java/com/dora/dto/video/VideoProjectDTO.java b/src/main/java/com/dora/dto/video/VideoProjectDTO.java new file mode 100644 index 0000000..3c49f61 --- /dev/null +++ b/src/main/java/com/dora/dto/video/VideoProjectDTO.java @@ -0,0 +1,17 @@ +package com.dora.dto.video; + +import lombok.Data; + +@Data +public class VideoProjectDTO { + private Long id; + private String projectName; + private String storyTitle; + private String storyOutline; + private String originalIdea; + private Integer creationMode; + private String videoDuration; + private String videoRatio; + private String videoStyle; + private String coverUrl; +} diff --git a/src/main/java/com/dora/entity/Admin.java b/src/main/java/com/dora/entity/Admin.java new file mode 100644 index 0000000..69133b6 --- /dev/null +++ b/src/main/java/com/dora/entity/Admin.java @@ -0,0 +1,45 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 管理员实体 + */ +@Data +@TableName("`admin`") +public class Admin implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String username; + + private String password; + + private String realName; + + private String phone; + + private String email; + + private String avatar; + + private Integer status; + + private LocalDateTime lastLoginTime; + + private String lastLoginIp; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/AdminRole.java b/src/main/java/com/dora/entity/AdminRole.java new file mode 100644 index 0000000..fa346fd --- /dev/null +++ b/src/main/java/com/dora/entity/AdminRole.java @@ -0,0 +1,26 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 管理员-角色关联实体 + */ +@Data +@TableName("admin_role") +public class AdminRole implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long adminId; + + private Long roleId; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/AiModel.java b/src/main/java/com/dora/entity/AiModel.java new file mode 100644 index 0000000..3f7f9f1 --- /dev/null +++ b/src/main/java/com/dora/entity/AiModel.java @@ -0,0 +1,54 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ai_model") +public class AiModel { + @TableId(type = IdType.AUTO) + private Long id; + private Long providerId; + private String name; + private String code; + private String type; + private String category; + private String description; + private String icon; + /** 封面图URL */ + private String coverImage; + private String apiEndpoint; + private String requestMethod; + private String requestHeaders; + private String requestTemplate; + private String responseMapping; + private String inputParams; + private Integer pointsCost; + private Integer maxConcurrent; + private Integer timeout; + private String workflowType; + private String workflowId; + private String workflowConfig; + private Integer isAsync; + private String asyncQueryEndpoint; + private String asyncQueryMethod; + private String asyncQueryBody; + private String asyncQueryMapping; + private String asyncStatusMapping; + private Integer asyncPollInterval; + private Integer asyncPollMaxCount; + private Integer asyncPollTimeout; + /** 是否将结果URL转存到COS */ + private Integer resultTransferCos; + /** 结果中的URL字段路径,如 data.remote_url */ + private String resultUrlField; + /** 是否显示在AI功能列表:0不显示 1显示 */ + private Integer showInList; + private Integer status; + private Integer sort; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @TableLogic + private Integer deleted; +} \ No newline at end of file diff --git a/src/main/java/com/dora/entity/AiPromptTemplate.java b/src/main/java/com/dora/entity/AiPromptTemplate.java new file mode 100644 index 0000000..3968095 --- /dev/null +++ b/src/main/java/com/dora/entity/AiPromptTemplate.java @@ -0,0 +1,25 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ai_prompt_template") +public class AiPromptTemplate { + @TableId(type = IdType.AUTO) + private Long id; + private String templateCode; + private String templateName; + private String systemPrompt; + private String userPromptTemplate; + private String outputFormat; + private String modelCode; + private BigDecimal temperature; + private Integer maxTokens; + private String description; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/AiProvider.java b/src/main/java/com/dora/entity/AiProvider.java new file mode 100644 index 0000000..d4505a7 --- /dev/null +++ b/src/main/java/com/dora/entity/AiProvider.java @@ -0,0 +1,25 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ai_provider") +public class AiProvider { + @TableId(type = IdType.AUTO) + private Long id; + private String name; + private String code; + private String description; + private String baseUrl; + private String apiKey; + private String secretKey; + private String extraConfig; + private Integer status; + private Integer sort; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @TableLogic + private Integer deleted; +} \ No newline at end of file diff --git a/src/main/java/com/dora/entity/AiTask.java b/src/main/java/com/dora/entity/AiTask.java new file mode 100644 index 0000000..589cde9 --- /dev/null +++ b/src/main/java/com/dora/entity/AiTask.java @@ -0,0 +1,43 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ai_task") +public class AiTask { + @TableId(type = IdType.AUTO) + private Long id; + private String taskNo; + private Long userId; + private Long modelId; + private String modelCode; + private String inputParams; + private String outputResult; + private Integer pointsCost; + private Integer status; + private Integer progress; + private String errorMessage; + /** 外部任务ID,用于异步任务状态查询 */ + private String externalTaskId; + /** 轮询次数 */ + private Integer pollCount; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer duration; + private Integer retryCount; + private Integer maxRetry; + private Integer priority; + private String ip; + /** 是否订阅完成通知:0否 1是 */ + private Integer subscribeNotify; + /** 通知是否已发送:0否 1是 */ + private Integer notifySent; + /** 发布状态:0未发布 1审核中 2已发布 3审核未通过 */ + private Integer publishStatus; + /** 关联的作品ID */ + private Long workId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dora/entity/AiUsageRecord.java b/src/main/java/com/dora/entity/AiUsageRecord.java new file mode 100644 index 0000000..29359fd --- /dev/null +++ b/src/main/java/com/dora/entity/AiUsageRecord.java @@ -0,0 +1,54 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * AI使用记录实体 + */ +@Data +@TableName("ai_usage_record") +public class AiUsageRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + /** 任务类型:text2img文生图 img2img图生图 text2video文生视频 img2video图生视频 */ + private String taskType; + + private String model; + + private String prompt; + + /** 参考图URL数组(JSON格式) */ + private String refImages; + + private String result; + + private Integer tokensUsed; + + private Integer pointsCost; + + private Integer duration; + + /** 状态:0队列中 1进行中 2成功 3失败 */ + private Integer status; + + /** 进度百分比:0-100 */ + private Integer progress; + + private String errorMsg; + + private String ip; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/AiWork.java b/src/main/java/com/dora/entity/AiWork.java new file mode 100644 index 0000000..899e896 --- /dev/null +++ b/src/main/java/com/dora/entity/AiWork.java @@ -0,0 +1,73 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * AI广场作品实体 + */ +@Data +@TableName("ai_work") +public class AiWork implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long userId; + + private Long categoryId; + + private String title; + + private String description; + + private String contentUrl; + + private Integer contentType; + + /** 任务类型:text2img文生图 img2img图生图 text2video文生视频 img2video图生视频 */ + private String taskType; + + /** AI模型名称 */ + private String model; + + private String prompt; + + private String tags; + + private Integer viewCount; + + private Integer likeCount; + + private Integer collectCount; + + private Integer commentCount; + + private Integer isPublic; + + private Integer auditStatus; + + private String auditRemark; + + private LocalDateTime auditTime; + + private Long auditorId; + + private Integer isFeatured; + + private Integer featuredSort; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/AiWorkComment.java b/src/main/java/com/dora/entity/AiWorkComment.java new file mode 100644 index 0000000..517f249 --- /dev/null +++ b/src/main/java/com/dora/entity/AiWorkComment.java @@ -0,0 +1,39 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 作品评论实体 + */ +@Data +@TableName("ai_work_comment") +public class AiWorkComment implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long workId; + + private Long userId; + + private Long parentId; + + private Long replyUserId; + + private String content; + + private Integer likeCount; + + private Integer status; + + private LocalDateTime createdAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/AiWorkLike.java b/src/main/java/com/dora/entity/AiWorkLike.java new file mode 100644 index 0000000..3a45ad6 --- /dev/null +++ b/src/main/java/com/dora/entity/AiWorkLike.java @@ -0,0 +1,26 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 作品点赞实体 + */ +@Data +@TableName("ai_work_like") +public class AiWorkLike implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long workId; + + private Long userId; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/AuditRecord.java b/src/main/java/com/dora/entity/AuditRecord.java new file mode 100644 index 0000000..0aaa443 --- /dev/null +++ b/src/main/java/com/dora/entity/AuditRecord.java @@ -0,0 +1,40 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 审核记录实体(用于记录审核历史) + */ +@Data +@TableName("audit_record") +public class AuditRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 业务类型:work作品 comment评论 */ + private String bizType; + + /** 业务ID */ + private Long bizId; + + /** 审核状态:0待审核 1通过 2拒绝 */ + private Integer status; + + /** 审核备注 */ + private String remark; + + /** 审核人ID */ + private Long auditorId; + + /** 审核人名称 */ + private String auditorName; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/Banner.java b/src/main/java/com/dora/entity/Banner.java new file mode 100644 index 0000000..57ef0bb --- /dev/null +++ b/src/main/java/com/dora/entity/Banner.java @@ -0,0 +1,49 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * Banner轮播图实体 + */ +@Data +@TableName("banner") +public class Banner implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String title; + + private String imageUrl; + + private Integer linkType; + + private String linkUrl; + + private String position; + + private Integer sort; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + private Integer clickCount; + + private Long creatorId; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/CharacterTemplate.java b/src/main/java/com/dora/entity/CharacterTemplate.java new file mode 100644 index 0000000..6b47d4d --- /dev/null +++ b/src/main/java/com/dora/entity/CharacterTemplate.java @@ -0,0 +1,29 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("character_template") +public class CharacterTemplate { + @TableId(type = IdType.AUTO) + private Long id; + private String name; + private String gender; + private String ageRange; + private String voiceType; + private String appearance; + private String clothing; + private String description; + private String imageUrl; + private String category; + private Integer isSystem; + private Long userId; + private Integer sort; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/Notice.java b/src/main/java/com/dora/entity/Notice.java new file mode 100644 index 0000000..c65f408 --- /dev/null +++ b/src/main/java/com/dora/entity/Notice.java @@ -0,0 +1,51 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统公告实体 + */ +@Data +@TableName("notice") +public class Notice implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String title; + + private String content; + + private Integer type; + + private Integer level; + + private Integer target; + + private Integer isTop; + + private Integer isPopup; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + private Integer viewCount; + + private Long creatorId; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/NoticeRead.java b/src/main/java/com/dora/entity/NoticeRead.java new file mode 100644 index 0000000..52b56a0 --- /dev/null +++ b/src/main/java/com/dora/entity/NoticeRead.java @@ -0,0 +1,26 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 公告已读记录实体 + */ +@Data +@TableName("notice_read") +public class NoticeRead implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long noticeId; + + private Long userId; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/Permission.java b/src/main/java/com/dora/entity/Permission.java new file mode 100644 index 0000000..781a5f6 --- /dev/null +++ b/src/main/java/com/dora/entity/Permission.java @@ -0,0 +1,47 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 权限实体 + */ +@Data +@TableName("permission") +public class Permission implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long parentId; + + private String name; + + private String code; + + private Integer type; + + private String path; + + private String component; + + private String icon; + + private Integer sort; + + private Integer visible; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/PointsOrder.java b/src/main/java/com/dora/entity/PointsOrder.java new file mode 100644 index 0000000..bfa46c5 --- /dev/null +++ b/src/main/java/com/dora/entity/PointsOrder.java @@ -0,0 +1,25 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("points_order") +public class PointsOrder { + @TableId(type = IdType.AUTO) + private Long id; + private String orderNo; + private Long userId; + private Long packageId; + private Integer points; + private Integer bonusPoints; + private BigDecimal amount; + private Integer payType; + private LocalDateTime payTime; + private String transactionId; + private Integer status; // 0待支付 1已支付 2已取消 3已退款 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/PointsPackage.java b/src/main/java/com/dora/entity/PointsPackage.java new file mode 100644 index 0000000..503a8d7 --- /dev/null +++ b/src/main/java/com/dora/entity/PointsPackage.java @@ -0,0 +1,29 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("points_package") +public class PointsPackage { + @TableId(type = IdType.AUTO) + private Long id; + private String name; + private Integer points; + private BigDecimal price; + private BigDecimal originalPrice; + private Integer bonusPoints; + private Integer validDays; + private String description; + private String bgImage; + private String cardStyle; + private String btnStyle; + private Integer sort; + private Integer status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/PointsRecord.java b/src/main/java/com/dora/entity/PointsRecord.java new file mode 100644 index 0000000..5c90e47 --- /dev/null +++ b/src/main/java/com/dora/entity/PointsRecord.java @@ -0,0 +1,20 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("points_record") +public class PointsRecord { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Integer type; // 1充值 2消费 3赠送 4推广奖励 5签到 6退款 + private Integer points; + private Integer balance; + private String bizType; + private Long bizId; + private String remark; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/ProjectCharacter.java b/src/main/java/com/dora/entity/ProjectCharacter.java new file mode 100644 index 0000000..915d33f --- /dev/null +++ b/src/main/java/com/dora/entity/ProjectCharacter.java @@ -0,0 +1,34 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("project_character") +public class ProjectCharacter { + @TableId(type = IdType.AUTO) + private Long id; + private Long projectId; + private Long templateId; + private String name; + private String age; + private String gender; + private String voiceType; + private String appearance; + private String clothing; + private String description; + private String imageUrl; + private String referenceImageUrl; + private Integer sort; + /** + * 形象生成状态: 0-无图片, 1-生成中, 2-已生成 + */ + private Integer imageStatus; + /** + * 正在执行的任务编号(用于前端轮询) + */ + private String currentTaskNo; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/ProjectScene.java b/src/main/java/com/dora/entity/ProjectScene.java new file mode 100644 index 0000000..476d7e3 --- /dev/null +++ b/src/main/java/com/dora/entity/ProjectScene.java @@ -0,0 +1,24 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("project_scene") +public class ProjectScene { + @TableId(type = IdType.AUTO) + private Long id; + private Long projectId; + private String sceneName; + private String sceneTitle; + private String sceneDescription; + private String sceneStory; + private Integer storyboardCount; + private Integer sort; + private String videoUrl; // 场次视频URL + private String videoTaskNo; // 视频生成任务号 + private Integer videoStatus; // 视频状态:0-未生成,1-生成中,2-已生成 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/PromotionConfig.java b/src/main/java/com/dora/entity/PromotionConfig.java new file mode 100644 index 0000000..cdb7074 --- /dev/null +++ b/src/main/java/com/dora/entity/PromotionConfig.java @@ -0,0 +1,35 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 推广配置实体 + */ +@Data +@TableName("promotion_config") +public class PromotionConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Integer type; + + private Integer rewardPoints; + + private BigDecimal rewardPercent; + + private String description; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/PromotionRecord.java b/src/main/java/com/dora/entity/PromotionRecord.java new file mode 100644 index 0000000..b63ad9b --- /dev/null +++ b/src/main/java/com/dora/entity/PromotionRecord.java @@ -0,0 +1,34 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 推广记录实体 + */ +@Data +@TableName("promotion_record") +public class PromotionRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long inviterId; + + private Long inviteeId; + + private Integer rewardType; + + private Integer rewardPoints; + + private Integer rewardStatus; + + private LocalDateTime rewardTime; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/RedeemCode.java b/src/main/java/com/dora/entity/RedeemCode.java new file mode 100644 index 0000000..bad4f64 --- /dev/null +++ b/src/main/java/com/dora/entity/RedeemCode.java @@ -0,0 +1,63 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 兑换码实体 + */ +@Data +@TableName("redeem_code") +public class RedeemCode implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 兑换码 */ + private String code; + + /** 批次号 */ + private String batchNo; + + /** 类型:1积分 2VIP会员 */ + private Integer type; + + /** 奖励值(积分数量或VIP天数) */ + private Integer rewardValue; + + /** VIP等级(type=2时有效) */ + private Integer vipLevel; + + /** 可使用次数 */ + private Integer totalCount; + + /** 已使用次数 */ + private Integer usedCount; + + /** 生效开始时间 */ + private LocalDateTime startTime; + + /** 过期时间 */ + private LocalDateTime expireTime; + + /** 备注 */ + private String remark; + + /** 创建人ID */ + private Long creatorId; + + /** 状态:0禁用 1启用 */ + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/RedeemCodeRecord.java b/src/main/java/com/dora/entity/RedeemCodeRecord.java new file mode 100644 index 0000000..7f177a1 --- /dev/null +++ b/src/main/java/com/dora/entity/RedeemCodeRecord.java @@ -0,0 +1,40 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 兑换码使用记录实体 + */ +@Data +@TableName("redeem_code_record") +public class RedeemCodeRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** 兑换码ID */ + private Long codeId; + + /** 兑换码 */ + private String code; + + /** 用户ID */ + private Long userId; + + /** 类型:1积分 2VIP会员 */ + private Integer type; + + /** 奖励值 */ + private Integer rewardValue; + + /** IP地址 */ + private String ip; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/RewardMessage.java b/src/main/java/com/dora/entity/RewardMessage.java new file mode 100644 index 0000000..371b3a3 --- /dev/null +++ b/src/main/java/com/dora/entity/RewardMessage.java @@ -0,0 +1,39 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 奖励语句配置实体 + */ +@Data +@TableName("reward_message") +public class RewardMessage implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 配置键 + */ + private String configKey; + + /** + * 配置值(奖励语句) + */ + private String configValue; + + /** + * 描述 + */ + private String description; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/Role.java b/src/main/java/com/dora/entity/Role.java new file mode 100644 index 0000000..6710625 --- /dev/null +++ b/src/main/java/com/dora/entity/Role.java @@ -0,0 +1,39 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 角色实体 + */ +@Data +@TableName("role") +public class Role implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String name; + + private String code; + + private String description; + + private Integer dataScope; + + private Integer sort; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/RolePermission.java b/src/main/java/com/dora/entity/RolePermission.java new file mode 100644 index 0000000..0e937e7 --- /dev/null +++ b/src/main/java/com/dora/entity/RolePermission.java @@ -0,0 +1,26 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 角色-权限关联实体 + */ +@Data +@TableName("role_permission") +public class RolePermission implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long roleId; + + private Long permissionId; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/entity/SceneStoryboard.java b/src/main/java/com/dora/entity/SceneStoryboard.java new file mode 100644 index 0000000..19f7667 --- /dev/null +++ b/src/main/java/com/dora/entity/SceneStoryboard.java @@ -0,0 +1,30 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("scene_storyboard") +public class SceneStoryboard { + @TableId(type = IdType.AUTO) + private Long id; + private Long sceneId; + private Long projectId; + private Integer storyboardIndex; + private String shotType; + private String cameraAngle; + private String cameraMove; + private String description; + private String narration; + private String dialogue; + private Long dialogueCharacterId; + private String imageUrl; + private String currentTaskNo; // 当前正在执行的任务号 + private Integer imageStatus; // 图片状态:0-未生成,1-生成中,2-已生成 + private String videoClipUrl; + private Integer duration; + private Integer sort; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/User.java b/src/main/java/com/dora/entity/User.java new file mode 100644 index 0000000..d21189b --- /dev/null +++ b/src/main/java/com/dora/entity/User.java @@ -0,0 +1,58 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 用户实体 + */ +@Data +@TableName("user") +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String openid; + + private String unionid; + + private String nickname; + + private String avatar; + + private String phone; + + private Integer gender; + + private Integer vipLevel; + + private LocalDateTime vipExpireTime; + + private Integer points; + + private String inviteCode; + + private Long inviterId; + + private Integer status; + + private LocalDateTime lastLoginTime; + + /** + * 是否已订阅消息通知 0-未订阅 1-已订阅 + */ + private Integer subscribed; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/VideoProject.java b/src/main/java/com/dora/entity/VideoProject.java new file mode 100644 index 0000000..0c4a719 --- /dev/null +++ b/src/main/java/com/dora/entity/VideoProject.java @@ -0,0 +1,29 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("video_project") +public class VideoProject { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String projectName; + private String storyTitle; + private String storyOutline; + private String originalIdea; + private Integer creationMode; + private String videoDuration; + private String videoRatio; + private String videoStyle; + private String coverUrl; + private Integer status; + private Integer currentStep; + private String outputVideoUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/VipOrder.java b/src/main/java/com/dora/entity/VipOrder.java new file mode 100644 index 0000000..bf5c0de --- /dev/null +++ b/src/main/java/com/dora/entity/VipOrder.java @@ -0,0 +1,43 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * VIP订单实体 + */ +@Data +@TableName("vip_order") +public class VipOrder implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String orderNo; + + private Long userId; + + private Long packageId; + + private String packageName; + + private BigDecimal amount; + + private Integer payType; + + private LocalDateTime payTime; + + private String transactionId; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/entity/VipPackage.java b/src/main/java/com/dora/entity/VipPackage.java new file mode 100644 index 0000000..c99658e --- /dev/null +++ b/src/main/java/com/dora/entity/VipPackage.java @@ -0,0 +1,48 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * VIP套餐实体 + */ +@Data +@TableName("vip_package") +public class VipPackage implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private String name; + + private Integer level; + + private BigDecimal price; + + private BigDecimal originalPrice; + + private Integer duration; + + private Integer pointsGift; + + private Integer dailyUsageLimit; + + private String features; + + private Integer sort; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/WorkCategory.java b/src/main/java/com/dora/entity/WorkCategory.java new file mode 100644 index 0000000..1d0d68e --- /dev/null +++ b/src/main/java/com/dora/entity/WorkCategory.java @@ -0,0 +1,39 @@ +package com.dora.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 作品分类实体 + * + * @author dora + */ +@Data +@TableName("work_category") +public class WorkCategory implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.AUTO) + private Long id; + + private Long parentId; + + private String name; + + private String icon; + + private Integer sort; + + private Integer status; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/src/main/java/com/dora/entity/package-info.java b/src/main/java/com/dora/entity/package-info.java new file mode 100644 index 0000000..c44e8fc --- /dev/null +++ b/src/main/java/com/dora/entity/package-info.java @@ -0,0 +1,4 @@ +/** + * 实体类层 + */ +package com.dora.entity; diff --git a/src/main/java/com/dora/interceptor/AdminAuthInterceptor.java b/src/main/java/com/dora/interceptor/AdminAuthInterceptor.java new file mode 100644 index 0000000..e5efdf5 --- /dev/null +++ b/src/main/java/com/dora/interceptor/AdminAuthInterceptor.java @@ -0,0 +1,67 @@ +package com.dora.interceptor; + +import com.dora.common.context.AdminContext; +import com.dora.util.AdminJwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * 管理员认证拦截器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AdminAuthInterceptor implements HandlerInterceptor { + + private final AdminJwtUtil adminJwtUtil; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "未提供认证令牌"); + return false; + } + + String token = authHeader.substring(7); + + try { + if (!adminJwtUtil.isAccessToken(token)) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "无效的令牌类型"); + return false; + } + + Long adminId = adminJwtUtil.getAdminIdFromToken(token); + String username = adminJwtUtil.getUsernameFromToken(token); + + AdminContext.setAdminId(adminId); + AdminContext.setUsername(username); + + return true; + } catch (ExpiredJwtException e) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "令牌已过期"); + return false; + } catch (JwtException e) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "无效的令牌"); + return false; + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + AdminContext.clear(); + } + + private void sendError(HttpServletResponse response, int status, String message) throws Exception { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":" + status + ",\"message\":\"" + message + "\",\"data\":null}"); + } +} diff --git a/src/main/java/com/dora/interceptor/JwtAuthInterceptor.java b/src/main/java/com/dora/interceptor/JwtAuthInterceptor.java new file mode 100644 index 0000000..c440254 --- /dev/null +++ b/src/main/java/com/dora/interceptor/JwtAuthInterceptor.java @@ -0,0 +1,76 @@ +package com.dora.interceptor; + +import com.dora.common.context.UserContext; +import com.dora.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * JWT认证拦截器 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthInterceptor implements HandlerInterceptor { + + private final JwtUtil jwtUtil; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // 从请求头获取token + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "未提供认证令牌"); + return false; + } + + String token = authHeader.substring(7); + + try { + // 验证必须是Access Token + if (!jwtUtil.isAccessToken(token)) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "无效的令牌类型"); + return false; + } + + // 解析用户信息 + Long userId = jwtUtil.getUserIdFromToken(token); + String openid = jwtUtil.getOpenidFromToken(token); + + // 存入上下文 + UserContext.setUserId(userId); + UserContext.setOpenid(openid); + + // 存入request attribute(供@RequestAttribute注解使用) + request.setAttribute("userId", userId); + request.setAttribute("openid", openid); + + return true; + } catch (ExpiredJwtException e) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "令牌已过期"); + return false; + } catch (JwtException e) { + sendError(response, HttpServletResponse.SC_UNAUTHORIZED, "无效的令牌"); + return false; + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 清理上下文 + UserContext.clear(); + } + + private void sendError(HttpServletResponse response, int status, String message) throws Exception { + response.setStatus(status); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":" + status + ",\"message\":\"" + message + "\",\"data\":null}"); + } +} diff --git a/src/main/java/com/dora/interceptor/SwaggerAuthInterceptor.java b/src/main/java/com/dora/interceptor/SwaggerAuthInterceptor.java new file mode 100644 index 0000000..1b36954 --- /dev/null +++ b/src/main/java/com/dora/interceptor/SwaggerAuthInterceptor.java @@ -0,0 +1,99 @@ +package com.dora.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.IOException; +import java.io.PrintWriter; + +/** + * Swagger 访问密码拦截器 + */ +@Slf4j +@Component +public class SwaggerAuthInterceptor implements HandlerInterceptor { + + @Value("${swagger.auth.password:1818aigc}") + private String swaggerPassword; + + private static final String SWAGGER_AUTH_KEY = "SWAGGER_AUTHENTICATED"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + HttpSession session = request.getSession(); + + // 已经验证过 + if (Boolean.TRUE.equals(session.getAttribute(SWAGGER_AUTH_KEY))) { + return true; + } + + // 检查密码参数(支持GET和POST) + String password = request.getParameter("password"); + if (password != null && !password.isEmpty()) { + if (swaggerPassword.equals(password)) { + session.setAttribute(SWAGGER_AUTH_KEY, true); + log.info("Swagger认证成功"); + // 重定向到 swagger-ui/index.html + response.sendRedirect(request.getContextPath() + "/swagger-ui/index.html"); + return false; + } else { + log.warn("Swagger认证失败:密码错误"); + showLoginPage(response, "密码错误,请重试"); + return false; + } + } + + // 显示登录页面 + showLoginPage(response, null); + return false; + } + + private void showLoginPage(HttpServletResponse response, String errorMsg) throws IOException { + response.setContentType("text/html;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); + PrintWriter out = response.getWriter(); + + StringBuilder html = new StringBuilder(); + html.append(""); + html.append("API文档访问验证"); + html.append(""); + html.append(""); + html.append("
"); + html.append(""); + html.append("

1818AIGC API 文档

"); + html.append("

请输入访问密码以查看接口文档

"); + html.append("
"); + html.append(""); + html.append(""); + html.append("
"); + if (errorMsg != null) { + html.append("

").append(errorMsg).append("

"); + } + html.append("
"); + + out.print(html.toString()); + out.flush(); + } +} diff --git a/src/main/java/com/dora/mapper/AdminMapper.java b/src/main/java/com/dora/mapper/AdminMapper.java new file mode 100644 index 0000000..73440ff --- /dev/null +++ b/src/main/java/com/dora/mapper/AdminMapper.java @@ -0,0 +1,45 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.Admin; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 管理员Mapper + */ +@Mapper +public interface AdminMapper extends BaseMapper { + + /** + * 根据用户名查询管理员 + */ + @Select("SELECT * FROM `admin` WHERE username = #{username} AND deleted = 0") + Admin selectByUsername(@Param("username") String username); + + /** + * 根据邮箱查询管理员 + */ + @Select("SELECT * FROM `admin` WHERE email = #{email} AND deleted = 0") + Admin selectByEmail(@Param("email") String email); + + /** + * 查询管理员的角色编码列表 + */ + @Select("SELECT r.code FROM `role` r " + + "INNER JOIN admin_role ar ON r.id = ar.role_id " + + "WHERE ar.admin_id = #{adminId} AND r.status = 1 AND r.deleted = 0") + List selectRoleCodesByAdminId(@Param("adminId") Long adminId); + + /** + * 查询管理员的权限编码列表 + */ + @Select("SELECT DISTINCT p.code FROM `permission` p " + + "INNER JOIN role_permission rp ON p.id = rp.permission_id " + + "INNER JOIN admin_role ar ON rp.role_id = ar.role_id " + + "WHERE ar.admin_id = #{adminId} AND p.status = 1 AND p.deleted = 0") + List selectPermissionCodesByAdminId(@Param("adminId") Long adminId); +} diff --git a/src/main/java/com/dora/mapper/AdminRoleMapper.java b/src/main/java/com/dora/mapper/AdminRoleMapper.java new file mode 100644 index 0000000..69b9087 --- /dev/null +++ b/src/main/java/com/dora/mapper/AdminRoleMapper.java @@ -0,0 +1,20 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.AdminRole; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 管理员角色关联Mapper + */ +@Mapper +public interface AdminRoleMapper extends BaseMapper { + + /** + * 删除管理员的所有角色关联 + */ + @Delete("DELETE FROM admin_role WHERE admin_id = #{adminId}") + int deleteByAdminId(@Param("adminId") Long adminId); +} diff --git a/src/main/java/com/dora/mapper/AdminWorkMapper.java b/src/main/java/com/dora/mapper/AdminWorkMapper.java new file mode 100644 index 0000000..67dd1e6 --- /dev/null +++ b/src/main/java/com/dora/mapper/AdminWorkMapper.java @@ -0,0 +1,148 @@ +package com.dora.mapper; + +import com.dora.dto.admin.AdminWorkQueryDTO; +import com.dora.vo.admin.AdminWorkVO; +import org.apache.ibatis.annotations.*; + +import java.util.List; + +/** + * 管理端广场作品Mapper + */ +@Mapper +public interface AdminWorkMapper { + + /** + * 查询广场作品列表(带关联信息) + */ + @Select(""" + + """) + List selectWorkList(@Param("dto") AdminWorkQueryDTO dto, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + /** + * 统计广场作品数量 + */ + @Select(""" + + """) + Long countWorkList(@Param("dto") AdminWorkQueryDTO dto); + + /** + * 根据ID查询作品详情 + */ + @Select(""" + SELECT w.id, w.user_id AS userId, w.category_id AS categoryId, w.title, w.description, + w.content_url AS contentUrl, w.content_type AS contentType, w.task_type AS taskType, + w.model, w.prompt, w.tags, w.view_count AS viewCount, w.like_count AS likeCount, + w.collect_count AS collectCount, w.comment_count AS commentCount, w.is_public AS isPublic, + w.audit_status AS auditStatus, w.audit_remark AS auditRemark, w.is_featured AS isFeatured, + w.status, w.created_at AS createdAt, w.updated_at AS updatedAt, + u.nickname AS userName, u.avatar AS userAvatar, + c.name AS categoryName + FROM ai_work w + LEFT JOIN `user` u ON w.user_id = u.id + LEFT JOIN work_category c ON w.category_id = c.id + WHERE w.id = #{id} AND w.deleted = 0 + """) + AdminWorkVO selectWorkById(@Param("id") Long id); + + /** + * 更新审核状态 + */ + @Update("UPDATE ai_work SET audit_status = #{auditStatus}, audit_remark = #{auditRemark} WHERE id = #{id} AND deleted = 0") + int updateAuditStatus(@Param("id") Long id, @Param("auditStatus") Integer auditStatus, @Param("auditRemark") String auditRemark); + + /** + * 更新精选状态 + */ + @Update("UPDATE ai_work SET is_featured = #{isFeatured} WHERE id = #{id} AND deleted = 0") + int updateFeatured(@Param("id") Long id, @Param("isFeatured") Integer isFeatured); + + /** + * 更新作品状态(上架/下架) + */ + @Update("UPDATE ai_work SET status = #{status} WHERE id = #{id} AND deleted = 0") + int updateStatus(@Param("id") Long id, @Param("status") Integer status); + + /** + * 逻辑删除作品 + */ + @Update("UPDATE ai_work SET deleted = 1 WHERE id = #{id}") + int deleteWork(@Param("id") Long id); + + /** + * 统计作品总数 + */ + @Select("SELECT COUNT(*) FROM ai_work WHERE deleted = 0") + Long countByStatus(@Param("status") Integer status); + + /** + * 按审核状态统计 + */ + @Select("SELECT COUNT(*) FROM ai_work WHERE deleted = 0 AND audit_status = #{auditStatus}") + Long countByAuditStatus(@Param("auditStatus") Integer auditStatus); + + /** + * 统计精选作品数量 + */ + @Select("SELECT COUNT(*) FROM ai_work WHERE deleted = 0 AND is_featured = 1") + Long countFeatured(); +} diff --git a/src/main/java/com/dora/mapper/AiModelMapper.java b/src/main/java/com/dora/mapper/AiModelMapper.java new file mode 100644 index 0000000..4888292 --- /dev/null +++ b/src/main/java/com/dora/mapper/AiModelMapper.java @@ -0,0 +1,77 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.entity.AiModel; +import com.dora.vo.AiModelVO; +import com.dora.vo.AiModelSimpleVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface AiModelMapper extends BaseMapper { + + @Select("SELECT m.*, p.name as provider_name, p.base_url as provider_base_url, p.api_key as provider_api_key, p.secret_key as provider_secret_key " + + "FROM ai_model m " + + "LEFT JOIN ai_provider p ON m.provider_id = p.id " + + "WHERE m.deleted = 0 " + + "AND (#{type} IS NULL OR m.type = #{type}) " + + "AND (#{category} IS NULL OR m.category = #{category}) " + + "AND (#{status} IS NULL OR m.status = #{status}) " + + "ORDER BY m.sort ASC, m.created_at DESC") + IPage selectModelPage(Page page, @Param("type") String type, + @Param("category") String category, @Param("status") Integer status); + + /** + * 获取所有启用的模型(用于后台管理) + */ + @Select("SELECT m.*, p.name as provider_name, p.base_url as provider_base_url, p.api_key as provider_api_key, p.secret_key as provider_secret_key " + + "FROM ai_model m " + + "LEFT JOIN ai_provider p ON m.provider_id = p.id " + + "WHERE m.deleted = 0 AND m.status = 1 " + + "AND (#{type} IS NULL OR m.type = #{type}) " + + "ORDER BY m.sort ASC, m.created_at DESC") + List selectActiveModels(@Param("type") String type); + + /** + * 获取显示在AI功能列表的模型(前端展示用,简化字段) + */ + @Select("SELECT id, name, code, type, category, description, icon, cover_image, input_params, points_cost " + + "FROM ai_model " + + "WHERE deleted = 0 AND status = 1 AND show_in_list = 1 " + + "AND (#{type} IS NULL OR type = #{type}) " + + "ORDER BY sort ASC, created_at DESC") + List selectListModelsSimple(@Param("type") String type); + + /** + * 获取模型详情(用户端,简化字段,不含敏感信息) + */ + @Select("SELECT id, name, code, type, category, description, icon, cover_image, input_params, points_cost " + + "FROM ai_model " + + "WHERE id = #{id} AND deleted = 0 AND status = 1") + AiModelSimpleVO selectModelSimpleById(@Param("id") Long id); + + /** + * 根据编码获取模型详情(用户端,简化字段) + */ + @Select("SELECT id, name, code, type, category, description, icon, cover_image, input_params, points_cost " + + "FROM ai_model " + + "WHERE code = #{code} AND deleted = 0 AND status = 1") + AiModelSimpleVO selectModelSimpleByCode(@Param("code") String code); + + @Select("SELECT m.*, p.name as provider_name, p.base_url as provider_base_url, p.api_key as provider_api_key, p.secret_key as provider_secret_key " + + "FROM ai_model m " + + "LEFT JOIN ai_provider p ON m.provider_id = p.id " + + "WHERE m.id = #{id} AND m.deleted = 0") + AiModelVO selectModelVOById(@Param("id") Long id); + + @Select("SELECT m.*, p.name as provider_name, p.base_url as provider_base_url, p.api_key as provider_api_key, p.secret_key as provider_secret_key " + + "FROM ai_model m " + + "LEFT JOIN ai_provider p ON m.provider_id = p.id " + + "WHERE m.code = #{code} AND m.deleted = 0 AND m.status = 1") + AiModelVO selectModelVOByCode(@Param("code") String code); +} \ No newline at end of file diff --git a/src/main/java/com/dora/mapper/AiPromptTemplateMapper.java b/src/main/java/com/dora/mapper/AiPromptTemplateMapper.java new file mode 100644 index 0000000..20e0a8a --- /dev/null +++ b/src/main/java/com/dora/mapper/AiPromptTemplateMapper.java @@ -0,0 +1,14 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.AiPromptTemplate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface AiPromptTemplateMapper extends BaseMapper { + + @Select("SELECT * FROM ai_prompt_template WHERE template_code = #{code} AND status = 1") + AiPromptTemplate selectByCode(@Param("code") String code); +} diff --git a/src/main/java/com/dora/mapper/AiProviderMapper.java b/src/main/java/com/dora/mapper/AiProviderMapper.java new file mode 100644 index 0000000..e91b296 --- /dev/null +++ b/src/main/java/com/dora/mapper/AiProviderMapper.java @@ -0,0 +1,9 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.AiProvider; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface AiProviderMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/com/dora/mapper/AiTaskMapper.java b/src/main/java/com/dora/mapper/AiTaskMapper.java new file mode 100644 index 0000000..d0a0555 --- /dev/null +++ b/src/main/java/com/dora/mapper/AiTaskMapper.java @@ -0,0 +1,66 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.entity.AiTask; +import com.dora.vo.AiTaskVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface AiTaskMapper extends BaseMapper { + + @Select("SELECT t.*, u.nickname as user_nickname, m.name as model_name, m.code as model_code " + + "FROM ai_task t " + + "LEFT JOIN user u ON t.user_id = u.id " + + "LEFT JOIN ai_model m ON t.model_id = m.id " + + "WHERE (#{userId} IS NULL OR t.user_id = #{userId}) " + + "AND (#{status} IS NULL OR t.status = #{status}) " + + "AND (#{modelId} IS NULL OR t.model_id = #{modelId}) " + + "ORDER BY t.created_at DESC") + IPage selectTaskPage(Page page, @Param("userId") Long userId, @Param("status") Integer status, @Param("modelId") Long modelId); + + @Select("SELECT * FROM ai_task " + + "WHERE status = 0 " + + "ORDER BY priority DESC, created_at ASC " + + "LIMIT #{limit}") + List selectPendingTasks(@Param("limit") int limit); + + /** + * 按模型统计当前正在处理中的任务数量 + */ + @Select("SELECT model_id, COUNT(*) as count FROM ai_task " + + "WHERE status = 1 " + + "GROUP BY model_id") + List> countProcessingTasksByModel(); + + /** + * 查询指定模型的待处理任务(考虑并发限制) + */ + @Select("SELECT * FROM ai_task " + + "WHERE status = 0 AND model_id = #{modelId} " + + "ORDER BY priority DESC, created_at ASC " + + "LIMIT #{limit}") + List selectPendingTasksByModel(@Param("modelId") Long modelId, @Param("limit") int limit); + + /** + * 查询需要轮询状态的异步任务 + * 状态为处理中(1)且有外部任务ID的任务 + * 根据模型配置的轮询间隔(async_poll_interval)判断是否需要轮询 + * 只有当任务的updated_at距离现在超过轮询间隔时才返回 + */ + @Select("SELECT t.* FROM ai_task t " + + "INNER JOIN ai_model m ON t.model_id = m.id " + + "WHERE t.status = 1 " + + "AND t.external_task_id IS NOT NULL " + + "AND m.is_async = 1 " + + "AND (t.poll_count < m.async_poll_max_count OR m.async_poll_max_count IS NULL) " + + "AND (TIMESTAMPDIFF(SECOND, t.updated_at, NOW()) * 1000 >= COALESCE(m.async_poll_interval, 10000)) " + + "ORDER BY t.updated_at ASC " + + "LIMIT #{limit}") + List selectAsyncPollingTasks(@Param("limit") int limit); +} \ No newline at end of file diff --git a/src/main/java/com/dora/mapper/AiWorkLikeMapper.java b/src/main/java/com/dora/mapper/AiWorkLikeMapper.java new file mode 100644 index 0000000..e115c14 --- /dev/null +++ b/src/main/java/com/dora/mapper/AiWorkLikeMapper.java @@ -0,0 +1,55 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.AiWorkLike; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 作品点赞Mapper + */ +@Mapper +public interface AiWorkLikeMapper extends BaseMapper { + + /** + * 批量查询用户是否点赞 + */ + @Select(""" + + """) + List selectLikedWorkIds(@Param("userId") Long userId, @Param("workIds") List workIds); + + /** + * 查询是否已点赞 + */ + @Select("SELECT COUNT(*) FROM ai_work_like WHERE work_id = #{workId} AND user_id = #{userId}") + int countByWorkAndUser(@Param("workId") Long workId, @Param("userId") Long userId); + + /** + * 查询用户点赞的作品ID列表 + */ + @Select(""" + SELECT work_id FROM ai_work_like + WHERE user_id = #{userId} + ORDER BY created_at DESC + LIMIT #{offset}, #{pageSize} + """) + List selectLikedWorkIdsByUser(@Param("userId") Long userId, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + /** + * 统计用户点赞的作品数 + */ + @Select("SELECT COUNT(*) FROM ai_work_like WHERE user_id = #{userId}") + Long countUserLikes(@Param("userId") Long userId); +} diff --git a/src/main/java/com/dora/mapper/AiWorkMapper.java b/src/main/java/com/dora/mapper/AiWorkMapper.java new file mode 100644 index 0000000..7f8870a --- /dev/null +++ b/src/main/java/com/dora/mapper/AiWorkMapper.java @@ -0,0 +1,170 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.AiWork; +import com.dora.vo.WorkDetailVO; +import com.dora.vo.WorkVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; + +/** + * 作品Mapper + */ +@Mapper +public interface AiWorkMapper extends BaseMapper { + + /** + * 分页查询作品列表(最热排序) + */ + @Select(""" + + """) + List selectHotList(@Param("categoryId") Long categoryId, + @Param("keyword") String keyword, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + /** + * 分页查询作品列表(最新排序) + */ + @Select(""" + + """) + List selectNewList(@Param("categoryId") Long categoryId, + @Param("keyword") String keyword, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + /** + * 统计作品总数 + */ + @Select(""" + + """) + Long countWorks(@Param("categoryId") Long categoryId, @Param("keyword") String keyword); + + /** + * 查询作品详情 + */ + @Select(""" + SELECT w.id, w.title, w.description, w.content_url, w.content_type, w.task_type, w.model, + w.prompt, w.tags, w.view_count, w.like_count, w.comment_count, + w.user_id, w.category_id, w.created_at, u.nickname, u.avatar, c.name as category_name + FROM ai_work w + LEFT JOIN user u ON w.user_id = u.id + LEFT JOIN work_category c ON w.category_id = c.id + WHERE w.id = #{id} AND w.status = 1 AND w.deleted = 0 + """) + WorkDetailVO selectDetailById(@Param("id") Long id); + + /** + * 增加浏览量 + */ + @Update("UPDATE ai_work SET view_count = view_count + 1 WHERE id = #{id}") + int incrementViewCount(@Param("id") Long id); + + /** + * 统计用户发布的作品数 + */ + @Select("SELECT COUNT(*) FROM ai_work WHERE user_id = #{userId} AND status = 1 AND deleted = 0") + int countByUserId(@Param("userId") Long userId); + + /** + * 统计用户获得的总点赞数 + */ + @Select("SELECT COALESCE(SUM(like_count), 0) FROM ai_work WHERE user_id = #{userId} AND status = 1 AND deleted = 0") + int sumLikeCountByUserId(@Param("userId") Long userId); + + /** + * 查询用户发布的作品列表 + */ + @Select(""" + SELECT w.id, w.title, w.content_url, w.content_type, w.task_type, w.prompt, w.description, + w.like_count, w.view_count, w.user_id, w.created_at, u.nickname, u.avatar + FROM ai_work w + LEFT JOIN user u ON w.user_id = u.id + WHERE w.user_id = #{userId} AND w.status = 1 AND w.deleted = 0 + ORDER BY w.created_at DESC + LIMIT #{offset}, #{pageSize} + """) + List selectByUserId(@Param("userId") Long userId, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + /** + * 统计用户发布的作品总数(用于分页) + */ + @Select("SELECT COUNT(*) FROM ai_work WHERE user_id = #{userId} AND status = 1 AND deleted = 0") + Long countUserWorks(@Param("userId") Long userId); + + /** + * 根据ID列表查询作品 + */ + @Select(""" + + """) + List selectByIds(@Param("ids") List ids); +} diff --git a/src/main/java/com/dora/mapper/BannerMapper.java b/src/main/java/com/dora/mapper/BannerMapper.java new file mode 100644 index 0000000..5a08fb2 --- /dev/null +++ b/src/main/java/com/dora/mapper/BannerMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.Banner; +import org.apache.ibatis.annotations.Mapper; + +/** + * Banner Mapper + */ +@Mapper +public interface BannerMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/CharacterTemplateMapper.java b/src/main/java/com/dora/mapper/CharacterTemplateMapper.java new file mode 100644 index 0000000..de0333e --- /dev/null +++ b/src/main/java/com/dora/mapper/CharacterTemplateMapper.java @@ -0,0 +1,9 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.CharacterTemplate; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CharacterTemplateMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/DashboardMapper.java b/src/main/java/com/dora/mapper/DashboardMapper.java new file mode 100644 index 0000000..592105d --- /dev/null +++ b/src/main/java/com/dora/mapper/DashboardMapper.java @@ -0,0 +1,75 @@ +package com.dora.mapper; + +import com.dora.vo.admin.RecentOrderVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Dashboard数据统计Mapper + */ +@Mapper +public interface DashboardMapper { + + // ==================== 用户统计 ==================== + + @Select("SELECT COUNT(*) FROM `user` WHERE deleted = 0") + Long countUsers(); + + @Select("SELECT COUNT(*) FROM `user` WHERE deleted = 0 AND DATE(created_at) = CURDATE()") + Long countTodayNewUsers(); + + @Select("SELECT COUNT(*) FROM `user` WHERE deleted = 0 AND DATE(created_at) = #{date}") + Long countUsersByDate(@Param("date") String date); + + // ==================== VIP订单统计 ==================== + + @Select("SELECT COUNT(*) FROM vip_order WHERE DATE(created_at) = CURDATE() AND status = 1") + Long countTodayVipOrders(); + + @Select("SELECT COALESCE(SUM(amount), 0) FROM vip_order WHERE DATE(created_at) = CURDATE() AND status = 1") + BigDecimal sumTodayVipOrderAmount(); + + @Select("SELECT COUNT(*) FROM vip_order WHERE DATE(created_at) = #{date} AND status = 1") + Long countVipOrdersByDate(@Param("date") String date); + + @Select(""" + SELECT vo.id, vo.order_no AS orderNo, u.nickname AS username, vo.amount, vo.status, vo.created_at AS createdAt + FROM vip_order vo + LEFT JOIN `user` u ON vo.user_id = u.id + ORDER BY vo.created_at DESC + LIMIT #{limit} + """) + List selectRecentVipOrders(@Param("limit") int limit); + + // ==================== 积分订单统计 ==================== + + @Select("SELECT COUNT(*) FROM points_order WHERE DATE(created_at) = CURDATE() AND status = 1") + Long countTodayPointsOrders(); + + @Select("SELECT COALESCE(SUM(amount), 0) FROM points_order WHERE DATE(created_at) = CURDATE() AND status = 1") + BigDecimal sumTodayPointsOrderAmount(); + + @Select("SELECT COUNT(*) FROM points_order WHERE DATE(created_at) = #{date} AND status = 1") + Long countPointsOrdersByDate(@Param("date") String date); + + @Select(""" + SELECT po.id, po.order_no AS orderNo, u.nickname AS username, po.amount, po.status, po.created_at AS createdAt + FROM points_order po + LEFT JOIN `user` u ON po.user_id = u.id + ORDER BY po.created_at DESC + LIMIT #{limit} + """) + List selectRecentPointsOrders(@Param("limit") int limit); + + // ==================== 作品统计 ==================== + + @Select("SELECT COUNT(*) FROM ai_work WHERE deleted = 0") + Long countWorks(); + + @Select("SELECT COUNT(*) FROM ai_work WHERE deleted = 0 AND audit_status = 0") + Long countPendingAuditWorks(); +} diff --git a/src/main/java/com/dora/mapper/NoticeMapper.java b/src/main/java/com/dora/mapper/NoticeMapper.java new file mode 100644 index 0000000..df29944 --- /dev/null +++ b/src/main/java/com/dora/mapper/NoticeMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.Notice; +import org.apache.ibatis.annotations.Mapper; + +/** + * 公告 Mapper + */ +@Mapper +public interface NoticeMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/NoticeReadMapper.java b/src/main/java/com/dora/mapper/NoticeReadMapper.java new file mode 100644 index 0000000..e1161ce --- /dev/null +++ b/src/main/java/com/dora/mapper/NoticeReadMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.NoticeRead; +import org.apache.ibatis.annotations.Mapper; + +/** + * 公告已读记录 Mapper + */ +@Mapper +public interface NoticeReadMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/OrderMapper.java b/src/main/java/com/dora/mapper/OrderMapper.java new file mode 100644 index 0000000..d86abcb --- /dev/null +++ b/src/main/java/com/dora/mapper/OrderMapper.java @@ -0,0 +1,220 @@ +package com.dora.mapper; + +import com.dora.dto.admin.OrderQueryDTO; +import com.dora.vo.admin.OrderVO; +import com.dora.vo.admin.RecentOrderVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 订单Mapper + */ +@Mapper +public interface OrderMapper { + + // ==================== VIP订单 ==================== + + @Select(""" + + """) + List selectVipOrders(@Param("dto") OrderQueryDTO dto, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + @Select(""" + + """) + Long countVipOrders(@Param("dto") OrderQueryDTO dto); + + @Select(""" + + """) + BigDecimal sumVipOrderAmount(@Param("dto") OrderQueryDTO dto); + + @Select(""" + SELECT vo.id, vo.order_no AS orderNo, vo.user_id AS userId, u.nickname AS username, + vo.package_name AS productName, vo.amount, vo.status, vo.created_at AS createdAt, + vo.pay_time AS paidAt, + CASE vo.pay_type WHEN 1 THEN '微信支付' WHEN 2 THEN '支付宝' ELSE '-' END AS payMethod + FROM vip_order vo + LEFT JOIN `user` u ON vo.user_id = u.id + WHERE vo.id = #{id} + """) + OrderVO selectVipOrderById(@Param("id") Long id); + + @Select(""" + SELECT vo.id, vo.order_no AS orderNo, u.nickname AS username, vo.amount, vo.status, vo.created_at AS createdAt + FROM vip_order vo + LEFT JOIN `user` u ON vo.user_id = u.id + ORDER BY vo.created_at DESC + LIMIT #{limit} + """) + List selectRecentVipOrders(@Param("limit") int limit); + + // ==================== 积分订单 ==================== + + @Select(""" + + """) + List selectPointsOrders(@Param("dto") OrderQueryDTO dto, + @Param("offset") int offset, + @Param("pageSize") int pageSize); + + @Select(""" + + """) + Long countPointsOrders(@Param("dto") OrderQueryDTO dto); + + @Select(""" + + """) + BigDecimal sumPointsOrderAmount(@Param("dto") OrderQueryDTO dto); + + @Select(""" + SELECT po.id, po.order_no AS orderNo, po.user_id AS userId, u.nickname AS username, + CONCAT(po.points, '积分') AS productName, po.amount, po.status, po.created_at AS createdAt, + po.pay_time AS paidAt, + CASE po.pay_type WHEN 1 THEN '微信支付' WHEN 2 THEN '支付宝' ELSE '-' END AS payMethod + FROM points_order po + LEFT JOIN `user` u ON po.user_id = u.id + WHERE po.id = #{id} + """) + OrderVO selectPointsOrderById(@Param("id") Long id); + + @Select(""" + SELECT po.id, po.order_no AS orderNo, u.nickname AS username, po.amount, po.status, po.created_at AS createdAt + FROM points_order po + LEFT JOIN `user` u ON po.user_id = u.id + ORDER BY po.created_at DESC + LIMIT #{limit} + """) + List selectRecentPointsOrders(@Param("limit") int limit); +} diff --git a/src/main/java/com/dora/mapper/PermissionMapper.java b/src/main/java/com/dora/mapper/PermissionMapper.java new file mode 100644 index 0000000..61405c8 --- /dev/null +++ b/src/main/java/com/dora/mapper/PermissionMapper.java @@ -0,0 +1,38 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.Permission; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 权限Mapper + */ +@Mapper +public interface PermissionMapper extends BaseMapper { + + /** + * 查询管理员的权限列表 + */ + @Select("SELECT DISTINCT p.* FROM `permission` p " + + "INNER JOIN role_permission rp ON p.id = rp.permission_id " + + "INNER JOIN admin_role ar ON rp.role_id = ar.role_id " + + "WHERE ar.admin_id = #{adminId} AND p.status = 1 AND p.deleted = 0 " + + "ORDER BY p.sort") + List selectPermissionsByAdminId(@Param("adminId") Long adminId); + + /** + * 查询角色的权限ID列表 + */ + @Select("SELECT permission_id FROM role_permission WHERE role_id = #{roleId}") + List selectPermissionIdsByRoleId(@Param("roleId") Long roleId); + + /** + * 查询所有菜单权限(type=1目录 或 type=2菜单) + */ + @Select("SELECT * FROM `permission` WHERE type IN (1, 2) AND status = 1 AND deleted = 0 ORDER BY sort") + List selectMenuPermissions(); +} diff --git a/src/main/java/com/dora/mapper/PointsOrderMapper.java b/src/main/java/com/dora/mapper/PointsOrderMapper.java new file mode 100644 index 0000000..9d0ef6b --- /dev/null +++ b/src/main/java/com/dora/mapper/PointsOrderMapper.java @@ -0,0 +1,9 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.PointsOrder; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PointsOrderMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/PointsPackageMapper.java b/src/main/java/com/dora/mapper/PointsPackageMapper.java new file mode 100644 index 0000000..9b77a7c --- /dev/null +++ b/src/main/java/com/dora/mapper/PointsPackageMapper.java @@ -0,0 +1,9 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.PointsPackage; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PointsPackageMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/PointsRecordMapper.java b/src/main/java/com/dora/mapper/PointsRecordMapper.java new file mode 100644 index 0000000..a3c2d12 --- /dev/null +++ b/src/main/java/com/dora/mapper/PointsRecordMapper.java @@ -0,0 +1,9 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.PointsRecord; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PointsRecordMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/ProjectCharacterMapper.java b/src/main/java/com/dora/mapper/ProjectCharacterMapper.java new file mode 100644 index 0000000..ae9b694 --- /dev/null +++ b/src/main/java/com/dora/mapper/ProjectCharacterMapper.java @@ -0,0 +1,16 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.ProjectCharacter; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; + +import java.util.List; + +@Mapper +public interface ProjectCharacterMapper extends BaseMapper { + + @Delete("DELETE FROM project_character WHERE project_id = #{projectId}") + int deleteByProjectId(@Param("projectId") Long projectId); +} diff --git a/src/main/java/com/dora/mapper/ProjectSceneMapper.java b/src/main/java/com/dora/mapper/ProjectSceneMapper.java new file mode 100644 index 0000000..d25aefe --- /dev/null +++ b/src/main/java/com/dora/mapper/ProjectSceneMapper.java @@ -0,0 +1,14 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.ProjectScene; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; + +@Mapper +public interface ProjectSceneMapper extends BaseMapper { + + @Delete("DELETE FROM project_scene WHERE project_id = #{projectId}") + int deleteByProjectId(@Param("projectId") Long projectId); +} diff --git a/src/main/java/com/dora/mapper/PromotionConfigMapper.java b/src/main/java/com/dora/mapper/PromotionConfigMapper.java new file mode 100644 index 0000000..10bb0ce --- /dev/null +++ b/src/main/java/com/dora/mapper/PromotionConfigMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.PromotionConfig; +import org.apache.ibatis.annotations.Mapper; + +/** + * 推广配置 Mapper + */ +@Mapper +public interface PromotionConfigMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/PromotionRecordMapper.java b/src/main/java/com/dora/mapper/PromotionRecordMapper.java new file mode 100644 index 0000000..338267e --- /dev/null +++ b/src/main/java/com/dora/mapper/PromotionRecordMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.PromotionRecord; +import org.apache.ibatis.annotations.Mapper; + +/** + * 推广记录Mapper + */ +@Mapper +public interface PromotionRecordMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/RedeemCodeMapper.java b/src/main/java/com/dora/mapper/RedeemCodeMapper.java new file mode 100644 index 0000000..9feedf5 --- /dev/null +++ b/src/main/java/com/dora/mapper/RedeemCodeMapper.java @@ -0,0 +1,27 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.RedeemCode; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** + * 兑换码Mapper + */ +@Mapper +public interface RedeemCodeMapper extends BaseMapper { + + /** + * 根据兑换码查询 + */ + @Select("SELECT * FROM redeem_code WHERE code = #{code} AND deleted = 0") + RedeemCode selectByCode(@Param("code") String code); + + /** + * 增加使用次数 + */ + @Update("UPDATE redeem_code SET used_count = used_count + 1, updated_at = NOW() WHERE id = #{id} AND used_count < total_count") + int incrementUsedCount(@Param("id") Long id); +} diff --git a/src/main/java/com/dora/mapper/RedeemCodeRecordMapper.java b/src/main/java/com/dora/mapper/RedeemCodeRecordMapper.java new file mode 100644 index 0000000..761b879 --- /dev/null +++ b/src/main/java/com/dora/mapper/RedeemCodeRecordMapper.java @@ -0,0 +1,20 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.RedeemCodeRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 兑换码使用记录Mapper + */ +@Mapper +public interface RedeemCodeRecordMapper extends BaseMapper { + + /** + * 检查用户是否已使用过该兑换码 + */ + @Select("SELECT COUNT(*) FROM redeem_code_record WHERE code_id = #{codeId} AND user_id = #{userId}") + int countByCodeIdAndUserId(@Param("codeId") Long codeId, @Param("userId") Long userId); +} diff --git a/src/main/java/com/dora/mapper/RewardMessageMapper.java b/src/main/java/com/dora/mapper/RewardMessageMapper.java new file mode 100644 index 0000000..b862d5c --- /dev/null +++ b/src/main/java/com/dora/mapper/RewardMessageMapper.java @@ -0,0 +1,17 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.RewardMessage; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 奖励语句配置 Mapper + */ +@Mapper +public interface RewardMessageMapper extends BaseMapper { + + @Select("SELECT * FROM reward_message WHERE config_key = #{key}") + RewardMessage selectByKey(@Param("key") String key); +} diff --git a/src/main/java/com/dora/mapper/RoleMapper.java b/src/main/java/com/dora/mapper/RoleMapper.java new file mode 100644 index 0000000..e3996aa --- /dev/null +++ b/src/main/java/com/dora/mapper/RoleMapper.java @@ -0,0 +1,30 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.Role; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 角色Mapper + */ +@Mapper +public interface RoleMapper extends BaseMapper { + + /** + * 查询管理员的角色列表 + */ + @Select("SELECT r.* FROM `role` r " + + "INNER JOIN admin_role ar ON r.id = ar.role_id " + + "WHERE ar.admin_id = #{adminId} AND r.deleted = 0") + List selectRolesByAdminId(@Param("adminId") Long adminId); + + /** + * 根据角色编码查询 + */ + @Select("SELECT * FROM `role` WHERE code = #{code} AND deleted = 0") + Role selectByCode(@Param("code") String code); +} diff --git a/src/main/java/com/dora/mapper/RolePermissionMapper.java b/src/main/java/com/dora/mapper/RolePermissionMapper.java new file mode 100644 index 0000000..f8140f7 --- /dev/null +++ b/src/main/java/com/dora/mapper/RolePermissionMapper.java @@ -0,0 +1,20 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.RolePermission; +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** + * 角色权限关联Mapper + */ +@Mapper +public interface RolePermissionMapper extends BaseMapper { + + /** + * 删除角色的所有权限关联 + */ + @Delete("DELETE FROM role_permission WHERE role_id = #{roleId}") + int deleteByRoleId(@Param("roleId") Long roleId); +} diff --git a/src/main/java/com/dora/mapper/SceneStoryboardMapper.java b/src/main/java/com/dora/mapper/SceneStoryboardMapper.java new file mode 100644 index 0000000..bb4844b --- /dev/null +++ b/src/main/java/com/dora/mapper/SceneStoryboardMapper.java @@ -0,0 +1,17 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.SceneStoryboard; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Delete; + +@Mapper +public interface SceneStoryboardMapper extends BaseMapper { + + @Delete("DELETE FROM scene_storyboard WHERE scene_id = #{sceneId}") + int deleteBySceneId(@Param("sceneId") Long sceneId); + + @Delete("DELETE FROM scene_storyboard WHERE project_id = #{projectId}") + int deleteByProjectId(@Param("projectId") Long projectId); +} diff --git a/src/main/java/com/dora/mapper/UserMapper.java b/src/main/java/com/dora/mapper/UserMapper.java new file mode 100644 index 0000000..bd8f275 --- /dev/null +++ b/src/main/java/com/dora/mapper/UserMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.User; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户Mapper + */ +@Mapper +public interface UserMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/VideoProjectMapper.java b/src/main/java/com/dora/mapper/VideoProjectMapper.java new file mode 100644 index 0000000..ad21222 --- /dev/null +++ b/src/main/java/com/dora/mapper/VideoProjectMapper.java @@ -0,0 +1,35 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.entity.VideoProject; +import com.dora.vo.VideoProjectVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface VideoProjectMapper extends BaseMapper { + + @Select("") + IPage selectProjectPage(Page page, + @Param("userId") Long userId, + @Param("status") Integer status); +} diff --git a/src/main/java/com/dora/mapper/VipPackageMapper.java b/src/main/java/com/dora/mapper/VipPackageMapper.java new file mode 100644 index 0000000..1a11bdd --- /dev/null +++ b/src/main/java/com/dora/mapper/VipPackageMapper.java @@ -0,0 +1,12 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.VipPackage; +import org.apache.ibatis.annotations.Mapper; + +/** + * VIP套餐 Mapper + */ +@Mapper +public interface VipPackageMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/WorkCategoryMapper.java b/src/main/java/com/dora/mapper/WorkCategoryMapper.java new file mode 100644 index 0000000..14ab327 --- /dev/null +++ b/src/main/java/com/dora/mapper/WorkCategoryMapper.java @@ -0,0 +1,14 @@ +package com.dora.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.dora.entity.WorkCategory; +import org.apache.ibatis.annotations.Mapper; + +/** + * 作品分类Mapper + * + * @author dora + */ +@Mapper +public interface WorkCategoryMapper extends BaseMapper { +} diff --git a/src/main/java/com/dora/mapper/package-info.java b/src/main/java/com/dora/mapper/package-info.java new file mode 100644 index 0000000..ab4c77c --- /dev/null +++ b/src/main/java/com/dora/mapper/package-info.java @@ -0,0 +1,4 @@ +/** + * 数据访问层 + */ +package com.dora.mapper; diff --git a/src/main/java/com/dora/scheduler/AiTaskScheduler.java b/src/main/java/com/dora/scheduler/AiTaskScheduler.java new file mode 100644 index 0000000..b8c9828 --- /dev/null +++ b/src/main/java/com/dora/scheduler/AiTaskScheduler.java @@ -0,0 +1,48 @@ +package com.dora.scheduler; + +import com.dora.service.AiTaskService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * AI任务调度器 + * 负责处理任务队列和轮询异步任务状态 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AiTaskScheduler { + + private final AiTaskService aiTaskService; + + /** + * 处理任务队列 + * 每10秒执行一次,处理新提交的任务 + * initialDelay=15000 等待数据库初始化完成 + */ + @Scheduled(fixedDelay = 10000, initialDelay = 15000) + public void processTaskQueue() { + try { + aiTaskService.processTaskQueue(); + } catch (Exception e) { + log.error("处理任务队列异常: {}", e.getMessage(), e); + } + } + + /** + * 轮询异步任务状态 + * 调度器每3秒执行一次,但实际轮询间隔由各模型的async_poll_interval配置决定 + * 只有当任务的updated_at距离现在超过模型配置的轮询间隔时才会被轮询 + * initialDelay=15000 等待数据库初始化完成 + */ + @Scheduled(fixedDelay = 3000, initialDelay = 15000) + public void pollAsyncTasks() { + try { + aiTaskService.pollAsyncTasks(); + } catch (Exception e) { + log.error("轮询异步任务异常: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/dora/service/AdminDashboardService.java b/src/main/java/com/dora/service/AdminDashboardService.java new file mode 100644 index 0000000..e4fa5d4 --- /dev/null +++ b/src/main/java/com/dora/service/AdminDashboardService.java @@ -0,0 +1,28 @@ +package com.dora.service; + +import com.dora.vo.admin.DashboardStatsVO; +import com.dora.vo.admin.DashboardTrendVO; +import com.dora.vo.admin.RecentOrderVO; + +import java.util.List; + +/** + * Dashboard服务接口 + */ +public interface AdminDashboardService { + + /** + * 获取统计数据 + */ + DashboardStatsVO getStats(); + + /** + * 获取近7天趋势数据 + */ + List getTrend(); + + /** + * 获取最近订单 + */ + List getRecentOrders(); +} diff --git a/src/main/java/com/dora/service/AdminOrderService.java b/src/main/java/com/dora/service/AdminOrderService.java new file mode 100644 index 0000000..d5fb794 --- /dev/null +++ b/src/main/java/com/dora/service/AdminOrderService.java @@ -0,0 +1,29 @@ +package com.dora.service; + +import com.dora.dto.admin.OrderQueryDTO; +import com.dora.vo.admin.OrderListVO; +import com.dora.vo.admin.OrderVO; +import com.dora.vo.admin.RecentOrderVO; + +import java.util.List; + +/** + * 管理后台订单服务接口 + */ +public interface AdminOrderService { + + /** + * 获取订单列表 + */ + OrderListVO getOrderList(OrderQueryDTO dto); + + /** + * 获取订单详情 + */ + OrderVO getOrderDetail(Long id, Integer type); + + /** + * 获取最近订单 + */ + List getRecentOrders(); +} diff --git a/src/main/java/com/dora/service/AdminPointsService.java b/src/main/java/com/dora/service/AdminPointsService.java new file mode 100644 index 0000000..f29a306 --- /dev/null +++ b/src/main/java/com/dora/service/AdminPointsService.java @@ -0,0 +1,14 @@ +package com.dora.service; + +import com.dora.dto.admin.PointsPackageDTO; +import com.dora.entity.PointsPackage; +import java.util.List; + +public interface AdminPointsService { + List getPackageList(); + PointsPackage getPackageById(Long id); + void createPackage(PointsPackageDTO dto); + void updatePackage(PointsPackageDTO dto); + void deletePackage(Long id); + void updateStatus(Long id, Integer status); +} diff --git a/src/main/java/com/dora/service/AdminService.java b/src/main/java/com/dora/service/AdminService.java new file mode 100644 index 0000000..3a6d615 --- /dev/null +++ b/src/main/java/com/dora/service/AdminService.java @@ -0,0 +1,68 @@ +package com.dora.service; + +import com.dora.dto.admin.*; +import com.dora.vo.PageVO; +import com.dora.vo.admin.*; + +import java.util.List; + +/** + * 管理员服务接口 + */ +public interface AdminService { + + /** + * 管理员登录(用户名密码) + */ + AdminLoginVO login(AdminLoginDTO dto, String ip); + + /** + * 管理员邮箱验证码登录 + */ + AdminLoginVO loginByEmail(EmailLoginDTO dto, String ip); + + /** + * 校验邮箱是否存在 + */ + void checkEmailExists(String email); + + /** + * 注册管理员(仅用于测试) + */ + void register(AdminRegisterDTO dto); + + /** + * 获取管理员信息 + */ + AdminInfoVO getAdminInfo(Long adminId); + + /** + * 分页查询管理员 + */ + PageVO getAdminList(AdminQueryDTO dto); + + /** + * 创建管理员 + */ + void createAdmin(AdminCreateDTO dto); + + /** + * 更新管理员 + */ + void updateAdmin(Long id, AdminUpdateDTO dto); + + /** + * 删除管理员 + */ + void deleteAdmin(Long id); + + /** + * 获取权限树 + */ + List getPermissionTree(); + + /** + * 获取管理员的菜单树 + */ + List getAdminMenus(Long adminId); +} diff --git a/src/main/java/com/dora/service/AdminWorkService.java b/src/main/java/com/dora/service/AdminWorkService.java new file mode 100644 index 0000000..35d5f9d --- /dev/null +++ b/src/main/java/com/dora/service/AdminWorkService.java @@ -0,0 +1,47 @@ +package com.dora.service; + +import com.dora.dto.admin.AdminWorkQueryDTO; +import com.dora.vo.PageVO; +import com.dora.vo.admin.AdminWorkVO; +import com.dora.vo.admin.AdminWorkStatsVO; + +/** + * 管理端广场作品服务接口 + */ +public interface AdminWorkService { + + /** + * 获取广场作品列表 + */ + PageVO getWorkList(AdminWorkQueryDTO dto); + + /** + * 获取广场作品统计 + */ + AdminWorkStatsVO getWorkStats(); + + /** + * 获取作品详情 + */ + AdminWorkVO getWorkDetail(Long id); + + /** + * 审核作品 + */ + void auditWork(Long id, Integer auditStatus, String auditRemark); + + /** + * 设置/取消精选 + */ + void setFeatured(Long id, Integer isFeatured); + + /** + * 下架/上架作品 + */ + void setWorkStatus(Long id, Integer status); + + /** + * 删除作品 + */ + void deleteWork(Long id); +} diff --git a/src/main/java/com/dora/service/AiModelService.java b/src/main/java/com/dora/service/AiModelService.java new file mode 100644 index 0000000..f6591e2 --- /dev/null +++ b/src/main/java/com/dora/service/AiModelService.java @@ -0,0 +1,82 @@ +package com.dora.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.dto.AiModelDTO; +import com.dora.vo.AiModelVO; +import com.dora.vo.AiModelSimpleVO; + +import java.util.List; + +public interface AiModelService { + + /** + * 分页查询AI模型 + */ + IPage getModelPage(Page page, String type, String category, Integer status); + + /** + * 获取启用的模型列表 + */ + List getActiveModels(String type); + + /** + * 获取用户端模型列表(简化字段,不含敏感信息) + */ + List getUserModels(String type); + + /** + * 获取首页展示的模型列表(用户端) + */ + java.util.Map getHomeModels(Integer limit); + + /** + * 根据ID获取模型详情(后台管理用,含敏感信息) + */ + AiModelVO getModelById(Long id); + + /** + * 根据ID获取模型详情(用户端,不含敏感信息) + */ + AiModelSimpleVO getModelSimpleById(Long id); + + /** + * 根据编码获取模型详情(后台管理用) + */ + AiModelVO getModelByCode(String code); + + /** + * 根据编码获取模型详情(用户端) + */ + AiModelSimpleVO getModelSimpleByCode(String code); + + /** + * 创建模型 + */ + void createModel(AiModelDTO dto); + + /** + * 更新模型 + */ + void updateModel(AiModelDTO dto); + + /** + * 删除模型 + */ + void deleteModel(Long id); + + /** + * 更新模型状态 + */ + void updateModelStatus(Long id, Integer status); + + /** + * 调试模型 + */ + java.util.Map debugModel(Long id, java.util.Map params); + + /** + * 创建调试任务(不扣积分,直接执行) + */ + String createDebugTask(Long modelId, java.util.Map params); +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/AiProviderService.java b/src/main/java/com/dora/service/AiProviderService.java new file mode 100644 index 0000000..56c87fd --- /dev/null +++ b/src/main/java/com/dora/service/AiProviderService.java @@ -0,0 +1,46 @@ +package com.dora.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.dto.AiProviderDTO; +import com.dora.vo.AiProviderVO; + +import java.util.List; + +public interface AiProviderService { + + /** + * 分页查询AI厂商 + */ + IPage getProviderPage(Page page, String name, Integer status); + + /** + * 获取所有启用的厂商 + */ + List getActiveProviders(); + + /** + * 根据ID获取厂商详情 + */ + AiProviderVO getProviderById(Long id); + + /** + * 创建厂商 + */ + void createProvider(AiProviderDTO dto); + + /** + * 更新厂商 + */ + void updateProvider(AiProviderDTO dto); + + /** + * 删除厂商 + */ + void deleteProvider(Long id); + + /** + * 更新厂商状态 + */ + void updateProviderStatus(Long id, Integer status); +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/AiTaskService.java b/src/main/java/com/dora/service/AiTaskService.java new file mode 100644 index 0000000..b3a62dc --- /dev/null +++ b/src/main/java/com/dora/service/AiTaskService.java @@ -0,0 +1,59 @@ +package com.dora.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.dto.AiTaskDTO; +import com.dora.vo.AiTaskVO; + +public interface AiTaskService { + + /** + * 分页查询AI任务 + */ + IPage getTaskPage(Page page, Long userId, Integer status, Long modelId); + + /** + * 根据ID获取任务详情 + */ + AiTaskVO getTaskById(Long id); + + /** + * 根据任务编号获取任务详情 + */ + AiTaskVO getTaskByNo(String taskNo); + + /** + * 创建AI任务 + */ + String createTask(Long userId, AiTaskDTO dto); + + /** + * 取消任务 + */ + void cancelTask(Long userId, Long taskId); + + /** + * 删除任务 + */ + void deleteTask(Long userId, Long taskId); + + /** + * 处理任务队列(处理新提交的任务) + */ + void processTaskQueue(); + + /** + * 轮询异步任务状态 + */ + void pollAsyncTasks(); + + /** + * 更新任务状态 + */ + void updateTaskStatus(Long taskId, Integer status, Integer progress, String errorMessage); + + /** + * 完成任务 + */ + void completeTask(Long taskId, String outputResult); +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/AiWorkService.java b/src/main/java/com/dora/service/AiWorkService.java new file mode 100644 index 0000000..2c6d9e8 --- /dev/null +++ b/src/main/java/com/dora/service/AiWorkService.java @@ -0,0 +1,44 @@ +package com.dora.service; + +import com.dora.dto.PublishWorkDTO; +import com.dora.dto.WorkQueryDTO; +import com.dora.vo.LikeResultVO; +import com.dora.vo.PageVO; +import com.dora.vo.WorkDetailVO; +import com.dora.vo.WorkVO; + +/** + * 作品服务接口 + */ +public interface AiWorkService { + + /** + * 分页查询作品列表 + */ + PageVO listWorks(WorkQueryDTO query, Long currentUserId); + + /** + * 获取作品详情 + */ + WorkDetailVO getWorkDetail(Long id, Long currentUserId); + + /** + * 切换点赞状态(已点赞则取消,未点赞则点赞) + */ + LikeResultVO toggleLike(Long workId, Long userId); + + /** + * 获取用户发布的作品列表 + */ + PageVO getUserWorks(Long userId, int pageNum, int pageSize); + + /** + * 获取用户点赞的作品列表 + */ + PageVO getUserLikedWorks(Long userId, int pageNum, int pageSize); + + /** + * 发布作品 + */ + void publishWork(PublishWorkDTO dto, Long userId); +} diff --git a/src/main/java/com/dora/service/AiWorkflowService.java b/src/main/java/com/dora/service/AiWorkflowService.java new file mode 100644 index 0000000..1c5b7b1 --- /dev/null +++ b/src/main/java/com/dora/service/AiWorkflowService.java @@ -0,0 +1,27 @@ +package com.dora.service; + +import com.dora.vo.AiModelVO; + +/** + * AI工作流处理服务 + */ +public interface AiWorkflowService { + + /** + * 执行AI任务 + * @param model AI模型配置 + * @param inputParams 输入参数JSON + * @param taskId 任务ID + * @return 执行结果JSON + */ + String executeTask(AiModelVO model, String inputParams, Long taskId); + + /** + * 检查异步任务状态 + * @param model AI模型配置 + * @param taskId 任务ID + * @param externalTaskId 外部任务ID + * @return 任务状态和结果 + */ + String checkTaskStatus(AiModelVO model, Long taskId, String externalTaskId); +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/CosTransferService.java b/src/main/java/com/dora/service/CosTransferService.java new file mode 100644 index 0000000..167bf90 --- /dev/null +++ b/src/main/java/com/dora/service/CosTransferService.java @@ -0,0 +1,23 @@ +package com.dora.service; + +/** + * COS文件转存服务 + * 用于将远程URL的文件下载并上传到COS + */ +public interface CosTransferService { + + /** + * 将远程URL的文件转存到COS + * @param remoteUrl 远程文件URL + * @param folder 存储文件夹,如 "ai-result" + * @return COS文件URL + */ + String transferToCos(String remoteUrl, String folder); + + /** + * 将远程URL的文件转存到COS(使用默认文件夹) + * @param remoteUrl 远程文件URL + * @return COS文件URL + */ + String transferToCos(String remoteUrl); +} diff --git a/src/main/java/com/dora/service/EmailService.java b/src/main/java/com/dora/service/EmailService.java new file mode 100644 index 0000000..574f52c --- /dev/null +++ b/src/main/java/com/dora/service/EmailService.java @@ -0,0 +1,21 @@ +package com.dora.service; + +/** + * 邮箱服务接口 + */ +public interface EmailService { + + /** + * 发送验证码 + * @param email 邮箱地址 + */ + void sendVerificationCode(String email); + + /** + * 验证验证码 + * @param email 邮箱地址 + * @param code 验证码 + * @return 是否验证通过 + */ + boolean verifyCode(String email, String code); +} diff --git a/src/main/java/com/dora/service/ImageProcessingService.java b/src/main/java/com/dora/service/ImageProcessingService.java new file mode 100644 index 0000000..86471a1 --- /dev/null +++ b/src/main/java/com/dora/service/ImageProcessingService.java @@ -0,0 +1,55 @@ +package com.dora.service; + +import java.util.List; + +/** + * 图片处理服务 + * 用于图片拼接、风格转换等 + */ +public interface ImageProcessingService { + + /** + * 将多张图片拼接成一张网格图 + * @param imageUrls 图片URL列表 + * @return 拼接后的图片字节数组 + */ + byte[] stitchImages(List imageUrls); + + /** + * 将图片转换为黑白素描风格(本地算法实现) + * @param imageData 原始图片数据 + * @return 转换后的图片字节数组 + */ + byte[] convertToSketch(byte[] imageData); + + /** + * 拼接图片并转换为素描风格,上传到COS(本地算法) + * @param imageUrls 图片URL列表 + * @param folder COS文件夹 + * @return COS文件URL + */ + String stitchAndConvertToCos(List imageUrls, String folder); + + /** + * 仅拼接图片并上传到COS(不做素描转换) + * @param imageUrls 图片URL列表 + * @param folder COS文件夹 + * @return COS文件URL + */ + String stitchAndUploadToCos(List imageUrls, String folder); + + /** + * 使用火山方舟AI模型将图片转换为黑白素描风格(异步任务) + * @param sourceImageUrl 原图URL + * @param userId 用户ID + * @return 任务编号 + */ + String convertToSketchByAi(String sourceImageUrl, Long userId); + + /** + * 使用火山方舟AI模型将图片转换为黑白素描风格(同步等待结果) + * @param sourceImageUrl 原图URL + * @return 素描图URL,失败返回null + */ + String convertToSketchByAiSync(String sourceImageUrl); +} diff --git a/src/main/java/com/dora/service/PointsService.java b/src/main/java/com/dora/service/PointsService.java new file mode 100644 index 0000000..f0820be --- /dev/null +++ b/src/main/java/com/dora/service/PointsService.java @@ -0,0 +1,18 @@ +package com.dora.service; + +import com.dora.dto.CreatePointsOrderDTO; +import com.dora.vo.PointsPackageVO; +import com.dora.vo.WxPayOrderVO; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; + +public interface PointsService { + List getPackageList(); + WxPayOrderVO createOrder(Long userId, CreatePointsOrderDTO dto); + String handlePayNotify(HttpServletRequest request); + void cancelOrder(Long userId, String orderNo); + + // AI任务相关积分操作 + void consumePoints(Long userId, Integer points, String bizType, Long bizId); + void refundPoints(Long userId, Integer points, String bizType, Long bizId); +} diff --git a/src/main/java/com/dora/service/RedeemCodeService.java b/src/main/java/com/dora/service/RedeemCodeService.java new file mode 100644 index 0000000..fd15473 --- /dev/null +++ b/src/main/java/com/dora/service/RedeemCodeService.java @@ -0,0 +1,50 @@ +package com.dora.service; + +import com.dora.dto.admin.RedeemCodeCreateDTO; +import com.dora.dto.admin.RedeemCodeQueryDTO; +import com.dora.entity.RedeemCode; +import com.dora.vo.PageVO; +import com.dora.vo.admin.RedeemCodeVO; + +import java.util.List; + +/** + * 兑换码服务接口 + */ +public interface RedeemCodeService { + + /** + * 获取兑换码列表 + */ + PageVO getCodeList(RedeemCodeQueryDTO dto); + + /** + * 获取兑换码详情 + */ + RedeemCodeVO getCodeDetail(Long id); + + /** + * 批量生成兑换码 + */ + List generateCodes(RedeemCodeCreateDTO dto); + + /** + * 更新兑换码 + */ + void updateCode(Long id, RedeemCode code); + + /** + * 删除兑换码 + */ + void deleteCode(Long id); + + /** + * 禁用/启用兑换码 + */ + void toggleStatus(Long id, Integer status); + + /** + * 用户兑换(预留接口) + */ + void redeem(Long userId, String code, String ip); +} diff --git a/src/main/java/com/dora/service/UserService.java b/src/main/java/com/dora/service/UserService.java new file mode 100644 index 0000000..53ec90a --- /dev/null +++ b/src/main/java/com/dora/service/UserService.java @@ -0,0 +1,76 @@ +package com.dora.service; + +import com.dora.dto.WxLoginDTO; +import com.dora.vo.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 用户服务接口 + */ +public interface UserService { + + /** + * 检查用户是否存在及信息完整度 + */ + UserCheckVO checkUser(String code); + + /** + * 微信登录 + */ + LoginVO wxLogin(WxLoginDTO dto); + + /** + * 刷新Token + */ + TokenVO refreshToken(String refreshToken); + + /** + * 获取用户信息 + */ + LoginVO getUserInfo(Long userId); + + /** + * 上传头像到COS + */ + String uploadAvatar(MultipartFile file); + + /** + * 获取用户个人主页信息 + */ + UserProfileVO getUserProfile(Long userId); + + /** + * 更新用户资料 + */ + void updateProfile(Long userId, String nickname, String avatar); + + /** + * 获取用户订阅状态 + */ + boolean isSubscribed(Long userId); + + /** + * 更新用户订阅状态 + */ + void updateSubscribed(Long userId, boolean subscribed); + + /** + * 获取邀请统计信息 + */ + InviteStatsVO getInviteStats(Long userId); + + /** + * 获取邀请记录列表 + */ + PageVO getInviteRecords(Long userId, Integer pageNum, Integer pageSize); + + /** + * 获取积分记录列表 + */ + PageVO getPointsRecords(Long userId, Integer pageNum, Integer pageSize, Integer type); + + /** + * 获取积分统计信息 + */ + PointsStatsVO getPointsStats(Long userId); +} diff --git a/src/main/java/com/dora/service/VideoProjectService.java b/src/main/java/com/dora/service/VideoProjectService.java new file mode 100644 index 0000000..c598d79 --- /dev/null +++ b/src/main/java/com/dora/service/VideoProjectService.java @@ -0,0 +1,50 @@ +package com.dora.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.dto.video.*; +import com.dora.vo.*; + +import java.util.List; +import java.util.Map; + +public interface VideoProjectService { + + // 项目管理 + Long createProject(Long userId); + VideoProjectVO getProjectById(Long projectId); + IPage getProjectList(Page page, Long userId, Integer status); + void updateProjectSettings(Long projectId, VideoProjectDTO dto); + void deleteProject(Long userId, Long projectId); + + // 剧本生成 + ScriptGenerationResult generateScript(Long projectId, String idea, Long userId); + + // 角色管理 + List getProjectCharacters(Long projectId); + List getUserCharacterLibrary(Long userId); + Long saveCharacter(Long projectId, ProjectCharacterDTO dto); + void deleteCharacter(Long projectId, Long characterId); + String generateCharacterImage(Long projectId, Long characterId, Long userId); + + // 角色模板 + List getCharacterTemplates(String category); + + // 场次管理 + List getProjectScenes(Long projectId); + Long saveScene(Long projectId, ProjectSceneDTO dto); + void deleteScene(Long projectId, Long sceneId); + + // 分镜管理 + List getSceneStoryboards(Long sceneId); + StoryboardGenerationResult generateStoryboards(Long projectId, Long sceneId, Long userId); + void updateStoryboard(Long projectId, Long storyboardId, SceneStoryboardDTO dto); + void deleteStoryboard(Long projectId, Long storyboardId); + Long addStoryboard(Long projectId, Long sceneId, Integer afterIndex); + String generateStoryboardImage(Long projectId, Long storyboardId, Long userId); + String optimizeDescription(Long projectId, Long storyboardId, Long userId); + + // 视频生成 + Map generateSceneVideo(Long projectId, Long sceneId, Long userId); + Map compositeFinalVideo(Long projectId, Long userId); +} diff --git a/src/main/java/com/dora/service/WorkCategoryService.java b/src/main/java/com/dora/service/WorkCategoryService.java new file mode 100644 index 0000000..52c01e4 --- /dev/null +++ b/src/main/java/com/dora/service/WorkCategoryService.java @@ -0,0 +1,20 @@ +package com.dora.service; + +import com.dora.vo.CategoryVO; + +import java.util.List; + +/** + * 作品分类服务接口 + * + * @author dora + */ +public interface WorkCategoryService { + + /** + * 获取所有启用的分类列表 + * + * @return 分类列表 + */ + List listCategories(); +} diff --git a/src/main/java/com/dora/service/WxSubscribeMessageService.java b/src/main/java/com/dora/service/WxSubscribeMessageService.java new file mode 100644 index 0000000..5fbd9f0 --- /dev/null +++ b/src/main/java/com/dora/service/WxSubscribeMessageService.java @@ -0,0 +1,17 @@ +package com.dora.service; + +/** + * 微信订阅消息服务 + */ +public interface WxSubscribeMessageService { + + /** + * 发送AI任务完成通知 + * @param userId 用户ID + * @param taskNo 任务编号 + * @param modelName 模型名称 + * @param status 任务状态:success/failed + * @param message 消息内容 + */ + void sendTaskCompleteNotify(Long userId, String taskNo, String modelName, String status, String message); +} diff --git a/src/main/java/com/dora/service/impl/AdminDashboardServiceImpl.java b/src/main/java/com/dora/service/impl/AdminDashboardServiceImpl.java new file mode 100644 index 0000000..399d012 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AdminDashboardServiceImpl.java @@ -0,0 +1,105 @@ +package com.dora.service.impl; + +import com.dora.mapper.DashboardMapper; +import com.dora.service.AdminDashboardService; +import com.dora.vo.admin.DashboardStatsVO; +import com.dora.vo.admin.DashboardTrendVO; +import com.dora.vo.admin.RecentOrderVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Dashboard服务实现 + */ +@Service +@RequiredArgsConstructor +public class AdminDashboardServiceImpl implements AdminDashboardService { + + private final DashboardMapper dashboardMapper; + + @Override + public DashboardStatsVO getStats() { + DashboardStatsVO stats = new DashboardStatsVO(); + + // 用户总数 + stats.setUserCount(dashboardMapper.countUsers()); + + // 今日新增用户 + stats.setTodayNewUsers(dashboardMapper.countTodayNewUsers()); + + // 今日订单数(VIP订单 + 积分订单) + Long vipOrders = dashboardMapper.countTodayVipOrders(); + Long pointsOrders = dashboardMapper.countTodayPointsOrders(); + stats.setTodayOrderCount(vipOrders + pointsOrders); + + // 今日订单金额 + BigDecimal vipAmount = dashboardMapper.sumTodayVipOrderAmount(); + BigDecimal pointsAmount = dashboardMapper.sumTodayPointsOrderAmount(); + stats.setTodayOrderAmount( + (vipAmount != null ? vipAmount : BigDecimal.ZERO) + .add(pointsAmount != null ? pointsAmount : BigDecimal.ZERO) + ); + + // 作品总数 + stats.setWorkCount(dashboardMapper.countWorks()); + + // 待审核作品数 + stats.setPendingAudit(dashboardMapper.countPendingAuditWorks()); + + return stats; + } + + @Override + public List getTrend() { + List result = new ArrayList<>(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("M/d"); + + // 获取近7天每天的数据 + for (int i = 6; i >= 0; i--) { + LocalDate date = LocalDate.now().minusDays(i); + String dateStr = date.format(formatter); + String dateQuery = date.toString(); // yyyy-MM-dd + + // 新增用户 + Long newUsers = dashboardMapper.countUsersByDate(dateQuery); + result.add(new DashboardTrendVO(dateStr, "新增用户", newUsers != null ? newUsers : 0L)); + + // 订单数 + Long vipOrders = dashboardMapper.countVipOrdersByDate(dateQuery); + Long pointsOrders = dashboardMapper.countPointsOrdersByDate(dateQuery); + Long totalOrders = (vipOrders != null ? vipOrders : 0L) + (pointsOrders != null ? pointsOrders : 0L); + result.add(new DashboardTrendVO(dateStr, "订单数", totalOrders)); + } + + return result; + } + + @Override + public List getRecentOrders() { + List result = new ArrayList<>(); + + // 获取最近的VIP订单 + List vipOrders = dashboardMapper.selectRecentVipOrders(5); + vipOrders.forEach(o -> o.setType(1)); + result.addAll(vipOrders); + + // 获取最近的积分订单 + List pointsOrders = dashboardMapper.selectRecentPointsOrders(5); + pointsOrders.forEach(o -> o.setType(2)); + result.addAll(pointsOrders); + + // 按创建时间排序,取前10条 + return result.stream() + .sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt())) + .limit(10) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/dora/service/impl/AdminOrderServiceImpl.java b/src/main/java/com/dora/service/impl/AdminOrderServiceImpl.java new file mode 100644 index 0000000..fdfe4e0 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AdminOrderServiceImpl.java @@ -0,0 +1,105 @@ +package com.dora.service.impl; + +import com.dora.dto.admin.OrderQueryDTO; +import com.dora.mapper.OrderMapper; +import com.dora.service.AdminOrderService; +import com.dora.vo.admin.OrderListVO; +import com.dora.vo.admin.OrderVO; +import com.dora.vo.admin.RecentOrderVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 管理后台订单服务实现 + */ +@Service +@RequiredArgsConstructor +public class AdminOrderServiceImpl implements AdminOrderService { + + private final OrderMapper orderMapper; + + @Override + public OrderListVO getOrderList(OrderQueryDTO dto) { + OrderListVO result = new OrderListVO(); + List allOrders = new ArrayList<>(); + BigDecimal totalAmount = BigDecimal.ZERO; + long total = 0; + + int offset = (dto.getPage() - 1) * dto.getPageSize(); + + // 根据类型查询 + if (dto.getType() == null || dto.getType() == 1) { + // 查询VIP订单 + List vipOrders = orderMapper.selectVipOrders(dto, offset, dto.getPageSize()); + vipOrders.forEach(o -> o.setType(1)); + allOrders.addAll(vipOrders); + + Long vipCount = orderMapper.countVipOrders(dto); + total += vipCount != null ? vipCount : 0; + + BigDecimal vipAmount = orderMapper.sumVipOrderAmount(dto); + totalAmount = totalAmount.add(vipAmount != null ? vipAmount : BigDecimal.ZERO); + } + + if (dto.getType() == null || dto.getType() == 2) { + // 查询积分订单 + List pointsOrders = orderMapper.selectPointsOrders(dto, offset, dto.getPageSize()); + pointsOrders.forEach(o -> o.setType(2)); + allOrders.addAll(pointsOrders); + + Long pointsCount = orderMapper.countPointsOrders(dto); + total += pointsCount != null ? pointsCount : 0; + + BigDecimal pointsAmount = orderMapper.sumPointsOrderAmount(dto); + totalAmount = totalAmount.add(pointsAmount != null ? pointsAmount : BigDecimal.ZERO); + } + + // 按创建时间排序 + List sortedOrders = allOrders.stream() + .sorted(Comparator.comparing(OrderVO::getCreatedAt).reversed()) + .limit(dto.getPageSize()) + .collect(Collectors.toList()); + + result.setList(sortedOrders); + result.setTotal(total); + result.setTotalAmount(totalAmount); + + return result; + } + + @Override + public OrderVO getOrderDetail(Long id, Integer type) { + if (type == 1) { + return orderMapper.selectVipOrderById(id); + } else { + return orderMapper.selectPointsOrderById(id); + } + } + + @Override + public List getRecentOrders() { + List result = new ArrayList<>(); + + // 获取最近的VIP订单 + List vipOrders = orderMapper.selectRecentVipOrders(5); + vipOrders.forEach(o -> o.setType(1)); + result.addAll(vipOrders); + + // 获取最近的积分订单 + List pointsOrders = orderMapper.selectRecentPointsOrders(5); + pointsOrders.forEach(o -> o.setType(2)); + result.addAll(pointsOrders); + + // 按创建时间排序,取前10条 + return result.stream() + .sorted(Comparator.comparing(RecentOrderVO::getCreatedAt).reversed()) + .limit(10) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/dora/service/impl/AdminPointsServiceImpl.java b/src/main/java/com/dora/service/impl/AdminPointsServiceImpl.java new file mode 100644 index 0000000..230f9b3 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AdminPointsServiceImpl.java @@ -0,0 +1,85 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.dto.admin.PointsPackageDTO; +import com.dora.entity.PointsPackage; +import com.dora.mapper.PointsPackageMapper; +import com.dora.service.AdminPointsService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminPointsServiceImpl implements AdminPointsService { + + private final PointsPackageMapper packageMapper; + + @Override + public List getPackageList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsPackage::getDeleted, 0) + .orderByAsc(PointsPackage::getSort); + return packageMapper.selectList(wrapper); + } + + @Override + public PointsPackage getPackageById(Long id) { + return packageMapper.selectById(id); + } + + @Override + public void createPackage(PointsPackageDTO dto) { + PointsPackage pkg = new PointsPackage(); + BeanUtils.copyProperties(dto, pkg); + pkg.setCreatedAt(LocalDateTime.now()); + pkg.setUpdatedAt(LocalDateTime.now()); + pkg.setDeleted(0); + if (pkg.getStatus() == null) pkg.setStatus(1); + if (pkg.getSort() == null) pkg.setSort(0); + if (pkg.getBonusPoints() == null) pkg.setBonusPoints(0); + if (pkg.getValidDays() == null) pkg.setValidDays(365); + packageMapper.insert(pkg); + } + + @Override + public void updatePackage(PointsPackageDTO dto) { + if (dto.getId() == null) { + throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "套餐ID不能为空"); + } + PointsPackage pkg = packageMapper.selectById(dto.getId()); + if (pkg == null) { + throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "套餐不存在"); + } + BeanUtils.copyProperties(dto, pkg); + pkg.setUpdatedAt(LocalDateTime.now()); + packageMapper.updateById(pkg); + } + + @Override + public void deletePackage(Long id) { + PointsPackage pkg = packageMapper.selectById(id); + if (pkg == null) { + throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "套餐不存在"); + } + pkg.setDeleted(1); + pkg.setUpdatedAt(LocalDateTime.now()); + packageMapper.updateById(pkg); + } + + @Override + public void updateStatus(Long id, Integer status) { + PointsPackage pkg = packageMapper.selectById(id); + if (pkg == null) { + throw new BusinessException(ResultCode.PARAM_ERROR.getCode(), "套餐不存在"); + } + pkg.setStatus(status); + pkg.setUpdatedAt(LocalDateTime.now()); + packageMapper.updateById(pkg); + } +} diff --git a/src/main/java/com/dora/service/impl/AdminServiceImpl.java b/src/main/java/com/dora/service/impl/AdminServiceImpl.java new file mode 100644 index 0000000..4d9fcde --- /dev/null +++ b/src/main/java/com/dora/service/impl/AdminServiceImpl.java @@ -0,0 +1,348 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.dto.admin.*; +import com.dora.entity.*; +import com.dora.mapper.*; +import com.dora.service.AdminService; +import com.dora.service.EmailService; +import com.dora.util.AdminJwtUtil; +import com.dora.vo.PageVO; +import com.dora.vo.admin.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 管理员服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminServiceImpl implements AdminService { + + private final AdminMapper adminMapper; + private final RoleMapper roleMapper; + private final PermissionMapper permissionMapper; + private final AdminRoleMapper adminRoleMapper; + private final RolePermissionMapper rolePermissionMapper; + private final AdminJwtUtil adminJwtUtil; + private final EmailService emailService; + + private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private static final String DEFAULT_PASSWORD = "123456"; + + @Override + public AdminLoginVO login(AdminLoginDTO dto, String ip) { + Admin admin = adminMapper.selectByUsername(dto.getUsername()); + if (admin == null) { + throw new BusinessException(ResultCode.LOGIN_FAILED); + } + + if (!PASSWORD_ENCODER.matches(dto.getPassword(), admin.getPassword())) { + throw new BusinessException(ResultCode.LOGIN_FAILED); + } + + if (admin.getStatus() != 1) { + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); + } + + return generateLoginResponse(admin, ip); + } + + @Override + public AdminLoginVO loginByEmail(EmailLoginDTO dto, String ip) { + // 验证验证码 + if (!emailService.verifyCode(dto.getEmail(), dto.getCode())) { + throw new BusinessException(ResultCode.VERIFICATION_CODE_ERROR); + } + + // 查询管理员 + Admin admin = adminMapper.selectByEmail(dto.getEmail()); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + + if (admin.getStatus() != 1) { + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); + } + + return generateLoginResponse(admin, ip); + } + + @Override + public void checkEmailExists(String email) { + Admin admin = adminMapper.selectByEmail(email); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + if (admin.getStatus() != 1) { + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); + } + } + + private AdminLoginVO generateLoginResponse(Admin admin, String ip) { + // 更新登录信息 + admin.setLastLoginTime(LocalDateTime.now()); + admin.setLastLoginIp(ip); + adminMapper.updateById(admin); + + // 获取角色 + List roles = adminMapper.selectRoleCodesByAdminId(admin.getId()); + + // 生成Token + AdminLoginVO vo = new AdminLoginVO(); + vo.setToken(adminJwtUtil.generateAccessToken(admin.getId(), admin.getUsername(), roles)); + vo.setRefreshToken(adminJwtUtil.generateRefreshToken(admin.getId(), admin.getUsername())); + vo.setExpiresIn(adminJwtUtil.getAccessTokenExpire()); + + return vo; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void register(AdminRegisterDTO dto) { + // 检查用户名是否存在 + Admin existing = adminMapper.selectByUsername(dto.getUsername()); + if (existing != null) { + throw new BusinessException(ResultCode.USER_ALREADY_EXISTS); + } + + // 创建管理员 + Admin admin = new Admin(); + admin.setUsername(dto.getUsername()); + admin.setPassword(PASSWORD_ENCODER.encode(dto.getPassword())); + admin.setRealName(dto.getRealName()); + admin.setPhone(dto.getPhone()); + admin.setEmail(dto.getEmail()); + admin.setStatus(1); + admin.setCreatedAt(LocalDateTime.now()); + admin.setUpdatedAt(LocalDateTime.now()); + adminMapper.insert(admin); + + // 分配默认角色(超级管理员) + Role superAdminRole = roleMapper.selectByCode("SUPER_ADMIN"); + if (superAdminRole != null) { + AdminRole adminRole = new AdminRole(); + adminRole.setAdminId(admin.getId()); + adminRole.setRoleId(superAdminRole.getId()); + adminRole.setCreatedAt(LocalDateTime.now()); + adminRoleMapper.insert(adminRole); + log.info("为管理员 {} 分配了超级管理员角色", dto.getUsername()); + } else { + log.warn("超级管理员角色不存在,尝试创建默认角色"); + // 如果角色不存在,创建一个默认的超级管理员角色 + Role newRole = new Role(); + newRole.setName("超级管理员"); + newRole.setCode("SUPER_ADMIN"); + newRole.setDescription("拥有所有权限"); + newRole.setDataScope(1); + newRole.setSort(1); + newRole.setStatus(1); + newRole.setCreatedAt(LocalDateTime.now()); + newRole.setUpdatedAt(LocalDateTime.now()); + roleMapper.insert(newRole); + + // 分配角色 + AdminRole adminRole = new AdminRole(); + adminRole.setAdminId(admin.getId()); + adminRole.setRoleId(newRole.getId()); + adminRole.setCreatedAt(LocalDateTime.now()); + adminRoleMapper.insert(adminRole); + log.info("创建了超级管理员角色并为管理员 {} 分配", dto.getUsername()); + } + + log.info("管理员注册成功: {}", dto.getUsername()); + } + + @Override + public AdminInfoVO getAdminInfo(Long adminId) { + Admin admin = adminMapper.selectById(adminId); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + + AdminInfoVO vo = new AdminInfoVO(); + + // 管理员基本信息 + AdminVO adminVO = new AdminVO(); + BeanUtils.copyProperties(admin, adminVO); + + // 获取角色 + List roles = roleMapper.selectRolesByAdminId(adminId); + List roleVOs = roles.stream().map(r -> { + RoleVO roleVO = new RoleVO(); + BeanUtils.copyProperties(r, roleVO); + return roleVO; + }).collect(Collectors.toList()); + adminVO.setRoles(roleVOs); + vo.setAdmin(adminVO); + + // 角色编码 + vo.setRoles(roles.stream().map(Role::getCode).collect(Collectors.toList())); + + // 权限编码 + vo.setPermissions(adminMapper.selectPermissionCodesByAdminId(adminId)); + + // 菜单树 + vo.setMenus(getAdminMenus(adminId)); + + return vo; + } + + @Override + public PageVO getAdminList(AdminQueryDTO dto) { + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (StringUtils.hasText(dto.getKeyword())) { + wrapper.and(w -> w.like(Admin::getUsername, dto.getKeyword()) + .or().like(Admin::getRealName, dto.getKeyword())); + } + if (dto.getStatus() != null) { + wrapper.eq(Admin::getStatus, dto.getStatus()); + } + wrapper.orderByDesc(Admin::getCreatedAt); + + Page result = adminMapper.selectPage(page, wrapper); + + List list = result.getRecords().stream().map(admin -> { + AdminVO vo = new AdminVO(); + BeanUtils.copyProperties(admin, vo); + // 获取角色 + List roles = roleMapper.selectRolesByAdminId(admin.getId()); + vo.setRoles(roles.stream().map(r -> { + RoleVO roleVO = new RoleVO(); + BeanUtils.copyProperties(r, roleVO); + return roleVO; + }).collect(Collectors.toList())); + return vo; + }).collect(Collectors.toList()); + + return PageVO.of(list, result.getTotal(), dto.getPage(), dto.getPageSize()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void createAdmin(AdminCreateDTO dto) { + // 检查用户名 + Admin existing = adminMapper.selectByUsername(dto.getUsername()); + if (existing != null) { + throw new BusinessException(ResultCode.USER_ALREADY_EXISTS); + } + + // 创建管理员 + Admin admin = new Admin(); + admin.setUsername(dto.getUsername()); + admin.setPassword(PASSWORD_ENCODER.encode(dto.getPassword())); + admin.setRealName(dto.getRealName()); + admin.setPhone(dto.getPhone()); + admin.setEmail(dto.getEmail()); + admin.setStatus(dto.getStatus()); + admin.setCreatedAt(LocalDateTime.now()); + admin.setUpdatedAt(LocalDateTime.now()); + adminMapper.insert(admin); + + // 分配角色 + for (Long roleId : dto.getRoleIds()) { + AdminRole adminRole = new AdminRole(); + adminRole.setAdminId(admin.getId()); + adminRole.setRoleId(roleId); + adminRole.setCreatedAt(LocalDateTime.now()); + adminRoleMapper.insert(adminRole); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateAdmin(Long id, AdminUpdateDTO dto) { + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + + // 更新基本信息 + if (dto.getRealName() != null) admin.setRealName(dto.getRealName()); + if (dto.getPhone() != null) admin.setPhone(dto.getPhone()); + if (dto.getEmail() != null) admin.setEmail(dto.getEmail()); + if (dto.getStatus() != null) admin.setStatus(dto.getStatus()); + + // 更新密码(如果提供了新密码) + if (StringUtils.hasText(dto.getPassword())) { + admin.setPassword(PASSWORD_ENCODER.encode(dto.getPassword())); + } + + admin.setUpdatedAt(LocalDateTime.now()); + adminMapper.updateById(admin); + + // 更新角色 + if (dto.getRoleIds() != null && !dto.getRoleIds().isEmpty()) { + adminRoleMapper.deleteByAdminId(id); + for (Long roleId : dto.getRoleIds()) { + AdminRole adminRole = new AdminRole(); + adminRole.setAdminId(id); + adminRole.setRoleId(roleId); + adminRole.setCreatedAt(LocalDateTime.now()); + adminRoleMapper.insert(adminRole); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAdmin(Long id) { + Admin admin = adminMapper.selectById(id); + if (admin == null) { + throw new BusinessException(ResultCode.USER_NOT_FOUND); + } + + // 删除角色关联 + adminRoleMapper.deleteByAdminId(id); + + // 逻辑删除管理员 + adminMapper.deleteById(id); + } + + @Override + public List getPermissionTree() { + List permissions = permissionMapper.selectList( + new LambdaQueryWrapper() + .eq(Permission::getStatus, 1) + .orderByAsc(Permission::getSort) + ); + return buildPermissionTree(permissions, 0L); + } + + @Override + public List getAdminMenus(Long adminId) { + List permissions = permissionMapper.selectPermissionsByAdminId(adminId); + // 只返回目录和菜单 + List menus = permissions.stream() + .filter(p -> p.getType() == 1 || p.getType() == 2) + .collect(Collectors.toList()); + return buildPermissionTree(menus, 0L); + } + + private List buildPermissionTree(List permissions, Long parentId) { + return permissions.stream() + .filter(p -> Objects.equals(p.getParentId(), parentId)) + .map(p -> { + PermissionTreeVO vo = new PermissionTreeVO(); + BeanUtils.copyProperties(p, vo); + vo.setChildren(buildPermissionTree(permissions, p.getId())); + return vo; + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/dora/service/impl/AdminWorkServiceImpl.java b/src/main/java/com/dora/service/impl/AdminWorkServiceImpl.java new file mode 100644 index 0000000..627e8c9 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AdminWorkServiceImpl.java @@ -0,0 +1,141 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.dto.admin.AdminWorkQueryDTO; +import com.dora.entity.AiTask; +import com.dora.mapper.AdminWorkMapper; +import com.dora.mapper.AiTaskMapper; +import com.dora.service.AdminWorkService; +import com.dora.vo.PageVO; +import com.dora.vo.admin.AdminWorkVO; +import com.dora.vo.admin.AdminWorkStatsVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 管理端广场作品服务实现 + */ +@Service +@RequiredArgsConstructor +public class AdminWorkServiceImpl implements AdminWorkService { + + private final AdminWorkMapper adminWorkMapper; + private final AiTaskMapper aiTaskMapper; + + // 任务类型映射 + private static final Map TASK_TYPE_MAP = new HashMap<>(); + static { + TASK_TYPE_MAP.put("text2img", "文生图"); + TASK_TYPE_MAP.put("img2img", "图生图"); + TASK_TYPE_MAP.put("text2video", "文生视频"); + TASK_TYPE_MAP.put("img2video", "图生视频"); + TASK_TYPE_MAP.put("1", "文生图"); + TASK_TYPE_MAP.put("2", "图生图"); + } + + @Override + public PageVO getWorkList(AdminWorkQueryDTO dto) { + int offset = (dto.getPage() - 1) * dto.getPageSize(); + + List list = adminWorkMapper.selectWorkList(dto, offset, dto.getPageSize()); + Long total = adminWorkMapper.countWorkList(dto); + + // 填充任务类型名称 + list.forEach(work -> { + if (work.getTaskType() != null) { + work.setTaskTypeName(TASK_TYPE_MAP.getOrDefault(work.getTaskType(), work.getTaskType())); + } + }); + + return PageVO.of(list, total, dto.getPage(), dto.getPageSize()); + } + + @Override + public AdminWorkStatsVO getWorkStats() { + AdminWorkStatsVO stats = new AdminWorkStatsVO(); + stats.setTotal(adminWorkMapper.countByStatus(null)); + stats.setPending(adminWorkMapper.countByAuditStatus(0)); + stats.setPassed(adminWorkMapper.countByAuditStatus(1)); + stats.setRejected(adminWorkMapper.countByAuditStatus(2)); + stats.setFeatured(adminWorkMapper.countFeatured()); + return stats; + } + + @Override + public AdminWorkVO getWorkDetail(Long id) { + AdminWorkVO work = adminWorkMapper.selectWorkById(id); + if (work == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "作品不存在"); + } + if (work.getTaskType() != null) { + work.setTaskTypeName(TASK_TYPE_MAP.getOrDefault(work.getTaskType(), work.getTaskType())); + } + return work; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void auditWork(Long id, Integer auditStatus, String auditRemark) { + // 1. 更新作品审核状态 + int rows = adminWorkMapper.updateAuditStatus(id, auditStatus, auditRemark); + if (rows == 0) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "作品不存在"); + } + + // 2. 查询作品详情获取关联的任务ID + AdminWorkVO work = adminWorkMapper.selectWorkById(id); + if (work == null) { + return; + } + + // 3. 更新关联任务的发布状态 + // auditStatus: 0待审核 1已通过 2已拒绝 + // publishStatus: 0未发布 1审核中 2已发布 3审核未通过 + Integer publishStatus; + if (auditStatus == 1) { + publishStatus = 2; // 已发布 + } else if (auditStatus == 2) { + publishStatus = 3; // 审核未通过 + } else { + publishStatus = 1; // 审核中 + } + + // 通过work_id更新任务状态 + aiTaskMapper.update(null, new LambdaUpdateWrapper() + .eq(AiTask::getWorkId, id) + .set(AiTask::getPublishStatus, publishStatus)); + } + + @Override + public void setFeatured(Long id, Integer isFeatured) { + int rows = adminWorkMapper.updateFeatured(id, isFeatured); + if (rows == 0) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "作品不存在"); + } + } + + @Override + public void setWorkStatus(Long id, Integer status) { + int rows = adminWorkMapper.updateStatus(id, status); + if (rows == 0) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "作品不存在"); + } + } + + @Override + public void deleteWork(Long id) { + // 将作品审核状态设置为拒绝,而不是真正删除 + // 这个方法现在主要用于兼容旧的删除接口,实际应该通过审核接口处理 + int rows = adminWorkMapper.updateAuditStatus(id, 2, "管理员操作:作品不符合规范"); + if (rows == 0) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "作品不存在"); + } + } +} diff --git a/src/main/java/com/dora/service/impl/AiModelServiceImpl.java b/src/main/java/com/dora/service/impl/AiModelServiceImpl.java new file mode 100644 index 0000000..b0cc63f --- /dev/null +++ b/src/main/java/com/dora/service/impl/AiModelServiceImpl.java @@ -0,0 +1,320 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.dto.AiModelDTO; +import com.dora.entity.AiModel; +import com.dora.entity.AiProvider; +import com.dora.entity.AiTask; +import com.dora.mapper.AiModelMapper; +import com.dora.mapper.AiProviderMapper; +import com.dora.mapper.AiTaskMapper; +import com.dora.service.AiModelService; +import com.dora.service.AiWorkflowService; +import com.dora.vo.AiModelVO; +import com.dora.vo.AiModelSimpleVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiModelServiceImpl implements AiModelService { + + private final AiModelMapper aiModelMapper; + private final AiProviderMapper aiProviderMapper; + private final AiTaskMapper aiTaskMapper; + private final AiWorkflowService aiWorkflowService; + + @Override + public IPage getModelPage(Page page, String type, String category, Integer status) { + return aiModelMapper.selectModelPage(page, type, category, status); + } + + @Override + public List getActiveModels(String type) { + return aiModelMapper.selectActiveModels(type); + } + + @Override + public List getUserModels(String type) { + // 用户端模型列表,只返回简化字段 + return aiModelMapper.selectListModelsSimple(type); + } + + @Override + public Map getHomeModels(Integer limit) { + // 获取显示在列表中的模型(用户端,简化字段) + List allModels = aiModelMapper.selectListModelsSimple(null); + + // 构建返回结果 + Map result = new HashMap<>(); + + // 总数 + int total = allModels.size(); + result.put("total", total); + + // 是否有更多 + result.put("hasMore", total > limit); + + // 返回指定数量的模型 + List models = allModels.size() > limit + ? allModels.subList(0, limit) + : allModels; + result.put("models", models); + + return result; + } + + @Override + public AiModelVO getModelById(Long id) { + AiModelVO vo = aiModelMapper.selectModelVOById(id); + if (vo == null) { + throw new BusinessException("模型不存在"); + } + return vo; + } + + @Override + public AiModelSimpleVO getModelSimpleById(Long id) { + AiModelSimpleVO vo = aiModelMapper.selectModelSimpleById(id); + if (vo == null) { + throw new BusinessException("模型不存在或已禁用"); + } + return vo; + } + + @Override + public AiModelVO getModelByCode(String code) { + AiModelVO vo = aiModelMapper.selectModelVOByCode(code); + if (vo == null) { + throw new BusinessException("模型不存在或已禁用"); + } + return vo; + } + + @Override + public AiModelSimpleVO getModelSimpleByCode(String code) { + AiModelSimpleVO vo = aiModelMapper.selectModelSimpleByCode(code); + if (vo == null) { + throw new BusinessException("模型不存在或已禁用"); + } + return vo; + } + + @Override + public void createModel(AiModelDTO dto) { + // 检查编码是否重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiModel::getCode, dto.getCode()); + if (aiModelMapper.selectCount(wrapper) > 0) { + throw new BusinessException("模型编码已存在"); + } + + AiModel model = new AiModel(); + BeanUtils.copyProperties(dto, model); + aiModelMapper.insert(model); + } + + @Override + public void updateModel(AiModelDTO dto) { + AiModel existModel = aiModelMapper.selectById(dto.getId()); + if (existModel == null) { + throw new BusinessException("模型不存在"); + } + + // 检查编码是否重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiModel::getCode, dto.getCode()) + .ne(AiModel::getId, dto.getId()); + if (aiModelMapper.selectCount(wrapper) > 0) { + throw new BusinessException("模型编码已存在"); + } + + AiModel model = new AiModel(); + BeanUtils.copyProperties(dto, model); + aiModelMapper.updateById(model); + } + + @Override + public void deleteModel(Long id) { + AiModel model = aiModelMapper.selectById(id); + if (model == null) { + throw new BusinessException("模型不存在"); + } + + aiModelMapper.deleteById(id); + } + + @Override + public void updateModelStatus(Long id, Integer status) { + AiModel model = aiModelMapper.selectById(id); + if (model == null) { + throw new BusinessException("模型不存在"); + } + + model.setStatus(status); + aiModelMapper.updateById(model); + } + + @Override + public Map debugModel(Long id, Map params) { + // 获取模型信息 + AiModelVO model = getModelById(id); + + // 获取厂商信息 + AiProvider provider = aiProviderMapper.selectById(model.getProviderId()); + if (provider == null) { + throw new BusinessException("厂商不存在"); + } + + AiTask task = null; + try { + // 将参数转为JSON字符串 + String inputParams = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(params); + + // 创建调试任务记录(不扣积分) + task = new AiTask(); + task.setTaskNo("DEBUG_" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 4).toUpperCase()); + task.setUserId(0L); // 管理员调试,userId设为0 + task.setModelId(id); + task.setModelCode(model.getCode()); + task.setInputParams(inputParams); + task.setPointsCost(0); // 调试不消耗积分 + task.setStatus(0); // 队列中 + task.setProgress(0); + task.setRetryCount(0); + task.setMaxRetry(3); + task.setPollCount(0); + task.setPriority(10); // 调试任务优先级最高 + + aiTaskMapper.insert(task); + log.info("创建调试任务 - 任务ID: {}, 任务编号: {}, 模型: {}", task.getId(), task.getTaskNo(), model.getCode()); + + // 更新任务状态为处理中 + task.setStatus(1); + task.setProgress(10); + task.setStartTime(java.time.LocalDateTime.now()); + aiTaskMapper.updateById(task); + + // 执行AI任务 + String result = aiWorkflowService.executeTask(model, inputParams, task.getId()); + + log.info("调试任务执行结果 - 任务ID: {}, 结果: {}", task.getId(), result); + + // 解析结果 + Map resultMap = parseJsonToMap(result); + String status = (String) resultMap.get("status"); + + if ("completed".equals(status) || "success".equals(status)) { + // 同步任务成功完成 + task.setStatus(2); + task.setProgress(100); + task.setOutputResult(result); + task.setEndTime(java.time.LocalDateTime.now()); + if (task.getStartTime() != null) { + long duration = java.time.Duration.between(task.getStartTime(), task.getEndTime()).toSeconds(); + task.setDuration((int) Math.max(0, duration)); + } + aiTaskMapper.updateById(task); + + } else if ("processing".equals(status) || "pending".equals(status)) { + // 异步任务,保存外部任务ID,等待后续轮询 + String externalTaskId = (String) resultMap.get("external_task_id"); + if (StringUtils.hasText(externalTaskId)) { + task.setExternalTaskId(externalTaskId); + } + + // 解析进度 + Object progressObj = resultMap.get("progress"); + int progress = 20; + if (progressObj != null) { + try { + progress = Integer.parseInt(String.valueOf(progressObj)); + } catch (NumberFormatException ignored) {} + } + + task.setStatus(1); + task.setProgress(progress); + task.setPollCount(0); + aiTaskMapper.updateById(task); + + log.info("异步调试任务已提交 - 任务ID: {}, 外部任务ID: {}", task.getId(), externalTaskId); + + } else if ("failed".equals(status) || "error".equals(status)) { + // 任务失败 + String error = (String) resultMap.get("error"); + task.setStatus(3); + task.setProgress(0); + task.setErrorMessage(error != null ? error : "任务执行失败"); + task.setEndTime(java.time.LocalDateTime.now()); + aiTaskMapper.updateById(task); + } + + // 构建调试结果 + Map debugResult = new HashMap<>(); + debugResult.put("success", true); + debugResult.put("taskId", task.getId()); + debugResult.put("taskNo", task.getTaskNo()); + debugResult.put("taskStatus", task.getStatus()); + debugResult.put("isAsync", model.getIsAsync() != null && model.getIsAsync() == 1); + debugResult.put("response", resultMap); + debugResult.put("rawResponse", result); + + return debugResult; + + } catch (Exception e) { + log.error("调试模型失败 - 模型ID: {}, 错误: {}", id, e.getMessage(), e); + + // 更新任务状态为失败 + if (task != null && task.getId() != null) { + task.setStatus(3); + task.setProgress(0); + task.setErrorMessage(e.getMessage()); + task.setEndTime(java.time.LocalDateTime.now()); + if (task.getStartTime() != null) { + long duration = java.time.Duration.between(task.getStartTime(), task.getEndTime()).toSeconds(); + task.setDuration((int) Math.max(0, duration)); + } + aiTaskMapper.updateById(task); + } + + Map errorResult = new HashMap<>(); + errorResult.put("success", false); + errorResult.put("error", e.getMessage()); + errorResult.put("stackTrace", e.toString()); + if (task != null) { + errorResult.put("taskId", task.getId()); + errorResult.put("taskNo", task.getTaskNo()); + errorResult.put("taskStatus", 3); + } + return errorResult; + } + } + + private Map parseJsonToMap(String jsonStr) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().readValue(jsonStr, Map.class); + } catch (Exception e) { + throw new RuntimeException("JSON解析失败: " + e.getMessage()); + } + } + + @Override + public String createDebugTask(Long modelId, Map params) { + // 调试任务直接调用 debugModel,返回任务编号(这里用时间戳模拟) + String taskNo = "DEBUG_" + System.currentTimeMillis(); + return taskNo; + } +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/impl/AiProviderServiceImpl.java b/src/main/java/com/dora/service/impl/AiProviderServiceImpl.java new file mode 100644 index 0000000..a3627c7 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AiProviderServiceImpl.java @@ -0,0 +1,139 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.dto.AiProviderDTO; +import com.dora.entity.AiProvider; +import com.dora.mapper.AiProviderMapper; +import com.dora.service.AiProviderService; +import com.dora.vo.AiProviderVO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AiProviderServiceImpl implements AiProviderService { + + private final AiProviderMapper aiProviderMapper; + + @Override + public IPage getProviderPage(Page page, String name, Integer status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StringUtils.hasText(name), AiProvider::getName, name) + .eq(status != null, AiProvider::getStatus, status) + .orderByAsc(AiProvider::getSort) + .orderByDesc(AiProvider::getCreatedAt); + + // 创建正确类型的分页对象 + Page providerPage = new Page<>(page.getCurrent(), page.getSize()); + IPage result = aiProviderMapper.selectPage(providerPage, wrapper); + + // 转换为VO对象 + IPage voResult = new Page<>(result.getCurrent(), result.getSize(), result.getTotal()); + List voList = result.getRecords().stream().map(provider -> { + AiProviderVO vo = new AiProviderVO(); + BeanUtils.copyProperties(provider, vo); + return vo; + }).collect(Collectors.toList()); + voResult.setRecords(voList); + + return voResult; + } + + @Override + public List getActiveProviders() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiProvider::getStatus, 1) + .orderByAsc(AiProvider::getSort) + .orderByDesc(AiProvider::getCreatedAt); + + List providers = aiProviderMapper.selectList(wrapper); + return providers.stream().map(provider -> { + AiProviderVO vo = new AiProviderVO(); + BeanUtils.copyProperties(provider, vo); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public AiProviderVO getProviderById(Long id) { + AiProvider provider = aiProviderMapper.selectById(id); + if (provider == null) { + throw new BusinessException("厂商不存在"); + } + + AiProviderVO vo = new AiProviderVO(); + BeanUtils.copyProperties(provider, vo); + return vo; + } + + @Override + public void createProvider(AiProviderDTO dto) { + // 检查编码是否重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiProvider::getCode, dto.getCode()); + if (aiProviderMapper.selectCount(wrapper) > 0) { + throw new BusinessException("厂商编码已存在"); + } + + AiProvider provider = new AiProvider(); + BeanUtils.copyProperties(dto, provider); + // 处理extraConfig为空的情况,MySQL JSON类型不接受空字符串 + if (!StringUtils.hasText(provider.getExtraConfig())) { + provider.setExtraConfig(null); + } + aiProviderMapper.insert(provider); + } + + @Override + public void updateProvider(AiProviderDTO dto) { + AiProvider existProvider = aiProviderMapper.selectById(dto.getId()); + if (existProvider == null) { + throw new BusinessException("厂商不存在"); + } + + // 检查编码是否重复 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiProvider::getCode, dto.getCode()) + .ne(AiProvider::getId, dto.getId()); + if (aiProviderMapper.selectCount(wrapper) > 0) { + throw new BusinessException("厂商编码已存在"); + } + + AiProvider provider = new AiProvider(); + BeanUtils.copyProperties(dto, provider); + // 处理extraConfig为空的情况,MySQL JSON类型不接受空字符串 + if (!StringUtils.hasText(provider.getExtraConfig())) { + provider.setExtraConfig(null); + } + aiProviderMapper.updateById(provider); + } + + @Override + public void deleteProvider(Long id) { + AiProvider provider = aiProviderMapper.selectById(id); + if (provider == null) { + throw new BusinessException("厂商不存在"); + } + + aiProviderMapper.deleteById(id); + } + + @Override + public void updateProviderStatus(Long id, Integer status) { + AiProvider provider = aiProviderMapper.selectById(id); + if (provider == null) { + throw new BusinessException("厂商不存在"); + } + + provider.setStatus(status); + aiProviderMapper.updateById(provider); + } +} \ No newline at end of file diff --git a/src/main/java/com/dora/service/impl/AiTaskServiceImpl.java b/src/main/java/com/dora/service/impl/AiTaskServiceImpl.java new file mode 100644 index 0000000..78b4568 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AiTaskServiceImpl.java @@ -0,0 +1,752 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.dto.AiTaskDTO; +import com.dora.entity.AiTask; +import com.dora.entity.User; +import com.dora.entity.ProjectCharacter; +import com.dora.entity.ProjectScene; +import com.dora.entity.SceneStoryboard; +import com.dora.mapper.AiTaskMapper; +import com.dora.mapper.UserMapper; +import com.dora.mapper.ProjectCharacterMapper; +import com.dora.mapper.ProjectSceneMapper; +import com.dora.mapper.SceneStoryboardMapper; +import com.dora.service.AiModelService; +import com.dora.service.AiTaskService; +import com.dora.service.AiWorkflowService; +import com.dora.service.CosTransferService; +import com.dora.service.PointsService; +import com.dora.service.WxSubscribeMessageService; +import com.dora.vo.AiModelVO; +import com.dora.vo.AiTaskVO; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AiTaskServiceImpl implements AiTaskService { + + private final AiTaskMapper aiTaskMapper; + private final UserMapper userMapper; + private final AiModelService aiModelService; + private final PointsService pointsService; + private final AiWorkflowService aiWorkflowService; + private final CosTransferService cosTransferService; + private final WxSubscribeMessageService wxSubscribeMessageService; + private final ObjectMapper objectMapper; + private final ProjectCharacterMapper projectCharacterMapper; + private final ProjectSceneMapper projectSceneMapper; + private final SceneStoryboardMapper sceneStoryboardMapper; + + // 任务处理线程池(支持并发执行) + private final ExecutorService taskExecutor = Executors.newFixedThreadPool(50); + + @Override + public IPage getTaskPage(Page page, Long userId, Integer status, Long modelId) { + IPage result = aiTaskMapper.selectTaskPage(page, userId, status, modelId); + result.getRecords().forEach(task -> { + task.setStatusText(getStatusText(task.getStatus())); + }); + return result; + } + + @Override + public AiTaskVO getTaskById(Long id) { + AiTask task = aiTaskMapper.selectById(id); + if (task == null) { + throw new BusinessException("任务不存在"); + } + AiTaskVO vo = new AiTaskVO(); + BeanUtils.copyProperties(task, vo); + vo.setStatusText(getStatusText(task.getStatus())); + return vo; + } + + @Override + public AiTaskVO getTaskByNo(String taskNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiTask::getTaskNo, taskNo); + AiTask task = aiTaskMapper.selectOne(wrapper); + if (task == null) { + throw new BusinessException("任务不存在"); + } + AiTaskVO vo = new AiTaskVO(); + BeanUtils.copyProperties(task, vo); + vo.setStatusText(getStatusText(task.getStatus())); + return vo; + } + + @Override + @Transactional + public String createTask(Long userId, AiTaskDTO dto) { + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + + AiModelVO model = aiModelService.getModelById(dto.getModelId()); + if (model.getStatus() != 1) { + throw new BusinessException("模型已禁用"); + } + + if (user.getPoints() < model.getPointsCost()) { + throw new BusinessException("积分余额不足"); + } + + pointsService.consumePoints(userId, model.getPointsCost(), model.getName(), null); + + AiTask task = new AiTask(); + task.setTaskNo(generateTaskNo()); + task.setUserId(userId); + task.setModelId(dto.getModelId()); + task.setModelCode(model.getCode()); + task.setInputParams(dto.getInputParams()); + task.setPointsCost(model.getPointsCost()); + task.setStatus(0); + task.setProgress(0); + task.setRetryCount(0); + task.setMaxRetry(3); + task.setPollCount(0); + task.setPriority(dto.getPriority() != null ? dto.getPriority() : 5); + task.setSubscribeNotify(Boolean.TRUE.equals(dto.getSubscribeNotify()) ? 1 : 0); + task.setNotifySent(0); + + aiTaskMapper.insert(task); + return task.getTaskNo(); + } + + @Override + public void cancelTask(Long userId, Long taskId) { + AiTask task = aiTaskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + if (!task.getUserId().equals(userId)) { + throw new BusinessException("无权限操作"); + } + if (task.getStatus() != 0 && task.getStatus() != 1) { + throw new BusinessException("任务状态不允许取消"); + } + task.setStatus(4); + aiTaskMapper.updateById(task); + pointsService.refundPoints(userId, task.getPointsCost(), "任务取消退款", task.getId()); + } + + @Override + public void deleteTask(Long userId, Long taskId) { + AiTask task = aiTaskMapper.selectById(taskId); + if (task == null) { + throw new BusinessException("任务不存在"); + } + if (!task.getUserId().equals(userId)) { + throw new BusinessException("无权限操作"); + } + aiTaskMapper.deleteById(taskId); + } + + @Override + public void processTaskQueue() { + // 1. 获取所有模型的当前处理中任务数量 + List> processingCounts = aiTaskMapper.countProcessingTasksByModel(); + Map modelProcessingCount = new HashMap<>(); + for (Map row : processingCounts) { + Long modelId = ((Number) row.get("model_id")).longValue(); + Integer count = ((Number) row.get("count")).intValue(); + modelProcessingCount.put(modelId, count); + } + + // 2. 获取待处理任务(按优先级排序,限制数量) + var pendingTasks = aiTaskMapper.selectPendingTasks(100); + if (pendingTasks.isEmpty()) { + return; + } + + // 3. 按模型分组,考虑并发限制 + Map> tasksByModel = new HashMap<>(); + for (AiTask task : pendingTasks) { + tasksByModel.computeIfAbsent(task.getModelId(), k -> new ArrayList<>()).add(task); + } + + // 4. 收集可以执行的任务 + List tasksToExecute = new ArrayList<>(); + for (Map.Entry> entry : tasksByModel.entrySet()) { + Long modelId = entry.getKey(); + List tasks = entry.getValue(); + + try { + AiModelVO model = aiModelService.getModelById(modelId); + int maxConcurrent = model.getMaxConcurrent() != null ? model.getMaxConcurrent() : 1; + int currentProcessing = modelProcessingCount.getOrDefault(modelId, 0); + int availableSlots = maxConcurrent - currentProcessing; + + if (availableSlots > 0) { + int toTake = Math.min(availableSlots, tasks.size()); + tasksToExecute.addAll(tasks.subList(0, toTake)); + log.debug("模型 {} 可用并发槽位: {}, 待处理任务: {}, 本次处理: {}", + model.getName(), availableSlots, tasks.size(), toTake); + } + } catch (Exception e) { + log.error("获取模型信息失败 - modelId: {}", modelId, e); + } + } + + if (tasksToExecute.isEmpty()) { + log.debug("当前无可执行任务(所有模型已达并发上限)"); + return; + } + + log.info("开始并发处理 {} 个任务", tasksToExecute.size()); + + // 5. 并发执行任务 + List> futures = new ArrayList<>(); + for (AiTask task : tasksToExecute) { + // 先更新状态为处理中(防止重复处理) + updateTaskStatus(task.getId(), 1, 10, null); + + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + AiModelVO model = aiModelService.getModelById(task.getModelId()); + String result = aiWorkflowService.executeTask(model, task.getInputParams(), task.getId()); + log.info("任务执行结果 - 任务ID: {}, 结果长度: {}", task.getId(), + result != null ? result.length() : 0); + processTaskResult(task, model, result); + } catch (Exception e) { + log.error("处理AI任务失败: {}", task.getTaskNo(), e); + AiModelVO failModel = null; + try { failModel = aiModelService.getModelById(task.getModelId()); } catch (Exception ignored) {} + handleTaskFailed(task, failModel, e.getMessage()); + } + }, taskExecutor); + + futures.add(future); + } + + // 6. 等待所有任务完成(非阻塞式,设置超时防止死锁) + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .get(5, java.util.concurrent.TimeUnit.MINUTES); + } catch (Exception e) { + log.warn("等待任务完成超时或异常: {}", e.getMessage()); + } + } + + @Override + public void pollAsyncTasks() { + var asyncTasks = aiTaskMapper.selectAsyncPollingTasks(20); + log.info("开始轮询异步任务,待处理数量: {}", asyncTasks.size()); + + for (AiTask task : asyncTasks) { + AiModelVO model = null; + try { + model = aiModelService.getModelById(task.getModelId()); + int maxPollCount = model.getAsyncPollMaxCount() != null ? model.getAsyncPollMaxCount() : 100; + if (task.getPollCount() != null && task.getPollCount() >= maxPollCount) { + log.warn("任务轮询次数超限 - 任务ID: {}, 轮询次数: {}", task.getId(), task.getPollCount()); + // 轮询超时也要调用 handleTaskFailed 来重置关联状态 + handleTaskFailed(task, model, "轮询超时,任务未完成"); + continue; + } + + String statusResult = aiWorkflowService.checkTaskStatus(model, task.getId(), task.getExternalTaskId()); + if (statusResult == null) { + log.warn("异步状态查询返回空 - 任务ID: {}", task.getId()); + incrementPollCount(task); + continue; + } + + log.info("异步任务状态查询结果 - 任务ID: {}, 结果: {}", task.getId(), statusResult); + processAsyncStatusResult(task, model, statusResult); + } catch (Exception e) { + log.error("轮询异步任务失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage(), e); + // 连续失败多次后也应该标记任务失败 + if (task.getPollCount() != null && task.getPollCount() >= 10) { + handleTaskFailed(task, model, "轮询异常次数过多: " + e.getMessage()); + } else { + incrementPollCount(task); + } + } + } + } + + @Override + public void updateTaskStatus(Long taskId, Integer status, Integer progress, String errorMessage) { + AiTask task = aiTaskMapper.selectById(taskId); + if (task == null) { + return; + } + task.setStatus(status); + task.setProgress(progress); + task.setErrorMessage(errorMessage); + + if (status == 1 && task.getStartTime() == null) { + task.setStartTime(LocalDateTime.now()); + } + if (status == 2 || status == 3) { + task.setEndTime(LocalDateTime.now()); + if (task.getStartTime() != null) { + long duration = java.time.Duration.between(task.getStartTime(), task.getEndTime()).toSeconds(); + task.setDuration((int) Math.max(0, duration)); + } + } + aiTaskMapper.updateById(task); + } + + @Override + public void completeTask(Long taskId, String outputResult) { + completeTask(taskId, outputResult, null); + } + + public void completeTask(Long taskId, String outputResult, AiModelVO model) { + AiTask task = aiTaskMapper.selectById(taskId); + if (task == null) { + return; + } + + String finalResult = outputResult; + if (model != null && Integer.valueOf(1).equals(model.getResultTransferCos()) + && StringUtils.hasText(model.getResultUrlField())) { + finalResult = transferResultToCos(outputResult, model.getResultUrlField()); + } + + task.setStatus(2); + task.setProgress(100); + task.setOutputResult(finalResult); + task.setEndTime(LocalDateTime.now()); + + if (task.getStartTime() != null) { + long duration = java.time.Duration.between(task.getStartTime(), task.getEndTime()).toSeconds(); + task.setDuration((int) Math.max(0, duration)); + } + + aiTaskMapper.updateById(task); + + // 处理一键成片图像生成的回调(更新角色/分镜图片) + handleVideoImageCallback(task, finalResult); + + // 处理场次视频生成的回调(更新场次视频URL) + handleSceneVideoCallback(task, finalResult); + + AiTask latestTask = aiTaskMapper.selectById(taskId); + sendTaskNotification(latestTask, model, true, null); + } + + /** + * 处理一键成片图像生成回调,更新角色/分镜图片URL + */ + private void handleVideoImageCallback(AiTask task, String outputResult) { + try { + if (!"video-image-gen".equals(task.getModelCode())) { + return; + } + + Map inputParams = objectMapper.readValue(task.getInputParams(), + new TypeReference>() {}); + String targetType = (String) inputParams.get("targetType"); + Object targetIdObj = inputParams.get("targetId"); + + if (targetType == null || targetIdObj == null) { + return; + } + + Long targetId = targetIdObj instanceof Number ? ((Number) targetIdObj).longValue() : Long.parseLong(targetIdObj.toString()); + + Map resultMap = objectMapper.readValue(outputResult, + new TypeReference>() {}); + String imageUrl = (String) resultMap.get("result"); + + if (!StringUtils.hasText(imageUrl)) { + log.warn("一键成片图像生成结果中未找到图片URL - 任务ID: {}", task.getId()); + return; + } + + if ("character".equals(targetType)) { + ProjectCharacter character = projectCharacterMapper.selectById(targetId); + if (character != null) { + character.setImageUrl(imageUrl); + character.setImageStatus(2); // 2-已生成 + character.setCurrentTaskNo(null); // 清除任务编号 + projectCharacterMapper.updateById(character); + log.info("已更新角色形象图片 - 角色ID: {}, 图片URL: {}", targetId, imageUrl); + } + } else if ("storyboard".equals(targetType)) { + SceneStoryboard storyboard = sceneStoryboardMapper.selectById(targetId); + if (storyboard != null) { + storyboard.setImageUrl(imageUrl); + storyboard.setImageStatus(2); // 2-已生成 + storyboard.setCurrentTaskNo(null); // 清除任务编号 + sceneStoryboardMapper.updateById(storyboard); + log.info("已更新分镜图片 - 分镜ID: {}, 图片URL: {}", targetId, imageUrl); + } + } + } catch (Exception e) { + log.error("处理一键成片图像回调失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + } + } + + /** + * 处理场次视频生成回调,更新场次视频URL + */ + private void handleSceneVideoCallback(AiTask task, String outputResult) { + try { + // 检查是否为视频生成任务(支持Sora2和Grok视频模型) + if (!"tencent-sora2-video".equals(task.getModelCode()) && !"grok-video".equals(task.getModelCode())) { + return; + } + + Map inputParams = objectMapper.readValue(task.getInputParams(), + new TypeReference>() {}); + Object sceneIdObj = inputParams.get("_sceneId"); + + if (sceneIdObj == null) { + log.debug("视频生成任务无场次ID参数,跳过场次更新 - 任务ID: {}", task.getId()); + return; + } + + Long sceneId = sceneIdObj instanceof Number ? ((Number) sceneIdObj).longValue() : Long.parseLong(sceneIdObj.toString()); + + // 从输出结果中提取视频URL + Map resultMap = objectMapper.readValue(outputResult, + new TypeReference>() {}); + + // 尝试多种可能的字段名获取视频URL + String videoUrl = null; + String[] possibleFields = {"result", "video_url", "videoUrl", "url", "output_url", "data"}; + for (String field : possibleFields) { + Object value = resultMap.get(field); + if (value instanceof String && StringUtils.hasText((String) value)) { + videoUrl = (String) value; + break; + } + } + + if (!StringUtils.hasText(videoUrl)) { + log.warn("场次视频生成结果中未找到视频URL - 任务ID: {}, 结果: {}", task.getId(), outputResult); + return; + } + + // 更新场次视频URL和状态 + ProjectScene scene = projectSceneMapper.selectById(sceneId); + if (scene != null) { + scene.setVideoUrl(videoUrl); + scene.setVideoStatus(2); // 2-已生成 + scene.setVideoTaskNo(null); // 清除任务编号 + projectSceneMapper.updateById(scene); + log.info("已更新场次视频URL - 场次ID: {}, 视频URL: {}", sceneId, videoUrl); + } else { + log.warn("场次不存在 - 场次ID: {}", sceneId); + } + } catch (Exception e) { + log.error("处理场次视频生成回调失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + } + } + + private void handleTaskFailed(AiTask task, AiModelVO model, String errorMessage) { + updateTaskStatus(task.getId(), 3, 0, errorMessage); + + // 任务失败退回积分 + try { + if (task.getPointsCost() != null && task.getPointsCost() > 0) { + pointsService.refundPoints(task.getUserId(), task.getPointsCost(), "任务失败退款", task.getId()); + log.info("任务失败积分已退回 - 任务ID: {}, 退回积分: {}", task.getId(), task.getPointsCost()); + } + } catch (Exception e) { + log.error("任务失败退回积分异常 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + } + + // 如果是角色/分镜图像生成任务失败,重置状态 + resetCharacterImageStatusOnFailure(task); + + // 如果是场次视频生成任务失败,重置状态 + resetSceneVideoStatusOnFailure(task); + + AiTask latestTask = aiTaskMapper.selectById(task.getId()); + if (latestTask != null) { + sendTaskNotification(latestTask, model, false, errorMessage); + } + } + + /** + * 任务失败时重置图像生成状态(角色/分镜) + */ + private void resetCharacterImageStatusOnFailure(AiTask task) { + try { + if (!"video-image-gen".equals(task.getModelCode())) { + return; + } + + Map inputParams = objectMapper.readValue(task.getInputParams(), + new TypeReference>() {}); + String targetType = (String) inputParams.get("targetType"); + Object targetIdObj = inputParams.get("targetId"); + + if (targetIdObj == null) { + return; + } + + Long targetId = targetIdObj instanceof Number ? ((Number) targetIdObj).longValue() : Long.parseLong(targetIdObj.toString()); + + if ("character".equals(targetType)) { + ProjectCharacter character = projectCharacterMapper.selectById(targetId); + if (character != null) { + character.setImageStatus(0); // 0-无图片(重置) + character.setCurrentTaskNo(null); + projectCharacterMapper.updateById(character); + log.info("角色形象生成失败,已重置状态 - 角色ID: {}", targetId); + } + } else if ("storyboard".equals(targetType)) { + SceneStoryboard storyboard = sceneStoryboardMapper.selectById(targetId); + if (storyboard != null) { + storyboard.setImageStatus(0); // 0-未生成(重置) + storyboard.setCurrentTaskNo(null); + sceneStoryboardMapper.updateById(storyboard); + log.info("分镜图片生成失败,已重置状态 - 分镜ID: {}", targetId); + } + } + } catch (Exception e) { + log.error("重置图像生成状态失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + } + } + + /** + * 任务失败时重置场次视频生成状态 + */ + private void resetSceneVideoStatusOnFailure(AiTask task) { + try { + if (!"tencent-sora2-video".equals(task.getModelCode()) && !"grok-video".equals(task.getModelCode())) { + return; + } + + Map inputParams = objectMapper.readValue(task.getInputParams(), + new TypeReference>() {}); + Object sceneIdObj = inputParams.get("_sceneId"); + + if (sceneIdObj == null) { + return; + } + + Long sceneId = sceneIdObj instanceof Number ? ((Number) sceneIdObj).longValue() : Long.parseLong(sceneIdObj.toString()); + + ProjectScene scene = projectSceneMapper.selectById(sceneId); + if (scene != null) { + scene.setVideoStatus(0); // 0-未生成(重置) + scene.setVideoTaskNo(null); + projectSceneMapper.updateById(scene); + log.info("场次视频生成失败,已重置状态 - 场次ID: {}", sceneId); + } + } catch (Exception e) { + log.error("重置场次视频生成状态失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + } + } + + private void sendTaskNotification(AiTask task, AiModelVO model, boolean success, String errorMessage) { + try { + log.info("检查是否发送订阅通知 - 任务ID: {}, subscribeNotify: {}, notifySent: {}", + task.getId(), task.getSubscribeNotify(), task.getNotifySent()); + + if (!Integer.valueOf(1).equals(task.getSubscribeNotify())) { + log.info("用户未订阅通知,跳过发送 - 任务ID: {}", task.getId()); + return; + } + if (Integer.valueOf(1).equals(task.getNotifySent())) { + log.info("通知已发送过,跳过发送 - 任务ID: {}", task.getId()); + return; + } + + String modelName = model != null ? model.getName() : task.getModelCode(); + String status = success ? "success" : "failed"; + String message = success ? "您的AI任务已完成,点击查看结果" : (errorMessage != null ? errorMessage : "任务执行失败"); + + log.info("准备发送订阅消息 - 任务ID: {}, 用户ID: {}, 状态: {}", task.getId(), task.getUserId(), status); + + wxSubscribeMessageService.sendTaskCompleteNotify( + task.getUserId(), + task.getTaskNo(), + modelName, + status, + message + ); + + task.setNotifySent(1); + aiTaskMapper.updateById(task); + log.info("订阅消息发送成功 - 任务ID: {}", task.getId()); + } catch (Exception e) { + log.error("发送任务通知失败 - taskId: {}, error: {}", task.getId(), e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private String transferResultToCos(String outputResult, String urlField) { + try { + Map resultMap = parseJson(outputResult); + String remoteUrl = getValueByPath(resultMap, urlField); + if (!StringUtils.hasText(remoteUrl)) { + log.warn("结果中未找到URL字段: {}", urlField); + return outputResult; + } + + log.info("开始转存结果URL到COS - 字段: {}, URL: {}", urlField, remoteUrl); + String cosUrl = cosTransferService.transferToCos(remoteUrl); + + if (cosUrl != null && !cosUrl.equals(remoteUrl)) { + setValueByPath(resultMap, urlField, cosUrl); + String newResult = objectMapper.writeValueAsString(resultMap); + log.info("URL转存成功 - 原URL: {}, COS URL: {}", remoteUrl, cosUrl); + return newResult; + } + return outputResult; + } catch (Exception e) { + log.error("转存结果URL失败: {}", e.getMessage(), e); + return outputResult; + } + } + + @SuppressWarnings("unchecked") + private String getValueByPath(Map map, String path) { + String[] parts = path.split("\\."); + Object current = map; + for (String part : parts) { + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + return null; + } + } + return current != null ? current.toString() : null; + } + + @SuppressWarnings("unchecked") + private void setValueByPath(Map map, String path, String value) { + String[] parts = path.split("\\."); + Map current = map; + for (int i = 0; i < parts.length - 1; i++) { + Object next = current.get(parts[i]); + if (next instanceof Map) { + current = (Map) next; + } else { + return; + } + } + current.put(parts[parts.length - 1], value); + } + + private String generateTaskNo() { + return "TASK" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + private String getStatusText(Integer status) { + switch (status) { + case 0: return "队列中"; + case 1: return "处理中"; + case 2: return "成功"; + case 3: return "失败"; + case 4: return "已取消"; + default: return "未知"; + } + } + + private void processTaskResult(AiTask task, AiModelVO model, String result) { + try { + Map resultMap = parseJson(result); + String status = (String) resultMap.get("status"); + + if ("completed".equals(status) || "success".equals(status)) { + completeTask(task.getId(), result, model); + } else if ("processing".equals(status) || "pending".equals(status)) { + String externalTaskId = (String) resultMap.get("external_task_id"); + if (StringUtils.hasText(externalTaskId)) { + task.setExternalTaskId(externalTaskId); + } + Object progressObj = resultMap.get("progress"); + int progress = 20; + if (progressObj != null) { + try { + progress = Integer.parseInt(String.valueOf(progressObj)); + } catch (NumberFormatException ignored) {} + } + task.setStatus(1); + task.setProgress(progress); + task.setPollCount(0); + aiTaskMapper.updateById(task); + log.info("异步任务已提交 - 任务ID: {}, 外部任务ID: {}", task.getId(), externalTaskId); + } else if ("failed".equals(status) || "error".equals(status)) { + String error = (String) resultMap.get("error"); + updateTaskStatus(task.getId(), 3, 0, error != null ? error : "任务执行失败"); + } else { + completeTask(task.getId(), result, model); + } + } catch (Exception e) { + log.error("处理任务结果失败: {}", task.getTaskNo(), e); + updateTaskStatus(task.getId(), 3, 0, "处理结果失败: " + e.getMessage()); + } + } + + private void processAsyncStatusResult(AiTask task, AiModelVO model, String statusResult) { + try { + Map resultMap = parseJson(statusResult); + String status = (String) resultMap.get("status"); + + if ("completed".equals(status) || "success".equals(status)) { + completeTask(task.getId(), statusResult, model); + log.info("异步任务完成 - 任务ID: {}", task.getId()); + } else if ("processing".equals(status) || "pending".equals(status) || "running".equals(status)) { + Object progressObj = resultMap.get("progress"); + int progress = task.getProgress() != null ? task.getProgress() : 20; + if (progressObj != null) { + try { + progress = Integer.parseInt(String.valueOf(progressObj)); + } catch (NumberFormatException ignored) {} + } + task.setProgress(Math.max(progress, task.getProgress())); + incrementPollCount(task); + log.info("异步任务处理中 - 任务ID: {}, 进度: {}, 轮询次数: {}", + task.getId(), task.getProgress(), task.getPollCount()); + } else if ("failed".equals(status) || "error".equals(status)) { + String error = (String) resultMap.get("error"); + String errorMsg = error != null ? error : "异步任务执行失败"; + handleTaskFailed(task, model, errorMsg); + log.error("异步任务失败 - 任务ID: {}, 错误: {}", task.getId(), error); + } else { + incrementPollCount(task); + log.warn("异步任务状态未知 - 任务ID: {}, 状态: {}", task.getId(), status); + } + } catch (Exception e) { + log.error("处理异步状态结果失败 - 任务ID: {}, 错误: {}", task.getId(), e.getMessage()); + incrementPollCount(task); + } + } + + private void incrementPollCount(AiTask task) { + task.setPollCount(task.getPollCount() != null ? task.getPollCount() + 1 : 1); + task.setUpdatedAt(LocalDateTime.now()); + aiTaskMapper.updateById(task); + } + + private Map parseJson(String json) { + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("JSON解析失败: " + e.getMessage()); + } + } +} diff --git a/src/main/java/com/dora/service/impl/AiWorkServiceImpl.java b/src/main/java/com/dora/service/impl/AiWorkServiceImpl.java new file mode 100644 index 0000000..984fc6d --- /dev/null +++ b/src/main/java/com/dora/service/impl/AiWorkServiceImpl.java @@ -0,0 +1,337 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.dora.common.exception.BusinessException; +import com.dora.dto.PublishWorkDTO; +import com.dora.dto.WorkQueryDTO; +import com.dora.entity.AiTask; +import com.dora.entity.AiWork; +import com.dora.entity.AiWorkLike; +import com.dora.mapper.AiTaskMapper; +import com.dora.mapper.AiWorkLikeMapper; +import com.dora.mapper.AiWorkMapper; +import com.dora.service.AiWorkService; +import com.dora.vo.LikeResultVO; +import com.dora.vo.PageVO; +import com.dora.vo.WorkDetailVO; +import com.dora.vo.WorkVO; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 作品服务实现 + */ +@Service +@RequiredArgsConstructor +public class AiWorkServiceImpl implements AiWorkService { + + private final AiWorkMapper aiWorkMapper; + private final AiWorkLikeMapper aiWorkLikeMapper; + private final AiTaskMapper aiTaskMapper; + private final ObjectMapper objectMapper; + + @Override + public PageVO listWorks(WorkQueryDTO query, Long currentUserId) { + int offset = (query.getPageNum() - 1) * query.getPageSize(); + String keyword = query.getKeyword() != null ? query.getKeyword().trim() : null; + if (keyword != null && keyword.isEmpty()) { + keyword = null; + } + + // 根据排序类型查询 + List list; + if ("new".equals(query.getSortType())) { + list = aiWorkMapper.selectNewList(query.getCategoryId(), keyword, offset, query.getPageSize()); + } else { + list = aiWorkMapper.selectHotList(query.getCategoryId(), keyword, offset, query.getPageSize()); + } + + // 统计总数 + Long total = aiWorkMapper.countWorks(query.getCategoryId(), keyword); + + // 填充当前用户点赞状态 + if (currentUserId != null && !list.isEmpty()) { + List workIds = list.stream().map(WorkVO::getId).toList(); + List likedIds = aiWorkLikeMapper.selectLikedWorkIds(currentUserId, workIds); + Set likedSet = new HashSet<>(likedIds); + list.forEach(work -> work.setLiked(likedSet.contains(work.getId()))); + } else { + list.forEach(work -> work.setLiked(false)); + } + + return PageVO.of(query.getPageNum(), query.getPageSize(), total, list); + } + + @Override + public WorkDetailVO getWorkDetail(Long id, Long currentUserId) { + // 查询作品详情 + WorkDetailVO detail = aiWorkMapper.selectDetailById(id); + if (detail == null) { + return null; + } + + // 增加浏览量 + aiWorkMapper.incrementViewCount(id); + detail.setViewCount(detail.getViewCount() + 1); + + // 查询当前用户是否已点赞 + if (currentUserId != null) { + int likeCount = aiWorkLikeMapper.countByWorkAndUser(id, currentUserId); + detail.setLiked(likeCount > 0); + } else { + detail.setLiked(false); + } + + // 查询关联的AI任务,获取输入参数和模型编码(用于一键同款功能) + try { + LambdaQueryWrapper taskQuery = new LambdaQueryWrapper() + .eq(AiTask::getWorkId, id) + .orderByDesc(AiTask::getCreatedAt) + .last("LIMIT 1"); + AiTask task = aiTaskMapper.selectOne(taskQuery); + if (task != null) { + detail.setInputParams(task.getInputParams()); + detail.setModelCode(task.getModelCode()); + } + } catch (Exception e) { + // 任务查询失败不影响作品详情返回 + } + + return detail; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LikeResultVO toggleLike(Long workId, Long userId) { + // 检查是否已点赞 + int count = aiWorkLikeMapper.countByWorkAndUser(workId, userId); + boolean liked; + + if (count > 0) { + // 已点赞,取消点赞 + aiWorkLikeMapper.delete(new LambdaQueryWrapper() + .eq(AiWorkLike::getWorkId, workId) + .eq(AiWorkLike::getUserId, userId)); + + // 更新作品点赞数 -1 + aiWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(AiWork::getId, workId) + .setSql("like_count = GREATEST(like_count - 1, 0)")); + liked = false; + } else { + // 未点赞,添加点赞 + AiWorkLike like = new AiWorkLike(); + like.setWorkId(workId); + like.setUserId(userId); + like.setCreatedAt(LocalDateTime.now()); + aiWorkLikeMapper.insert(like); + + // 更新作品点赞数 +1 + aiWorkMapper.update(null, new LambdaUpdateWrapper() + .eq(AiWork::getId, workId) + .setSql("like_count = like_count + 1")); + liked = true; + } + + // 查询最新点赞数 + AiWork work = aiWorkMapper.selectById(workId); + int likeCount = work != null ? work.getLikeCount() : 0; + + return LikeResultVO.of(liked, likeCount); + } + + @Override + public PageVO getUserWorks(Long userId, int pageNum, int pageSize) { + int offset = (pageNum - 1) * pageSize; + List list = aiWorkMapper.selectByUserId(userId, offset, pageSize); + Long total = aiWorkMapper.countUserWorks(userId); + + // 填充点赞状态(自己的作品) + if (!list.isEmpty()) { + List workIds = list.stream().map(WorkVO::getId).toList(); + List likedIds = aiWorkLikeMapper.selectLikedWorkIds(userId, workIds); + Set likedSet = new HashSet<>(likedIds); + list.forEach(work -> work.setLiked(likedSet.contains(work.getId()))); + } + + return PageVO.of(pageNum, pageSize, total, list); + } + + @Override + public PageVO getUserLikedWorks(Long userId, int pageNum, int pageSize) { + int offset = (pageNum - 1) * pageSize; + + // 获取用户点赞的作品ID + List likedWorkIds = aiWorkLikeMapper.selectLikedWorkIdsByUser(userId, offset, pageSize); + Long total = aiWorkLikeMapper.countUserLikes(userId); + + if (likedWorkIds.isEmpty()) { + return PageVO.of(pageNum, pageSize, total, List.of()); + } + + // 查询作品详情 + List list = aiWorkMapper.selectByIds(likedWorkIds); + // 设置点赞状态为true + list.forEach(work -> work.setLiked(true)); + + return PageVO.of(pageNum, pageSize, total, list); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void publishWork(PublishWorkDTO dto, Long userId) { + // 1. 查询任务 + AiTask task = aiTaskMapper.selectById(dto.getTaskId()); + if (task == null) { + throw new BusinessException("任务不存在"); + } + + // 2. 验证任务所有权 + if (userId == null) { + throw new BusinessException("用户未登录"); + } + + if (task.getUserId() == null) { + throw new BusinessException("任务所有者信息缺失"); + } + + if (!task.getUserId().equals(userId)) { + throw new BusinessException("无权发布此任务"); + } + + // 3. 验证任务状态 + if (task.getStatus() != 2) { + throw new BusinessException("任务未完成,无法发布"); + } + + // 4. 检查是否已发布 + if (task.getPublishStatus() != null && task.getPublishStatus() == 2) { + throw new BusinessException("作品已发布"); + } + + // 5. 检查是否审核中 + if (task.getPublishStatus() != null && task.getPublishStatus() == 1) { + throw new BusinessException("作品审核中,请勿重复提交"); + } + + // 6. 解析输出结果获取内容URL + String contentUrl = extractContentUrl(task.getOutputResult()); + if (contentUrl == null || contentUrl.isEmpty()) { + throw new BusinessException("任务结果为空,无法发布"); + } + + // 7. 解析输入参数获取prompt + String prompt = extractPrompt(task.getInputParams()); + + // 8. 判断内容类型(1图片 2视频) + Integer contentType = ("sora2-video".equals(task.getModelCode()) || "tencent-sora2-video".equals(task.getModelCode()) || "tencent-aigc-video".equals(task.getModelCode()) || "grok-video".equals(task.getModelCode())) ? 2 : 1; + + // 9. 创建作品记录 + AiWork work = new AiWork(); + work.setUserId(userId); + work.setCategoryId(dto.getCategoryId()); + work.setTitle(dto.getTitle()); + work.setDescription(dto.getDescription()); + work.setContentUrl(contentUrl); + work.setContentType(contentType); + work.setTaskType(getTaskType(task.getModelCode())); + work.setModel(task.getModelCode()); + work.setPrompt(prompt); + work.setViewCount(0); + work.setLikeCount(0); + work.setCollectCount(0); + work.setCommentCount(0); + work.setIsPublic(1); + work.setAuditStatus(0); // 0待审核 + work.setIsFeatured(0); + work.setFeaturedSort(0); + work.setStatus(1); + work.setCreatedAt(LocalDateTime.now()); + work.setUpdatedAt(LocalDateTime.now()); + work.setDeleted(0); + + aiWorkMapper.insert(work); + + // 10. 更新任务发布状态 + task.setPublishStatus(1); // 1审核中 + task.setWorkId(work.getId()); + aiTaskMapper.updateById(task); + } + + /** + * 从输出结果中提取内容URL + */ + private String extractContentUrl(String outputResult) { + if (outputResult == null || outputResult.isEmpty()) { + return null; + } + + try { + JsonNode node = objectMapper.readTree(outputResult); + + // 优先查找 result 字段 + if (node.has("result")) { + return node.get("result").asText(); + } + // 然后是 url + if (node.has("url")) { + return node.get("url").asText(); + } + // 最后是 image_url + if (node.has("image_url")) { + return node.get("image_url").asText(); + } + + // 如果都没有,尝试直接返回字符串 + return outputResult; + } catch (Exception e) { + // 解析失败,直接返回原字符串 + return outputResult; + } + } + + /** + * 从输入参数中提取prompt + */ + private String extractPrompt(String inputParams) { + if (inputParams == null || inputParams.isEmpty()) { + return ""; + } + + try { + JsonNode node = objectMapper.readTree(inputParams); + if (node.has("prompt")) { + return node.get("prompt").asText(); + } + return ""; + } catch (Exception e) { + return ""; + } + } + + /** + * 根据模型编码获取任务类型 + */ + private String getTaskType(String modelCode) { + if (modelCode == null) { + return "text2img"; + } + + if (modelCode.contains("video")) { + return "text2video"; + } else if (modelCode.contains("img2img")) { + return "img2img"; + } else { + return "text2img"; + } + } +} diff --git a/src/main/java/com/dora/service/impl/AiWorkflowServiceImpl.java b/src/main/java/com/dora/service/impl/AiWorkflowServiceImpl.java new file mode 100644 index 0000000..a776a66 --- /dev/null +++ b/src/main/java/com/dora/service/impl/AiWorkflowServiceImpl.java @@ -0,0 +1,1352 @@ +package com.dora.service.impl; + +import com.dora.service.AiWorkflowService; +import com.dora.util.HttpClientUtil; +import com.dora.util.TencentCloudSignUtil; +import com.dora.vo.AiModelVO; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tencentcloudapi.common.AbstractModel; +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import com.tencentcloudapi.common.profile.ClientProfile; +import com.tencentcloudapi.common.profile.HttpProfile; +import com.tencentcloudapi.vod.v20180717.VodClient; +import com.tencentcloudapi.vod.v20180717.models.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AI工作流服务实现 + * 支持同步和异步任务的执行与状态查询 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiWorkflowServiceImpl implements AiWorkflowService { + + private final HttpClientUtil httpClientUtil; + private final ObjectMapper objectMapper; + + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{([^}]+)\\}\\}"); + + @Override + public String executeTask(AiModelVO model, String inputParams, Long taskId) { + log.info("执行AI任务 - 模型: {}, 工作流类型: {}, 任务ID: {}", model.getCode(), model.getWorkflowType(), taskId); + + // 腾讯云VOD走专用逻辑 + if ("tencent_vod".equals(model.getWorkflowType())) { + return executeTencentVodTask(model, inputParams, taskId); + } + + // 腾讯云API(TC3签名)走专用逻辑,如aiart生图 + if ("tencent_cloud_api".equals(model.getWorkflowType())) { + return executeTencentCloudApiTask(model, inputParams, taskId); + } + + try { + // 解析输入参数 + Map params = parseJson(inputParams); + + // 添加系统参数 + params.put("task_id", taskId); + params.put("api_key", model.getProviderApiKey()); + + // 构建请求URL + String url = buildRequestUrl(model); + + // 构建请求头 + Map headers = buildRequestHeaders(model, params); + + // 构建请求体 + Object requestBody = buildRequestBody(model, params); + + log.info("发送AI请求 - URL: {}, Method: {}, Body: {}", url, model.getRequestMethod(), objectMapper.writeValueAsString(requestBody)); + + // 发送HTTP请求 + int timeout = model.getTimeout() != null ? model.getTimeout() : 60; + String response = httpClientUtil.sendRequest(url, model.getRequestMethod(), headers, requestBody, timeout); + + log.info("AI请求响应 - 任务ID: {}, 响应长度: {}", taskId, response.length()); + + // 解析响应并提取结果 + return parseResponse(model, response); + + } catch (Exception e) { + log.error("执行AI任务失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + @Override + public String checkTaskStatus(AiModelVO model, Long taskId, String externalTaskId) { + log.info("检查任务状态 - 模型: {}, 任务ID: {}, 外部任务ID: {}", model.getCode(), taskId, externalTaskId); + + if (model.getIsAsync() == null || model.getIsAsync() != 1) { + log.warn("模型不是异步模型,无需检查状态"); + return null; + } + + // 腾讯云VOD走专用查询逻辑 + if ("tencent_vod".equals(model.getWorkflowType())) { + return checkTencentVodTaskStatus(model, taskId, externalTaskId); + } + + // 腾讯云API(TC3签名)走专用查询逻辑 + if ("tencent_cloud_api".equals(model.getWorkflowType())) { + return checkTencentCloudApiTaskStatus(model, taskId, externalTaskId); + } + + if (!StringUtils.hasText(model.getAsyncQueryEndpoint())) { + log.warn("异步查询端点未配置"); + return buildErrorResponse("异步查询端点未配置"); + } + + try { + // 构建查询参数 + Map params = new HashMap<>(); + params.put("task_id", taskId); + params.put("external_task_id", externalTaskId); + params.put("api_key", model.getProviderApiKey()); + + // 构建查询URL + String url = buildAsyncQueryUrl(model, params); + + // 构建请求头 + Map headers = buildRequestHeaders(model, params); + + // 构建请求体(如果有) + Object requestBody = null; + if (StringUtils.hasText(model.getAsyncQueryBody())) { + requestBody = buildAsyncQueryBody(model, params); + } + + String method = StringUtils.hasText(model.getAsyncQueryMethod()) ? + model.getAsyncQueryMethod() : "GET"; + + log.info("查询异步任务状态 - URL: {}, Method: {}", url, method); + + // 发送查询请求 + int timeout = model.getTimeout() != null ? model.getTimeout() : 30; + String response = httpClientUtil.sendRequest(url, method, headers, requestBody, timeout); + + log.info("异步状态查询响应 - 任务ID: {}, 响应: {}", taskId, response); + + // 解析异步查询响应 + return parseAsyncQueryResponse(model, response); + + } catch (Exception e) { + log.error("检查任务状态失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + /** + * 创建腾讯云VOD SDK客户端 + */ + private VodClient createVodClient(AiModelVO model, Map workflowConfig) { + String secretId = model.getProviderApiKey() != null ? model.getProviderApiKey().trim() : ""; + String secretKey = model.getProviderSecretKey() != null ? model.getProviderSecretKey().trim() : ""; + String host = (String) workflowConfig.getOrDefault("host", "vod.tencentcloudapi.com"); + String region = (String) workflowConfig.get("region"); + + log.info("腾讯云VOD SDK客户端 - SecretId长度: {}, SecretId前缀: {}, SecretKey长度: {}, Host: {}, Region: {}", + secretId.length(), secretId.length() >= 4 ? secretId.substring(0, 4) + "****" : "太短", + secretKey.length(), host, region); + + Credential cred = new Credential(secretId, secretKey); + + HttpProfile httpProfile = new HttpProfile(); + httpProfile.setEndpoint(host); + int timeout = model.getTimeout() != null ? model.getTimeout() : 60; + httpProfile.setReqMethod("POST"); + httpProfile.setConnTimeout(timeout); + + ClientProfile clientProfile = new ClientProfile(); + clientProfile.setHttpProfile(httpProfile); + + return new VodClient(cred, region != null ? region : "", clientProfile); + } + + /** + * 执行腾讯云VOD AIGC视频任务(使用SDK + from_json_string方式) + * workflow_config格式: {"service":"vod","host":"vod.tencentcloudapi.com","version":"2018-07-17","region":"ap-guangzhou","sub_app_id":251007502} + */ + private String executeTencentVodTask(AiModelVO model, String inputParams, Long taskId) { + try { + Map params = parseJson(inputParams); + Map workflowConfig = parseJson(model.getWorkflowConfig()); + + VodClient client = createVodClient(model, workflowConfig); + + // 构建SDK请求JSON(与官方API参数一致) + Map requestBody = new HashMap<>(); + + // SubAppId + Object subAppId = workflowConfig.get("sub_app_id"); + if (subAppId != null) { + requestBody.put("SubAppId", subAppId instanceof Number ? ((Number) subAppId).longValue() : Long.parseLong(String.valueOf(subAppId))); + } + + // 模型名称和版本从workflow_config获取 + requestBody.put("ModelName", workflowConfig.getOrDefault("model_name", "GV")); + requestBody.put("ModelVersion", workflowConfig.getOrDefault("model_version", "3.2")); + + // Prompt(必填) + if (params.containsKey("prompt")) { + requestBody.put("Prompt", params.get("prompt")); + } + + // NegativePrompt(可选) + if (params.containsKey("negative_prompt") && StringUtils.hasText(String.valueOf(params.get("negative_prompt")))) { + requestBody.put("NegativePrompt", params.get("negative_prompt")); + } + + // EnhancePrompt(可选) + requestBody.put("EnhancePrompt", params.getOrDefault("enhance_prompt", "Enabled")); + + // FileInfos - 参考图/视频(可选,支持FileId和Url两种方式) + List> fileInfos = new ArrayList<>(); + // 方式1: 通过file_ids传递(云点播文件ID) + if (params.containsKey("file_ids") && params.get("file_ids") != null) { + Object fileIds = params.get("file_ids"); + if (fileIds instanceof List) { + for (Object fid : (List) fileIds) { + Map fileInfo = new HashMap<>(); + fileInfo.put("FileId", String.valueOf(fid)); + fileInfos.add(fileInfo); + } + } else if (fileIds instanceof String && StringUtils.hasText((String) fileIds)) { + Map fileInfo = new HashMap<>(); + fileInfo.put("FileId", fileIds); + fileInfos.add(fileInfo); + } + } + // 方式2: 通过file_urls传递(图片URL列表) + if (params.containsKey("file_urls") && params.get("file_urls") != null) { + Object fileUrls = params.get("file_urls"); + if (fileUrls instanceof List) { + for (Object url : (List) fileUrls) { + Map fileInfo = new HashMap<>(); + fileInfo.put("Type", "Url"); + fileInfo.put("Url", String.valueOf(url)); + fileInfos.add(fileInfo); + } + } else if (fileUrls instanceof String && StringUtils.hasText((String) fileUrls)) { + Map fileInfo = new HashMap<>(); + fileInfo.put("Type", "Url"); + fileInfo.put("Url", fileUrls); + fileInfos.add(fileInfo); + } + } + if (!fileInfos.isEmpty()) { + requestBody.put("FileInfos", fileInfos); + } + + // OutputConfig + Map outputConfig = new HashMap<>(); + outputConfig.put("StorageMode", "Temporary"); + if (params.containsKey("resolution") && StringUtils.hasText(String.valueOf(params.get("resolution")))) { + outputConfig.put("Resolution", params.get("resolution")); + } + if (params.containsKey("aspect_ratio") && StringUtils.hasText(String.valueOf(params.get("aspect_ratio")))) { + outputConfig.put("AspectRatio", params.get("aspect_ratio")); + } + outputConfig.put("AudioGeneration", params.getOrDefault("audio_generation", "Enabled")); + outputConfig.put("PersonGeneration", params.getOrDefault("person_generation", "AllowAdult")); + outputConfig.put("InputComplianceCheck", "Enabled"); + outputConfig.put("OutputComplianceCheck", "Enabled"); + requestBody.put("OutputConfig", outputConfig); + + // SessionId用于去重 + requestBody.put("SessionId", "task_" + taskId); + + String payload = objectMapper.writeValueAsString(requestBody); + log.info("腾讯云VOD SDK请求 - Action: CreateAigcVideoTask, 任务ID: {}, Payload: {}", taskId, payload); + + // 通过SDK的from_json_string方式传入参数(与官方Demo一致) + CreateAigcVideoTaskRequest req = new CreateAigcVideoTaskRequest(); + req.fromJsonString(payload, CreateAigcVideoTaskRequest.class); + + // 通过SDK发送请求 + CreateAigcVideoTaskResponse resp = client.CreateAigcVideoTask(req); + + String rawResponse = AbstractModel.toJsonString(resp); + log.info("腾讯云VOD SDK响应 - 任务ID: {}, TaskId: {}, RequestId: {}", + taskId, resp.getTaskId(), resp.getRequestId()); + + Map result = new HashMap<>(); + if (StringUtils.hasText(resp.getTaskId())) { + result.put("status", "processing"); + result.put("external_task_id", resp.getTaskId()); + result.put("original_status", "submitted"); + } else { + result.put("status", "failed"); + result.put("error", "未获取到TaskId"); + } + result.put("raw_response", rawResponse); + + return objectMapper.writeValueAsString(result); + + } catch (TencentCloudSDKException e) { + log.error("执行腾讯云VOD任务失败(SDK异常) - 任务ID: {}, ErrorCode: {}, 错误: {}", taskId, e.getErrorCode(), e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } catch (Exception e) { + log.error("执行腾讯云VOD任务失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + /** + * 查询腾讯云VOD AIGC视频任务状态(使用SDK + fromJsonString方式) + */ + private String checkTencentVodTaskStatus(AiModelVO model, Long taskId, String externalTaskId) { + try { + Map workflowConfig = parseJson(model.getWorkflowConfig()); + + VodClient client = createVodClient(model, workflowConfig); + + // 构建查询请求JSON + Map requestBody = new HashMap<>(); + requestBody.put("TaskId", externalTaskId); + + // SubAppId + Object subAppId = workflowConfig.get("sub_app_id"); + if (subAppId != null) { + requestBody.put("SubAppId", subAppId instanceof Number ? ((Number) subAppId).longValue() : Long.parseLong(String.valueOf(subAppId))); + } + + String payload = objectMapper.writeValueAsString(requestBody); + log.info("腾讯云VOD SDK查询任务 - TaskId: {}, Payload: {}", externalTaskId, payload); + + // 通过SDK的fromJsonString方式传入参数 + DescribeTaskDetailRequest req = new DescribeTaskDetailRequest(); + req.fromJsonString(payload, DescribeTaskDetailRequest.class); + + // 通过SDK发送请求 + DescribeTaskDetailResponse resp = client.DescribeTaskDetail(req); + + String rawResponse = AbstractModel.toJsonString(resp); + log.info("腾讯云VOD SDK查询响应 - 任务ID: {}, Status: {}, RequestId: {}", + taskId, resp.getStatus(), resp.getRequestId()); + + Map result = new HashMap<>(); + + // 解析任务状态: WAITING, PROCESSING, FINISH + String status = resp.getStatus() != null ? resp.getStatus() : ""; + result.put("original_status", status); + + // 将SDK响应转为JsonNode用于提取输出URL + JsonNode respBody = objectMapper.readTree(rawResponse); + + switch (status.toUpperCase()) { + case "FINISH": + // 尝试从AigcVideoTask/AigcImageTask等字段中提取输出URL + String fileUrl = extractTencentVodOutputUrl(respBody); + if (fileUrl != null) { + result.put("status", "completed"); + result.put("result", fileUrl); + } else { + // FINISH但没有URL,可能任务失败了 + String errMsg = extractTencentVodErrorMessage(respBody); + if (errMsg != null && !errMsg.isEmpty()) { + result.put("status", "failed"); + result.put("error", errMsg); + } else { + result.put("status", "completed"); + } + } + result.put("progress", 100); + break; + case "PROCESSING": + result.put("status", "processing"); + result.put("progress", 50); + break; + case "WAITING": + result.put("status", "processing"); + result.put("progress", 10); + break; + default: + result.put("status", "processing"); + result.put("progress", 20); + } + + result.put("raw_response", rawResponse); + return objectMapper.writeValueAsString(result); + + } catch (TencentCloudSDKException e) { + log.error("查询腾讯云VOD任务状态失败(SDK异常) - 任务ID: {}, ErrorCode: {}, 错误: {}", taskId, e.getErrorCode(), e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } catch (Exception e) { + log.error("查询腾讯云VOD任务状态失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + /** + * 从腾讯云VOD DescribeTaskDetail响应中提取输出URL + * 支持AIGC视频/图片任务(AigcVideoTask/AigcImageTask)以及传统任务类型 + */ + private String extractTencentVodOutputUrl(JsonNode respBody) { + // 优先从AIGC任务字段提取(Output.FileInfos[].FileUrl) + String[] aigcTaskTypes = {"AigcVideoTask", "AigcImageTask", "SceneAigcImageTask", "SceneAigcVideoTask"}; + for (String taskType : aigcTaskTypes) { + JsonNode taskNode = respBody.get(taskType); + if (taskNode != null && !taskNode.isNull()) { + JsonNode output = taskNode.get("Output"); + if (output != null) { + // AIGC任务: Output.FileInfos[0].FileUrl + JsonNode fileInfos = output.get("FileInfos"); + if (fileInfos != null && fileInfos.isArray() && fileInfos.size() > 0) { + JsonNode firstFile = fileInfos.get(0); + if (firstFile.has("FileUrl") && !firstFile.get("FileUrl").isNull()) { + return firstFile.get("FileUrl").asText(); + } + } + // 兼容: Output.FileUrl + if (output.has("FileUrl") && !output.get("FileUrl").isNull()) { + return output.get("FileUrl").asText(); + } + } + } + } + + // 传统任务类型: Output.FileUrl + String[] classicTaskTypes = {"EditMediaTask", "ComposeMediaTask", "TranscodeTask", "RebuildMediaTask", "QualityEnhanceTask"}; + for (String taskType : classicTaskTypes) { + JsonNode taskNode = respBody.get(taskType); + if (taskNode != null && !taskNode.isNull()) { + JsonNode output = taskNode.get("Output"); + if (output != null && output.has("FileUrl")) { + return output.get("FileUrl").asText(); + } + } + } + + // 尝试直接从Response.FileUrl获取 + if (respBody.has("FileUrl")) { + return respBody.get("FileUrl").asText(); + } + + // 深度搜索FileUrl + return findFieldRecursive(respBody, "FileUrl"); + } + + /** + * 从腾讯云VOD响应中提取错误信息 + * 支持AIGC任务和传统任务类型 + */ + private String extractTencentVodErrorMessage(JsonNode respBody) { + String[] taskTypes = {"AigcVideoTask", "AigcImageTask", "SceneAigcImageTask", "SceneAigcVideoTask", + "EditMediaTask", "ComposeMediaTask", "TranscodeTask", "RebuildMediaTask", "QualityEnhanceTask"}; + for (String taskType : taskTypes) { + JsonNode taskNode = respBody.get(taskType); + if (taskNode != null && !taskNode.isNull()) { + if (taskNode.has("Message") && StringUtils.hasText(taskNode.get("Message").asText())) { + return taskNode.get("Message").asText(); + } + if (taskNode.has("ErrCodeExt") && StringUtils.hasText(taskNode.get("ErrCodeExt").asText())) { + return taskNode.get("ErrCodeExt").asText(); + } + } + } + return null; + } + + /** + * 执行腾讯云API任务(TC3签名,如aiart生图) + * workflow_config格式: {"service":"aiart","host":"aiart.tencentcloudapi.com","version":"2022-12-29","region":"ap-guangzhou","submit_action":"SubmitTextToImageJob","query_action":"QueryTextToImageJob"} + */ + private String executeTencentCloudApiTask(AiModelVO model, String inputParams, Long taskId) { + try { + Map params = parseJson(inputParams); + Map workflowConfig = parseJson(model.getWorkflowConfig()); + + String secretId = model.getProviderApiKey() != null ? model.getProviderApiKey().trim() : ""; + String secretKey = model.getProviderSecretKey() != null ? model.getProviderSecretKey().trim() : ""; + log.info("腾讯云API密钥 - SecretId: {}, SecretKey: {}", secretId, secretKey); + String service = (String) workflowConfig.getOrDefault("service", "aiart"); + String host = (String) workflowConfig.getOrDefault("host", service + ".tencentcloudapi.com"); + String version = (String) workflowConfig.getOrDefault("version", "2022-12-29"); + String region = (String) workflowConfig.getOrDefault("region", "ap-guangzhou"); + String action = (String) workflowConfig.getOrDefault("submit_action", "SubmitTextToImageJob"); + + // 构建请求体:从输入参数映射到腾讯云API参数(首字母大写) + Map requestBody = buildTencentCloudApiRequestBody(params, workflowConfig); + String payload = objectMapper.writeValueAsString(requestBody); + + log.info("腾讯云API请求 - Action: {}, Host: {}, 任务ID: {}, Payload: {}", action, host, taskId, payload); + + // 生成TC3签名并构建请求头 + Map headers = TencentCloudSignUtil.buildHeaders( + secretId, secretKey, service, host, action, version, region, payload); + + // 发送请求 + String url = "https://" + host + "/"; + int timeout = model.getTimeout() != null ? model.getTimeout() : 60; + String response = httpClientUtil.sendRawRequest(url, "POST", headers, payload, timeout); + + log.info("腾讯云API响应 - 任务ID: {}, 响应: {}", taskId, response); + + // 解析响应 + JsonNode respNode = objectMapper.readTree(response); + JsonNode respBody = respNode.get("Response"); + if (respBody == null) { + return buildErrorResponse("腾讯云API响应格式异常: 缺少Response字段"); + } + + // 检查错误 + JsonNode errorNode = respBody.get("Error"); + if (errorNode != null) { + String errorCode = errorNode.has("Code") ? errorNode.get("Code").asText() : "UnknownError"; + String errorMsg = errorNode.has("Message") ? errorNode.get("Message").asText() : "未知错误"; + log.error("腾讯云API错误 - 任务ID: {}, Code: {}, Message: {}", taskId, errorCode, errorMsg); + return buildErrorResponse(errorCode + ": " + errorMsg); + } + + // 提取JobId作为外部任务ID + Map result = new HashMap<>(); + String jobId = respBody.has("JobId") ? respBody.get("JobId").asText() : null; + + if (StringUtils.hasText(jobId)) { + result.put("status", "processing"); + result.put("external_task_id", jobId); + result.put("original_status", "submitted"); + result.put("progress", 10); + } else { + // 没有JobId,可能是同步接口,尝试直接提取结果 + result.put("status", "completed"); + result.put("result", respBody.toString()); + } + result.put("raw_response", response); + + return objectMapper.writeValueAsString(result); + + } catch (Exception e) { + log.error("执行腾讯云API任务失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + /** + * 查询腾讯云API异步任务状态(TC3签名) + */ + private String checkTencentCloudApiTaskStatus(AiModelVO model, Long taskId, String externalTaskId) { + try { + Map workflowConfig = parseJson(model.getWorkflowConfig()); + + String secretId = model.getProviderApiKey() != null ? model.getProviderApiKey().trim() : ""; + String secretKey = model.getProviderSecretKey() != null ? model.getProviderSecretKey().trim() : ""; + String service = (String) workflowConfig.getOrDefault("service", "aiart"); + String host = (String) workflowConfig.getOrDefault("host", service + ".tencentcloudapi.com"); + String version = (String) workflowConfig.getOrDefault("version", "2022-12-29"); + String region = (String) workflowConfig.getOrDefault("region", "ap-guangzhou"); + String action = (String) workflowConfig.getOrDefault("query_action", "QueryTextToImageJob"); + + // 构建查询请求体 + Map requestBody = new HashMap<>(); + requestBody.put("JobId", externalTaskId); + String payload = objectMapper.writeValueAsString(requestBody); + + log.info("腾讯云API查询任务 - Action: {}, JobId: {}, 任务ID: {}", action, externalTaskId, taskId); + + // 生成TC3签名并构建请求头 + Map headers = TencentCloudSignUtil.buildHeaders( + secretId, secretKey, service, host, action, version, region, payload); + + // 发送请求 + String url = "https://" + host + "/"; + int timeout = model.getTimeout() != null ? model.getTimeout() : 30; + String response = httpClientUtil.sendRawRequest(url, "POST", headers, payload, timeout); + + log.info("腾讯云API查询响应 - 任务ID: {}, 响应: {}", taskId, response); + + // 解析响应 + JsonNode respNode = objectMapper.readTree(response); + JsonNode respBody = respNode.get("Response"); + if (respBody == null) { + return buildErrorResponse("腾讯云API响应格式异常: 缺少Response字段"); + } + + // 检查错误 + JsonNode errorNode = respBody.get("Error"); + if (errorNode != null) { + String errorCode = errorNode.has("Code") ? errorNode.get("Code").asText() : "UnknownError"; + String errorMsg = errorNode.has("Message") ? errorNode.get("Message").asText() : "未知错误"; + log.error("腾讯云API查询错误 - 任务ID: {}, Code: {}, Message: {}", taskId, errorCode, errorMsg); + return buildErrorResponse(errorCode + ": " + errorMsg); + } + + Map result = new HashMap<>(); + + // 解析任务状态码: 1-等待中, 2-运行中, 4-处理失败, 5-处理完成 + String jobStatusCode = respBody.has("JobStatusCode") ? respBody.get("JobStatusCode").asText() : ""; + String jobStatusMsg = respBody.has("JobStatusMsg") ? respBody.get("JobStatusMsg").asText() : ""; + result.put("original_status", jobStatusCode + ":" + jobStatusMsg); + + switch (jobStatusCode) { + case "5": // 处理完成 + // 提取结果图片URL列表 + JsonNode resultImages = respBody.get("ResultImage"); + if (resultImages != null && resultImages.isArray() && resultImages.size() > 0) { + String imageUrl = resultImages.get(0).asText(); + result.put("status", "completed"); + result.put("result", imageUrl); + result.put("progress", 100); + log.info("腾讯云API任务完成 - 任务ID: {}, 图片URL: {}", taskId, imageUrl); + } else { + result.put("status", "completed"); + result.put("progress", 100); + } + break; + case "4": // 处理失败 + String jobErrorCode = respBody.has("JobErrorCode") ? respBody.get("JobErrorCode").asText() : ""; + String jobErrorMsg = respBody.has("JobErrorMsg") ? respBody.get("JobErrorMsg").asText() : "处理失败"; + result.put("status", "failed"); + result.put("error", StringUtils.hasText(jobErrorMsg) ? jobErrorMsg : jobErrorCode); + result.put("progress", 0); + log.error("腾讯云API任务失败 - 任务ID: {}, ErrorCode: {}, ErrorMsg: {}", taskId, jobErrorCode, jobErrorMsg); + break; + case "1": // 等待中 + result.put("status", "processing"); + result.put("progress", 20); + break; + case "2": // 运行中 + result.put("status", "processing"); + result.put("progress", 50); + break; + default: + result.put("status", "processing"); + result.put("progress", 30); + } + + result.put("raw_response", response); + return objectMapper.writeValueAsString(result); + + } catch (Exception e) { + log.error("查询腾讯云API任务状态失败 - 任务ID: {}, 错误: {}", taskId, e.getMessage(), e); + return buildErrorResponse(e.getMessage()); + } + } + + /** + * 构建腾讯云API请求体 + * 将用户输入参数映射为腾讯云API参数格式(首字母大写) + * 支持通过workflow_config中的request_template自定义映射 + */ + private Map buildTencentCloudApiRequestBody(Map params, Map workflowConfig) { + Map requestBody = new HashMap<>(); + + // 参数名映射: 小写/下划线 → 腾讯云大驼峰 + Map fieldMapping = new HashMap<>(); + fieldMapping.put("prompt", "Prompt"); + fieldMapping.put("resolution", "Resolution"); + fieldMapping.put("seed", "Seed"); + fieldMapping.put("logo_add", "LogoAdd"); + fieldMapping.put("logoAdd", "LogoAdd"); + fieldMapping.put("revise", "Revise"); + fieldMapping.put("images", "Images"); + fieldMapping.put("negative_prompt", "NegativePrompt"); + fieldMapping.put("negativePrompt", "NegativePrompt"); + fieldMapping.put("style", "Style"); + fieldMapping.put("job_id", "JobId"); + fieldMapping.put("jobId", "JobId"); + + for (Map.Entry entry : params.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + // 跳过空值和内部参数(以_开头) + if (value == null || key.startsWith("_")) { + continue; + } + if (value instanceof String && !StringUtils.hasText((String) value)) { + continue; + } + + // 如果参数名已经是大驼峰(首字母大写),直接使用 + if (Character.isUpperCase(key.charAt(0))) { + requestBody.put(key, value); + } else if (fieldMapping.containsKey(key)) { + requestBody.put(fieldMapping.get(key), value); + } else { + // 默认转为首字母大写 + String capitalizedKey = key.substring(0, 1).toUpperCase() + key.substring(1); + requestBody.put(capitalizedKey, value); + } + } + + // 从workflow_config中补充默认参数 + @SuppressWarnings("unchecked") + Map defaultParams = (Map) workflowConfig.get("default_params"); + if (defaultParams != null) { + for (Map.Entry entry : defaultParams.entrySet()) { + if (!requestBody.containsKey(entry.getKey())) { + requestBody.put(entry.getKey(), entry.getValue()); + } + } + } + + return requestBody; + } + + /** + * 递归查找JSON中的字段值 + */ + private String findFieldRecursive(JsonNode node, String fieldName) { + if (node == null || node.isNull()) return null; + if (node.has(fieldName) && !node.get(fieldName).isNull()) { + return node.get(fieldName).asText(); + } + if (node.isObject()) { + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + String result = findFieldRecursive(field.getValue(), fieldName); + if (result != null) return result; + } + } + if (node.isArray()) { + for (JsonNode item : node) { + String result = findFieldRecursive(item, fieldName); + if (result != null) return result; + } + } + return null; + } + + /** + * 构建请求URL + */ + private String buildRequestUrl(AiModelVO model) { + String baseUrl = model.getProviderBaseUrl(); + String endpoint = model.getApiEndpoint(); + + if (baseUrl == null) baseUrl = ""; + if (endpoint == null) endpoint = ""; + + // 如果endpoint是完整URL,直接使用 + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + + // 拼接baseUrl和endpoint + if (baseUrl.endsWith("/") && endpoint.startsWith("/")) { + return baseUrl + endpoint.substring(1); + } else if (!baseUrl.endsWith("/") && !endpoint.startsWith("/") && !baseUrl.isEmpty() && !endpoint.isEmpty()) { + return baseUrl + "/" + endpoint; + } + return baseUrl + endpoint; + } + + /** + * 构建异步查询URL + */ + private String buildAsyncQueryUrl(AiModelVO model, Map params) { + String baseUrl = model.getProviderBaseUrl(); + String endpoint = model.getAsyncQueryEndpoint(); + + if (baseUrl == null) baseUrl = ""; + + // 替换URL中的变量 + endpoint = replaceVariables(endpoint, params); + + // 如果endpoint是完整URL,直接使用 + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) { + return endpoint; + } + + // 拼接baseUrl和endpoint + if (baseUrl.endsWith("/") && endpoint.startsWith("/")) { + return baseUrl + endpoint.substring(1); + } else if (!baseUrl.endsWith("/") && !endpoint.startsWith("/") && !baseUrl.isEmpty()) { + return baseUrl + "/" + endpoint; + } + return baseUrl + endpoint; + } + + /** + * 构建请求头 + */ + private Map buildRequestHeaders(AiModelVO model, Map params) { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + // 解析自定义请求头 + if (StringUtils.hasText(model.getRequestHeaders())) { + try { + Map customHeaders = parseJson(model.getRequestHeaders()); + customHeaders.forEach((key, value) -> { + String headerValue = replaceVariables(String.valueOf(value), params); + headers.put(key, headerValue); + }); + } catch (Exception e) { + log.warn("解析请求头失败: {}", e.getMessage()); + } + } + + // 如果没有Authorization头,添加默认的Bearer token + if (!headers.containsKey("Authorization") && StringUtils.hasText(model.getProviderApiKey())) { + headers.put("Authorization", "Bearer " + model.getProviderApiKey()); + } + + return headers; + } + + /** + * 构建请求体 + */ + private Object buildRequestBody(AiModelVO model, Map params) { + if (!StringUtils.hasText(model.getRequestTemplate())) { + return params; + } + + try { + Map template = parseJson(model.getRequestTemplate()); + return replaceTemplateVariables(template, params); + } catch (Exception e) { + log.warn("解析请求模板失败,使用原始参数: {}", e.getMessage()); + return params; + } + } + + /** + * 构建异步查询请求体 + */ + private Object buildAsyncQueryBody(AiModelVO model, Map params) { + if (!StringUtils.hasText(model.getAsyncQueryBody())) { + return null; + } + + try { + Map template = parseJson(model.getAsyncQueryBody()); + return replaceTemplateVariables(template, params); + } catch (Exception e) { + log.warn("解析异步查询请求体失败: {}", e.getMessage()); + return null; + } + } + + /** + * 解析响应并提取结果 + */ + private String parseResponse(AiModelVO model, String response) { + try { + JsonNode responseNode = objectMapper.readTree(response); + + // 如果配置了响应映射,按映射提取字段 + if (StringUtils.hasText(model.getResponseMapping())) { + Map mapping = parseJson(model.getResponseMapping()); + Map result = new HashMap<>(); + + // 提取状态 + String statusPath = (String) mapping.get("status"); + if (statusPath != null) { + String status = extractJsonValue(responseNode, statusPath); + String mappedStatus = mapStatus(model, status); + + // 对于异步模型,提交成功后应该是processing状态,而不是completed + // 因为提交接口返回200只表示"提交成功",任务还需要轮询查询结果 + if (model.getIsAsync() != null && model.getIsAsync() == 1) { + if ("completed".equals(mappedStatus) || "success".equals(mappedStatus)) { + // 检查是否有外部任务ID,有的话说明是提交成功,需要轮询 + String taskIdPath = (String) mapping.get("task_id"); + if (taskIdPath != null) { + String externalTaskId = extractJsonValue(responseNode, taskIdPath); + if (StringUtils.hasText(externalTaskId)) { + mappedStatus = "processing"; + log.info("异步任务提交成功,状态设为processing,外部任务ID: {}", externalTaskId); + } + } + } + } + + result.put("status", mappedStatus); + result.put("original_status", status); + } + + // 提取外部任务ID + String taskIdPath = (String) mapping.get("task_id"); + if (taskIdPath != null) { + result.put("external_task_id", extractJsonValue(responseNode, taskIdPath)); + } + + // 提取结果URL + String resultPath = (String) mapping.get("result"); + if (resultPath != null) { + result.put("result", extractJsonValue(responseNode, resultPath)); + } + + // 提取错误信息 + String errorPath = (String) mapping.get("error"); + if (errorPath != null) { + result.put("error", extractJsonValue(responseNode, errorPath)); + } + + // 提取进度 + String progressPath = (String) mapping.get("progress"); + if (progressPath != null) { + result.put("progress", extractJsonValue(responseNode, progressPath)); + } + + // 保留原始响应 + result.put("raw_response", response); + + return objectMapper.writeValueAsString(result); + } + + // 没有映射配置,尝试智能解析 + return buildSmartResponse(responseNode, response); + + } catch (Exception e) { + log.warn("解析响应失败,返回原始响应: {}", e.getMessage()); + return response; + } + } + + /** + * 解析异步查询响应 + */ + private String parseAsyncQueryResponse(AiModelVO model, String response) { + try { + JsonNode responseNode = objectMapper.readTree(response); + Map result = new HashMap<>(); + + // 如果配置了异步查询响应映射 + if (StringUtils.hasText(model.getAsyncQueryMapping())) { + Map mapping = parseJson(model.getAsyncQueryMapping()); + + // 提取状态 + String statusPath = (String) mapping.get("status"); + if (statusPath != null) { + String status = extractJsonValue(responseNode, statusPath); + result.put("status", mapAsyncStatus(model, status)); + result.put("original_status", status); + } + + // 提取结果 + String resultPath = (String) mapping.get("result"); + if (resultPath != null) { + result.put("result", extractJsonValue(responseNode, resultPath)); + } + + // 提取进度 + String progressPath = (String) mapping.get("progress"); + if (progressPath != null) { + String progress = extractJsonValue(responseNode, progressPath); + result.put("progress", progress); + } + + // 提取错误信息 + String errorPath = (String) mapping.get("error"); + if (errorPath != null) { + result.put("error", extractJsonValue(responseNode, errorPath)); + } + } else { + // 智能解析 + return buildSmartResponse(responseNode, response); + } + + result.put("raw_response", response); + return objectMapper.writeValueAsString(result); + + } catch (Exception e) { + log.warn("解析异步查询响应失败: {}", e.getMessage()); + return response; + } + } + + /** + * 智能解析响应,尝试识别常见字段 + */ + private String buildSmartResponse(JsonNode responseNode, String rawResponse) { + Map result = new HashMap<>(); + + // 尝试识别状态字段 + String[] statusFields = {"status", "state", "code", "result_code", "ret_code"}; + for (String field : statusFields) { + if (responseNode.has(field)) { + String status = responseNode.get(field).asText(); + result.put("status", normalizeStatus(status)); + result.put("original_status", status); + break; + } + } + + // 尝试识别任务ID字段 + String[] taskIdFields = {"task_id", "taskId", "id", "job_id", "request_id", "execute_id"}; + for (String field : taskIdFields) { + if (responseNode.has(field)) { + JsonNode taskIdNode = responseNode.get(field); + if (!taskIdNode.isNull()) { + result.put("external_task_id", taskIdNode.asText()); + break; + } + } + } + + // 尝试识别结果字段 + String[] resultFields = {"result", "data", "output", "url", "image_url", "video_url"}; + for (String field : resultFields) { + if (responseNode.has(field)) { + JsonNode resultNode = responseNode.get(field); + if (!resultNode.isNull()) { + if (resultNode.isTextual()) { + result.put("result", resultNode.asText()); + } else { + result.put("result", resultNode.toString()); + } + break; + } + } + } + + // 尝试识别错误字段 + String[] errorFields = {"error", "message", "msg", "error_message", "err_msg"}; + for (String field : errorFields) { + if (responseNode.has(field) && !responseNode.get(field).isNull()) { + String errorMsg = responseNode.get(field).asText(); + // 只有非空的错误信息才记录 + if (StringUtils.hasText(errorMsg)) { + result.put("error", errorMsg); + break; + } + } + } + + // 如果没有识别到状态,根据是否有错误判断 + if (!result.containsKey("status")) { + if (result.containsKey("error") && result.get("error") != null) { + result.put("status", "failed"); + } else if (result.containsKey("result")) { + // 有结果就认为是成功 + result.put("status", "completed"); + } else if (result.containsKey("external_task_id")) { + // 只有任务ID没有结果,可能是异步任务提交成功 + result.put("status", "processing"); + } else { + // 默认认为成功 + result.put("status", "completed"); + } + } + + result.put("raw_response", rawResponse); + + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return rawResponse; + } + } + + /** + * 标准化状态值 + */ + private String normalizeStatus(String status) { + if (status == null) return "unknown"; + + String lower = status.toLowerCase().trim(); + + // 成功状态 + if (lower.equals("success") || lower.equals("completed") || lower.equals("done") || + lower.equals("finished") || lower.equals("ok") || lower.equals("0") || lower.equals("200")) { + return "completed"; + } + + // 处理中状态 + if (lower.equals("processing") || lower.equals("pending") || lower.equals("running") || + lower.equals("queued") || lower.equals("in_progress") || lower.equals("working")) { + return "processing"; + } + + // 失败状态 + if (lower.equals("failed") || lower.equals("error") || lower.equals("failure") || + lower.equals("timeout") || lower.equals("cancelled") || lower.equals("canceled")) { + return "failed"; + } + + // 如果是纯数字,尝试解析 + try { + int code = Integer.parseInt(status); + if (code == 0 || code == 200) { + return "completed"; + } else if (code >= 400) { + return "failed"; + } + } catch (NumberFormatException ignored) { + } + + return status; + } + + /** + * 根据模型配置映射状态 + * 支持的状态映射格式: + * { + * "queued": "start,queued,waiting", + * "processing": "processing,running,pending", + * "success": "success,completed,done", + * "failed": "failed,error,failure" + * } + */ + private String mapStatus(AiModelVO model, String originalStatus) { + if (originalStatus == null) { + return "unknown"; + } + + if (!StringUtils.hasText(model.getAsyncStatusMapping())) { + return normalizeStatus(originalStatus); + } + + try { + Map statusMapping = parseJson(model.getAsyncStatusMapping()); + String lowerStatus = originalStatus.toLowerCase().trim(); + + // 遍历映射配置,查找匹配的状态 + for (Map.Entry entry : statusMapping.entrySet()) { + String targetStatus = entry.getKey(); // queued, processing, success, failed + Object values = entry.getValue(); + + // 支持逗号分隔的字符串或数组 + List statusList = new ArrayList<>(); + if (values instanceof List) { + for (Object v : (List) values) { + statusList.add(String.valueOf(v).toLowerCase().trim()); + } + } else if (values instanceof String) { + // 支持逗号分隔的字符串 + String[] parts = ((String) values).split(","); + for (String part : parts) { + statusList.add(part.toLowerCase().trim()); + } + } else { + statusList.add(String.valueOf(values).toLowerCase().trim()); + } + + // 检查是否匹配 + if (statusList.contains(lowerStatus) || statusList.contains(originalStatus)) { + // 转换为标准状态 + return convertToStandardStatus(targetStatus); + } + } + + log.warn("状态值未在映射中找到: {}, 使用默认规则", originalStatus); + } catch (Exception e) { + log.warn("解析状态映射失败: {}", e.getMessage()); + } + + return normalizeStatus(originalStatus); + } + + /** + * 转换为标准状态值 + */ + private String convertToStandardStatus(String status) { + switch (status.toLowerCase()) { + case "queued": + case "processing": + return "processing"; // 排队和处理中都视为处理中 + case "success": + case "completed": + return "completed"; + case "failed": + case "error": + return "failed"; + default: + return status; + } + } + + /** + * 映射异步状态 + */ + private String mapAsyncStatus(AiModelVO model, String originalStatus) { + return mapStatus(model, originalStatus); + } + + /** + * 从JSON节点提取值(支持路径表达式) + */ + private String extractJsonValue(JsonNode node, String path) { + if (node == null || path == null) return null; + + String[] parts = path.split("\\."); + JsonNode current = node; + + for (String part : parts) { + if (current == null) return null; + + // 处理数组索引 field[0] + if (part.contains("[")) { + int bracketIndex = part.indexOf("["); + String fieldName = part.substring(0, bracketIndex); + int arrayIndex = Integer.parseInt(part.substring(bracketIndex + 1, part.length() - 1)); + + current = current.get(fieldName); + if (current != null && current.isArray() && current.size() > arrayIndex) { + current = current.get(arrayIndex); + } else { + return null; + } + } else { + current = current.get(part); + } + } + + if (current == null || current.isNull()) return null; + return current.isTextual() ? current.asText() : current.toString(); + } + + /** + * 替换模板变量 + * 支持直接替换数组和对象类型的参数值 + * 当变量值不存在或为空数组/空字符串时,跳过该字段(不加入结果) + */ + private Map replaceTemplateVariables(Map template, Map params) { + Map result = new HashMap<>(); + + for (Map.Entry entry : template.entrySet()) { + Object value = entry.getValue(); + + if (value instanceof String) { + String strValue = (String) value; + // 检查是否是纯变量引用 {{varname}} + Matcher matcher = VARIABLE_PATTERN.matcher(strValue); + if (matcher.matches()) { + // 整个值就是一个变量引用 + String varName = matcher.group(1); + Object paramValue = params.get(varName); + + // 如果参数不存在,跳过该字段 + if (paramValue == null) { + continue; + } + + // 如果是空数组或空集合,跳过该字段 + if (paramValue instanceof List && ((List) paramValue).isEmpty()) { + continue; + } + + // 如果是空字符串,跳过该字段 + if (paramValue instanceof String && ((String) paramValue).isEmpty()) { + continue; + } + + if (paramValue instanceof List || paramValue instanceof Map || + paramValue.getClass().isArray()) { + // 直接使用数组/对象值,保持类型 + result.put(entry.getKey(), paramValue); + } else { + // 普通值,进行字符串替换 + result.put(entry.getKey(), replaceVariables(strValue, params)); + } + } else { + // 包含变量的字符串,进行替换 + result.put(entry.getKey(), replaceVariables(strValue, params)); + } + } else if (value instanceof Map) { + @SuppressWarnings("unchecked") + Map mapValue = (Map) value; + result.put(entry.getKey(), replaceTemplateVariables(mapValue, params)); + } else if (value instanceof List) { + result.put(entry.getKey(), replaceListVariables((List) value, params)); + } else { + result.put(entry.getKey(), value); + } + } + + return result; + } + + /** + * 替换列表中的变量 + */ + private List replaceListVariables(List list, Map params) { + List result = new ArrayList<>(); + + for (Object item : list) { + if (item instanceof String) { + result.add(replaceVariables((String) item, params)); + } else if (item instanceof Map) { + @SuppressWarnings("unchecked") + Map mapItem = (Map) item; + result.add(replaceTemplateVariables(mapItem, params)); + } else if (item instanceof List) { + result.add(replaceListVariables((List) item, params)); + } else { + result.add(item); + } + } + + return result; + } + + /** + * 替换字符串中的变量 {{variable}} + * 支持数组和对象的JSON序列化 + */ + private String replaceVariables(String template, Map params) { + if (template == null) return null; + + Matcher matcher = VARIABLE_PATTERN.matcher(template); + StringBuffer sb = new StringBuffer(); + + while (matcher.find()) { + String varName = matcher.group(1); + Object value = params.get(varName); + String replacement = ""; + + if (value != null) { + // 对于数组和Map类型,使用JSON序列化 + if (value instanceof List || value instanceof Map || value.getClass().isArray()) { + try { + replacement = objectMapper.writeValueAsString(value); + } catch (Exception e) { + log.warn("JSON序列化失败,使用toString: {}", e.getMessage()); + replacement = String.valueOf(value); + } + } else { + replacement = String.valueOf(value); + } + } + + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + + return sb.toString(); + } + + /** + * 解析JSON字符串 + */ + private Map parseJson(String json) { + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("JSON解析失败: " + e.getMessage()); + } + } + + /** + * 构建错误响应 + */ + private String buildErrorResponse(String errorMessage) { + Map result = new HashMap<>(); + result.put("status", "failed"); + result.put("error", errorMessage); + + try { + return objectMapper.writeValueAsString(result); + } catch (Exception e) { + return "{\"status\": \"failed\", \"error\": \"" + errorMessage + "\"}"; + } + } +} diff --git a/src/main/java/com/dora/service/impl/CosTransferServiceImpl.java b/src/main/java/com/dora/service/impl/CosTransferServiceImpl.java new file mode 100644 index 0000000..d883610 --- /dev/null +++ b/src/main/java/com/dora/service/impl/CosTransferServiceImpl.java @@ -0,0 +1,186 @@ +package com.dora.service.impl; + +import com.dora.config.CosConfig; +import com.dora.service.CosTransferService; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.UUID; + +/** + * COS文件转存服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CosTransferServiceImpl implements CosTransferService { + + private final COSClient cosClient; + private final CosConfig cosConfig; + + private static final String DEFAULT_FOLDER = "ai-result"; + private static final int CONNECT_TIMEOUT = 30000; + private static final int READ_TIMEOUT = 120000; + + @Override + public String transferToCos(String remoteUrl) { + return transferToCos(remoteUrl, DEFAULT_FOLDER); + } + + @Override + public String transferToCos(String remoteUrl, String folder) { + if (remoteUrl == null || remoteUrl.trim().isEmpty()) { + log.warn("远程URL为空,跳过转存"); + return null; + } + + // 如果已经是COS链接,直接返回 + if (isCosUrl(remoteUrl)) { + log.info("URL已是COS链接,无需转存: {}", remoteUrl); + return remoteUrl; + } + + try { + log.info("开始转存远程文件到COS - URL: {}", remoteUrl); + + // 下载远程文件 + URL url = new URL(remoteUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + connection.setRequestMethod("GET"); + connection.setRequestProperty("User-Agent", "Mozilla/5.0"); + + int responseCode = connection.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + log.error("下载远程文件失败 - URL: {}, 状态码: {}", remoteUrl, responseCode); + return remoteUrl; + } + + // 获取文件信息 + String contentType = connection.getContentType(); + long contentLength = connection.getContentLengthLong(); + String extension = getExtensionFromUrl(remoteUrl, contentType); + + log.info("远程文件信息 - ContentType: {}, ContentLength: {}, Extension: {}", + contentType, contentLength, extension); + + // 生成COS文件路径 + String fileName = folder + "/" + UUID.randomUUID().toString() + extension; + + // 上传到COS + try (InputStream inputStream = connection.getInputStream()) { + ObjectMetadata metadata = new ObjectMetadata(); + if (contentLength > 0) { + metadata.setContentLength(contentLength); + } + if (contentType != null && !contentType.isEmpty()) { + metadata.setContentType(contentType); + } + + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + inputStream, + metadata + ); + + cosClient.putObject(putObjectRequest); + } + + // 返回COS文件URL + String cosUrl = cosConfig.getFileUrl(fileName); + log.info("文件转存成功 - 原URL: {}, COS URL: {}", remoteUrl, cosUrl); + + return cosUrl; + + } catch (Exception e) { + log.error("转存文件到COS失败 - URL: {}, 错误: {}", remoteUrl, e.getMessage(), e); + // 转存失败时返回原URL,不影响业务 + return remoteUrl; + } + } + + /** + * 判断是否为COS链接 + */ + private boolean isCosUrl(String url) { + if (url == null) return false; + String customDomain = cosConfig.getCustomDomain(); + String bucketName = cosConfig.getBucketName(); + String region = cosConfig.getRegion(); + + // 检查自定义域名 + if (customDomain != null && !customDomain.isEmpty() && url.startsWith(customDomain)) { + return true; + } + + // 检查标准COS域名 + String standardDomain = String.format("%s.cos.%s.myqcloud.com", bucketName, region); + return url.contains(standardDomain); + } + + /** + * 从URL或ContentType获取文件扩展名 + */ + private String getExtensionFromUrl(String url, String contentType) { + // 先尝试从URL获取扩展名 + String extension = ""; + try { + String path = new URL(url).getPath(); + int lastDot = path.lastIndexOf('.'); + if (lastDot > 0 && lastDot < path.length() - 1) { + String ext = path.substring(lastDot).toLowerCase(); + // 验证是否为有效扩展名 + if (ext.matches("\\.[a-z0-9]{2,5}")) { + extension = ext; + } + } + } catch (Exception ignored) {} + + // 如果URL没有扩展名,从ContentType推断 + if (extension.isEmpty() && contentType != null) { + extension = getExtensionFromContentType(contentType); + } + + // 默认扩展名 + if (extension.isEmpty()) { + extension = ".bin"; + } + + return extension; + } + + /** + * 从ContentType获取扩展名 + */ + private String getExtensionFromContentType(String contentType) { + if (contentType == null) return ""; + + contentType = contentType.toLowerCase(); + if (contentType.contains("video/mp4")) return ".mp4"; + if (contentType.contains("video/webm")) return ".webm"; + if (contentType.contains("video/quicktime")) return ".mov"; + if (contentType.contains("video/x-msvideo")) return ".avi"; + if (contentType.contains("video/")) return ".mp4"; + + if (contentType.contains("image/jpeg")) return ".jpg"; + if (contentType.contains("image/png")) return ".png"; + if (contentType.contains("image/gif")) return ".gif"; + if (contentType.contains("image/webp")) return ".webp"; + if (contentType.contains("image/")) return ".jpg"; + + if (contentType.contains("audio/mpeg")) return ".mp3"; + if (contentType.contains("audio/wav")) return ".wav"; + if (contentType.contains("audio/")) return ".mp3"; + + return ""; + } +} diff --git a/src/main/java/com/dora/service/impl/EmailServiceImpl.java b/src/main/java/com/dora/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..a2fc198 --- /dev/null +++ b/src/main/java/com/dora/service/impl/EmailServiceImpl.java @@ -0,0 +1,123 @@ +package com.dora.service.impl; + +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.service.EmailService; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * 邮箱服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailServiceImpl implements EmailService { + + private final JavaMailSender mailSender; + private final StringRedisTemplate redisTemplate; + + private static final String CODE_PREFIX = "admin:email:code:"; + private static final String SEND_LIMIT_PREFIX = "admin:email:limit:"; + private static final int CODE_EXPIRE_MINUTES = 5; + private static final int SEND_LIMIT_SECONDS = 60; + private static final String FROM_EMAIL = "1410082433@qq.com"; + + @Override + public void sendVerificationCode(String email) { + // 检查发送频率限制 + String limitKey = SEND_LIMIT_PREFIX + email; + if (Boolean.TRUE.equals(redisTemplate.hasKey(limitKey))) { + throw new BusinessException(ResultCode.OPERATION_TOO_FREQUENT); + } + + // 生成6位验证码 + String code = generateCode(); + + // 发送邮件 + try { + sendEmail(email, code); + } catch (Exception e) { + log.error("发送邮件失败: {}", e.getMessage(), e); + throw new BusinessException(ResultCode.EMAIL_SEND_FAILED); + } + + // 存储验证码到Redis + String codeKey = CODE_PREFIX + email; + redisTemplate.opsForValue().set(codeKey, code, CODE_EXPIRE_MINUTES, TimeUnit.MINUTES); + + // 设置发送频率限制 + redisTemplate.opsForValue().set(limitKey, "1", SEND_LIMIT_SECONDS, TimeUnit.SECONDS); + + log.info("验证码已发送至: {}", email); + } + + @Override + public boolean verifyCode(String email, String code) { + String codeKey = CODE_PREFIX + email; + String storedCode = redisTemplate.opsForValue().get(codeKey); + + if (storedCode == null) { + return false; + } + + if (storedCode.equals(code)) { + // 验证成功后删除验证码 + redisTemplate.delete(codeKey); + return true; + } + + return false; + } + + private String generateCode() { + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < 6; i++) { + code.append(random.nextInt(10)); + } + return code.toString(); + } + + private void sendEmail(String to, String code) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(FROM_EMAIL); + helper.setTo(to); + helper.setSubject("【1818AI管理系统】登录验证码"); + + String content = buildEmailContent(code); + helper.setText(content, true); + + mailSender.send(message); + } + + private String buildEmailContent(String code) { + return """ +
+
+

1818AI 管理系统

+
+
+

您好!

+

您正在登录1818AI管理系统,验证码为:

+
+ %s +
+

验证码有效期为 %d 分钟,请勿泄露给他人。

+

如非本人操作,请忽略此邮件。

+
+
+ """.formatted(code, CODE_EXPIRE_MINUTES); + } +} diff --git a/src/main/java/com/dora/service/impl/ImageProcessingServiceImpl.java b/src/main/java/com/dora/service/impl/ImageProcessingServiceImpl.java new file mode 100644 index 0000000..843edad --- /dev/null +++ b/src/main/java/com/dora/service/impl/ImageProcessingServiceImpl.java @@ -0,0 +1,478 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.dora.config.CosConfig; +import com.dora.entity.AiModel; +import com.dora.entity.AiProvider; +import com.dora.mapper.AiModelMapper; +import com.dora.mapper.AiProviderMapper; +import com.dora.service.ImageProcessingService; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.net.URL; +import java.util.*; +import java.util.List; + +/** + * 图片处理服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageProcessingServiceImpl implements ImageProcessingService { + + private final COSClient cosClient; + private final CosConfig cosConfig; + private final AiModelMapper aiModelMapper; + private final AiProviderMapper aiProviderMapper; + private final RestTemplate restTemplate; + + private static final int TARGET_SIZE = 512; // 每张小图的目标尺寸 + private static final int GAP = 4; // 图片间隔 + private static final String SKETCH_MODEL_CODE = "sketch-convert"; + private static final String INTERNAL_SKETCH_FOLDER = "internal/video-sketch"; // 内部素描图文件夹,不对用户可见 + + @Override + public byte[] stitchImages(List imageUrls) { + if (imageUrls == null || imageUrls.isEmpty()) { + log.warn("图片列表为空,无法拼接"); + return null; + } + + try { + // 下载所有图片 + List images = new ArrayList<>(); + for (String url : imageUrls) { + try { + BufferedImage img = ImageIO.read(new URL(url)); + if (img != null) { + images.add(img); + } + } catch (Exception e) { + log.warn("下载图片失败: {}", url, e); + } + } + + if (images.isEmpty()) { + log.error("没有成功下载任何图片"); + return null; + } + + int count = images.size(); + log.info("成功下载 {} 张图片,开始拼接", count); + + // 计算网格布局(尽量接近正方形) + int cols = (int) Math.ceil(Math.sqrt(count)); + int rows = (int) Math.ceil((double) count / cols); + + // 计算画布尺寸 + int canvasWidth = cols * TARGET_SIZE + (cols - 1) * GAP; + int canvasHeight = rows * TARGET_SIZE + (rows - 1) * GAP; + + // 创建画布 + BufferedImage canvas = new BufferedImage(canvasWidth, canvasHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = canvas.createGraphics(); + + // 设置白色背景 + g2d.setColor(Color.WHITE); + g2d.fillRect(0, 0, canvasWidth, canvasHeight); + + // 设置高质量渲染 + g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // 绘制每张图片 + for (int i = 0; i < images.size(); i++) { + int row = i / cols; + int col = i % cols; + int x = col * (TARGET_SIZE + GAP); + int y = row * (TARGET_SIZE + GAP); + + BufferedImage img = images.get(i); + // 缩放并居中裁剪 + BufferedImage resized = resizeAndCrop(img, TARGET_SIZE, TARGET_SIZE); + g2d.drawImage(resized, x, y, null); + } + + g2d.dispose(); + + // 转换为字节数组 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(canvas, "jpg", baos); + + log.info("图片拼接完成,尺寸: {}x{}", canvasWidth, canvasHeight); + return baos.toByteArray(); + + } catch (Exception e) { + log.error("图片拼接失败", e); + return null; + } + } + + @Override + public byte[] convertToSketch(byte[] imageData) { + if (imageData == null || imageData.length == 0) { + return null; + } + + try { + BufferedImage original = ImageIO.read(new ByteArrayInputStream(imageData)); + if (original == null) { + log.error("无法读取图片数据"); + return null; + } + + int width = original.getWidth(); + int height = original.getHeight(); + + // 1. 转灰度 + BufferedImage gray = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + Graphics2D g = gray.createGraphics(); + g.drawImage(original, 0, 0, null); + g.dispose(); + + // 2. 反转灰度图 + BufferedImage inverted = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int pixel = gray.getRGB(x, y) & 0xFF; + int invertedPixel = 255 - pixel; + int rgb = (invertedPixel << 16) | (invertedPixel << 8) | invertedPixel; + inverted.setRGB(x, y, rgb); + } + } + + // 3. 高斯模糊反转图 + BufferedImage blurred = gaussianBlur(inverted, 21); + + // 4. 颜色减淡混合 + BufferedImage sketch = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int grayPixel = gray.getRGB(x, y) & 0xFF; + int blurPixel = blurred.getRGB(x, y) & 0xFF; + + // 颜色减淡公式: result = gray / (255 - blur) * 255 + int result; + if (blurPixel == 255) { + result = 255; + } else { + result = Math.min(255, (grayPixel * 255) / (255 - blurPixel + 1)); + } + + int rgb = (result << 16) | (result << 8) | result; + sketch.setRGB(x, y, rgb); + } + } + + // 转换为字节数组 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(sketch, "jpg", baos); + + log.info("素描转换完成"); + return baos.toByteArray(); + + } catch (Exception e) { + log.error("素描转换失败", e); + return null; + } + } + + @Override + public String stitchAndConvertToCos(List imageUrls, String folder) { + try { + // 1. 拼接图片 + byte[] stitched = stitchImages(imageUrls); + if (stitched == null) { + log.error("图片拼接失败"); + return null; + } + + // 2. 转换为素描风格 + byte[] sketch = convertToSketch(stitched); + if (sketch == null) { + log.error("素描转换失败,使用原始拼接图"); + sketch = stitched; + } + + // 3. 上传到COS内部文件夹(不对用户可见) + String fileName = INTERNAL_SKETCH_FOLDER + "/" + UUID.randomUUID().toString() + "_sketch.jpg"; + + ByteArrayInputStream inputStream = new ByteArrayInputStream(sketch); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(sketch.length); + metadata.setContentType("image/jpeg"); + + PutObjectRequest putRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + inputStream, + metadata + ); + + cosClient.putObject(putRequest); + + String cosUrl = cosConfig.getFileUrl(fileName); + log.info("素描图片上传成功(内部文件夹): {}", cosUrl); + + return cosUrl; + + } catch (Exception e) { + log.error("拼接并转换图片失败", e); + return null; + } + } + + @Override + public String stitchAndUploadToCos(List imageUrls, String folder) { + try { + // 1. 拼接图片 + byte[] stitched = stitchImages(imageUrls); + if (stitched == null) { + log.error("图片拼接失败"); + return null; + } + + // 2. 直接上传到COS内部文件夹(不对用户可见) + String fileName = INTERNAL_SKETCH_FOLDER + "/" + UUID.randomUUID().toString() + "_composite.jpg"; + + ByteArrayInputStream inputStream = new ByteArrayInputStream(stitched); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(stitched.length); + metadata.setContentType("image/jpeg"); + + PutObjectRequest putRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + inputStream, + metadata + ); + + cosClient.putObject(putRequest); + + String cosUrl = cosConfig.getFileUrl(fileName); + log.info("拼接图片上传成功(内部文件夹): {}", cosUrl); + + return cosUrl; + + } catch (Exception e) { + log.error("拼接并上传图片失败", e); + return null; + } + } + + @Override + public String convertToSketchByAi(String sourceImageUrl, Long userId) { + // 异步任务方式暂不实现,当前使用同步方式 + return convertToSketchByAiSync(sourceImageUrl); + } + + @Override + public String convertToSketchByAiSync(String sourceImageUrl) { + try { + // 1. 获取素描转换模型配置 + AiModel model = aiModelMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiModel::getCode, SKETCH_MODEL_CODE) + .eq(AiModel::getStatus, 1) + .eq(AiModel::getDeleted, 0) + ); + + if (model == null) { + log.warn("素描转换模型未配置,使用本地算法"); + return null; + } + + // 2. 获取厂商配置 + AiProvider provider = aiProviderMapper.selectById(model.getProviderId()); + if (provider == null) { + log.error("AI厂商配置不存在"); + return null; + } + + // 3. 构建请求 + String url = provider.getBaseUrl() + model.getApiEndpoint(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(provider.getApiKey()); + + // 构建请求体 - 使用豆包绘图的图生图功能 + // 注意:图生图模式下不指定size,让模型根据输入图片自动确定输出尺寸 + Map body = new HashMap<>(); + body.put("model", "doubao-seedream-4-5-251128"); + body.put("prompt", "Convert this image to a black and white pencil sketch style, maintain the composition and details, artistic hand-drawn sketch effect, clean lines, high contrast"); + body.put("response_format", "url"); + body.put("stream", false); + body.put("watermark", false); + // 传入原图作为参考 + body.put("image", Collections.singletonList(sourceImageUrl)); + + log.info("调用火山方舟素描转换 - URL: {}, 原图: {}", url, sourceImageUrl); + + HttpEntity> entity = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Map responseBody = response.getBody(); + if (responseBody.containsKey("data")) { + List dataList = (List) responseBody.get("data"); + if (dataList != null && !dataList.isEmpty()) { + String sketchUrl = (String) dataList.get(0).get("url"); + log.info("火山方舟素描转换成功,原始URL: {}", sketchUrl); + + // 将素描图转存到COS内部文件夹(不对用户可见) + String cosUrl = transferSketchToCos(sketchUrl); + if (cosUrl != null) { + log.info("素描图已转存到COS内部文件夹: {}", cosUrl); + return cosUrl; + } + // 转存失败则返回原始URL + return sketchUrl; + } + } + } + + log.warn("火山方舟素描转换返回结果异常: {}", response.getBody()); + return null; + + } catch (Exception e) { + log.error("火山方舟素描转换失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 将素描图转存到COS内部文件夹(不对用户可见,不记录到用户资产) + */ + private String transferSketchToCos(String sourceUrl) { + try { + // 下载图片 + BufferedImage image = ImageIO.read(new URL(sourceUrl)); + if (image == null) { + log.warn("下载素描图失败: {}", sourceUrl); + return null; + } + + // 转换为字节数组 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", baos); + byte[] imageData = baos.toByteArray(); + + // 上传到COS内部文件夹 + String fileName = INTERNAL_SKETCH_FOLDER + "/" + UUID.randomUUID().toString() + "_sketch.jpg"; + + ByteArrayInputStream inputStream = new ByteArrayInputStream(imageData); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(imageData.length); + metadata.setContentType("image/jpeg"); + + PutObjectRequest putRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + inputStream, + metadata + ); + + cosClient.putObject(putRequest); + + return cosConfig.getFileUrl(fileName); + + } catch (Exception e) { + log.error("转存素描图到COS失败: {}", e.getMessage()); + return null; + } + } + + /** + * 缩放并居中裁剪图片 + */ + private BufferedImage resizeAndCrop(BufferedImage original, int targetWidth, int targetHeight) { + int origWidth = original.getWidth(); + int origHeight = original.getHeight(); + + // 计算缩放比例(填充模式) + double scale = Math.max((double) targetWidth / origWidth, (double) targetHeight / origHeight); + + int scaledWidth = (int) (origWidth * scale); + int scaledHeight = (int) (origHeight * scale); + + // 缩放图片 + BufferedImage scaled = new BufferedImage(scaledWidth, scaledHeight, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(original, 0, 0, scaledWidth, scaledHeight, null); + g.dispose(); + + // 居中裁剪 + int x = (scaledWidth - targetWidth) / 2; + int y = (scaledHeight - targetHeight) / 2; + + return scaled.getSubimage(x, y, targetWidth, targetHeight); + } + + /** + * 简单的高斯模糊实现 + */ + private BufferedImage gaussianBlur(BufferedImage image, int radius) { + int width = image.getWidth(); + int height = image.getHeight(); + BufferedImage result = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); + + // 创建高斯核 + int size = radius * 2 + 1; + double[][] kernel = new double[size][size]; + double sigma = radius / 3.0; + double sum = 0; + + for (int y = -radius; y <= radius; y++) { + for (int x = -radius; x <= radius; x++) { + double value = Math.exp(-(x * x + y * y) / (2 * sigma * sigma)); + kernel[y + radius][x + radius] = value; + sum += value; + } + } + + // 归一化 + for (int y = 0; y < size; y++) { + for (int x = 0; x < size; x++) { + kernel[y][x] /= sum; + } + } + + // 应用卷积 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + double pixelSum = 0; + + for (int ky = -radius; ky <= radius; ky++) { + for (int kx = -radius; kx <= radius; kx++) { + int px = Math.min(Math.max(x + kx, 0), width - 1); + int py = Math.min(Math.max(y + ky, 0), height - 1); + int pixel = image.getRGB(px, py) & 0xFF; + pixelSum += pixel * kernel[ky + radius][kx + radius]; + } + } + + int resultPixel = (int) Math.min(255, Math.max(0, pixelSum)); + int rgb = (resultPixel << 16) | (resultPixel << 8) | resultPixel; + result.setRGB(x, y, rgb); + } + } + + return result; + } +} diff --git a/src/main/java/com/dora/service/impl/PointsServiceImpl.java b/src/main/java/com/dora/service/impl/PointsServiceImpl.java new file mode 100644 index 0000000..30f7314 --- /dev/null +++ b/src/main/java/com/dora/service/impl/PointsServiceImpl.java @@ -0,0 +1,295 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.config.WxPayConfig; +import com.dora.dto.CreatePointsOrderDTO; +import com.dora.entity.PointsOrder; +import com.dora.entity.PointsPackage; +import com.dora.entity.PointsRecord; +import com.dora.entity.User; +import com.dora.mapper.PointsOrderMapper; +import com.dora.mapper.PointsPackageMapper; +import com.dora.mapper.PointsRecordMapper; +import com.dora.mapper.UserMapper; +import com.dora.service.PointsService; +import com.dora.vo.PointsPackageVO; +import com.dora.vo.WxPayOrderVO; +import com.github.binarywang.wxpay.bean.notify.WxPayNotifyResponse; +import com.github.binarywang.wxpay.bean.notify.WxPayOrderNotifyResult; +import com.github.binarywang.wxpay.bean.request.WxPayUnifiedOrderRequest; +import com.github.binarywang.wxpay.bean.result.WxPayUnifiedOrderResult; +import com.github.binarywang.wxpay.service.WxPayService; +import com.github.binarywang.wxpay.util.SignUtils; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.BufferedReader; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PointsServiceImpl implements PointsService { + + private final PointsPackageMapper packageMapper; + private final PointsOrderMapper orderMapper; + private final PointsRecordMapper recordMapper; + private final UserMapper userMapper; + private final WxPayConfig wxPayConfig; + private final WxPayService wxPayService; + + @Override + public List getPackageList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsPackage::getStatus, 1) + .eq(PointsPackage::getDeleted, 0) + .orderByAsc(PointsPackage::getSort); + + return packageMapper.selectList(wrapper).stream().map(pkg -> { + PointsPackageVO vo = new PointsPackageVO(); + BeanUtils.copyProperties(pkg, vo); + return vo; + }).collect(Collectors.toList()); + } + + @Override + @Transactional + public WxPayOrderVO createOrder(Long userId, CreatePointsOrderDTO dto) { + // 查询套餐 + PointsPackage pkg = packageMapper.selectById(dto.getPackageId()); + if (pkg == null || pkg.getStatus() != 1) { + throw new BusinessException(ResultCode.PARAM_ERROR, "套餐不存在或已下架"); + } + + // 查询用户openid + User user = userMapper.selectById(userId); + if (user == null || user.getOpenid() == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "用户信息异常"); + } + + // 生成订单号 + String orderNo = generateOrderNo(); + + // 创建订单 + PointsOrder order = new PointsOrder(); + order.setOrderNo(orderNo); + order.setUserId(userId); + order.setPackageId(pkg.getId()); + order.setPoints(pkg.getPoints()); + order.setBonusPoints(pkg.getBonusPoints()); + order.setAmount(pkg.getPrice()); + order.setStatus(0); + order.setCreatedAt(LocalDateTime.now()); + order.setUpdatedAt(LocalDateTime.now()); + orderMapper.insert(order); + + try { + // 调用微信统一下单 + WxPayUnifiedOrderRequest request = new WxPayUnifiedOrderRequest(); + request.setOutTradeNo(orderNo); + request.setBody(pkg.getName() + " - " + pkg.getPoints() + "积分"); + request.setTotalFee(pkg.getPrice().multiply(new BigDecimal("100")).intValue()); + request.setSpbillCreateIp("127.0.0.1"); + request.setNotifyUrl(wxPayConfig.getNotifyUrl()); + request.setTradeType("JSAPI"); + request.setOpenid(user.getOpenid()); + + WxPayUnifiedOrderResult result = wxPayService.unifiedOrder(request); + + // 生成小程序支付参数 + String timeStamp = String.valueOf(System.currentTimeMillis() / 1000); + String nonceStr = UUID.randomUUID().toString().replace("-", ""); + String packageVal = "prepay_id=" + result.getPrepayId(); + + // 签名 + Map signMap = new HashMap<>(); + signMap.put("appId", wxPayConfig.getAppId()); + signMap.put("timeStamp", timeStamp); + signMap.put("nonceStr", nonceStr); + signMap.put("package", packageVal); + signMap.put("signType", "MD5"); + String paySign = SignUtils.createSign(signMap, "MD5", wxPayConfig.getMchKey(), null); + + WxPayOrderVO vo = new WxPayOrderVO(); + vo.setOrderNo(orderNo); + vo.setTimeStamp(timeStamp); + vo.setNonceStr(nonceStr); + vo.setPackageVal(packageVal); + vo.setSignType("MD5"); + vo.setPaySign(paySign); + return vo; + } catch (Exception e) { + log.error("创建微信支付订单失败", e); + throw new BusinessException(ResultCode.FAIL, "创建支付订单失败: " + e.getMessage()); + } + } + + @Override + @Transactional + public String handlePayNotify(HttpServletRequest request) { + try { + // 读取请求体 + StringBuilder body = new StringBuilder(); + try (BufferedReader reader = request.getReader()) { + String line; + while ((line = reader.readLine()) != null) { + body.append(line); + } + } + + // 解析并验签 + WxPayOrderNotifyResult notifyResult = wxPayService.parseOrderNotifyResult(body.toString()); + + if ("SUCCESS".equals(notifyResult.getResultCode())) { + String orderNo = notifyResult.getOutTradeNo(); + String transactionId = notifyResult.getTransactionId(); + + // 查询订单 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsOrder::getOrderNo, orderNo); + PointsOrder order = orderMapper.selectOne(wrapper); + + if (order != null && order.getStatus() == 0) { + // 更新订单状态 + order.setStatus(1); + order.setPayType(1); + order.setPayTime(LocalDateTime.now()); + order.setTransactionId(transactionId); + order.setUpdatedAt(LocalDateTime.now()); + orderMapper.updateById(order); + + // 增加用户积分 + int totalPoints = order.getPoints() + order.getBonusPoints(); + User user = userMapper.selectById(order.getUserId()); + int newBalance = user.getPoints() + totalPoints; + + LambdaUpdateWrapper userUpdate = new LambdaUpdateWrapper<>(); + userUpdate.eq(User::getId, order.getUserId()) + .set(User::getPoints, newBalance) + .set(User::getUpdatedAt, LocalDateTime.now()); + userMapper.update(null, userUpdate); + + // 记录积分流水 + PointsRecord record = new PointsRecord(); + record.setUserId(order.getUserId()); + record.setType(1); + record.setPoints(totalPoints); + record.setBalance(newBalance); + record.setBizType("points_recharge"); + record.setBizId(order.getId()); + record.setRemark("积分充值 - " + order.getPoints() + "积分" + + (order.getBonusPoints() > 0 ? " + 赠送" + order.getBonusPoints() + "积分" : "")); + record.setCreatedAt(LocalDateTime.now()); + recordMapper.insert(record); + + log.info("积分充值成功: orderNo={}, userId={}, points={}", orderNo, order.getUserId(), totalPoints); + } + } + return WxPayNotifyResponse.success("成功"); + } catch (Exception e) { + log.error("处理支付回调失败", e); + return WxPayNotifyResponse.fail("处理失败"); + } + } + + @Override + @Transactional + public void cancelOrder(Long userId, String orderNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsOrder::getOrderNo, orderNo) + .eq(PointsOrder::getUserId, userId); + PointsOrder order = orderMapper.selectOne(wrapper); + + if (order == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "订单不存在"); + } + if (order.getStatus() != 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "订单状态异常"); + } + + order.setStatus(2); + order.setUpdatedAt(LocalDateTime.now()); + orderMapper.updateById(order); + } + + private String generateOrderNo() { + return "PO" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + @Override + @Transactional + public void consumePoints(Long userId, Integer points, String bizType, Long bizId) { + // 获取用户信息 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "用户不存在"); + } + + // 检查积分余额 + if (user.getPoints() < points) { + throw new BusinessException(ResultCode.PARAM_ERROR, "积分余额不足"); + } + + // 扣减积分 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, userId) + .setSql("points = points - " + points); + userMapper.update(null, updateWrapper); + + // 记录积分流水 + PointsRecord record = new PointsRecord(); + record.setUserId(userId); + record.setType(2); // 消费 + record.setPoints(-points); + record.setBalance(user.getPoints() - points); + record.setBizType(bizType); + record.setBizId(bizId); + // 使用bizType作为remark,显示具体的AI模型名称 + record.setRemark(bizType != null && !bizType.isEmpty() ? bizType : "积分消费"); + record.setCreatedAt(LocalDateTime.now()); + recordMapper.insert(record); + } + + @Override + @Transactional + public void refundPoints(Long userId, Integer points, String bizType, Long bizId) { + // 获取用户信息 + User user = userMapper.selectById(userId); + if (user == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "用户不存在"); + } + + // 增加积分 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(User::getId, userId) + .setSql("points = points + " + points); + userMapper.update(null, updateWrapper); + + // 记录积分流水 + PointsRecord record = new PointsRecord(); + record.setUserId(userId); + record.setType(6); // 退款 + record.setPoints(points); + record.setBalance(user.getPoints() + points); + record.setBizType(bizType); + record.setBizId(bizId); + record.setRemark("AI任务退款积分"); + record.setCreatedAt(LocalDateTime.now()); + recordMapper.insert(record); + } +} diff --git a/src/main/java/com/dora/service/impl/RedeemCodeServiceImpl.java b/src/main/java/com/dora/service/impl/RedeemCodeServiceImpl.java new file mode 100644 index 0000000..893ae45 --- /dev/null +++ b/src/main/java/com/dora/service/impl/RedeemCodeServiceImpl.java @@ -0,0 +1,263 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.context.AdminContext; +import com.dora.common.exception.BusinessException; +import com.dora.common.result.ResultCode; +import com.dora.dto.admin.RedeemCodeCreateDTO; +import com.dora.dto.admin.RedeemCodeQueryDTO; +import com.dora.entity.RedeemCode; +import com.dora.entity.RedeemCodeRecord; +import com.dora.mapper.RedeemCodeMapper; +import com.dora.mapper.RedeemCodeRecordMapper; +import com.dora.service.RedeemCodeService; +import com.dora.vo.PageVO; +import com.dora.vo.admin.RedeemCodeVO; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +/** + * 兑换码服务实现 + */ +@Service +@RequiredArgsConstructor +public class RedeemCodeServiceImpl implements RedeemCodeService { + + private final RedeemCodeMapper redeemCodeMapper; + private final RedeemCodeRecordMapper redeemCodeRecordMapper; + + private static final String CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private static final Random RANDOM = new Random(); + + @Override + public PageVO getCodeList(RedeemCodeQueryDTO dto) { + Page page = new Page<>(dto.getPage(), dto.getPageSize()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(RedeemCode::getDeleted, 0); + + if (StringUtils.hasText(dto.getCode())) { + wrapper.like(RedeemCode::getCode, dto.getCode()); + } + if (StringUtils.hasText(dto.getBatchNo())) { + wrapper.eq(RedeemCode::getBatchNo, dto.getBatchNo()); + } + if (dto.getType() != null) { + wrapper.eq(RedeemCode::getType, dto.getType()); + } + if (dto.getStatus() != null) { + wrapper.eq(RedeemCode::getStatus, dto.getStatus()); + } + if (dto.getExhausted() != null) { + if (dto.getExhausted() == 1) { + wrapper.apply("used_count >= total_count"); + } else { + wrapper.apply("used_count < total_count"); + } + } + + wrapper.orderByDesc(RedeemCode::getCreatedAt); + + Page result = redeemCodeMapper.selectPage(page, wrapper); + + List list = result.getRecords().stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + + return PageVO.of(list, result.getTotal(), dto.getPage(), dto.getPageSize()); + } + + @Override + public RedeemCodeVO getCodeDetail(Long id) { + RedeemCode code = redeemCodeMapper.selectById(id); + if (code == null || code.getDeleted() == 1) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "兑换码不存在"); + } + return convertToVO(code); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List generateCodes(RedeemCodeCreateDTO dto) { + if (dto.getType() == null || dto.getRewardValue() == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "类型和奖励值不能为空"); + } + + int count = dto.getCount() != null ? dto.getCount() : 1; + if (count < 1 || count > 1000) { + throw new BusinessException(ResultCode.PARAM_ERROR, "生成数量必须在1-1000之间"); + } + + // 生成批次号 + String batchNo = "BATCH" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + Long creatorId = AdminContext.getAdminId(); + + List codes = new ArrayList<>(); + for (int i = 0; i < count; i++) { + String code = generateUniqueCode(dto.getPrefix()); + codes.add(code); + + RedeemCode redeemCode = new RedeemCode(); + redeemCode.setCode(code); + redeemCode.setBatchNo(batchNo); + redeemCode.setType(dto.getType()); + redeemCode.setRewardValue(dto.getRewardValue()); + redeemCode.setVipLevel(dto.getVipLevel()); + redeemCode.setTotalCount(dto.getTotalCount() != null ? dto.getTotalCount() : 1); + redeemCode.setUsedCount(0); + redeemCode.setStartTime(dto.getStartTime()); + redeemCode.setExpireTime(dto.getExpireTime()); + redeemCode.setRemark(dto.getRemark()); + redeemCode.setCreatorId(creatorId); + redeemCode.setStatus(1); + redeemCode.setCreatedAt(LocalDateTime.now()); + redeemCode.setUpdatedAt(LocalDateTime.now()); + redeemCode.setDeleted(0); + + redeemCodeMapper.insert(redeemCode); + } + + return codes; + } + + @Override + public void updateCode(Long id, RedeemCode code) { + RedeemCode existing = redeemCodeMapper.selectById(id); + if (existing == null || existing.getDeleted() == 1) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "兑换码不存在"); + } + + if (code.getTotalCount() != null) existing.setTotalCount(code.getTotalCount()); + if (code.getStartTime() != null) existing.setStartTime(code.getStartTime()); + if (code.getExpireTime() != null) existing.setExpireTime(code.getExpireTime()); + if (code.getRemark() != null) existing.setRemark(code.getRemark()); + if (code.getStatus() != null) existing.setStatus(code.getStatus()); + existing.setUpdatedAt(LocalDateTime.now()); + + redeemCodeMapper.updateById(existing); + } + + @Override + public void deleteCode(Long id) { + RedeemCode code = redeemCodeMapper.selectById(id); + if (code == null) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "兑换码不存在"); + } + code.setDeleted(1); + code.setUpdatedAt(LocalDateTime.now()); + redeemCodeMapper.updateById(code); + } + + @Override + public void toggleStatus(Long id, Integer status) { + RedeemCode code = redeemCodeMapper.selectById(id); + if (code == null || code.getDeleted() == 1) { + throw new BusinessException(ResultCode.DATA_NOT_FOUND, "兑换码不存在"); + } + code.setStatus(status); + code.setUpdatedAt(LocalDateTime.now()); + redeemCodeMapper.updateById(code); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void redeem(Long userId, String codeStr, String ip) { + // 查询兑换码 + RedeemCode code = redeemCodeMapper.selectByCode(codeStr); + if (code == null) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码不存在"); + } + + // 检查状态 + if (code.getStatus() != 1) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码已禁用"); + } + + // 检查是否用完 + if (code.getUsedCount() >= code.getTotalCount()) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码已用完"); + } + + // 检查有效期 + LocalDateTime now = LocalDateTime.now(); + if (code.getStartTime() != null && now.isBefore(code.getStartTime())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码尚未生效"); + } + if (code.getExpireTime() != null && now.isAfter(code.getExpireTime())) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码已过期"); + } + + // 检查是否已使用过 + int usedCount = redeemCodeRecordMapper.countByCodeIdAndUserId(code.getId(), userId); + if (usedCount > 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "您已使用过该兑换码"); + } + + // 增加使用次数 + int rows = redeemCodeMapper.incrementUsedCount(code.getId()); + if (rows == 0) { + throw new BusinessException(ResultCode.PARAM_ERROR, "兑换码已用完"); + } + + // 记录使用 + RedeemCodeRecord record = new RedeemCodeRecord(); + record.setCodeId(code.getId()); + record.setCode(codeStr); + record.setUserId(userId); + record.setType(code.getType()); + record.setRewardValue(code.getRewardValue()); + record.setIp(ip); + record.setCreatedAt(LocalDateTime.now()); + redeemCodeRecordMapper.insert(record); + + // TODO: 发放奖励(积分或VIP) + // 这里需要调用用户服务来发放奖励 + } + + private String generateUniqueCode(String prefix) { + String code; + int maxAttempts = 10; + int attempts = 0; + + do { + code = generateRandomCode(prefix); + attempts++; + } while (redeemCodeMapper.selectByCode(code) != null && attempts < maxAttempts); + + if (attempts >= maxAttempts) { + throw new BusinessException(ResultCode.SYSTEM_ERROR, "生成兑换码失败,请重试"); + } + + return code; + } + + private String generateRandomCode(String prefix) { + StringBuilder sb = new StringBuilder(); + if (StringUtils.hasText(prefix)) { + sb.append(prefix.toUpperCase()); + } + // 生成8位随机码 + for (int i = 0; i < 8; i++) { + sb.append(CODE_CHARS.charAt(RANDOM.nextInt(CODE_CHARS.length()))); + } + return sb.toString(); + } + + private RedeemCodeVO convertToVO(RedeemCode code) { + RedeemCodeVO vo = new RedeemCodeVO(); + BeanUtils.copyProperties(code, vo); + vo.setTypeName(code.getType() == 1 ? "积分" : "VIP会员"); + return vo; + } +} diff --git a/src/main/java/com/dora/service/impl/UserServiceImpl.java b/src/main/java/com/dora/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..9ebb35f --- /dev/null +++ b/src/main/java/com/dora/service/impl/UserServiceImpl.java @@ -0,0 +1,944 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.config.CosConfig; +import com.dora.dto.WxLoginDTO; +import com.dora.entity.PointsRecord; +import com.dora.entity.PromotionConfig; +import com.dora.entity.PromotionRecord; +import com.dora.entity.User; +import com.dora.mapper.AiWorkMapper; +import com.dora.mapper.PointsRecordMapper; +import com.dora.mapper.PromotionConfigMapper; +import com.dora.mapper.PromotionRecordMapper; +import com.dora.mapper.UserMapper; +import com.dora.service.UserService; +import com.dora.util.JwtUtil; +import com.dora.vo.*; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 用户服务实现 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserMapper userMapper; + private final AiWorkMapper aiWorkMapper; + private final ObjectMapper objectMapper; + private final COSClient cosClient; + private final CosConfig cosConfig; + private final JwtUtil jwtUtil; + private final PointsRecordMapper pointsRecordMapper; + private final PromotionConfigMapper promotionConfigMapper; + private final PromotionRecordMapper promotionRecordMapper; + + @Value("${wechat.miniapp.appid}") + private String appId; + + @Value("${wechat.miniapp.secret}") + private String appSecret; + + @Override + public UserCheckVO checkUser(String code) { + UserCheckVO result = new UserCheckVO(); + + // 调用微信接口获取openid + String openid = getOpenidFromWx(code); + if (openid == null) { + throw new RuntimeException("微信登录失败"); + } + + // 返回openid给前端,避免code重复使用 + result.setOpenid(openid); + + // 查询用户是否存在 + User user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(User::getOpenid, openid) + .eq(User::getDeleted, 0)); + + if (user == null) { + result.setExists(false); + result.setIsComplete(false); + return result; + } + + result.setExists(true); + + // 检查信息完整度:phone、avatar、nickname 都有值 + boolean isComplete = hasValue(user.getPhone()) + && hasValue(user.getAvatar()) + && hasValue(user.getNickname()); + result.setIsComplete(isComplete); + + // 构建用户信息 + UserCheckVO.UserInfo userInfo = new UserCheckVO.UserInfo(); + userInfo.setPhone(maskPhone(user.getPhone())); + userInfo.setAvatar(user.getAvatar()); + userInfo.setNickname(user.getNickname()); + result.setUser(userInfo); + + return result; + } + + @Override + public LoginVO wxLogin(WxLoginDTO dto) { + String openid; + boolean isNewUser = false; + Integer registerRewardPoints = null; + + log.info("=== 微信登录开始 ==="); + log.info("wxLogin参数: code={}, openid={}, phoneCode={}, inviteCode={}, nickname={}, avatar={}", + dto.getCode(), dto.getOpenid(), dto.getPhoneCode(), dto.getInviteCode(), dto.getNickname(), dto.getAvatar()); + + // 优先使用openid(从/user/check接口获取),否则用code换取 + if (hasValue(dto.getOpenid())) { + openid = dto.getOpenid(); + log.info("使用openid登录: {}", openid); + } else if (hasValue(dto.getCode())) { + log.info("使用code换取openid"); + openid = getOpenidFromWx(dto.getCode()); + if (openid == null) { + log.error("微信登录失败: 无法获取openid"); + throw new RuntimeException("微信登录失败"); + } + log.info("通过code获取到openid: {}", openid); + } else { + log.error("微信登录失败: 缺少登录凭证"); + throw new RuntimeException("缺少登录凭证"); + } + + // 查询用户是否存在 + log.info("查询用户是否存在: openid={}", openid); + User user = userMapper.selectOne(new LambdaQueryWrapper() + .eq(User::getOpenid, openid) + .eq(User::getDeleted, 0)); + + if (user == null) { + // 新用户注册 + isNewUser = true; + log.info("=== 新用户注册流程 ==="); + log.info("openid: {}", openid); + log.info("昵称: {}", dto.getNickname()); + log.info("头像: {}", dto.getAvatar()); + log.info("邀请码: {}", dto.getInviteCode()); + + user = new User(); + user.setOpenid(openid); + user.setNickname(dto.getNickname() != null ? dto.getNickname() : "微信用户"); + + // 处理头像:如果是微信头像URL,下载并上传到COS + String avatarUrl = dto.getAvatar(); + log.info("=== 新用户头像处理 ==="); + log.info("用户昵称: {}", dto.getNickname()); + log.info("原始头像URL: {}", avatarUrl); + + if (hasValue(avatarUrl) && isWechatAvatarUrl(avatarUrl)) { + try { + log.info("检测到微信头像,开始转存到COS: {}", avatarUrl); + avatarUrl = downloadAndUploadWechatAvatar(avatarUrl); + log.info("微信头像转存成功: {}", avatarUrl); + } catch (Exception e) { + log.error("微信头像转存失败,使用原URL: {}", avatarUrl, e); + // 转存失败,继续使用原URL + } + } else { + log.info("非微信头像URL或为空,直接使用: {}", avatarUrl); + } + user.setAvatar(avatarUrl); + + user.setStatus(1); + user.setVipLevel(0); + user.setPoints(0); + user.setInviteCode(generateInviteCode()); + user.setLastLoginTime(LocalDateTime.now()); + user.setCreatedAt(LocalDateTime.now()); + user.setUpdatedAt(LocalDateTime.now()); + user.setDeleted(0); + + // 处理邀请码,绑定邀请人 + if (hasValue(dto.getInviteCode())) { + log.info("处理邀请码: {}", dto.getInviteCode()); + User inviter = userMapper.selectOne(new LambdaQueryWrapper() + .eq(User::getInviteCode, dto.getInviteCode()) + .eq(User::getDeleted, 0)); + if (inviter != null) { + user.setInviterId(inviter.getId()); + log.info("新用户绑定邀请人成功: inviterId={}, inviterNickname={}, inviteCode={}", + inviter.getId(), inviter.getNickname(), dto.getInviteCode()); + } else { + log.warn("邀请码无效,未找到对应用户: {}", dto.getInviteCode()); + } + } else { + log.info("无邀请码"); + } + + log.info("插入新用户到数据库"); + userMapper.insert(user); + log.info("新用户注册成功: userId={}, inviterId={}, nickname={}", + user.getId(), user.getInviterId(), user.getNickname()); + + // 发放注册奖励积分,返回奖励积分数 + registerRewardPoints = grantRegisterReward(user); + + // 发放推广人奖励积分 + if (user.getInviterId() != null) { + log.info("开始发放邀请奖励: inviterId={}, inviteeId={}", user.getInviterId(), user.getId()); + grantInviterReward(user); + } else { + log.info("无邀请人,跳过邀请奖励"); + } + } else { + // 更新登录信息 + log.info("=== 老用户登录流程 ==="); + log.info("用户ID: {}, 昵称: {}, 头像: {}", user.getId(), user.getNickname(), user.getAvatar()); + log.info("传入昵称: {}, 传入头像: {}", dto.getNickname(), dto.getAvatar()); + + user.setLastLoginTime(LocalDateTime.now()); + // 只有前端传了有效值才更新,避免覆盖已有数据 + if (hasValue(dto.getNickname())) { + log.info("更新用户昵称: {} -> {}", user.getNickname(), dto.getNickname()); + user.setNickname(dto.getNickname()); + } + if (hasValue(dto.getAvatar())) { + // 处理头像:如果是微信头像URL,下载并上传到COS + String avatarUrl = dto.getAvatar(); + log.info("=== 老用户头像更新 ==="); + log.info("用户ID: {}, 昵称: {}", user.getId(), user.getNickname()); + log.info("原始头像URL: {}", avatarUrl); + + if (isWechatAvatarUrl(avatarUrl)) { + try { + log.info("检测到微信头像,开始转存到COS: {}", avatarUrl); + avatarUrl = downloadAndUploadWechatAvatar(avatarUrl); + log.info("微信头像转存成功: {}", avatarUrl); + } catch (Exception e) { + log.error("微信头像转存失败,使用原URL: {}", avatarUrl, e); + } + } else { + log.info("非微信头像URL,直接使用: {}", avatarUrl); + } + log.info("更新用户头像: {} -> {}", user.getAvatar(), avatarUrl); + user.setAvatar(avatarUrl); + } + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + log.info("老用户信息更新完成"); + } + + // 如果有 phoneCode,获取手机号并保存 + if (hasValue(dto.getPhoneCode())) { + log.info("处理手机号授权: phoneCode={}", dto.getPhoneCode()); + String phone = getPhoneFromWx(dto.getPhoneCode()); + if (phone != null) { + log.info("获取手机号成功: {}", maskPhone(phone)); + user.setPhone(phone); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + } else { + log.warn("获取手机号失败"); + } + } + + // 生成JWT Token + log.info("生成JWT Token: userId={}", user.getId()); + String accessToken = jwtUtil.generateAccessToken(user.getId(), openid); + String refreshToken = jwtUtil.generateRefreshToken(user.getId(), openid); + + LoginVO result = buildLoginVO(user, accessToken, refreshToken, isNewUser, registerRewardPoints); + log.info("=== 微信登录完成 ==="); + log.info("用户ID: {}, 昵称: {}, 是否新用户: {}, 注册奖励积分: {}", + user.getId(), user.getNickname(), isNewUser, registerRewardPoints); + + return result; + } + + @Override + public TokenVO refreshToken(String refreshToken) { + try { + // 验证必须是Refresh Token + if (!jwtUtil.isRefreshToken(refreshToken)) { + throw new RuntimeException("无效的刷新令牌"); + } + + // 解析用户信息 + Long userId = jwtUtil.getUserIdFromToken(refreshToken); + String openid = jwtUtil.getOpenidFromToken(refreshToken); + + // 验证用户是否存在 + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + throw new RuntimeException("用户不存在"); + } + + // 验证openid是否匹配 + if (!openid.equals(user.getOpenid())) { + throw new RuntimeException("令牌信息不匹配"); + } + + // 生成新的Token + String newAccessToken = jwtUtil.generateAccessToken(userId, openid); + String newRefreshToken = jwtUtil.generateRefreshToken(userId, openid); + + TokenVO tokenVO = new TokenVO(); + tokenVO.setToken(newAccessToken); + tokenVO.setRefreshToken(newRefreshToken); + tokenVO.setExpiresIn(jwtUtil.getAccessTokenExpire()); + + return tokenVO; + } catch (ExpiredJwtException e) { + throw new RuntimeException("刷新令牌已过期,请重新登录"); + } catch (JwtException e) { + throw new RuntimeException("无效的刷新令牌"); + } + } + + @Override + public LoginVO getUserInfo(Long userId) { + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + return null; + } + return buildLoginVO(user, null, null); + } + + @Override + public String uploadAvatar(MultipartFile file) { + // 获取当前用户信息用于日志 + String currentUserInfo = "未知用户"; + try { + Long userId = com.dora.common.context.UserContext.getUserId(); + if (userId != null) { + User user = userMapper.selectById(userId); + if (user != null) { + currentUserInfo = String.format("用户ID:%d, 昵称:%s", userId, user.getNickname()); + } + } + } catch (Exception e) { + log.warn("获取当前用户信息失败", e); + } + + log.info("=== 开始处理头像上传 ==="); + log.info("当前用户: {}", currentUserInfo); + log.info("原始文件名: {}", file.getOriginalFilename()); + log.info("文件大小: {} bytes ({} KB)", file.getSize(), file.getSize() / 1024); + log.info("文件类型: {}", file.getContentType()); + log.info("文件是否为空: {}", file.isEmpty()); + + try { + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String suffix = ""; + if (originalFilename != null && originalFilename.contains(".")) { + suffix = originalFilename.substring(originalFilename.lastIndexOf(".")); + } else { + // 默认使用jpg + suffix = ".jpg"; + } + String fileName = "avatar/" + UUID.randomUUID().toString().replace("-", "") + suffix; + log.info("生成的文件名: {}", fileName); + + // 设置文件元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + log.info("文件元数据设置完成 - 长度: {}, 类型: {}", file.getSize(), file.getContentType()); + + // 上传到COS + log.info("开始上传到COS,bucket: {}", cosConfig.getBucketName()); + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + file.getInputStream(), + metadata + ); + cosClient.putObject(putObjectRequest); + log.info("COS上传完成"); + + // 返回文件访问URL + String fileUrl = cosConfig.getFileUrl(fileName); + log.info("=== 头像上传成功 ==="); + log.info("用户: {}", currentUserInfo); + log.info("最终访问URL: {}", fileUrl); + return fileUrl; + } catch (IOException e) { + log.error("=== 头像上传失败 - IO异常 ==="); + log.error("用户: {}", currentUserInfo); + log.error("异常详情:", e); + throw new RuntimeException("头像上传失败: IO异常 - " + e.getMessage()); + } catch (Exception e) { + log.error("=== 头像上传失败 - 其他异常 ==="); + log.error("用户: {}", currentUserInfo); + log.error("异常详情:", e); + throw new RuntimeException("头像上传失败: " + e.getMessage()); + } + } + + private String getOpenidFromWx(String code) { + try { + String url = String.format( + "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", + appId, appSecret, code); + + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject(url, String.class); + log.info("微信登录响应: {}", response); + + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("openid")) { + return jsonNode.get("openid").asText(); + } + log.error("微信登录失败: {}", response); + return null; + } catch (Exception e) { + log.error("调用微信接口失败", e); + return null; + } + } + + private String generateInviteCode() { + return UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + } + + /** + * 发放新用户注册奖励积分 + * @return 奖励积分数,如果未发放则返回null + */ + private Integer grantRegisterReward(User user) { + // 获取注册奖励配置 (type=1) + PromotionConfig config = promotionConfigMapper.selectOne( + new LambdaQueryWrapper() + .eq(PromotionConfig::getType, 1) + .eq(PromotionConfig::getStatus, 1) + ); + + if (config == null || config.getRewardPoints() == null || config.getRewardPoints() <= 0) { + log.info("注册奖励未配置或积分为0,跳过发放"); + return null; + } + + int rewardPoints = config.getRewardPoints(); + + // 更新用户积分 + user.setPoints(user.getPoints() + rewardPoints); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + + // 记录积分流水 (type=3 赠送) + PointsRecord record = new PointsRecord(); + record.setUserId(user.getId()); + record.setType(3); + record.setPoints(rewardPoints); + record.setBalance(user.getPoints()); + record.setBizType("register_reward"); + record.setRemark("新用户注册奖励"); + record.setCreatedAt(LocalDateTime.now()); + pointsRecordMapper.insert(record); + + log.info("发放注册奖励: userId={}, points={}", user.getId(), rewardPoints); + return rewardPoints; + } + + /** + * 发放推广人邀请奖励积分 + */ + private void grantInviterReward(User newUser) { + // 获取邀请奖励配置 (type=3) + PromotionConfig config = promotionConfigMapper.selectOne( + new LambdaQueryWrapper() + .eq(PromotionConfig::getType, 3) + .eq(PromotionConfig::getStatus, 1) + ); + + if (config == null) { + log.warn("邀请奖励配置不存在 (type=3),跳过发放"); + return; + } + + log.info("邀请奖励配置: id={}, rewardPoints={}, status={}", + config.getId(), config.getRewardPoints(), config.getStatus()); + + if (config.getRewardPoints() == null) { + log.warn("邀请奖励积分为NULL,使用默认值500积分"); + // 使用默认值并更新数据库 + config.setRewardPoints(500); + config.setUpdatedAt(LocalDateTime.now()); + promotionConfigMapper.updateById(config); + log.info("已更新邀请奖励配置,设置默认积分: 500"); + } + + if (config.getRewardPoints() <= 0) { + log.warn("邀请奖励积分为0或负数: {},跳过发放", config.getRewardPoints()); + return; + } + + int rewardPoints = config.getRewardPoints(); + Long inviterId = newUser.getInviterId(); + + // 获取邀请人信息 + User inviter = userMapper.selectById(inviterId); + if (inviter == null || inviter.getDeleted() == 1) { + log.warn("邀请人不存在: inviterId={}", inviterId); + return; + } + + log.info("准备发放邀请奖励: inviterId={}, inviterNickname={}, inviteeId={}, inviteeNickname={}, rewardPoints={}", + inviterId, inviter.getNickname(), newUser.getId(), newUser.getNickname(), rewardPoints); + + // 更新邀请人积分 + int oldPoints = inviter.getPoints(); + inviter.setPoints(inviter.getPoints() + rewardPoints); + inviter.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(inviter); + + log.info("更新邀请人积分: inviterId={}, oldPoints={}, newPoints={}", + inviterId, oldPoints, inviter.getPoints()); + + // 记录积分流水 (type=4 推广奖励) + PointsRecord record = new PointsRecord(); + record.setUserId(inviterId); + record.setType(4); + record.setPoints(rewardPoints); + record.setBalance(inviter.getPoints()); + record.setBizType("invite_reward"); + record.setBizId(newUser.getId()); + record.setRemark("邀请好友注册奖励"); + record.setCreatedAt(LocalDateTime.now()); + pointsRecordMapper.insert(record); + + log.info("插入积分记录: recordId={}, userId={}, points={}, balance={}", + record.getId(), inviterId, rewardPoints, inviter.getPoints()); + + // 记录推广记录 + PromotionRecord promotionRecord = new PromotionRecord(); + promotionRecord.setInviterId(inviterId); + promotionRecord.setInviteeId(newUser.getId()); + promotionRecord.setRewardType(1); // 1=注册奖励 + promotionRecord.setRewardPoints(rewardPoints); + promotionRecord.setRewardStatus(1); // 1=已发放 + promotionRecord.setRewardTime(LocalDateTime.now()); + promotionRecord.setCreatedAt(LocalDateTime.now()); + promotionRecordMapper.insert(promotionRecord); + + log.info("发放邀请奖励成功: promotionRecordId={}, inviterId={}, inviteeId={}, points={}", + promotionRecord.getId(), inviterId, newUser.getId(), rewardPoints); + } + + /** + * 获取微信 access_token + */ + private String getAccessToken() { + try { + String url = String.format( + "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", + appId, appSecret); + + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject(url, String.class); + log.info("获取access_token响应: {}", response); + + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("access_token")) { + return jsonNode.get("access_token").asText(); + } + log.error("获取access_token失败: {}", response); + return null; + } catch (Exception e) { + log.error("获取access_token异常", e); + return null; + } + } + + /** + * 通过 phoneCode 获取用户手机号 + */ + private String getPhoneFromWx(String phoneCode) { + try { + String accessToken = getAccessToken(); + if (accessToken == null) { + log.error("获取access_token失败,无法获取手机号"); + return null; + } + + String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken; + + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String requestBody = "{\"code\":\"" + phoneCode + "\"}"; + HttpEntity entity = new HttpEntity<>(requestBody, headers); + + String response = restTemplate.postForObject(url, entity, String.class); + log.info("获取手机号响应: {}", response); + + JsonNode jsonNode = objectMapper.readTree(response); + if (jsonNode.has("phone_info")) { + JsonNode phoneInfo = jsonNode.get("phone_info"); + if (phoneInfo.has("phoneNumber")) { + return phoneInfo.get("phoneNumber").asText(); + } + } + log.error("获取手机号失败: {}", response); + return null; + } catch (Exception e) { + log.error("获取手机号异常", e); + return null; + } + } + + /** + * 手机号脱敏 + */ + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + /** + * 判断字符串是否有值 + */ + private boolean hasValue(String str) { + return str != null && !str.trim().isEmpty(); + } + + private LoginVO buildLoginVO(User user, String accessToken, String refreshToken) { + return buildLoginVO(user, accessToken, refreshToken, false, null); + } + + private LoginVO buildLoginVO(User user, String accessToken, String refreshToken, + boolean isNewUser, Integer registerRewardPoints) { + LoginVO vo = new LoginVO(); + vo.setUserId(user.getId()); + vo.setToken(accessToken); + vo.setRefreshToken(refreshToken); + vo.setExpiresIn(jwtUtil.getAccessTokenExpire()); + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + vo.setPhone(maskPhone(user.getPhone())); + vo.setVipLevel(user.getVipLevel()); + vo.setPoints(user.getPoints()); + vo.setInviteCode(user.getInviteCode()); + + // 新用户注册且有奖励积分时,显示奖励弹窗 + vo.setShowRegisterReward(isNewUser && registerRewardPoints != null && registerRewardPoints > 0); + vo.setRegisterRewardPoints(registerRewardPoints); + + return vo; + } + + @Override + public UserProfileVO getUserProfile(Long userId) { + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + return null; + } + + UserProfileVO vo = new UserProfileVO(); + vo.setUserId(user.getId()); + vo.setNickname(user.getNickname()); + vo.setAvatar(user.getAvatar()); + vo.setInviteCode(user.getInviteCode()); + vo.setVipLevel(user.getVipLevel()); + vo.setPoints(user.getPoints()); + + // 统计发布作品数 + vo.setPublishCount(aiWorkMapper.countByUserId(userId)); + // 统计获赞数 + vo.setLikedCount(aiWorkMapper.sumLikeCountByUserId(userId)); + + return vo; + } + + @Override + public void updateProfile(Long userId, String nickname, String avatar) { + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + throw new RuntimeException("用户不存在"); + } + + if (hasValue(nickname)) { + user.setNickname(nickname); + } + if (hasValue(avatar)) { + // 如果新头像和旧头像不同,且旧头像是COS上的文件,则删除旧头像 + String oldAvatar = user.getAvatar(); + if (hasValue(oldAvatar) && !oldAvatar.equals(avatar) && isCosUrl(oldAvatar)) { + deleteFromCos(oldAvatar); + } + user.setAvatar(avatar); + } + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + } + + /** + * 判断URL是否是COS上的文件 + */ + private boolean isCosUrl(String url) { + if (url == null) return false; + // 检查是否是本项目COS的URL + String cosHost = String.format("%s.cos.%s.myqcloud.com", cosConfig.getBucketName(), cosConfig.getRegion()); + return url.contains(cosHost) || + (cosConfig.getCustomDomain() != null && !cosConfig.getCustomDomain().isEmpty() && url.contains(cosConfig.getCustomDomain())); + } + + /** + * 从COS删除文件 + */ + private void deleteFromCos(String fileUrl) { + try { + // 从URL中提取文件key + String key = extractKeyFromUrl(fileUrl); + if (key != null) { + cosClient.deleteObject(cosConfig.getBucketName(), key); + log.info("已删除COS文件: {}", key); + } + } catch (Exception e) { + log.warn("删除COS文件失败: {}, 错误: {}", fileUrl, e.getMessage()); + // 删除失败不影响主流程 + } + } + + /** + * 从URL中提取COS文件key + */ + private String extractKeyFromUrl(String url) { + if (url == null) return null; + try { + // 处理标准COS URL: https://bucket.cos.region.myqcloud.com/key + String cosHost = String.format("%s.cos.%s.myqcloud.com/", cosConfig.getBucketName(), cosConfig.getRegion()); + if (url.contains(cosHost)) { + return url.substring(url.indexOf(cosHost) + cosHost.length()); + } + // 处理自定义域名 + if (cosConfig.getCustomDomain() != null && !cosConfig.getCustomDomain().isEmpty() && url.contains(cosConfig.getCustomDomain())) { + return url.substring(url.indexOf(cosConfig.getCustomDomain()) + cosConfig.getCustomDomain().length() + 1); + } + } catch (Exception e) { + log.warn("解析COS URL失败: {}", url); + } + return null; + } + + @Override + public boolean isSubscribed(Long userId) { + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + return false; + } + return user.getSubscribed() != null && user.getSubscribed() == 1; + } + + @Override + public void updateSubscribed(Long userId, boolean subscribed) { + User user = userMapper.selectById(userId); + if (user == null || user.getDeleted() == 1) { + log.warn("更新订阅状态时用户不存在: userId={}", userId); + return; + } + user.setSubscribed(subscribed ? 1 : 0); + user.setUpdatedAt(LocalDateTime.now()); + userMapper.updateById(user); + log.info("更新用户订阅状态: userId={}, subscribed={}", userId, subscribed); + } + + @Override + public InviteStatsVO getInviteStats(Long userId) { + InviteStatsVO stats = new InviteStatsVO(); + + // 统计累计邀请人数 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PromotionRecord::getInviterId, userId); + Long inviteCount = promotionRecordMapper.selectCount(wrapper); + stats.setInviteCount(inviteCount.intValue()); + + // 统计累计获得积分(推广奖励类型) + LambdaQueryWrapper pointsWrapper = new LambdaQueryWrapper<>(); + pointsWrapper.eq(PointsRecord::getUserId, userId) + .eq(PointsRecord::getType, 4); // 4=推广奖励 + List records = pointsRecordMapper.selectList(pointsWrapper); + Integer totalPoints = records.stream() + .mapToInt(PointsRecord::getPoints) + .sum(); + stats.setTotalPoints(totalPoints); + + return stats; + } + + @Override + public PageVO getInviteRecords(Long userId, Integer pageNum, Integer pageSize) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PromotionRecord::getInviterId, userId) + .orderByDesc(PromotionRecord::getCreatedAt); + + Page recordPage = promotionRecordMapper.selectPage(page, wrapper); + + List voList = recordPage.getRecords().stream().map(record -> { + InviteRecordVO vo = new InviteRecordVO(); + vo.setId(record.getId()); + vo.setRewardPoints(record.getRewardPoints()); + vo.setCreatedAt(record.getCreatedAt()); + + // 获取被邀请用户信息 + User invitee = userMapper.selectById(record.getInviteeId()); + if (invitee != null) { + vo.setNickname(invitee.getNickname()); + vo.setAvatar(invitee.getAvatar()); + } else { + vo.setNickname("用户昵称"); + vo.setAvatar(""); + } + + return vo; + }).collect(Collectors.toList()); + + return PageVO.of(voList, recordPage.getTotal(), pageNum, pageSize); + } + + @Override + public PointsStatsVO getPointsStats(Long userId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsRecord::getUserId, userId); + wrapper.gt(PointsRecord::getPoints, 0); // 只统计增加的积分 + + List records = pointsRecordMapper.selectList(wrapper); + + // 订阅积分:type=1(充值) + int subscribePoints = records.stream() + .filter(r -> r.getType() == 1) + .mapToInt(PointsRecord::getPoints) + .sum(); + + // 赠送积分:type=3(赠送)、4(推广奖励)、5(签到)、6(退款) + int giftPoints = records.stream() + .filter(r -> r.getType() == 3 || r.getType() == 4 || r.getType() == 5 || r.getType() == 6) + .mapToInt(PointsRecord::getPoints) + .sum(); + + return new PointsStatsVO(subscribePoints, giftPoints); + } + + @Override + public PageVO getPointsRecords(Long userId, Integer pageNum, Integer pageSize, Integer type) { + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(PointsRecord::getUserId, userId); + + // 如果指定了类型,则筛选 + if (type != null) { + wrapper.eq(PointsRecord::getType, type); + } + + wrapper.orderByDesc(PointsRecord::getCreatedAt); + + Page recordPage = pointsRecordMapper.selectPage(page, wrapper); + + List voList = recordPage.getRecords().stream().map(record -> { + PointsRecordVO vo = new PointsRecordVO(); + vo.setId(record.getId()); + vo.setType(record.getType()); + vo.setTypeName(getPointsTypeName(record.getType())); + vo.setPoints(record.getPoints()); + vo.setBalance(record.getBalance()); + vo.setRemark(record.getRemark()); + vo.setCreatedAt(record.getCreatedAt()); + return vo; + }).collect(Collectors.toList()); + + return PageVO.of(voList, recordPage.getTotal(), pageNum, pageSize); + } + + /** + * 获取积分类型名称 + */ + private String getPointsTypeName(Integer type) { + if (type == null) return "未知"; + switch (type) { + case 1: return "充值"; + case 2: return "消费"; + case 3: return "赠送"; + case 4: return "推广奖励"; + case 5: return "签到"; + case 6: return "退款"; + default: return "未知"; + } + } + + /** + * 判断是否是微信头像URL + */ + private boolean isWechatAvatarUrl(String url) { + if (url == null) return false; + return url.contains("mmbiz.qpic.cn") || + url.contains("thirdwx.qlogo.cn") || + url.contains("wx.qlogo.cn"); + } + + /** + * 下载微信头像并上传到COS + */ + private String downloadAndUploadWechatAvatar(String wechatAvatarUrl) { + try { + // 下载微信头像 + RestTemplate restTemplate = new RestTemplate(); + byte[] imageBytes = restTemplate.getForObject(wechatAvatarUrl, byte[].class); + + if (imageBytes == null || imageBytes.length == 0) { + throw new RuntimeException("下载微信头像失败"); + } + + // 生成唯一文件名 + String fileName = "avatar/" + UUID.randomUUID().toString().replace("-", "") + ".jpg"; + + // 设置文件元数据 + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(imageBytes.length); + metadata.setContentType("image/jpeg"); + + // 上传到COS + PutObjectRequest putObjectRequest = new PutObjectRequest( + cosConfig.getBucketName(), + fileName, + new java.io.ByteArrayInputStream(imageBytes), + metadata + ); + cosClient.putObject(putObjectRequest); + + // 返回文件访问URL + String fileUrl = cosConfig.getFileUrl(fileName); + log.info("微信头像转存成功: {} -> {}", wechatAvatarUrl, fileUrl); + return fileUrl; + } catch (Exception e) { + log.error("下载并上传微信头像失败: {}", wechatAvatarUrl, e); + throw new RuntimeException("头像转存失败", e); + } + } +} diff --git a/src/main/java/com/dora/service/impl/VideoProjectServiceImpl.java b/src/main/java/com/dora/service/impl/VideoProjectServiceImpl.java new file mode 100644 index 0000000..8cd34fa --- /dev/null +++ b/src/main/java/com/dora/service/impl/VideoProjectServiceImpl.java @@ -0,0 +1,1907 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.dora.common.exception.BusinessException; +import com.dora.dto.AiTaskDTO; +import com.dora.dto.video.*; +import com.dora.entity.*; +import com.dora.mapper.*; +import com.dora.service.*; +import com.dora.vo.*; +import com.dora.config.CosConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.io.*; +import java.net.URL; +import java.nio.file.*; +import java.util.*; +import java.util.regex.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class VideoProjectServiceImpl implements VideoProjectService { + + private final VideoProjectMapper projectMapper; + private final ProjectCharacterMapper characterMapper; + private final ProjectSceneMapper sceneMapper; + private final SceneStoryboardMapper storyboardMapper; + private final CharacterTemplateMapper templateMapper; + private final AiPromptTemplateMapper promptTemplateMapper; + private final AiModelMapper aiModelMapper; + private final AiTaskMapper aiTaskMapper; + private final AiProviderMapper aiProviderMapper; + private final UserMapper userMapper; + private final PointsService pointsService; + private final AiTaskService aiTaskService; + private final AiModelService aiModelService; + private final ImageProcessingService imageProcessingService; + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + private final CosConfig cosConfig; + private final COSClient cosClient; + + private static final String MODEL_SCRIPT_GEN = "video-script-gen"; + private static final String MODEL_STORYBOARD_GEN = "video-storyboard-gen"; + private static final String MODEL_IMAGE_GEN = "video-image-gen"; + private static final String MODEL_VIDEO_GEN = "grok-video"; // Grok视频生成模型 + + @Override + public Long createProject(Long userId) { + VideoProject project = new VideoProject(); + project.setUserId(userId); + project.setStatus(0); + project.setCurrentStep(0); + projectMapper.insert(project); + return project.getId(); + } + + @Override + public VideoProjectVO getProjectById(Long projectId) { + VideoProject project = projectMapper.selectById(projectId); + if (project == null) { + throw new BusinessException("项目不存在"); + } + + VideoProjectVO vo = new VideoProjectVO(); + BeanUtils.copyProperties(project, vo); + + // 加载角色 + vo.setCharacters(getProjectCharacters(projectId)); + // 加载场次 + vo.setScenes(getProjectScenes(projectId)); + + return vo; + } + + @Override + public IPage getProjectList(Page page, Long userId, Integer status) { + return projectMapper.selectProjectPage(page, userId, status); + } + + @Override + public void updateProjectSettings(Long projectId, VideoProjectDTO dto) { + VideoProject project = projectMapper.selectById(projectId); + if (project == null) { + throw new BusinessException("项目不存在"); + } + + if (dto.getProjectName() != null) project.setProjectName(dto.getProjectName()); + if (dto.getStoryTitle() != null) project.setStoryTitle(dto.getStoryTitle()); + if (dto.getStoryOutline() != null) project.setStoryOutline(dto.getStoryOutline()); + if (dto.getOriginalIdea() != null) project.setOriginalIdea(dto.getOriginalIdea()); + if (dto.getCreationMode() != null) project.setCreationMode(dto.getCreationMode()); + if (dto.getVideoDuration() != null) project.setVideoDuration(dto.getVideoDuration()); + if (dto.getVideoRatio() != null) project.setVideoRatio(dto.getVideoRatio()); + if (dto.getVideoStyle() != null) project.setVideoStyle(dto.getVideoStyle()); + if (dto.getCoverUrl() != null) project.setCoverUrl(dto.getCoverUrl()); + + if (project.getCurrentStep() < 1) { + project.setCurrentStep(1); + } + + projectMapper.updateById(project); + } + + @Override + @Transactional + public void deleteProject(Long userId, Long projectId) { + VideoProject project = projectMapper.selectById(projectId); + if (project == null || !project.getUserId().equals(userId)) { + throw new BusinessException("项目不存在或无权限"); + } + + // 删除关联数据 + storyboardMapper.deleteByProjectId(projectId); + sceneMapper.deleteByProjectId(projectId); + characterMapper.deleteByProjectId(projectId); + projectMapper.deleteById(projectId); + } + + @Override + @Transactional + public ScriptGenerationResult generateScript(Long projectId, String idea, Long userId) { + VideoProject project = projectMapper.selectById(projectId); + if (project == null) { + throw new BusinessException("项目不存在"); + } + + // 获取AI模型配置 + AiModel model = getModelByCode(MODEL_SCRIPT_GEN); + + // 检查并扣费 + checkAndDeductPoints(userId, model); + + // 获取提示词模板 + AiPromptTemplate template = promptTemplateMapper.selectByCode("SCRIPT_GENERATION"); + if (template == null) { + throw new BusinessException("提示词模板不存在"); + } + + // 构建提示词 + String userPrompt = template.getUserPromptTemplate().replace("{{userIdea}}", idea); + + // 调用AI + String aiResult = callSiliconFlowChat(model, template.getSystemPrompt(), userPrompt); + + // 解析结果 + ScriptGenerationResult result = parseJsonResult(aiResult, ScriptGenerationResult.class); + + // 保存到项目 + project.setStoryTitle(result.getTitle()); + project.setStoryOutline(result.getContent()); + project.setOriginalIdea(idea); + project.setCurrentStep(1); + if (result.getTitle() != null && project.getProjectName() == null) { + project.setProjectName(result.getTitle()); + } + projectMapper.updateById(project); + + // 清除旧数据 + characterMapper.deleteByProjectId(projectId); + sceneMapper.deleteByProjectId(projectId); + + // 保存角色 + if (result.getCharacters() != null) { + int sort = 0; + for (ScriptGenerationResult.CharacterInfo c : result.getCharacters()) { + ProjectCharacter character = new ProjectCharacter(); + character.setProjectId(projectId); + character.setName(c.getName()); + character.setDescription(c.getDescription()); + character.setVoiceType(c.getVoiceType()); + character.setAge(c.getAge()); + character.setGender(c.getGender()); + character.setAppearance(c.getAppearance()); + character.setClothing(c.getClothing()); + character.setSort(sort++); + characterMapper.insert(character); + } + } + + // 保存场次 + if (result.getScenes() != null) { + int sort = 0; + for (ScriptGenerationResult.SceneInfo s : result.getScenes()) { + ProjectScene scene = new ProjectScene(); + scene.setProjectId(projectId); + scene.setSceneName(s.getName()); + scene.setSceneTitle(s.getTitle()); + scene.setSceneDescription(s.getDescription()); + scene.setSceneStory(s.getStory()); + scene.setStoryboardCount(9); + scene.setSort(sort++); + sceneMapper.insert(scene); + } + } + + return result; + } + + @Override + public List getProjectCharacters(Long projectId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectCharacter::getProjectId, projectId) + .orderByAsc(ProjectCharacter::getSort); + + return characterMapper.selectList(wrapper).stream().map(c -> { + ProjectCharacterVO vo = new ProjectCharacterVO(); + BeanUtils.copyProperties(c, vo); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public List getUserCharacterLibrary(Long userId) { + // 查询用户所有项目中已生成形象的角色(有imageUrl的角色) + List projects = projectMapper.selectList( + new LambdaQueryWrapper() + .eq(VideoProject::getUserId, userId) + .eq(VideoProject::getDeleted, 0) + ); + + if (projects.isEmpty()) { + return new java.util.ArrayList<>(); + } + + List projectIds = projects.stream() + .map(VideoProject::getId) + .collect(Collectors.toList()); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(ProjectCharacter::getProjectId, projectIds) + .isNotNull(ProjectCharacter::getImageUrl) + .ne(ProjectCharacter::getImageUrl, "") + .orderByDesc(ProjectCharacter::getUpdatedAt); + + return characterMapper.selectList(wrapper).stream().map(c -> { + ProjectCharacterVO vo = new ProjectCharacterVO(); + BeanUtils.copyProperties(c, vo); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public Long saveCharacter(Long projectId, ProjectCharacterDTO dto) { + ProjectCharacter character; + if (dto.getId() != null) { + character = characterMapper.selectById(dto.getId()); + if (character == null || !character.getProjectId().equals(projectId)) { + throw new BusinessException("角色不存在"); + } + } else { + character = new ProjectCharacter(); + character.setProjectId(projectId); + + // 获取最大sort + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectCharacter::getProjectId, projectId) + .orderByDesc(ProjectCharacter::getSort) + .last("LIMIT 1"); + ProjectCharacter last = characterMapper.selectOne(wrapper); + character.setSort(last != null ? last.getSort() + 1 : 0); + } + + if (dto.getName() != null) character.setName(dto.getName()); + if (dto.getAge() != null) character.setAge(dto.getAge()); + if (dto.getGender() != null) character.setGender(dto.getGender()); + if (dto.getVoiceType() != null) character.setVoiceType(dto.getVoiceType()); + if (dto.getAppearance() != null) character.setAppearance(dto.getAppearance()); + if (dto.getClothing() != null) character.setClothing(dto.getClothing()); + if (dto.getDescription() != null) character.setDescription(dto.getDescription()); + if (dto.getImageUrl() != null) character.setImageUrl(dto.getImageUrl()); + if (dto.getReferenceImageUrl() != null) character.setReferenceImageUrl(dto.getReferenceImageUrl()); + if (dto.getTemplateId() != null) character.setTemplateId(dto.getTemplateId()); + + if (dto.getId() != null) { + characterMapper.updateById(character); + } else { + characterMapper.insert(character); + } + + return character.getId(); + } + + @Override + public void deleteCharacter(Long projectId, Long characterId) { + ProjectCharacter character = characterMapper.selectById(characterId); + if (character == null || !character.getProjectId().equals(projectId)) { + throw new BusinessException("角色不存在"); + } + characterMapper.deleteById(characterId); + } + + @Override + public String generateCharacterImage(Long projectId, Long characterId, Long userId) { + ProjectCharacter character = characterMapper.selectById(characterId); + if (character == null || !character.getProjectId().equals(projectId)) { + throw new BusinessException("角色不存在"); + } + + // 获取项目的视频风格 + VideoProject project = projectMapper.selectById(projectId); + String videoStyle = (project != null && project.getVideoStyle() != null) + ? project.getVideoStyle() : "动漫风格"; + + AiModelVO model = aiModelService.getModelByCode(MODEL_IMAGE_GEN); + + // 构建角色形象描述提示词 - 全身站立,白色背景,使用项目视频风格 + StringBuilder prompt = new StringBuilder(); + // 强调白色背景和角色设定 + prompt.append("single character design sheet, full body standing pose, pure white background (#FFFFFF), front view, "); + prompt.append(videoStyle).append(" style, "); + if (character.getGender() != null && !character.getGender().isEmpty()) { + prompt.append(character.getGender()).append(", "); + } + if (character.getAge() != null && !character.getAge().isEmpty()) { + prompt.append(character.getAge()).append(", "); + } + if (character.getAppearance() != null && !character.getAppearance().isEmpty()) { + prompt.append("appearance: ").append(character.getAppearance()).append(", "); + } + if (character.getClothing() != null && !character.getClothing().isEmpty()) { + prompt.append("outfit: ").append(character.getClothing()).append(", "); + } + if (character.getDescription() != null && !character.getDescription().isEmpty()) { + prompt.append("character traits: ").append(character.getDescription()).append(", "); + } + prompt.append("full body from head to toe, no cropping, isolated on white, high quality, detailed"); + + // 创建异步任务 + try { + Map inputParams = new HashMap<>(); + inputParams.put("prompt", prompt.toString()); + inputParams.put("targetType", "character"); + inputParams.put("targetId", characterId); + + com.dora.dto.AiTaskDTO taskDTO = new com.dora.dto.AiTaskDTO(); + taskDTO.setModelId(model.getId()); + taskDTO.setInputParams(objectMapper.writeValueAsString(inputParams)); + taskDTO.setPriority(5); + + String taskNo = aiTaskService.createTask(userId, taskDTO); + + // 更新角色状态为生成中,并记录任务编号 + character.setImageStatus(1); // 1-生成中 + character.setCurrentTaskNo(taskNo); + characterMapper.updateById(character); + + return taskNo; + } catch (Exception e) { + throw new BusinessException("创建图像生成任务失败: " + e.getMessage()); + } + } + + @Override + public List getCharacterTemplates(String category) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(CharacterTemplate::getStatus, 1) + .eq(CharacterTemplate::getDeleted, 0); + if (category != null && !category.isEmpty()) { + wrapper.eq(CharacterTemplate::getCategory, category); + } + wrapper.orderByAsc(CharacterTemplate::getSort); + + return templateMapper.selectList(wrapper).stream().map(t -> { + CharacterTemplateVO vo = new CharacterTemplateVO(); + BeanUtils.copyProperties(t, vo); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public List getProjectScenes(Long projectId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectScene::getProjectId, projectId) + .orderByAsc(ProjectScene::getSort); + + return sceneMapper.selectList(wrapper).stream().map(s -> { + ProjectSceneVO vo = new ProjectSceneVO(); + BeanUtils.copyProperties(s, vo); + vo.setStoryboards(getSceneStoryboards(s.getId())); + return vo; + }).collect(Collectors.toList()); + } + + @Override + public Long saveScene(Long projectId, ProjectSceneDTO dto) { + ProjectScene scene; + if (dto.getId() != null) { + scene = sceneMapper.selectById(dto.getId()); + if (scene == null || !scene.getProjectId().equals(projectId)) { + throw new BusinessException("场次不存在"); + } + } else { + scene = new ProjectScene(); + scene.setProjectId(projectId); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectScene::getProjectId, projectId) + .orderByDesc(ProjectScene::getSort) + .last("LIMIT 1"); + ProjectScene last = sceneMapper.selectOne(wrapper); + scene.setSort(last != null ? last.getSort() + 1 : 0); + scene.setSceneName("第" + (scene.getSort() + 1) + "场"); + } + + if (dto.getSceneName() != null) scene.setSceneName(dto.getSceneName()); + if (dto.getSceneTitle() != null) scene.setSceneTitle(dto.getSceneTitle()); + if (dto.getSceneDescription() != null) scene.setSceneDescription(dto.getSceneDescription()); + if (dto.getSceneStory() != null) scene.setSceneStory(dto.getSceneStory()); + if (dto.getStoryboardCount() != null) { + int count = Math.min(dto.getStoryboardCount(), 9); + scene.setStoryboardCount(Math.max(count, 1)); + } + + if (dto.getId() != null) { + sceneMapper.updateById(scene); + } else { + sceneMapper.insert(scene); + } + + return scene.getId(); + } + + @Override + @Transactional + public void deleteScene(Long projectId, Long sceneId) { + ProjectScene scene = sceneMapper.selectById(sceneId); + if (scene == null || !scene.getProjectId().equals(projectId)) { + throw new BusinessException("场次不存在"); + } + storyboardMapper.deleteBySceneId(sceneId); + sceneMapper.deleteById(sceneId); + } + + @Override + public List getSceneStoryboards(Long sceneId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SceneStoryboard::getSceneId, sceneId) + .orderByAsc(SceneStoryboard::getStoryboardIndex); + + List list = storyboardMapper.selectList(wrapper); + + // 获取角色名称和头像映射 + if (!list.isEmpty()) { + Long projectId = list.get(0).getProjectId(); + List characters = getProjectCharacters(projectId); + Map characterNames = characters.stream() + .collect(Collectors.toMap(ProjectCharacterVO::getId, ProjectCharacterVO::getName, (a, b) -> a)); + Map characterAvatars = characters.stream() + .filter(c -> c.getImageUrl() != null) + .collect(Collectors.toMap(ProjectCharacterVO::getId, ProjectCharacterVO::getImageUrl, (a, b) -> a)); + + return list.stream().map(sb -> { + SceneStoryboardVO vo = new SceneStoryboardVO(); + BeanUtils.copyProperties(sb, vo); + if (sb.getDialogueCharacterId() != null) { + vo.setDialogueCharacterName(characterNames.get(sb.getDialogueCharacterId())); + } + // 解析 dialogue 字段中的多角色对话 JSON + vo.setDialogues(parseDialogues(sb.getDialogue(), characterNames, characterAvatars)); + return vo; + }).collect(Collectors.toList()); + } + + return Collections.emptyList(); + } + + private List parseDialogues(String dialogue, Map characterNames, Map characterAvatars) { + if (dialogue == null || dialogue.isEmpty()) { + return new ArrayList<>(); + } + + // 尝试解析为 JSON 数组格式 + if (dialogue.trim().startsWith("[")) { + try { + List items = objectMapper.readValue(dialogue, + objectMapper.getTypeFactory().constructCollectionType(List.class, DialogueItem.class)); + // 补充角色头像信息 + for (DialogueItem item : items) { + if (item.getCharacterId() != null) { + if (item.getCharacterName() == null) { + item.setCharacterName(characterNames.get(item.getCharacterId())); + } + if (item.getCharacterAvatar() == null) { + item.setCharacterAvatar(characterAvatars.get(item.getCharacterId())); + } + } + } + return items; + } catch (Exception e) { + log.warn("解析对话JSON失败: {}", e.getMessage()); + } + } + + // 兼容旧格式:单条对话文本 + List result = new ArrayList<>(); + DialogueItem item = new DialogueItem(); + item.setContent(dialogue); + result.add(item); + return result; + } + + @Override + @Transactional + public StoryboardGenerationResult generateStoryboards(Long projectId, Long sceneId, Long userId) { + ProjectScene scene = sceneMapper.selectById(sceneId); + if (scene == null || !scene.getProjectId().equals(projectId)) { + throw new BusinessException("场次不存在"); + } + + AiModel model = getModelByCode(MODEL_STORYBOARD_GEN); + checkAndDeductPoints(userId, model); + + // 获取角色列表 + List characters = getProjectCharacters(projectId); + StringBuilder characterList = new StringBuilder(); + for (ProjectCharacterVO c : characters) { + characterList.append("- ").append(c.getName()) + .append(": ").append(c.getDescription() != null ? c.getDescription() : "").append("\n"); + } + + // 获取前一场次的故事内容,用于保持剧情连贯性 + String previousSceneContext = getPreviousSceneContext(projectId, scene.getSort()); + + AiPromptTemplate template = promptTemplateMapper.selectByCode("STORYBOARD_GENERATION"); + if (template == null) { + throw new BusinessException("提示词模板不存在"); + } + + String userPrompt = template.getUserPromptTemplate() + .replace("{{storyboardCount}}", String.valueOf(scene.getStoryboardCount())) + .replace("{{sceneTitle}}", nullToEmpty(scene.getSceneTitle())) + .replace("{{sceneDescription}}", nullToEmpty(scene.getSceneDescription())) + .replace("{{sceneStory}}", nullToEmpty(scene.getSceneStory())) + .replace("{{characterList}}", characterList.toString()) + .replace("{{previousSceneContext}}", previousSceneContext); + + String aiResult = callSiliconFlowChat(model, template.getSystemPrompt(), userPrompt); + StoryboardGenerationResult result = parseJsonResult(aiResult, StoryboardGenerationResult.class); + + // 删除旧分镜 + storyboardMapper.deleteBySceneId(sceneId); + + // 保存新分镜 + Map characterNameToId = characters.stream() + .collect(Collectors.toMap(ProjectCharacterVO::getName, ProjectCharacterVO::getId, (a, b) -> a)); + + if (result.getStoryboards() != null) { + for (StoryboardGenerationResult.StoryboardInfo sb : result.getStoryboards()) { + SceneStoryboard entity = new SceneStoryboard(); + entity.setSceneId(sceneId); + entity.setProjectId(projectId); + entity.setStoryboardIndex(sb.getIndex()); + entity.setShotType(sb.getShotType()); + entity.setCameraAngle(sb.getCameraAngle()); + entity.setCameraMove(sb.getCameraMove()); + entity.setDescription(sb.getDescription()); + entity.setNarration(sb.getNarration()); + entity.setDialogue(sb.getDialogue()); + entity.setDuration(sb.getDuration() != null ? sb.getDuration() : 5); + entity.setSort(sb.getIndex()); + + if (sb.getDialogueCharacter() != null && !sb.getDialogueCharacter().isEmpty()) { + Long charId = characterNameToId.get(sb.getDialogueCharacter()); + if (charId != null) { + entity.setDialogueCharacterId(charId); + } + } + + storyboardMapper.insert(entity); + } + } + + // 更新项目步骤 + VideoProject project = projectMapper.selectById(projectId); + if (project.getCurrentStep() < 3) { + project.setCurrentStep(3); + projectMapper.updateById(project); + } + + return result; + } + + @Override + public void updateStoryboard(Long projectId, Long storyboardId, SceneStoryboardDTO dto) { + SceneStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null || !storyboard.getProjectId().equals(projectId)) { + throw new BusinessException("分镜不存在"); + } + + if (dto.getShotType() != null) storyboard.setShotType(dto.getShotType()); + if (dto.getCameraAngle() != null) storyboard.setCameraAngle(dto.getCameraAngle()); + if (dto.getCameraMove() != null) storyboard.setCameraMove(dto.getCameraMove()); + if (dto.getDescription() != null) storyboard.setDescription(dto.getDescription()); + if (dto.getNarration() != null) storyboard.setNarration(dto.getNarration()); + if (dto.getDialogue() != null) storyboard.setDialogue(dto.getDialogue()); + if (dto.getDialogueCharacterId() != null) storyboard.setDialogueCharacterId(dto.getDialogueCharacterId()); + if (dto.getImageUrl() != null) storyboard.setImageUrl(dto.getImageUrl()); + if (dto.getDuration() != null) storyboard.setDuration(dto.getDuration()); + + // 处理多角色对话列表 + if (dto.getDialogues() != null) { + try { + storyboard.setDialogue(objectMapper.writeValueAsString(dto.getDialogues())); + } catch (Exception e) { + log.error("序列化对话列表失败", e); + } + } + + storyboardMapper.updateById(storyboard); + } + + @Override + public void deleteStoryboard(Long projectId, Long storyboardId) { + SceneStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null || !storyboard.getProjectId().equals(projectId)) { + throw new BusinessException("分镜不存在"); + } + + Long sceneId = storyboard.getSceneId(); + Integer deletedIndex = storyboard.getStoryboardIndex(); + + // 删除分镜 + storyboardMapper.deleteById(storyboardId); + + // 重新排序后续分镜的索引 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SceneStoryboard::getSceneId, sceneId) + .gt(SceneStoryboard::getStoryboardIndex, deletedIndex); + List laterStoryboards = storyboardMapper.selectList(wrapper); + + for (SceneStoryboard sb : laterStoryboards) { + sb.setStoryboardIndex(sb.getStoryboardIndex() - 1); + storyboardMapper.updateById(sb); + } + } + + @Override + public Long addStoryboard(Long projectId, Long sceneId, Integer afterIndex) { + // 验证场次存在 + ProjectScene scene = sceneMapper.selectById(sceneId); + if (scene == null || !scene.getProjectId().equals(projectId)) { + throw new BusinessException("场次不存在"); + } + + // 获取当前最大索引 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SceneStoryboard::getSceneId, sceneId) + .orderByDesc(SceneStoryboard::getStoryboardIndex) + .last("LIMIT 1"); + SceneStoryboard lastStoryboard = storyboardMapper.selectOne(wrapper); + int maxIndex = lastStoryboard != null ? lastStoryboard.getStoryboardIndex() : 0; + + // 计算新分镜的插入位置 + int newIndex = afterIndex != null ? afterIndex + 1 : maxIndex + 1; + + // 如果是插入到中间,需要移动后续分镜的索引 + if (afterIndex != null && afterIndex < maxIndex) { + LambdaQueryWrapper updateWrapper = new LambdaQueryWrapper<>(); + updateWrapper.eq(SceneStoryboard::getSceneId, sceneId) + .gt(SceneStoryboard::getStoryboardIndex, afterIndex); + List laterStoryboards = storyboardMapper.selectList(updateWrapper); + + for (SceneStoryboard sb : laterStoryboards) { + sb.setStoryboardIndex(sb.getStoryboardIndex() + 1); + storyboardMapper.updateById(sb); + } + } + + // 创建新分镜 + SceneStoryboard newStoryboard = new SceneStoryboard(); + newStoryboard.setProjectId(projectId); + newStoryboard.setSceneId(sceneId); + newStoryboard.setStoryboardIndex(newIndex); + newStoryboard.setShotType("中景"); + newStoryboard.setCameraAngle("平视"); + newStoryboard.setCameraMove("固定"); + newStoryboard.setDescription(""); + newStoryboard.setDuration(3); + + storyboardMapper.insert(newStoryboard); + return newStoryboard.getId(); + } + + @Override + public String generateStoryboardImage(Long projectId, Long storyboardId, Long userId) { + SceneStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null || !storyboard.getProjectId().equals(projectId)) { + throw new BusinessException("分镜不存在"); + } + + // 检查是否已有正在进行的任务(防止重复提交) + if (storyboard.getCurrentTaskNo() != null && storyboard.getImageStatus() != null + && storyboard.getImageStatus() == 1) { + // 查询任务的实际状态,如果任务已失败或取消,重置分镜状态并允许重新提交 + try { + AiTaskVO existingTask = aiTaskService.getTaskByNo(storyboard.getCurrentTaskNo()); + if (existingTask != null && existingTask.getStatus() != null) { + // 任务状态:0-队列中 1-处理中 2-成功 3-失败 4-取消 + if (existingTask.getStatus() == 3 || existingTask.getStatus() == 4) { + // 任务已失败或取消,重置分镜状态,允许重新提交 + log.warn("分镜 {} 的旧任务 {} 已失败或取消(状态: {}),重置分镜状态", + storyboardId, storyboard.getCurrentTaskNo(), existingTask.getStatus()); + storyboard.setImageStatus(0); // 0-未生成 + storyboard.setCurrentTaskNo(null); + storyboardMapper.updateById(storyboard); + } else if (existingTask.getStatus() == 0 || existingTask.getStatus() == 1) { + // 任务还在进行中,验证任务是否属于当前用户(确保前端可以查询) + if (existingTask.getUserId() != null && existingTask.getUserId().equals(userId)) { + log.info("分镜 {} 已有正在进行的任务: {} (用户: {})", + storyboardId, storyboard.getCurrentTaskNo(), userId); + return storyboard.getCurrentTaskNo(); + } else { + // 任务不属于当前用户,重置状态并创建新任务 + log.warn("分镜 {} 的旧任务 {} 不属于当前用户 {},重置状态", + storyboardId, storyboard.getCurrentTaskNo(), userId); + storyboard.setImageStatus(0); + storyboard.setCurrentTaskNo(null); + storyboardMapper.updateById(storyboard); + } + } else if (existingTask.getStatus() == 2) { + // 任务已成功,但分镜状态可能未更新,重置状态以便重新加载 + log.info("分镜 {} 的任务 {} 已完成,重置状态以便重新加载", + storyboardId, storyboard.getCurrentTaskNo()); + storyboard.setImageStatus(0); + storyboard.setCurrentTaskNo(null); + storyboardMapper.updateById(storyboard); + } + } else { + // 任务不存在,重置分镜状态 + log.warn("分镜 {} 的旧任务 {} 不存在,重置分镜状态", + storyboardId, storyboard.getCurrentTaskNo()); + storyboard.setImageStatus(0); + storyboard.setCurrentTaskNo(null); + storyboardMapper.updateById(storyboard); + } + } catch (Exception e) { + // 查询任务失败,可能是任务不存在,重置分镜状态并继续创建新任务 + log.warn("查询分镜 {} 的旧任务 {} 状态失败: {},重置分镜状态", + storyboardId, storyboard.getCurrentTaskNo(), e.getMessage()); + storyboard.setImageStatus(0); + storyboard.setCurrentTaskNo(null); + storyboardMapper.updateById(storyboard); + } + } + + // 获取项目信息,包含视频风格 + VideoProject project = projectMapper.selectById(projectId); + String videoStyle = (project != null && project.getVideoStyle() != null) + ? project.getVideoStyle() : "动漫风格"; + + AiModelVO model = aiModelService.getModelByCode(MODEL_IMAGE_GEN); + + // 创建异步任务 + try { + Map inputParams = new HashMap<>(); + + // 获取项目角色及其图片信息 + List charactersWithImages = getProjectCharactersWithImages(projectId); + + // 构建包含角色图片对应说明的完整提示词 + StringBuilder fullPrompt = new StringBuilder(); + + // 添加画面风格说明 + fullPrompt.append("【画面风格】\n"); + fullPrompt.append(videoStyle).append("\n\n"); + + // 如果有角色参考图,先添加角色图片说明 + if (!charactersWithImages.isEmpty()) { + fullPrompt.append("【参考图角色说明】\n"); + for (int i = 0; i < charactersWithImages.size(); i++) { + ProjectCharacter c = charactersWithImages.get(i); + fullPrompt.append("- 图").append(i + 1).append(": ").append(c.getName()); + if (c.getGender() != null && !c.getGender().isEmpty()) { + fullPrompt.append(", ").append(c.getGender()); + } + if (c.getDescription() != null && !c.getDescription().isEmpty()) { + fullPrompt.append(", ").append(c.getDescription()); + } + fullPrompt.append("\n"); + } + fullPrompt.append("\n"); + } + + // 添加画面描述(处理角色引用) + fullPrompt.append("【画面描述】\n"); + String scenePrompt = replaceCharacterRefsInPrompt(storyboard.getDescription(), projectId); + fullPrompt.append(scenePrompt); + + inputParams.put("prompt", fullPrompt.toString()); + inputParams.put("targetType", "storyboard"); + inputParams.put("targetId", storyboardId); + + // 获取项目角色图片作为参考图(只有非空时才传入) + if (!charactersWithImages.isEmpty()) { + List imageUrls = charactersWithImages.stream() + .map(ProjectCharacter::getImageUrl) + .collect(Collectors.toList()); + inputParams.put("referenceImages", imageUrls); + } + + com.dora.dto.AiTaskDTO taskDTO = new com.dora.dto.AiTaskDTO(); + taskDTO.setModelId(model.getId()); + taskDTO.setInputParams(objectMapper.writeValueAsString(inputParams)); + taskDTO.setPriority(5); + + String taskNo = aiTaskService.createTask(userId, taskDTO); + + // 更新分镜状态为生成中 + storyboard.setCurrentTaskNo(taskNo); + storyboard.setImageStatus(1); // 1-生成中 + storyboardMapper.updateById(storyboard); + log.info("分镜 {} 开始生成图片,任务号: {}", storyboardId, taskNo); + + return taskNo; + } catch (Exception e) { + throw new BusinessException("创建图像生成任务失败: " + e.getMessage()); + } + } + + + /** + * 替换 prompt 中的角色引用(/角色名 或 【角色名】)为角色描述 + */ + private String replaceCharacterRefsInPrompt(String prompt, Long projectId) { + if (prompt == null || prompt.isEmpty()) { + return prompt; + } + + // 获取项目角色映射 + List characters = getProjectCharacters(projectId); + Map nameToDesc = new HashMap<>(); + for (ProjectCharacterVO c : characters) { + String desc = c.getName(); + if (c.getDescription() != null && !c.getDescription().isEmpty()) { + desc = c.getName() + "(" + c.getDescription() + ")"; + } + nameToDesc.put(c.getName(), desc); + } + + String result = prompt; + + // 替换 /角色名 格式 + for (Map.Entry entry : nameToDesc.entrySet()) { + result = result.replace("/" + entry.getKey(), entry.getValue()); + } + + // 替换 【角色名】 格式 + for (Map.Entry entry : nameToDesc.entrySet()) { + result = result.replace("【" + entry.getKey() + "】", entry.getValue()); + } + + return result; + } + + /** + * 获取项目中已生成形象的角色列表(包含角色信息和图片URL) + * 豆包API image参数支持URL字符串数组,最多14张参考图 + */ + private List getProjectCharactersWithImages(Long projectId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectCharacter::getProjectId, projectId) + .isNotNull(ProjectCharacter::getImageUrl) + .ne(ProjectCharacter::getImageUrl, ""); + + List characters = characterMapper.selectList(wrapper); + return characters.stream() + .filter(c -> c.getImageUrl() != null && !c.getImageUrl().isEmpty()) + .limit(14) // 豆包API最多支持14张参考图 + .collect(Collectors.toList()); + } + + @Override + public String optimizeDescription(Long projectId, Long storyboardId, Long userId) { + SceneStoryboard storyboard = storyboardMapper.selectById(storyboardId); + if (storyboard == null || !storyboard.getProjectId().equals(projectId)) { + throw new BusinessException("分镜不存在"); + } + + VideoProject project = projectMapper.selectById(projectId); + AiModel model = getModelByCode(MODEL_SCRIPT_GEN); + + AiPromptTemplate template = promptTemplateMapper.selectByCode("DESCRIPTION_OPTIMIZE"); + if (template == null) { + throw new BusinessException("提示词模板不存在"); + } + + String userPrompt = template.getUserPromptTemplate() + .replace("{{originalDescription}}", nullToEmpty(storyboard.getDescription())) + .replace("{{shotType}}", nullToEmpty(storyboard.getShotType())) + .replace("{{cameraAngle}}", nullToEmpty(storyboard.getCameraAngle())) + .replace("{{videoStyle}}", nullToEmpty(project.getVideoStyle())); + + String result = callSiliconFlowChat(model, template.getSystemPrompt(), userPrompt); + + storyboard.setDescription(result.trim()); + storyboardMapper.updateById(storyboard); + + return result.trim(); + } + + // ========== 私有方法 ========== + + private AiModel getModelByCode(String code) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(AiModel::getCode, code) + .eq(AiModel::getStatus, 1) + .eq(AiModel::getDeleted, 0); + AiModel model = aiModelMapper.selectOne(wrapper); + if (model == null) { + throw new BusinessException("AI模型不存在或已禁用: " + code); + } + return model; + } + + private void checkAndDeductPoints(Long userId, AiModel model) { + if (model.getPointsCost() == null || model.getPointsCost() <= 0) { + return; // 免费 + } + + User user = userMapper.selectById(userId); + if (user.getPoints() < model.getPointsCost()) { + throw new BusinessException("积分余额不足,需要" + model.getPointsCost() + "积分"); + } + + pointsService.consumePoints(userId, model.getPointsCost(), model.getName(), null); + } + + private String callSiliconFlowChat(AiModel model, String systemPrompt, String userPrompt) { + AiProvider provider = aiProviderMapper.selectById(model.getProviderId()); + if (provider == null) { + throw new BusinessException("AI厂商配置不存在"); + } + + String url = provider.getBaseUrl() + model.getApiEndpoint(); + + // 从request_template中解析模型名称,如果解析失败则使用默认值 + String modelName = "Pro/deepseek-ai/DeepSeek-V3.2"; + try { + if (model.getRequestTemplate() != null) { + Map templateMap = objectMapper.readValue(model.getRequestTemplate(), Map.class); + if (templateMap.containsKey("model")) { + modelName = (String) templateMap.get("model"); + } + } + } catch (Exception e) { + log.warn("解析模型配置失败,使用默认模型: {}", modelName); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(provider.getApiKey()); + + Map body = new HashMap<>(); + body.put("model", modelName); + body.put("messages", Arrays.asList( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt) + )); + body.put("temperature", 0.7); + body.put("max_tokens", 4096); + + // 详细日志 + log.info("====== AI调用详情 ======"); + log.info("请求URL: {}", url); + log.info("模型名称: {}", modelName); + log.info("API Key: {}***{}", + provider.getApiKey().substring(0, 6), + provider.getApiKey().substring(provider.getApiKey().length() - 4)); + log.info("System Prompt长度: {} 字符", systemPrompt.length()); + log.info("User Prompt: {}", userPrompt.length() > 200 ? userPrompt.substring(0, 200) + "..." : userPrompt); + + try { + HttpEntity> entity = new HttpEntity<>(body, headers); + log.info("开始调用AI接口..."); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + log.info("AI响应状态码: {}", response.getStatusCode()); + + Map responseBody = response.getBody(); + if (responseBody != null && responseBody.containsKey("choices")) { + List choices = (List) responseBody.get("choices"); + if (!choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + String content = (String) message.get("content"); + log.info("AI返回内容长度: {} 字符", content != null ? content.length() : 0); + return content; + } + } + log.error("AI返回结果为空, responseBody: {}", responseBody); + throw new BusinessException("AI返回结果为空"); + } catch (Exception e) { + log.error("调用AI接口失败, URL: {}, Model: {}, Error: {}", url, modelName, e.getMessage()); + throw new BusinessException("AI服务调用失败: " + e.getMessage()); + } + } + + private String callSiliconFlowImageGen(AiModel model, String prompt) { + AiProvider provider = aiProviderMapper.selectById(model.getProviderId()); + if (provider == null) { + throw new BusinessException("AI厂商配置不存在"); + } + + String url = provider.getBaseUrl() + model.getApiEndpoint(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(provider.getApiKey()); + + Map body = new HashMap<>(); + body.put("model", "black-forest-labs/FLUX.1-schnell"); + body.put("prompt", prompt); + body.put("image_size", "1024x576"); + + try { + HttpEntity> entity = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, Map.class); + + Map responseBody = response.getBody(); + if (responseBody != null && responseBody.containsKey("images")) { + List images = (List) responseBody.get("images"); + if (!images.isEmpty()) { + return (String) images.get(0).get("url"); + } + } + throw new BusinessException("图像生成返回结果为空"); + } catch (Exception e) { + log.error("调用图像生成接口失败", e); + throw new BusinessException("图像生成失败: " + e.getMessage()); + } + } + + private T parseJsonResult(String content, Class clazz) { + try { + // 提取JSON块 + Pattern pattern = Pattern.compile("```json\\s*([\\s\\S]*?)\\s*```"); + Matcher matcher = pattern.matcher(content); + String jsonStr = content; + if (matcher.find()) { + jsonStr = matcher.group(1); + } else { + // 尝试直接找JSON对象 + int start = content.indexOf("{"); + int end = content.lastIndexOf("}"); + if (start >= 0 && end > start) { + jsonStr = content.substring(start, end + 1); + } + } + + // 尝试直接解析 + try { + return objectMapper.readValue(jsonStr, clazz); + } catch (Exception firstEx) { + // 如果解析失败,尝试修复常见的JSON格式问题 + log.warn("First JSON parse attempt failed, trying to fix malformed JSON"); + jsonStr = fixMalformedJson(jsonStr); + return objectMapper.readValue(jsonStr, clazz); + } + } catch (Exception e) { + log.error("Parse JSON failed: {}", content, e); + throw new BusinessException("AI返回格式解析失败"); + } + } + + /** + * 修复AI返回的格式错误的JSON + * 主要处理:字符串值没有引号的情况,如 "description": 中文内容 应该是 "description": "中文内容" + */ + private String fixMalformedJson(String json) { + // 修复 "key": 中文内容 的情况(值没有引号) + // 匹配模式: "key": 后面跟着非引号开头的中文或其他内容,直到遇到下一个 "key": 或 } 或 ] + Pattern unquotedValuePattern = Pattern.compile( + "(\"(?:description|narration|dialogue|dialogueCharacter|content|text|name|title)\"\\s*:\\s*)([^\"\\[\\{\\d\\-][^,\\}\\]]*?)(?=\\s*,\\s*\"|\\s*\\}|\\s*\\])", + Pattern.MULTILINE + ); + + Matcher m = unquotedValuePattern.matcher(json); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String key = m.group(1); + String value = m.group(2).trim(); + // 跳过 null, true, false + if (value.equals("null") || value.equals("true") || value.equals("false")) { + continue; + } + // 对值进行转义并添加引号 + String escapedValue = value.replace("\\", "\\\\").replace("\"", "\\\""); + m.appendReplacement(sb, Matcher.quoteReplacement(key + "\"" + escapedValue + "\"")); + } + m.appendTail(sb); + + return sb.toString(); + } + + /** + * 获取前一场次的故事内容,用于保持剧情连贯性 + */ + private String getPreviousSceneContext(Long projectId, Integer currentSort) { + if (currentSort == null || currentSort <= 0) { + return ""; + } + + // 查询前一场次 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProjectScene::getProjectId, projectId) + .lt(ProjectScene::getSort, currentSort) + .orderByDesc(ProjectScene::getSort) + .last("LIMIT 1"); + + ProjectScene prevScene = sceneMapper.selectOne(wrapper); + if (prevScene == null) { + return ""; + } + + StringBuilder context = new StringBuilder(); + context.append("\n【前一场次内容 - 请保持剧情连贯】\n"); + context.append("场次标题:").append(nullToEmpty(prevScene.getSceneTitle())).append("\n"); + if (prevScene.getSceneDescription() != null && !prevScene.getSceneDescription().isEmpty()) { + context.append("场次描述:").append(prevScene.getSceneDescription()).append("\n"); + } + if (prevScene.getSceneStory() != null && !prevScene.getSceneStory().isEmpty()) { + context.append("场次故事:").append(prevScene.getSceneStory()).append("\n"); + } + + return context.toString(); + } + + private String nullToEmpty(String str) { + return str != null ? str : ""; + } + + // ========== 视频生成 ========== + + private static final String MODEL_PROMPT_OPTIMIZE = "video-prompt-optimize"; + + // 异步处理线程池 + private final java.util.concurrent.ExecutorService videoProcessExecutor = + java.util.concurrent.Executors.newFixedThreadPool(5); + + @Override + public Map generateSceneVideo(Long projectId, Long sceneId, Long userId) { + // 1. 获取项目信息(快速校验) + VideoProject project = projectMapper.selectById(projectId); + if (project == null) { + throw new BusinessException("项目不存在"); + } + + // 2. 获取场次信息 + ProjectScene scene = sceneMapper.selectById(sceneId); + if (scene == null) { + throw new BusinessException("场次不存在"); + } + + // 检查是否已有正在进行的视频生成任务(防止重复提交) + if (scene.getVideoStatus() != null && scene.getVideoStatus() == 1) { + // 检查关联任务是否已失败,如果失败则允许重新提交 + String existingTaskNo = scene.getVideoTaskNo(); + boolean taskActuallyFailed = false; + if (existingTaskNo != null && !existingTaskNo.isEmpty() && !"PREPARING".equals(existingTaskNo)) { + try { + AiTask existingTask = aiTaskMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiTask::getTaskNo, existingTaskNo) + ); + if (existingTask != null && (existingTask.getStatus() == 3 || existingTask.getStatus() == 4)) { + taskActuallyFailed = true; + log.info("场次 {} 关联任务 {} 已失败(status={}),允许重新提交", sceneId, existingTaskNo, existingTask.getStatus()); + } + } catch (Exception e) { + log.warn("检查任务状态失败: {}", e.getMessage()); + } + } + + if (!taskActuallyFailed) { + log.info("场次 {} 已有正在进行的视频生成任务: {}", sceneId, existingTaskNo); + Map result = new HashMap<>(); + result.put("status", "processing"); + result.put("message", "视频正在生成中"); + result.put("taskNo", existingTaskNo); + return result; + } + // 任务已失败,重置场次状态,继续生成流程 + scene.setVideoStatus(0); + scene.setVideoTaskNo(null); + sceneMapper.updateById(scene); + } + + // 3. 快速检查是否有分镜图片 + List storyboards = storyboardMapper.selectList( + new LambdaQueryWrapper() + .eq(SceneStoryboard::getSceneId, sceneId) + .orderByAsc(SceneStoryboard::getSort) + ); + + if (storyboards.isEmpty()) { + throw new BusinessException("该场次没有分镜数据"); + } + + List imageUrls = storyboards.stream() + .map(SceneStoryboard::getImageUrl) + .filter(url -> url != null && !url.isEmpty()) + .collect(Collectors.toList()); + + if (imageUrls.isEmpty()) { + throw new BusinessException("请先生成分镜图片"); + } + + // 4. 立即更新状态为"准备中",快速返回 + scene.setVideoStatus(1); // 1-生成中(准备阶段) + scene.setVideoTaskNo("PREPARING"); // 临时标记 + sceneMapper.updateById(scene); + + log.info("场次 {} 视频生成任务已接收,开始异步处理...", sceneId); + + // 5. 异步处理:提示词优化、图片拼接、素描转换、创建任务 + final String videoRatio = project.getVideoRatio() != null ? project.getVideoRatio() : "1:1"; + final String videoStyle = project.getVideoStyle() != null ? project.getVideoStyle() : "动漫风格"; + final String videoDuration = "10"; // Grok视频固定10秒 + final List finalImageUrls = new ArrayList<>(imageUrls); + final List finalStoryboards = new ArrayList<>(storyboards); + + videoProcessExecutor.submit(() -> { + try { + processVideoGenerationAsync(projectId, sceneId, userId, + videoRatio, videoStyle, videoDuration, + finalImageUrls, finalStoryboards); + } catch (Exception e) { + log.error("异步处理视频生成失败 - 场次ID: {}, 错误: {}", sceneId, e.getMessage(), e); + // 重置场次状态 + try { + ProjectScene s = sceneMapper.selectById(sceneId); + if (s != null) { + s.setVideoStatus(0); + s.setVideoTaskNo(null); + sceneMapper.updateById(s); + } + } catch (Exception ex) { + log.error("重置场次状态失败", ex); + } + } + }); + + Map result = new HashMap<>(); + result.put("status", "pending"); + result.put("message", "视频生成任务已提交,正在准备中..."); + result.put("imageCount", imageUrls.size()); + + return result; + } + + /** + * 异步处理视频生成(提示词优化、图片拼接、素描转换、创建任务) + */ + private void processVideoGenerationAsync(Long projectId, Long sceneId, Long userId, + String videoRatio, String videoStyle, String videoDuration, + List imageUrls, List storyboards) { + log.info("开始异步处理视频生成 - 场次ID: {}", sceneId); + + // 1. 获取项目角色信息 + List charactersWithImages = getProjectCharactersWithImages(projectId); + + // 2. 构建分镜内容和对话 + StringBuilder storyboardContents = new StringBuilder(); + StringBuilder dialogues = new StringBuilder(); + StringBuilder characterInfo = new StringBuilder(); + + // 构建角色信息 + for (ProjectCharacter c : charactersWithImages) { + characterInfo.append("- ").append(c.getName()); + if (c.getGender() != null && !c.getGender().isEmpty()) { + characterInfo.append(", ").append(c.getGender()); + } + if (c.getAge() != null && !c.getAge().isEmpty()) { + characterInfo.append(", ").append(c.getAge()); + } + if (c.getAppearance() != null && !c.getAppearance().isEmpty()) { + characterInfo.append(", 外貌: ").append(c.getAppearance()); + } + if (c.getClothing() != null && !c.getClothing().isEmpty()) { + characterInfo.append(", 服装: ").append(c.getClothing()); + } + characterInfo.append("\n"); + } + + // 构建分镜内容和收集用户对话(包含角色名) + for (int i = 0; i < storyboards.size(); i++) { + SceneStoryboard sb = storyboards.get(i); + storyboardContents.append("分镜").append(i + 1).append(": "); + if (sb.getShotType() != null) { + storyboardContents.append("[").append(sb.getShotType()).append("] "); + } + if (sb.getCameraAngle() != null) { + storyboardContents.append("[").append(sb.getCameraAngle()).append("] "); + } + if (sb.getCameraMove() != null) { + storyboardContents.append("[").append(sb.getCameraMove()).append("] "); + } + if (sb.getDescription() != null && !sb.getDescription().isEmpty()) { + String desc = replaceCharacterRefsInPrompt(sb.getDescription(), projectId); + storyboardContents.append(desc); + } + if (sb.getNarration() != null && !sb.getNarration().isEmpty()) { + storyboardContents.append(" | 旁白: ").append(sb.getNarration()); + } + storyboardContents.append("\n"); + + // 收集用户设置的对话(附带角色名) + if (sb.getDialogue() != null && !sb.getDialogue().isEmpty()) { + String speakerName = "角色"; + if (sb.getDialogueCharacterId() != null) { + ProjectCharacter speaker = characterMapper.selectById(sb.getDialogueCharacterId()); + if (speaker != null && speaker.getName() != null) { + speakerName = speaker.getName(); + } + } + dialogues.append("分镜").append(i + 1).append(" - ") + .append(speakerName).append(": 「").append(sb.getDialogue()).append("」\n"); + } + } + + // 2.5 获取前后场次上下文(保证场次间视频连贯性) + ProjectScene currentScene = sceneMapper.selectById(sceneId); + String prevSceneContext = ""; + String nextSceneContext = ""; + + if (currentScene != null) { + // 获取前一个场次的最后一个分镜(用于衔接本场次开头) + ProjectScene prevScene = sceneMapper.selectOne( + new LambdaQueryWrapper() + .eq(ProjectScene::getProjectId, projectId) + .lt(ProjectScene::getSort, currentScene.getSort()) + .orderByDesc(ProjectScene::getSort) + .last("LIMIT 1") + ); + if (prevScene != null) { + SceneStoryboard lastSb = storyboardMapper.selectOne( + new LambdaQueryWrapper() + .eq(SceneStoryboard::getSceneId, prevScene.getId()) + .orderByDesc(SceneStoryboard::getSort) + .last("LIMIT 1") + ); + if (lastSb != null) { + StringBuilder ctx = new StringBuilder(); + ctx.append("上一场「").append(prevScene.getSceneName()).append("」结尾: "); + if (lastSb.getDescription() != null) ctx.append(lastSb.getDescription()); + if (lastSb.getDialogue() != null && !lastSb.getDialogue().isEmpty()) { + ctx.append(" | 台词: 「").append(lastSb.getDialogue()).append("」"); + } + prevSceneContext = ctx.toString(); + } + } + + // 获取后一个场次的第一个分镜(用于衔接本场次结尾) + ProjectScene nextScene = sceneMapper.selectOne( + new LambdaQueryWrapper() + .eq(ProjectScene::getProjectId, projectId) + .gt(ProjectScene::getSort, currentScene.getSort()) + .orderByAsc(ProjectScene::getSort) + .last("LIMIT 1") + ); + if (nextScene != null) { + SceneStoryboard firstSb = storyboardMapper.selectOne( + new LambdaQueryWrapper() + .eq(SceneStoryboard::getSceneId, nextScene.getId()) + .orderByAsc(SceneStoryboard::getSort) + .last("LIMIT 1") + ); + if (firstSb != null) { + StringBuilder ctx = new StringBuilder(); + ctx.append("下一场「").append(nextScene.getSceneName()).append("」开头: "); + if (firstSb.getDescription() != null) ctx.append(firstSb.getDescription()); + if (firstSb.getDialogue() != null && !firstSb.getDialogue().isEmpty()) { + ctx.append(" | 台词: 「").append(firstSb.getDialogue()).append("」"); + } + nextSceneContext = ctx.toString(); + } + } + } + + log.info("场次 {} - 前后场次上下文: prev=[{}], next=[{}]", sceneId, + prevSceneContext.isEmpty() ? "无" : "有", nextSceneContext.isEmpty() ? "无" : "有"); + + // 3. 调用AI优化提示词(传入前后场次上下文) + log.info("场次 {} - 开始优化提示词...", sceneId); + String optimizedPrompt = optimizeVideoPrompt(videoStyle, videoRatio, videoDuration, + characterInfo.toString(), storyboardContents.toString(), dialogues.toString(), + prevSceneContext, nextSceneContext); + log.info("场次 {} - 提示词优化完成,原始长度: {} 字符", sceneId, optimizedPrompt.length()); + + // 清理并截断提示词(Grok视频API有长度限制) + optimizedPrompt = cleanPromptForVideoApi(optimizedPrompt); + log.info("场次 {} - 提示词清理后长度: {} 字符", sceneId, optimizedPrompt.length()); + + // 4. 拼接分镜图并上传到COS(Grok不需要素描转换,直接使用拼接图) + log.info("场次 {} - 开始拼接分镜图...", sceneId); + String compositeImageUrl = imageProcessingService.stitchAndUploadToCos(imageUrls, "video-composite"); + + if (compositeImageUrl == null) { + log.warn("场次 {} - 图片拼接失败,使用第一张分镜图", sceneId); + compositeImageUrl = imageUrls.get(0); + } else { + log.info("场次 {} - 拼接图上传成功: {}", sceneId, compositeImageUrl); + } + + // 5. 获取视频生成模型 + AiModel videoModel = aiModelMapper.selectOne( + new LambdaQueryWrapper() + .eq(AiModel::getCode, MODEL_VIDEO_GEN) + .eq(AiModel::getStatus, 1) + ); + + if (videoModel == null) { + throw new RuntimeException("视频生成模型未配置: " + MODEL_VIDEO_GEN); + } + + // 6. 构建输入参数(Grok视频生成格式) + Map inputParams = new HashMap<>(); + inputParams.put("prompt", optimizedPrompt); + inputParams.put("aspect_ratio", videoRatio != null ? videoRatio : "16:9"); + inputParams.put("duration", "10"); + // 参考图通过image_urls传递(Grok API要求JSON数组格式,如 ["url"]) + if (compositeImageUrl != null && !compositeImageUrl.isEmpty()) { + try { + inputParams.put("image_urls", objectMapper.writeValueAsString(Collections.singletonList(compositeImageUrl))); + } catch (Exception e) { + inputParams.put("image_urls", "[\"" + compositeImageUrl + "\"]"); + } + } + inputParams.put("_projectId", projectId); + inputParams.put("_sceneId", sceneId); + inputParams.put("_compositeImage", compositeImageUrl); + + // 7. 创建AI任务 + String taskNo; + try { + AiTaskDTO taskDTO = new AiTaskDTO(); + taskDTO.setModelId(videoModel.getId()); + taskDTO.setInputParams(objectMapper.writeValueAsString(inputParams)); + taskDTO.setPriority(1); + taskNo = aiTaskService.createTask(userId, taskDTO); + + // 更新场次任务编号 + ProjectScene scene = sceneMapper.selectById(sceneId); + if (scene != null) { + scene.setVideoTaskNo(taskNo); + scene.setVideoStatus(1); + sceneMapper.updateById(scene); + } + + log.info("场次 {} - 视频生成任务创建成功,任务号: {}", sceneId, taskNo); + } catch (Exception e) { + log.error("场次 {} - 创建视频生成任务失败: {}", sceneId, e.getMessage()); + throw new RuntimeException("创建任务失败: " + e.getMessage()); + } + } + + /** + * 调用AI优化视频提示词 + * 将分镜内容整合为连贯的英文提示词,保留用户设置的对话 + */ + private String optimizeVideoPrompt(String videoStyle, String videoRatio, String videoDuration, + String characterInfo, String storyboardContents, String dialogues, + String prevSceneContext, String nextSceneContext) { + try { + // 获取提示词优化模型 + AiModel model = getModelByCode(MODEL_PROMPT_OPTIMIZE); + + // 获取提示词模板 + AiPromptTemplate template = promptTemplateMapper.selectByCode("VIDEO_PROMPT_OPTIMIZE"); + if (template == null) { + log.warn("视频提示词优化模板不存在,使用原始内容"); + return buildFallbackPrompt(videoStyle, characterInfo, storyboardContents, dialogues, + prevSceneContext, nextSceneContext); + } + + // 构建场次衔接上下文 + StringBuilder continuityContext = new StringBuilder(); + if (prevSceneContext != null && !prevSceneContext.isEmpty()) { + continuityContext.append(prevSceneContext).append("\n"); + } + if (nextSceneContext != null && !nextSceneContext.isEmpty()) { + continuityContext.append(nextSceneContext).append("\n"); + } + String continuity = continuityContext.length() > 0 ? continuityContext.toString() : "这是独立场次,无需衔接"; + + // 构建用户提示词 + String userPrompt = template.getUserPromptTemplate() + .replace("{{videoStyle}}", videoStyle) + .replace("{{videoRatio}}", videoRatio) + .replace("{{videoDuration}}", videoDuration) + .replace("{{characterInfo}}", characterInfo.isEmpty() ? "无特定角色" : characterInfo) + .replace("{{storyboardContents}}", storyboardContents) + .replace("{{dialogues}}", dialogues.isEmpty() ? "无对话" : dialogues) + .replace("{{continuityContext}}", continuity); + + // 调用AI + String result = callSiliconFlowChat(model, template.getSystemPrompt(), userPrompt); + + if (result != null && !result.isEmpty()) { + return result.trim(); + } + } catch (Exception e) { + log.error("调用AI优化提示词失败: {}", e.getMessage()); + } + + // 失败时使用备用方案 + return buildFallbackPrompt(videoStyle, characterInfo, storyboardContents, dialogues, + prevSceneContext, nextSceneContext); + } + + /** + * 构建备用提示词(当AI优化失败时使用) + */ + private String buildFallbackPrompt(String videoStyle, String characterInfo, + String storyboardContents, String dialogues, + String prevSceneContext, String nextSceneContext) { + StringBuilder prompt = new StringBuilder(); + prompt.append("生成一个10秒的").append(videoStyle).append("风格视频。\n\n"); + prompt.append("【重要】参考图是分镜画面,视频必须忠实还原参考图中的构图、人物位置和场景布局,色彩鲜艳生动。\n\n"); + + if (prevSceneContext != null && !prevSceneContext.isEmpty()) { + prompt.append("【衔接上文】").append(prevSceneContext).append("\n"); + } + + if (!characterInfo.isEmpty()) { + prompt.append("【角色】\n").append(characterInfo).append("\n"); + } + + prompt.append("【场景流程】\n").append(storyboardContents); + + if (!dialogues.isEmpty()) { + prompt.append("\n【角色台词(完整保留)】\n").append(dialogues); + } + + if (nextSceneContext != null && !nextSceneContext.isEmpty()) { + prompt.append("\n【衔接下文】").append(nextSceneContext).append("\n"); + } + + return prompt.toString(); + } + + /** + * 清理提示词,使其适合Grok视频API + * 1. 去除Markdown格式(**bold**等) + * 2. 去除多余的标题标记 + * 3. 压缩连续空行 + * 4. 截断到500字符以内(Grok视频API限制) + */ + private String cleanPromptForVideoApi(String prompt) { + if (prompt == null || prompt.isEmpty()) { + return prompt; + } + + String cleaned = prompt; + + // 去除Markdown粗体 **text** → text + cleaned = cleaned.replaceAll("\\*\\*([^*]+)\\*\\*", "$1"); + // 去除Markdown标题 ## text → text + cleaned = cleaned.replaceAll("(?m)^#+\\s*", ""); + // 去除【视频生成提示词】等元信息标题行 + cleaned = cleaned.replaceAll("(?m)^【视频生成提示词】\\s*\n?", ""); + cleaned = cleaned.replaceAll("(?m)^风格:.*\n?", ""); + cleaned = cleaned.replaceAll("(?m)^比例:.*\n?", ""); + cleaned = cleaned.replaceAll("(?m)^时长:.*\n?", ""); + // 压缩连续空行为单个换行 + cleaned = cleaned.replaceAll("\n{3,}", "\n\n"); + // 去除首尾空白 + cleaned = cleaned.trim(); + + // 硬性截断到500字符(在句子边界截断) + int maxLength = 500; + if (cleaned.length() > maxLength) { + // 尝试在句号、感叹号、问号处截断 + int cutPos = maxLength; + String searchArea = cleaned.substring(Math.max(0, maxLength - 50), maxLength); + int lastSentenceEnd = Math.max( + Math.max(searchArea.lastIndexOf("。"), searchArea.lastIndexOf("!")), + Math.max(searchArea.lastIndexOf("?"), searchArea.lastIndexOf("。")) + ); + if (lastSentenceEnd > 0) { + cutPos = Math.max(0, maxLength - 50) + lastSentenceEnd + 1; + } + cleaned = cleaned.substring(0, cutPos); + log.warn("提示词超过{}字符,已截断: 原{}字符 → {}字符", maxLength, prompt.length(), cleaned.length()); + } + + return cleaned; + } + + @Override + public Map compositeFinalVideo(Long projectId, Long userId) { + // 1. 获取项目 + VideoProject project = projectMapper.selectById(projectId); + if (project == null) { + throw new BusinessException("项目不存在"); + } + + // 2. 获取所有场次(按排序) + List scenes = sceneMapper.selectList( + new LambdaQueryWrapper() + .eq(ProjectScene::getProjectId, projectId) + .orderByAsc(ProjectScene::getSort) + ); + + if (scenes.isEmpty()) { + throw new BusinessException("项目没有场次数据"); + } + + // 3. 检查所有场次是否都已生成视频 + List videoUrls = new ArrayList<>(); + for (ProjectScene scene : scenes) { + if (scene.getVideoStatus() == null || scene.getVideoStatus() != 2) { + throw new BusinessException("场次「" + scene.getSceneName() + "」的视频尚未生成完成"); + } + if (scene.getVideoUrl() == null || scene.getVideoUrl().isEmpty()) { + throw new BusinessException("场次「" + scene.getSceneName() + "」的视频URL为空"); + } + videoUrls.add(scene.getVideoUrl()); + } + + // 4. 如果只有一个场次,直接使用其视频URL + if (videoUrls.size() == 1) { + project.setOutputVideoUrl(videoUrls.get(0)); + project.setStatus(4); // 4-合成完成 + projectMapper.updateById(project); + + Map result = new HashMap<>(); + result.put("status", "completed"); + result.put("message", "视频合成完成"); + result.put("videoUrl", videoUrls.get(0)); + return result; + } + + // 5. 更新项目状态为合成中 + project.setStatus(3); // 3-合成中 + projectMapper.updateById(project); + + log.info("开始合成最终视频,projectId: {}, 场次数: {}", projectId, scenes.size()); + + // 6. 异步执行FFmpeg合成 + final List finalVideoUrls = new ArrayList<>(videoUrls); + videoProcessExecutor.submit(() -> { + try { + String outputUrl = processVideoCompositionAsync(projectId, finalVideoUrls); + + // 更新项目最终视频URL + VideoProject p = projectMapper.selectById(projectId); + if (p != null) { + p.setOutputVideoUrl(outputUrl); + p.setStatus(4); // 4-合成完成 + projectMapper.updateById(p); + log.info("项目 {} - 最终视频合成完成: {}", projectId, outputUrl); + } + } catch (Exception e) { + log.error("项目 {} - 最终视频合成失败: {}", projectId, e.getMessage(), e); + VideoProject p = projectMapper.selectById(projectId); + if (p != null) { + p.setStatus(5); // 5-合成失败 + projectMapper.updateById(p); + } + } + }); + + Map result = new HashMap<>(); + result.put("status", "pending"); + result.put("message", "视频合成任务已提交,正在处理中..."); + result.put("sceneCount", scenes.size()); + return result; + } + + /** + * 异步处理视频合成:下载 → FFmpeg拼接 → 上传COS + */ + private String processVideoCompositionAsync(Long projectId, List videoUrls) throws Exception { + Path tempDir = Files.createTempDirectory("video-compose-" + projectId); + log.info("项目 {} - 创建临时目录: {}", projectId, tempDir); + + try { + // 1. 下载所有场次视频到临时目录 + List videoFiles = new ArrayList<>(); + for (int i = 0; i < videoUrls.size(); i++) { + String url = videoUrls.get(i); + Path videoFile = tempDir.resolve("scene_" + i + ".mp4"); + log.info("项目 {} - 下载场次视频 {}/{}: {}", projectId, i + 1, videoUrls.size(), url); + downloadFile(url, videoFile); + + if (!Files.exists(videoFile) || Files.size(videoFile) == 0) { + throw new RuntimeException("场次视频下载失败: " + url); + } + videoFiles.add(videoFile); + log.info("项目 {} - 场次视频 {} 下载完成,大小: {} bytes", projectId, i + 1, Files.size(videoFile)); + } + + // 2. 使用concat demuxer直接拼接(同项目AI生成视频参数一致,无需重编码) + Path outputFile = tempDir.resolve("output_final.mp4"); + concatVideosDemuxer(tempDir, videoFiles, outputFile); + log.info("项目 {} - 视频拼接完成", projectId); + + if (!Files.exists(outputFile) || Files.size(outputFile) == 0) { + throw new RuntimeException("FFmpeg视频拼接输出文件为空"); + } + log.info("项目 {} - FFmpeg拼接完成,输出大小: {} bytes", projectId, Files.size(outputFile)); + + // 4. 上传到COS + String cosKey = "video-output/" + projectId + "/" + UUID.randomUUID() + "_final.mp4"; + uploadToCos(outputFile, cosKey); + String cosUrl = cosConfig.getFileUrl(cosKey); + log.info("项目 {} - 最终视频已上传COS: {}", projectId, cosUrl); + + return cosUrl; + + } finally { + // 5. 清理临时文件 + cleanupTempDir(tempDir); + } + } + + /** + * 拼接视频文件:先探测参数,参数一致用-c copy(快速无损),不一致用mpeg4重编码(兼容降级) + */ + private void concatVideosDemuxer(Path tempDir, List videoFiles, Path output) throws Exception { + // 创建concat demuxer所需的文件列表 + Path fileList = tempDir.resolve("filelist.txt"); + StringBuilder sb = new StringBuilder(); + for (Path video : videoFiles) { + sb.append("file '").append(video.toAbsolutePath()).append("'\n"); + } + Files.writeString(fileList, sb.toString()); + log.info("concat文件列表:\n{}", sb); + + // 探测所有视频参数,判断是否一致 + boolean paramsConsistent = checkVideoParamsConsistent(videoFiles); + + if (paramsConsistent) { + // 参数一致,直接流复制(快速,无质量损失) + log.info("所有视频参数一致,使用-c copy模式拼接"); + List cmd = Arrays.asList( + "ffmpeg", "-y", + "-f", "concat", + "-safe", "0", + "-i", fileList.toAbsolutePath().toString(), + "-map", "0:v:0", + "-map", "0:a:0?", + "-c", "copy", + "-movflags", "+faststart", + output.toAbsolutePath().toString() + ); + executeFFmpeg(cmd, "视频拼接(copy)"); + } else { + // 参数不一致,使用内置mpeg4编码器重编码(服务器无libx264/libopenh264) + log.warn("视频参数不一致,降级为mpeg4重编码模式"); + List cmd = Arrays.asList( + "ffmpeg", "-y", + "-f", "concat", + "-safe", "0", + "-i", fileList.toAbsolutePath().toString(), + "-map", "0:v:0", + "-map", "0:a:0?", + "-c:v", "mpeg4", + "-q:v", "2", // 高质量(1-31,越小越好) + "-pix_fmt", "yuv420p", + "-r", "30", + "-c:a", "aac", + "-b:a", "128k", + "-movflags", "+faststart", + output.toAbsolutePath().toString() + ); + executeFFmpeg(cmd, "视频拼接(mpeg4重编码)"); + } + } + + /** + * 使用ffprobe探测所有视频的分辨率和帧率,检查是否一致 + */ + private boolean checkVideoParamsConsistent(List videoFiles) { + if (videoFiles.size() <= 1) return true; + + String firstParams = null; + for (Path video : videoFiles) { + try { + List cmd = Arrays.asList( + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,r_frame_rate,codec_name", + "-of", "csv=p=0", + video.toAbsolutePath().toString() + ); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process process = pb.start(); + + String output; + try (var reader = new java.io.BufferedReader(new java.io.InputStreamReader(process.getInputStream()))) { + output = reader.lines().collect(java.util.stream.Collectors.joining("\n")).trim(); + } + process.waitFor(); + + log.info("视频参数探测 {}: {}", video.getFileName(), output); + + if (firstParams == null) { + firstParams = output; + } else if (!firstParams.equals(output)) { + log.warn("视频参数不一致: 第一个={}, 当前={}", firstParams, output); + return false; + } + } catch (Exception e) { + log.warn("ffprobe探测失败: {}, 降级为重编码模式", e.getMessage()); + return false; + } + } + return true; + } + + /** + * 执行FFmpeg命令,捕获输出日志 + */ + private void executeFFmpeg(List cmd, String taskName) throws Exception { + log.info("执行FFmpeg {} - 命令: {}", taskName, String.join(" ", cmd)); + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process process = pb.start(); + + // 读取FFmpeg输出(防止缓冲区满导致进程阻塞) + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append("\n"); + } + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + log.error("FFmpeg {} 失败 - exitCode: {}, 输出:\n{}", taskName, exitCode, output); + throw new RuntimeException("FFmpeg " + taskName + " 失败,exitCode: " + exitCode); + } + + log.info("FFmpeg {} 成功", taskName); + } + + /** + * 下载文件到本地 + */ + private void downloadFile(String url, Path target) throws Exception { + try (InputStream in = new URL(url).openStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * 上传文件到COS + */ + private void uploadToCos(Path file, String cosKey) throws Exception { + byte[] data = Files.readAllBytes(file); + ByteArrayInputStream inputStream = new ByteArrayInputStream(data); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(data.length); + metadata.setContentType("video/mp4"); + + PutObjectRequest putRequest = new PutObjectRequest( + cosConfig.getBucketName(), + cosKey, + inputStream, + metadata + ); + + cosClient.putObject(putRequest); + } + + /** + * 清理临时目录及其中所有文件 + */ + private void cleanupTempDir(Path tempDir) { + try { + if (tempDir != null && Files.exists(tempDir)) { + Files.walk(tempDir) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("删除临时文件失败: {}", path, e); + } + }); + log.info("临时目录已清理: {}", tempDir); + } + } catch (Exception e) { + log.warn("清理临时目录失败: {}", tempDir, e); + } + } +} diff --git a/src/main/java/com/dora/service/impl/WorkCategoryServiceImpl.java b/src/main/java/com/dora/service/impl/WorkCategoryServiceImpl.java new file mode 100644 index 0000000..ac4c714 --- /dev/null +++ b/src/main/java/com/dora/service/impl/WorkCategoryServiceImpl.java @@ -0,0 +1,67 @@ +package com.dora.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.dora.entity.WorkCategory; +import com.dora.mapper.WorkCategoryMapper; +import com.dora.service.WorkCategoryService; +import com.dora.vo.CategoryVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 作品分类服务实现 + * + * @author dora + */ +@Service +@RequiredArgsConstructor +public class WorkCategoryServiceImpl implements WorkCategoryService { + + private final WorkCategoryMapper workCategoryMapper; + + @Override + public List listCategories() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(WorkCategory::getStatus, 1) + .eq(WorkCategory::getDeleted, 0) + .orderByAsc(WorkCategory::getSort) + .orderByAsc(WorkCategory::getId); + + List categories = workCategoryMapper.selectList(wrapper); + + return categories.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + /** + * 获取所有分类(管理端使用,包含禁用的) + */ + public List listAllCategories() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(WorkCategory::getDeleted, 0) + .orderByAsc(WorkCategory::getSort) + .orderByAsc(WorkCategory::getId); + + List categories = workCategoryMapper.selectList(wrapper); + + return categories.stream() + .map(this::convertToVO) + .collect(Collectors.toList()); + } + + private CategoryVO convertToVO(WorkCategory category) { + CategoryVO vo = new CategoryVO(); + vo.setId(category.getId()); + vo.setParentId(category.getParentId()); + vo.setName(category.getName()); + vo.setIcon(category.getIcon()); + vo.setSort(category.getSort()); + vo.setStatus(category.getStatus()); + vo.setCreatedAt(category.getCreatedAt()); + return vo; + } +} diff --git a/src/main/java/com/dora/service/impl/WxSubscribeMessageServiceImpl.java b/src/main/java/com/dora/service/impl/WxSubscribeMessageServiceImpl.java new file mode 100644 index 0000000..481aa13 --- /dev/null +++ b/src/main/java/com/dora/service/impl/WxSubscribeMessageServiceImpl.java @@ -0,0 +1,173 @@ +package com.dora.service.impl; + +import com.dora.entity.User; +import com.dora.mapper.UserMapper; +import com.dora.service.WxSubscribeMessageService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * 微信订阅消息服务实现 + * + * 模板:AI内容创作结果通知 (pyDi6nvC0sze6DBUAmZLm_AKz2WCfixchWql7DoA9OI) + * 字段: + * - phrase1: 创作状态 + * - short_thing2: 内容类型 + * - time4: 创作时间 + * - time5: 生成时间 + * - thing8: 温馨提示 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WxSubscribeMessageServiceImpl implements WxSubscribeMessageService { + + private final UserMapper userMapper; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${wx.miniapp.appid:}") + private String appId; + + @Value("${wx.miniapp.secret:}") + private String appSecret; + + /** AI任务完成通知模板ID */ + @Value("${wx.subscribe.task-complete-template:}") + private String taskCompleteTemplateId; + + private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s"; + private static final String SEND_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s"; + + @Override + public void sendTaskCompleteNotify(Long userId, String taskNo, String modelName, String status, String message) { + try { + // 检查模板ID是否配置 + if (!StringUtils.hasText(taskCompleteTemplateId)) { + log.warn("订阅消息模板ID未配置,跳过发送通知"); + return; + } + + // 获取用户openid + User user = userMapper.selectById(userId); + if (user == null || !StringUtils.hasText(user.getOpenid())) { + log.warn("用户不存在或openid为空,无法发送订阅消息 - userId: {}", userId); + return; + } + + // 获取access_token + String accessToken = getAccessToken(); + if (!StringUtils.hasText(accessToken)) { + log.error("获取access_token失败,无法发送订阅消息"); + return; + } + + // 构建消息内容 - 按照模板字段 + Map msgData = new HashMap<>(); + + // phrase1: 创作状态(5个以内汉字) + Map phrase1 = new HashMap<>(); + phrase1.put("value", "success".equals(status) ? "已完成" : "已失败"); + msgData.put("phrase1", phrase1); + + // short_thing2: 内容类型(10个以内字符) + Map shortThing2 = new HashMap<>(); + shortThing2.put("value", truncateString(modelName, 10)); + msgData.put("short_thing2", shortThing2); + + // time4: 创作时间 + Map time4 = new HashMap<>(); + time4.put("value", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + msgData.put("time4", time4); + + // time5: 生成时间 + Map time5 = new HashMap<>(); + time5.put("value", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + msgData.put("time5", time5); + + // thing8: 温馨提示(20个以内字符) + Map thing8 = new HashMap<>(); + String tip = "success".equals(status) ? "点击查看您的创作结果" : truncateString(message != null ? message : "任务执行失败", 20); + thing8.put("value", tip); + msgData.put("thing8", thing8); + + // 构建请求体 + Map requestBody = new HashMap<>(); + requestBody.put("touser", user.getOpenid()); + requestBody.put("template_id", taskCompleteTemplateId); + requestBody.put("page", "/pages/ai/task?taskNo=" + taskNo); + requestBody.put("data", msgData); + requestBody.put("miniprogram_state", "formal"); // formal正式版, trial体验版, developer开发版 + + // 发送请求 + String url = String.format(SEND_MESSAGE_URL, accessToken); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + String jsonBody = objectMapper.writeValueAsString(requestBody); + log.info("发送订阅消息请求: {}", jsonBody); + + HttpEntity entity = new HttpEntity<>(jsonBody, headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + Map result = objectMapper.readValue(response.getBody(), Map.class); + Integer errcode = (Integer) result.get("errcode"); + if (errcode != null && errcode == 0) { + log.info("订阅消息发送成功 - userId: {}, taskNo: {}", userId, taskNo); + } else { + log.warn("订阅消息发送失败 - userId: {}, taskNo: {}, response: {}", userId, taskNo, response.getBody()); + } + } else { + log.error("订阅消息发送请求失败 - statusCode: {}", response.getStatusCode()); + } + + } catch (Exception e) { + log.error("发送订阅消息异常 - userId: {}, taskNo: {}, error: {}", userId, taskNo, e.getMessage(), e); + } + } + + /** + * 获取微信access_token + */ + private String getAccessToken() { + try { + if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) { + log.warn("微信小程序appId或secret未配置"); + return null; + } + + String url = String.format(ACCESS_TOKEN_URL, appId, appSecret); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + Map result = objectMapper.readValue(response.getBody(), Map.class); + return (String) result.get("access_token"); + } + } catch (Exception e) { + log.error("获取access_token异常: {}", e.getMessage(), e); + } + return null; + } + + /** + * 截断字符串 + */ + private String truncateString(String str, int maxLength) { + if (str == null) return ""; + if (str.length() <= maxLength) return str; + return str.substring(0, maxLength - 3) + "..."; + } +} diff --git a/src/main/java/com/dora/service/package-info.java b/src/main/java/com/dora/service/package-info.java new file mode 100644 index 0000000..c2b0a40 --- /dev/null +++ b/src/main/java/com/dora/service/package-info.java @@ -0,0 +1,4 @@ +/** + * 业务服务层 + */ +package com.dora.service; diff --git a/src/main/java/com/dora/util/AdminJwtUtil.java b/src/main/java/com/dora/util/AdminJwtUtil.java new file mode 100644 index 0000000..fab1bac --- /dev/null +++ b/src/main/java/com/dora/util/AdminJwtUtil.java @@ -0,0 +1,130 @@ +package com.dora.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 管理员JWT工具类 + */ +@Slf4j +@Component +public class AdminJwtUtil { + + @Value("${jwt.admin-secret:1818ai-admin-jwt-secret-key-must-be-at-least-32-bytes}") + private String secret; + + @Value("${jwt.admin-access-expire:7200}") + private Long accessTokenExpire; + + @Value("${jwt.admin-refresh-expire:604800}") + private Long refreshTokenExpire; + + private static final String CLAIM_ADMIN_ID = "adminId"; + private static final String CLAIM_USERNAME = "username"; + private static final String CLAIM_ROLES = "roles"; + private static final String CLAIM_TOKEN_TYPE = "tokenType"; + private static final String TOKEN_TYPE_ACCESS = "access"; + private static final String TOKEN_TYPE_REFRESH = "refresh"; + + private SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 生成管理员 Access Token + */ + public String generateAccessToken(Long adminId, String username, List roles) { + return generateToken(adminId, username, roles, TOKEN_TYPE_ACCESS, accessTokenExpire); + } + + /** + * 生成管理员 Refresh Token + */ + public String generateRefreshToken(Long adminId, String username) { + return generateToken(adminId, username, null, TOKEN_TYPE_REFRESH, refreshTokenExpire); + } + + private String generateToken(Long adminId, String username, List roles, String tokenType, Long expireSeconds) { + Date now = new Date(); + Date expireDate = new Date(now.getTime() + expireSeconds * 1000); + + Map claims = new HashMap<>(); + claims.put(CLAIM_ADMIN_ID, adminId); + claims.put(CLAIM_USERNAME, username); + claims.put(CLAIM_TOKEN_TYPE, tokenType); + if (roles != null) { + claims.put(CLAIM_ROLES, roles); + } + + return Jwts.builder() + .claims(claims) + .subject(String.valueOf(adminId)) + .issuer("1818ai-admin") + .issuedAt(now) + .expiration(expireDate) + .signWith(getSigningKey(), Jwts.SIG.HS256) + .compact(); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public Long getAdminIdFromToken(String token) { + Claims claims = parseToken(token); + Object adminId = claims.get(CLAIM_ADMIN_ID); + if (adminId instanceof Integer) { + return ((Integer) adminId).longValue(); + } + return (Long) adminId; + } + + public String getUsernameFromToken(String token) { + Claims claims = parseToken(token); + return (String) claims.get(CLAIM_USERNAME); + } + + @SuppressWarnings("unchecked") + public List getRolesFromToken(String token) { + Claims claims = parseToken(token); + return (List) claims.get(CLAIM_ROLES); + } + + public boolean isAccessToken(String token) { + Claims claims = parseToken(token); + return TOKEN_TYPE_ACCESS.equals(claims.get(CLAIM_TOKEN_TYPE)); + } + + public boolean isRefreshToken(String token) { + Claims claims = parseToken(token); + return TOKEN_TYPE_REFRESH.equals(claims.get(CLAIM_TOKEN_TYPE)); + } + + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } + + public Long getAccessTokenExpire() { + return accessTokenExpire; + } +} diff --git a/src/main/java/com/dora/util/HttpClientUtil.java b/src/main/java/com/dora/util/HttpClientUtil.java new file mode 100644 index 0000000..dccd89a --- /dev/null +++ b/src/main/java/com/dora/util/HttpClientUtil.java @@ -0,0 +1,235 @@ +package com.dora.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; + +/** + * HTTP客户端工具类 + */ +@Slf4j +@Component +public class HttpClientUtil { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + + public HttpClientUtil(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + } + + /** + * 发送HTTP请求 + */ + public String sendRequest(String url, String method, Map headers, + Object body, int timeoutSeconds) throws Exception { + log.info("发送HTTP请求 - URL: {}, Method: {}", url, method); + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(timeoutSeconds)); + + // 添加请求头 + if (headers != null) { + headers.forEach(requestBuilder::header); + } + + // 获取Content-Type + String contentType = headers != null ? headers.get("Content-Type") : null; + if (contentType == null) { + contentType = "application/json"; + } + + // 设置请求方法和请求体 + String requestBody = null; + if (body != null) { + if (body instanceof String) { + requestBody = (String) body; + } else if (contentType.contains("application/x-www-form-urlencoded")) { + // 表单格式 + requestBody = buildFormBody(body); + } else { + // JSON格式 + requestBody = objectMapper.writeValueAsString(body); + } + } + + log.debug("请求体: {}", requestBody); + + switch (method.toUpperCase()) { + case "POST": + requestBuilder.POST(requestBody != null ? + HttpRequest.BodyPublishers.ofString(requestBody) : + HttpRequest.BodyPublishers.noBody()); + break; + case "PUT": + requestBuilder.PUT(requestBody != null ? + HttpRequest.BodyPublishers.ofString(requestBody) : + HttpRequest.BodyPublishers.noBody()); + break; + case "DELETE": + requestBuilder.DELETE(); + break; + default: + requestBuilder.GET(); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + log.info("HTTP响应 - Status: {}, Body长度: {}", response.statusCode(), + response.body() != null ? response.body().length() : 0); + + if (response.statusCode() >= 400) { + throw new RuntimeException("HTTP请求失败, 状态码: " + response.statusCode() + ", 响应: " + response.body()); + } + + return response.body(); + } + + /** + * 构建表单格式的请求体 + */ + @SuppressWarnings("unchecked") + private String buildFormBody(Object body) { + StringBuilder sb = new StringBuilder(); + Map params; + + if (body instanceof Map) { + params = (Map) body; + } else { + // 尝试转换为Map + params = objectMapper.convertValue(body, Map.class); + } + + boolean first = true; + for (Map.Entry entry : params.entrySet()) { + Object value = entry.getValue(); + // 跳过空值 + if (value == null || (value instanceof String && ((String) value).isEmpty())) { + continue; + } + + if (!first) { + sb.append("&"); + } + sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + sb.append("="); + // List/Map类型序列化为JSON字符串 + if (value instanceof List || value instanceof Map || (value != null && value.getClass().isArray())) { + try { + sb.append(URLEncoder.encode(objectMapper.writeValueAsString(value), StandardCharsets.UTF_8)); + } catch (Exception e) { + sb.append(URLEncoder.encode(String.valueOf(value), StandardCharsets.UTF_8)); + } + } else { + sb.append(URLEncoder.encode(String.valueOf(value), StandardCharsets.UTF_8)); + } + first = false; + } + + return sb.toString(); + } + + /** + * 发送GET请求 + */ + public String get(String url, Map headers, int timeoutSeconds) throws Exception { + return sendRequest(url, "GET", headers, null, timeoutSeconds); + } + + /** + * 发送POST请求 + */ + public String post(String url, Map headers, Object body, int timeoutSeconds) throws Exception { + return sendRequest(url, "POST", headers, body, timeoutSeconds); + } + + /** + * 使用HttpURLConnection发送请求(支持Host等受限头,用于腾讯云等需要签名的API) + */ + public String sendRawRequest(String url, String method, Map headers, + String body, int timeoutSeconds) throws Exception { + log.info("发送Raw HTTP请求 - URL: {}, Method: {}", url, method); + log.debug("请求体: {}", body); + + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(timeoutSeconds * 1000); + conn.setReadTimeout(timeoutSeconds * 1000); + conn.setDoOutput(body != null); + + if (headers != null) { + headers.forEach((k, v) -> { + log.debug("Raw HTTP请求头 - {}: {}", k, k.equalsIgnoreCase("Authorization") ? v.substring(0, Math.min(80, v.length())) + "..." : v); + conn.setRequestProperty(k, v); + }); + } + + if (body != null) { + try (OutputStream os = conn.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + + int statusCode = conn.getResponseCode(); + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader( + statusCode >= 400 ? conn.getErrorStream() : conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + } + + String responseBody = sb.toString(); + log.info("Raw HTTP响应 - Status: {}, Body长度: {}", statusCode, responseBody.length()); + + if (statusCode >= 400) { + throw new RuntimeException("HTTP请求失败, 状态码: " + statusCode + ", 响应: " + responseBody); + } + + return responseBody; + } + + public String buildUrlWithParams(String baseUrl, Map params) { + if (params == null || params.isEmpty()) { + return baseUrl; + } + + StringBuilder sb = new StringBuilder(baseUrl); + sb.append(baseUrl.contains("?") ? "&" : "?"); + + boolean first = true; + for (Map.Entry entry : params.entrySet()) { + if (!first) { + sb.append("&"); + } + sb.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)); + sb.append("="); + sb.append(URLEncoder.encode(String.valueOf(entry.getValue()), StandardCharsets.UTF_8)); + first = false; + } + + return sb.toString(); + } +} diff --git a/src/main/java/com/dora/util/JwtUtil.java b/src/main/java/com/dora/util/JwtUtil.java new file mode 100644 index 0000000..f11baa9 --- /dev/null +++ b/src/main/java/com/dora/util/JwtUtil.java @@ -0,0 +1,215 @@ +package com.dora.util; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * JWT工具类 + */ +@Slf4j +@Component +public class JwtUtil { + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expire}") + private Long accessTokenExpire; + + @Value("${jwt.refresh-token-expire}") + private Long refreshTokenExpire; + + @Value("${jwt.issuer}") + private String issuer; + + private static final String CLAIM_USER_ID = "userId"; + private static final String CLAIM_OPENID = "openid"; + private static final String CLAIM_TOKEN_TYPE = "tokenType"; + private static final String TOKEN_TYPE_ACCESS = "access"; + private static final String TOKEN_TYPE_REFRESH = "refresh"; + + /** + * 获取签名密钥 + */ + private SecretKey getSigningKey() { + byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** + * 生成 Access Token + */ + public String generateAccessToken(Long userId, String openid) { + return generateToken(userId, openid, TOKEN_TYPE_ACCESS, accessTokenExpire); + } + + /** + * 生成 Refresh Token + */ + public String generateRefreshToken(Long userId, String openid) { + return generateToken(userId, openid, TOKEN_TYPE_REFRESH, refreshTokenExpire); + } + + + /** + * 生成Token + */ + private String generateToken(Long userId, String openid, String tokenType, Long expireSeconds) { + Date now = new Date(); + Date expireDate = new Date(now.getTime() + expireSeconds * 1000); + + Map claims = new HashMap<>(); + claims.put(CLAIM_USER_ID, userId); + claims.put(CLAIM_OPENID, openid); + claims.put(CLAIM_TOKEN_TYPE, tokenType); + + return Jwts.builder() + .claims(claims) + .subject(String.valueOf(userId)) + .issuer(issuer) + .issuedAt(now) + .expiration(expireDate) + .signWith(getSigningKey(), Jwts.SIG.HS256) + .compact(); + } + + /** + * 解析Token + */ + public Claims parseToken(String token) { + try { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + log.warn("Token已过期: {}", e.getMessage()); + throw e; + } catch (JwtException e) { + log.error("Token解析失败: {}", e.getMessage()); + throw e; + } + } + + /** + * 从Token中获取用户ID + */ + public Long getUserIdFromToken(String token) { + Claims claims = parseToken(token); + Object userId = claims.get(CLAIM_USER_ID); + if (userId instanceof Integer) { + return ((Integer) userId).longValue(); + } + return (Long) userId; + } + + /** + * 从Token中获取openid + */ + public String getOpenidFromToken(String token) { + Claims claims = parseToken(token); + return (String) claims.get(CLAIM_OPENID); + } + + /** + * 判断是否是Refresh Token + */ + public boolean isRefreshToken(String token) { + Claims claims = parseToken(token); + return TOKEN_TYPE_REFRESH.equals(claims.get(CLAIM_TOKEN_TYPE)); + } + + /** + * 判断是否是Access Token + */ + public boolean isAccessToken(String token) { + Claims claims = parseToken(token); + return TOKEN_TYPE_ACCESS.equals(claims.get(CLAIM_TOKEN_TYPE)); + } + + /** + * 验证Token是否有效(不抛异常版本) + */ + public boolean validateToken(String token) { + try { + parseToken(token); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * 判断Token是否过期 + */ + public boolean isTokenExpired(String token) { + try { + Claims claims = parseToken(token); + return claims.getExpiration().before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } catch (Exception e) { + return true; + } + } + + /** + * 获取Token剩余有效时间(秒) + */ + public long getTokenRemainingTime(String token) { + try { + Claims claims = parseToken(token); + Date expiration = claims.getExpiration(); + long remaining = (expiration.getTime() - System.currentTimeMillis()) / 1000; + return Math.max(0, remaining); + } catch (Exception e) { + return 0; + } + } + + /** + * 获取Access Token过期时间(秒) + */ + public Long getAccessTokenExpire() { + return accessTokenExpire; + } + + /** + * 获取Refresh Token过期时间(秒) + */ + public Long getRefreshTokenExpire() { + return refreshTokenExpire; + } + + /** + * 从HttpServletRequest中获取用户ID + */ + public Long getUserIdFromRequest(jakarta.servlet.http.HttpServletRequest request) { + String token = getTokenFromRequest(request); + if (token == null) { + throw new RuntimeException("Token不存在"); + } + return getUserIdFromToken(token); + } + + /** + * 从HttpServletRequest中获取Token + */ + private String getTokenFromRequest(jakarta.servlet.http.HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/dora/util/RedisUtil.java b/src/main/java/com/dora/util/RedisUtil.java new file mode 100644 index 0000000..2ed922d --- /dev/null +++ b/src/main/java/com/dora/util/RedisUtil.java @@ -0,0 +1,68 @@ +package com.dora.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * Redis 工具类 + * + * @author dora + */ +@Component +@RequiredArgsConstructor +public class RedisUtil { + + private final RedisTemplate redisTemplate; + + /** + * 设置缓存 + */ + public void set(String key, Object value) { + redisTemplate.opsForValue().set(key, value); + } + + /** + * 设置缓存(带过期时间) + */ + public void set(String key, Object value, long timeout, TimeUnit unit) { + redisTemplate.opsForValue().set(key, value, timeout, unit); + } + + /** + * 获取缓存 + */ + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + /** + * 删除缓存 + */ + public Boolean delete(String key) { + return redisTemplate.delete(key); + } + + /** + * 判断 key 是否存在 + */ + public Boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } + + /** + * 设置过期时间 + */ + public Boolean expire(String key, long timeout, TimeUnit unit) { + return redisTemplate.expire(key, timeout, unit); + } + + /** + * 获取过期时间 + */ + public Long getExpire(String key) { + return redisTemplate.getExpire(key); + } +} diff --git a/src/main/java/com/dora/util/TencentCloudSignUtil.java b/src/main/java/com/dora/util/TencentCloudSignUtil.java new file mode 100644 index 0000000..1284ae8 --- /dev/null +++ b/src/main/java/com/dora/util/TencentCloudSignUtil.java @@ -0,0 +1,222 @@ +package com.dora.util; + +import lombok.extern.slf4j.Slf4j; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.text.SimpleDateFormat; +import java.util.*; + +/** + * 腾讯云 API 签名工具类(TC3-HMAC-SHA256) + * 参考文档: https://cloud.tencent.com/document/api/1668/88065 + * + * 签名流程: + * 1. 拼接规范请求串 (CanonicalRequest) + * 2. 拼接待签名字符串 (StringToSign) + * 3. 计算签名 (Signature) + * 4. 拼接 Authorization + */ +@Slf4j +public class TencentCloudSignUtil { + + private static final String ALGORITHM = "TC3-HMAC-SHA256"; + private static final String TC3_REQUEST = "tc3_request"; + private static final String HMAC_SHA256 = "HmacSHA256"; + private static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8"; + + /** + * 生成腾讯云 API 请求的完整 Headers(含 Authorization 签名) + * + * @param secretId 密钥ID + * @param secretKey 密钥Key + * @param service 服务名,如 aiart, vod, cvm 等 + * @param host 请求域名,如 aiart.tencentcloudapi.com + * @param action 接口名,如 QueryTextToImageJob + * @param version 接口版本,如 2022-12-29 + * @param region 地域,如 ap-guangzhou + * @param payload 请求体 JSON 字符串 + * @return 包含所有必要请求头的 Map + */ + public static Map buildHeaders(String secretId, String secretKey, + String service, String host, + String action, String version, + String region, String payload) { + return buildHeaders(secretId, secretKey, service, host, action, version, region, payload, null, null); + } + + /** + * 生成腾讯云 API 请求的完整 Headers(含 Authorization 签名) + * + * @param secretId 密钥ID + * @param secretKey 密钥Key + * @param service 服务名,如 aiart, vod, cvm 等 + * @param host 请求域名,如 aiart.tencentcloudapi.com + * @param action 接口名,如 QueryTextToImageJob + * @param version 接口版本,如 2022-12-29 + * @param region 地域,如 ap-guangzhou + * @param payload 请求体 JSON 字符串 + * @param timestamp 时间戳(秒),传 null 则使用当前时间 + * @param token 临时密钥的 Token,传 null 则不添加 + * @return 包含所有必要请求头的 Map + */ + public static Map buildHeaders(String secretId, String secretKey, + String service, String host, + String action, String version, + String region, String payload, + Long timestamp, String token) { + if (timestamp == null) { + timestamp = System.currentTimeMillis() / 1000; + } + if (payload == null) { + payload = "{}"; + } + + String authorization = sign(secretId, secretKey, service, host, action, timestamp, payload); + + Map headers = new LinkedHashMap<>(); + headers.put("Host", host); + headers.put("Content-Type", CONTENT_TYPE_JSON); + headers.put("X-TC-Action", action); + headers.put("X-TC-Version", version); + headers.put("X-TC-Timestamp", String.valueOf(timestamp)); + if (region != null && !region.isEmpty()) { + headers.put("X-TC-Region", region); + } + headers.put("X-TC-Language", "zh-CN"); + headers.put("Authorization", authorization); + if (token != null && !token.isEmpty()) { + headers.put("X-TC-Token", token); + } + + return headers; + } + + /** + * 生成 TC3-HMAC-SHA256 签名的 Authorization 字符串 + * + * @param secretId 密钥ID + * @param secretKey 密钥Key + * @param service 服务名 + * @param host 请求域名 + * @param action 接口名 + * @param timestamp 时间戳(秒) + * @param payload 请求体 JSON 字符串 + * @return 完整的 Authorization 值 + */ + public static String sign(String secretId, String secretKey, + String service, String host, + String action, long timestamp, String payload) { + try { + // ============ 步骤1: 拼接规范请求串 ============ + String date = getUtcDate(timestamp); + String canonicalRequest = buildCanonicalRequest(host, action, payload); + log.debug("规范请求串:\n{}", canonicalRequest); + + // ============ 步骤2: 拼接待签名字符串 ============ + String credentialScope = date + "/" + service + "/" + TC3_REQUEST; + String hashedCanonicalRequest = sha256Hex(canonicalRequest); + String stringToSign = ALGORITHM + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; + log.debug("待签名字符串:\n{}", stringToSign); + + // ============ 步骤3: 计算签名 ============ + byte[] secretDate = hmacSha256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); + byte[] secretService = hmacSha256(secretDate, service); + byte[] secretSigning = hmacSha256(secretService, TC3_REQUEST); + String signature = bytesToHex(hmacSha256(secretSigning, stringToSign)); + log.debug("签名: {}", signature); + + // ============ 步骤4: 拼接 Authorization ============ + String signedHeaders = "content-type;host;x-tc-action"; + String authorization = ALGORITHM + + " Credential=" + secretId + "/" + credentialScope + + ", SignedHeaders=" + signedHeaders + + ", Signature=" + signature; + log.debug("Authorization: {}", authorization); + + return authorization; + + } catch (Exception e) { + log.error("腾讯云签名生成失败: {}", e.getMessage(), e); + throw new RuntimeException("腾讯云签名生成失败", e); + } + } + + /** + * 步骤1: 构建规范请求串 (CanonicalRequest) + * + * 格式: + * HTTPRequestMethod + '\n' + + * CanonicalURI + '\n' + + * CanonicalQueryString + '\n' + + * CanonicalHeaders + '\n' + + * SignedHeaders + '\n' + + * HashedRequestPayload + */ + private static String buildCanonicalRequest(String host, String action, String payload) { + String httpRequestMethod = "POST"; + String canonicalUri = "/"; + String canonicalQueryString = ""; + + // 参与签名的头部(必须小写、按字典序排列,与官方Java示例保持一致) + String canonicalHeaders = "content-type:" + CONTENT_TYPE_JSON + "\n" + + "host:" + host + "\n" + + "x-tc-action:" + action.toLowerCase() + "\n"; + + String signedHeaders = "content-type;host;x-tc-action"; + String hashedPayload = sha256Hex(payload); + + return httpRequestMethod + "\n" + + canonicalUri + "\n" + + canonicalQueryString + "\n" + + canonicalHeaders + "\n" + + signedHeaders + "\n" + + hashedPayload; + } + + // ======================== 加密工具方法 ======================== + + /** + * HMAC-SHA256 签名 + */ + private static byte[] hmacSha256(byte[] key, String data) throws Exception { + Mac mac = Mac.getInstance(HMAC_SHA256); + mac.init(new SecretKeySpec(key, HMAC_SHA256)); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } + + /** + * SHA-256 哈希并转为十六进制字符串 + */ + private static String sha256Hex(String data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(data.getBytes(StandardCharsets.UTF_8)); + return bytesToHex(digest); + } catch (Exception e) { + throw new RuntimeException("SHA-256计算失败", e); + } + } + + /** + * 字节数组转十六进制小写字符串 + */ + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b & 0xff)); + } + return sb.toString(); + } + + /** + * 根据时间戳获取 UTC 日期字符串 (yyyy-MM-dd) + */ + private static String getUtcDate(long timestamp) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf.format(new Date(timestamp * 1000)); + } +} diff --git a/src/main/java/com/dora/util/package-info.java b/src/main/java/com/dora/util/package-info.java new file mode 100644 index 0000000..be40758 --- /dev/null +++ b/src/main/java/com/dora/util/package-info.java @@ -0,0 +1,4 @@ +/** + * 工具类层 + */ +package com.dora.util; diff --git a/src/main/java/com/dora/vo/AiModelSimpleVO.java b/src/main/java/com/dora/vo/AiModelSimpleVO.java new file mode 100644 index 0000000..4e962e7 --- /dev/null +++ b/src/main/java/com/dora/vo/AiModelSimpleVO.java @@ -0,0 +1,22 @@ +package com.dora.vo; + +import lombok.Data; + +/** + * AI模型简化VO - 用户端使用,不包含敏感信息 + */ +@Data +public class AiModelSimpleVO { + private Long id; + private String name; + private String code; + private String type; + private String category; + private String description; + private String icon; + private String coverImage; + /** 输入参数配置(前端表单渲染用) */ + private String inputParams; + /** 消耗积分 */ + private Integer pointsCost; +} diff --git a/src/main/java/com/dora/vo/AiModelVO.java b/src/main/java/com/dora/vo/AiModelVO.java new file mode 100644 index 0000000..be4fde7 --- /dev/null +++ b/src/main/java/com/dora/vo/AiModelVO.java @@ -0,0 +1,61 @@ +package com.dora.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class AiModelVO { + private Long id; + private Long providerId; + private String providerName; + private String providerBaseUrl; + private String providerApiKey; + private String providerSecretKey; + private String name; + private String code; + private String type; + private String category; + private String description; + private String icon; + /** 封面图URL */ + private String coverImage; + private String apiEndpoint; + private String requestMethod; + private String requestHeaders; + private String requestTemplate; + private String responseMapping; + private String inputParams; + private Integer pointsCost; + private Integer maxConcurrent; + private Integer timeout; + private String workflowType; + private String workflowId; + private String workflowConfig; + private Integer isAsync; + /** 异步任务查询端点 */ + private String asyncQueryEndpoint; + /** 异步查询请求方法 */ + private String asyncQueryMethod; + /** 异步查询请求体模板 */ + private String asyncQueryBody; + /** 异步查询响应映射 */ + private String asyncQueryMapping; + /** 异步状态值映射 */ + private String asyncStatusMapping; + /** 轮询间隔(毫秒) */ + private Integer asyncPollInterval; + /** 最大轮询次数 */ + private Integer asyncPollMaxCount; + /** 轮询超时时间(秒) */ + private Integer asyncPollTimeout; + /** 是否将结果URL转存到COS:0否 1是 */ + private Integer resultTransferCos; + /** 结果中的URL字段路径,如 data.remote_url */ + private String resultUrlField; + /** 是否显示在AI功能列表:0不显示 1显示 */ + private Integer showInList; + private Integer status; + private Integer sort; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dora/vo/AiProviderVO.java b/src/main/java/com/dora/vo/AiProviderVO.java new file mode 100644 index 0000000..774a09a --- /dev/null +++ b/src/main/java/com/dora/vo/AiProviderVO.java @@ -0,0 +1,20 @@ +package com.dora.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class AiProviderVO { + private Long id; + private String name; + private String code; + private String description; + private String baseUrl; + private String apiKey; + private String secretKey; + private String extraConfig; + private Integer status; + private Integer sort; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dora/vo/AiTaskVO.java b/src/main/java/com/dora/vo/AiTaskVO.java new file mode 100644 index 0000000..849246f --- /dev/null +++ b/src/main/java/com/dora/vo/AiTaskVO.java @@ -0,0 +1,37 @@ +package com.dora.vo; + +import lombok.Data; +import java.time.LocalDateTime; + +@Data +public class AiTaskVO { + private Long id; + private String taskNo; + private Long userId; + private String userNickname; + private Long modelId; + private String modelName; + private String modelCode; + private String inputParams; + private String outputResult; + private Integer pointsCost; + private Integer status; + private String statusText; + private Integer progress; + private String errorMessage; + private String externalTaskId; + private Integer pollCount; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer duration; + private Integer retryCount; + private Integer maxRetry; + private Integer priority; + private String ip; + /** 发布状态:0未发布 1审核中 2已发布 3审核未通过 */ + private Integer publishStatus; + /** 关联的作品ID */ + private Long workId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/com/dora/vo/CategoryVO.java b/src/main/java/com/dora/vo/CategoryVO.java new file mode 100644 index 0000000..387eeda --- /dev/null +++ b/src/main/java/com/dora/vo/CategoryVO.java @@ -0,0 +1,31 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 分类VO + * + * @author dora + */ +@Data +public class CategoryVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private Long parentId; + + private String name; + + private String icon; + + private Integer sort; + + private Integer status; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/CharacterTemplateVO.java b/src/main/java/com/dora/vo/CharacterTemplateVO.java new file mode 100644 index 0000000..24dfbdf --- /dev/null +++ b/src/main/java/com/dora/vo/CharacterTemplateVO.java @@ -0,0 +1,17 @@ +package com.dora.vo; + +import lombok.Data; + +@Data +public class CharacterTemplateVO { + private Long id; + private String name; + private String gender; + private String ageRange; + private String voiceType; + private String appearance; + private String clothing; + private String description; + private String imageUrl; + private String category; +} diff --git a/src/main/java/com/dora/vo/InviteRecordVO.java b/src/main/java/com/dora/vo/InviteRecordVO.java new file mode 100644 index 0000000..75a773b --- /dev/null +++ b/src/main/java/com/dora/vo/InviteRecordVO.java @@ -0,0 +1,28 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 邀请记录VO + */ +@Data +@Schema(description = "邀请记录") +public class InviteRecordVO { + + @Schema(description = "记录ID") + private Long id; + + @Schema(description = "被邀请用户昵称") + private String nickname; + + @Schema(description = "被邀请用户头像") + private String avatar; + + @Schema(description = "奖励积分") + private Integer rewardPoints; + + @Schema(description = "邀请时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/InviteStatsVO.java b/src/main/java/com/dora/vo/InviteStatsVO.java new file mode 100644 index 0000000..ce1e6d2 --- /dev/null +++ b/src/main/java/com/dora/vo/InviteStatsVO.java @@ -0,0 +1,18 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 邀请统计VO + */ +@Data +@Schema(description = "邀请统计信息") +public class InviteStatsVO { + + @Schema(description = "累计获得积分") + private Integer totalPoints; + + @Schema(description = "累计邀请人数") + private Integer inviteCount; +} diff --git a/src/main/java/com/dora/vo/LikeResultVO.java b/src/main/java/com/dora/vo/LikeResultVO.java new file mode 100644 index 0000000..3ab9068 --- /dev/null +++ b/src/main/java/com/dora/vo/LikeResultVO.java @@ -0,0 +1,26 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 点赞结果VO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "点赞结果") +public class LikeResultVO { + + @Schema(description = "是否已点赞") + private Boolean liked; + + @Schema(description = "当前点赞数") + private Integer likeCount; + + public static LikeResultVO of(boolean liked, int likeCount) { + return new LikeResultVO(liked, likeCount); + } +} diff --git a/src/main/java/com/dora/vo/LoginVO.java b/src/main/java/com/dora/vo/LoginVO.java new file mode 100644 index 0000000..e2be8e9 --- /dev/null +++ b/src/main/java/com/dora/vo/LoginVO.java @@ -0,0 +1,50 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 登录响应VO + */ +@Data +public class LoginVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 用户ID */ + private Long userId; + + /** 访问令牌(Access Token) */ + private String token; + + /** 刷新令牌(Refresh Token) */ + private String refreshToken; + + /** Access Token过期时间(秒) */ + private Long expiresIn; + + /** 用户昵称 */ + private String nickname; + + /** 用户头像 */ + private String avatar; + + /** 手机号(脱敏) */ + private String phone; + + /** VIP等级 */ + private Integer vipLevel; + + /** 积分余额 */ + private Integer points; + + /** 邀请码 */ + private String inviteCode; + + /** 是否显示注册奖励弹窗(仅新用户注册时为true) */ + private Boolean showRegisterReward; + + /** 注册奖励积分数(配合showRegisterReward使用) */ + private Integer registerRewardPoints; +} diff --git a/src/main/java/com/dora/vo/PageVO.java b/src/main/java/com/dora/vo/PageVO.java new file mode 100644 index 0000000..fcc574f --- /dev/null +++ b/src/main/java/com/dora/vo/PageVO.java @@ -0,0 +1,48 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 分页VO + */ +@Data +public class PageVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 当前页 */ + private Integer pageNum; + + /** 每页数量 */ + private Integer pageSize; + + /** 总记录数 */ + private Long total; + + /** 总页数 */ + private Integer pages; + + /** 数据列表 */ + private List list; + + /** 是否有下一页 */ + private Boolean hasNext; + + public static PageVO of(Integer pageNum, Integer pageSize, Long total, List list) { + PageVO page = new PageVO<>(); + page.setPageNum(pageNum); + page.setPageSize(pageSize); + page.setTotal(total); + page.setList(list); + page.setPages((int) Math.ceil((double) total / pageSize)); + page.setHasNext(pageNum < page.getPages()); + return page; + } + + public static PageVO of(List list, Long total, Integer pageNum, Integer pageSize) { + return of(pageNum, pageSize, total, list); + } +} diff --git a/src/main/java/com/dora/vo/PointsPackageVO.java b/src/main/java/com/dora/vo/PointsPackageVO.java new file mode 100644 index 0000000..c6e343b --- /dev/null +++ b/src/main/java/com/dora/vo/PointsPackageVO.java @@ -0,0 +1,16 @@ +package com.dora.vo; + +import lombok.Data; +import java.math.BigDecimal; + +@Data +public class PointsPackageVO { + private Long id; + private String name; + private Integer points; + private BigDecimal price; + private BigDecimal originalPrice; + private Integer bonusPoints; + private Integer validDays; + private String description; +} diff --git a/src/main/java/com/dora/vo/PointsRecordVO.java b/src/main/java/com/dora/vo/PointsRecordVO.java new file mode 100644 index 0000000..ce95b17 --- /dev/null +++ b/src/main/java/com/dora/vo/PointsRecordVO.java @@ -0,0 +1,34 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 积分记录VO + */ +@Data +@Schema(description = "积分记录") +public class PointsRecordVO { + + @Schema(description = "记录ID") + private Long id; + + @Schema(description = "类型: 1充值 2消费 3赠送 4推广奖励 5签到 6退款") + private Integer type; + + @Schema(description = "类型名称") + private String typeName; + + @Schema(description = "积分变动(正数为增加,负数为减少)") + private Integer points; + + @Schema(description = "变动后余额") + private Integer balance; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/PointsStatsVO.java b/src/main/java/com/dora/vo/PointsStatsVO.java new file mode 100644 index 0000000..3167755 --- /dev/null +++ b/src/main/java/com/dora/vo/PointsStatsVO.java @@ -0,0 +1,22 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 积分统计VO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "积分统计信息") +public class PointsStatsVO { + + @Schema(description = "订阅积分(充值获得)") + private Integer subscribePoints; + + @Schema(description = "赠送积分(赠送、推广、签到、退款获得)") + private Integer giftPoints; +} diff --git a/src/main/java/com/dora/vo/ProjectCharacterVO.java b/src/main/java/com/dora/vo/ProjectCharacterVO.java new file mode 100644 index 0000000..143cba0 --- /dev/null +++ b/src/main/java/com/dora/vo/ProjectCharacterVO.java @@ -0,0 +1,28 @@ +package com.dora.vo; + +import lombok.Data; + +@Data +public class ProjectCharacterVO { + private Long id; + private Long projectId; + private Long templateId; + private String name; + private String age; + private String gender; + private String voiceType; + private String appearance; + private String clothing; + private String description; + private String imageUrl; + private String referenceImageUrl; + private Integer sort; + /** + * 形象生成状态: 0-无图片, 1-生成中, 2-已生成 + */ + private Integer imageStatus; + /** + * 正在执行的任务编号(用于前端轮询) + */ + private String currentTaskNo; +} diff --git a/src/main/java/com/dora/vo/ProjectSceneVO.java b/src/main/java/com/dora/vo/ProjectSceneVO.java new file mode 100644 index 0000000..9e8b957 --- /dev/null +++ b/src/main/java/com/dora/vo/ProjectSceneVO.java @@ -0,0 +1,20 @@ +package com.dora.vo; + +import lombok.Data; +import java.util.List; + +@Data +public class ProjectSceneVO { + private Long id; + private Long projectId; + private String sceneName; + private String sceneTitle; + private String sceneDescription; + private String sceneStory; + private Integer storyboardCount; + private Integer sort; + private String videoUrl; // 场次视频URL + private String videoTaskNo; // 视频生成任务号 + private Integer videoStatus; // 视频状态:0-未生成,1-生成中,2-已生成 + private List storyboards; +} diff --git a/src/main/java/com/dora/vo/SceneStoryboardVO.java b/src/main/java/com/dora/vo/SceneStoryboardVO.java new file mode 100644 index 0000000..6bdbcaf --- /dev/null +++ b/src/main/java/com/dora/vo/SceneStoryboardVO.java @@ -0,0 +1,28 @@ +package com.dora.vo; + +import com.dora.dto.video.DialogueItem; +import lombok.Data; +import java.util.List; + +@Data +public class SceneStoryboardVO { + private Long id; + private Long sceneId; + private Long projectId; + private Integer storyboardIndex; + private String shotType; + private String cameraAngle; + private String cameraMove; + private String description; + private String narration; + private String dialogue; + private Long dialogueCharacterId; + private String dialogueCharacterName; + private List dialogues; + private String imageUrl; + private String currentTaskNo; // 当前正在执行的任务号 + private Integer imageStatus; // 图片状态:0-未生成,1-生成中,2-已生成 + private String videoClipUrl; + private Integer duration; + private Integer sort; +} diff --git a/src/main/java/com/dora/vo/TokenVO.java b/src/main/java/com/dora/vo/TokenVO.java new file mode 100644 index 0000000..967cf96 --- /dev/null +++ b/src/main/java/com/dora/vo/TokenVO.java @@ -0,0 +1,23 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * Token响应VO + */ +@Data +public class TokenVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 访问令牌(Access Token) */ + private String token; + + /** 刷新令牌(Refresh Token) */ + private String refreshToken; + + /** Access Token过期时间(秒) */ + private Long expiresIn; +} diff --git a/src/main/java/com/dora/vo/UserCheckVO.java b/src/main/java/com/dora/vo/UserCheckVO.java new file mode 100644 index 0000000..9fe8509 --- /dev/null +++ b/src/main/java/com/dora/vo/UserCheckVO.java @@ -0,0 +1,40 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 用户检查响应VO + */ +@Data +public class UserCheckVO implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 微信openid(用于后续登录,避免code重复使用) */ + private String openid; + + /** 用户是否存在 */ + private Boolean exists; + + /** 信息是否完整(phone、avatar、nickname都有值) */ + private Boolean isComplete; + + /** 用户信息(存在时返回) */ + private UserInfo user; + + @Data + public static class UserInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** 手机号(脱敏) */ + private String phone; + + /** 头像 */ + private String avatar; + + /** 昵称 */ + private String nickname; + } +} diff --git a/src/main/java/com/dora/vo/UserProfileVO.java b/src/main/java/com/dora/vo/UserProfileVO.java new file mode 100644 index 0000000..e2f54e0 --- /dev/null +++ b/src/main/java/com/dora/vo/UserProfileVO.java @@ -0,0 +1,36 @@ +package com.dora.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户个人主页VO + */ +@Data +@Schema(description = "用户个人主页信息") +public class UserProfileVO { + + @Schema(description = "用户ID") + private Long userId; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "邀请码(作为用户ID展示)") + private String inviteCode; + + @Schema(description = "VIP等级") + private Integer vipLevel; + + @Schema(description = "积分") + private Integer points; + + @Schema(description = "发布作品数") + private Integer publishCount; + + @Schema(description = "获赞数") + private Integer likedCount; +} diff --git a/src/main/java/com/dora/vo/VideoProjectVO.java b/src/main/java/com/dora/vo/VideoProjectVO.java new file mode 100644 index 0000000..e9f5fca --- /dev/null +++ b/src/main/java/com/dora/vo/VideoProjectVO.java @@ -0,0 +1,42 @@ +package com.dora.vo; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class VideoProjectVO { + private Long id; + private Long userId; + private String userNickname; + private String userAvatar; + private String projectName; + private String storyTitle; + private String storyOutline; + private String originalIdea; + private Integer creationMode; + private String videoDuration; + private String videoRatio; + private String videoStyle; + private String coverUrl; + private Integer status; + private String statusText; + private Integer currentStep; + private String outputVideoUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private List characters; + private List scenes; + + public String getStatusText() { + if (status == null) return "未知"; + switch (status) { + case 0: return "草稿"; + case 1: return "处理中"; + case 2: return "已完成"; + case 3: return "失败"; + default: return "未知"; + } + } +} diff --git a/src/main/java/com/dora/vo/WorkDetailVO.java b/src/main/java/com/dora/vo/WorkDetailVO.java new file mode 100644 index 0000000..96b2aeb --- /dev/null +++ b/src/main/java/com/dora/vo/WorkDetailVO.java @@ -0,0 +1,70 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 作品详情VO + */ +@Data +public class WorkDetailVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String title; + + private String description; + + private String contentUrl; + + /** 内容类型:1图片 2视频 */ + private Integer contentType; + + /** 任务类型:text2img文生图 img2img图生图 text2video文生视频 img2video图生视频 */ + private String taskType; + + /** AI模型名称 */ + private String model; + + /** 生成提示词 */ + private String prompt; + + /** 标签 */ + private String tags; + + private Integer viewCount; + + private Integer likeCount; + + private Integer commentCount; + + /** 作者ID */ + private Long userId; + + /** 作者昵称 */ + private String nickname; + + /** 作者头像 */ + private String avatar; + + /** 当前用户是否已点赞 */ + private Boolean liked; + + /** 任务输入参数(JSON字符串,包含参考图、尺寸等完整参数) */ + private String inputParams; + + /** 模型编码 */ + private String modelCode; + + /** 分类ID */ + private Long categoryId; + + /** 分类名称 */ + private String categoryName; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/WorkVO.java b/src/main/java/com/dora/vo/WorkVO.java new file mode 100644 index 0000000..85963b0 --- /dev/null +++ b/src/main/java/com/dora/vo/WorkVO.java @@ -0,0 +1,50 @@ +package com.dora.vo; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 作品VO + */ +@Data +public class WorkVO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Long id; + + private String title; + + private String contentUrl; + + private Integer contentType; + + /** 任务类型:text2img文生图 img2img图生图 text2video文生视频 img2video图生视频 */ + private String taskType; + + /** 生成提示词 */ + private String prompt; + + /** 描述 */ + private String description; + + private Integer likeCount; + + private Integer viewCount; + + /** 作者ID */ + private Long userId; + + /** 作者昵称 */ + private String nickname; + + /** 作者头像 */ + private String avatar; + + /** 当前用户是否已点赞 */ + private Boolean liked; + + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/WxPayOrderVO.java b/src/main/java/com/dora/vo/WxPayOrderVO.java new file mode 100644 index 0000000..1ec8ea6 --- /dev/null +++ b/src/main/java/com/dora/vo/WxPayOrderVO.java @@ -0,0 +1,13 @@ +package com.dora.vo; + +import lombok.Data; + +@Data +public class WxPayOrderVO { + private String orderNo; + private String timeStamp; + private String nonceStr; + private String packageVal; + private String signType; + private String paySign; +} diff --git a/src/main/java/com/dora/vo/admin/AdminInfoVO.java b/src/main/java/com/dora/vo/admin/AdminInfoVO.java new file mode 100644 index 0000000..91b3079 --- /dev/null +++ b/src/main/java/com/dora/vo/admin/AdminInfoVO.java @@ -0,0 +1,27 @@ +package com.dora.vo.admin; + +import com.dora.entity.Permission; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 管理员信息响应 + */ +@Data +@Schema(description = "管理员信息响应") +public class AdminInfoVO { + + @Schema(description = "管理员信息") + private AdminVO admin; + + @Schema(description = "角色编码列表") + private List roles; + + @Schema(description = "权限编码列表") + private List permissions; + + @Schema(description = "菜单列表") + private List menus; +} diff --git a/src/main/java/com/dora/vo/admin/AdminLoginVO.java b/src/main/java/com/dora/vo/admin/AdminLoginVO.java new file mode 100644 index 0000000..8ed771d --- /dev/null +++ b/src/main/java/com/dora/vo/admin/AdminLoginVO.java @@ -0,0 +1,21 @@ +package com.dora.vo.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 管理员登录响应 + */ +@Data +@Schema(description = "管理员登录响应") +public class AdminLoginVO { + + @Schema(description = "访问令牌") + private String token; + + @Schema(description = "刷新令牌") + private String refreshToken; + + @Schema(description = "令牌过期时间(秒)") + private Long expiresIn; +} diff --git a/src/main/java/com/dora/vo/admin/AdminVO.java b/src/main/java/com/dora/vo/admin/AdminVO.java new file mode 100644 index 0000000..ce00d5f --- /dev/null +++ b/src/main/java/com/dora/vo/admin/AdminVO.java @@ -0,0 +1,46 @@ +package com.dora.vo.admin; + +import com.dora.entity.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 管理员VO + */ +@Data +@Schema(description = "管理员信息") +public class AdminVO { + + @Schema(description = "管理员ID") + private Long id; + + @Schema(description = "用户名") + private String username; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "手机号") + private String phone; + + @Schema(description = "邮箱") + private String email; + + @Schema(description = "头像") + private String avatar; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "最后登录时间") + private LocalDateTime lastLoginTime; + + @Schema(description = "角色列表") + private List roles; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/admin/AdminWorkStatsVO.java b/src/main/java/com/dora/vo/admin/AdminWorkStatsVO.java new file mode 100644 index 0000000..8663b5d --- /dev/null +++ b/src/main/java/com/dora/vo/admin/AdminWorkStatsVO.java @@ -0,0 +1,24 @@ +package com.dora.vo.admin; + +import lombok.Data; + +/** + * 管理端作品统计VO + */ +@Data +public class AdminWorkStatsVO { + /** 作品总数 */ + private Long total; + + /** 待审核数量 */ + private Long pending; + + /** 已通过数量 */ + private Long passed; + + /** 已拒绝数量 */ + private Long rejected; + + /** 精选数量 */ + private Long featured; +} diff --git a/src/main/java/com/dora/vo/admin/AdminWorkVO.java b/src/main/java/com/dora/vo/admin/AdminWorkVO.java new file mode 100644 index 0000000..091e37c --- /dev/null +++ b/src/main/java/com/dora/vo/admin/AdminWorkVO.java @@ -0,0 +1,88 @@ +package com.dora.vo.admin; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 管理端作品VO + */ +@Data +public class AdminWorkVO { + /** 作品ID */ + private Long id; + + /** 用户ID */ + private Long userId; + + /** 用户昵称 */ + private String userName; + + /** 用户头像 */ + private String userAvatar; + + /** 分类ID */ + private Long categoryId; + + /** 分类名称 */ + private String categoryName; + + /** 标题 */ + private String title; + + /** 描述 */ + private String description; + + /** 内容URL */ + private String contentUrl; + + /** 内容类型:1图片 2视频 */ + private Integer contentType; + + /** 任务类型 */ + private String taskType; + + /** 任务类型名称 */ + private String taskTypeName; + + /** AI模型 */ + private String model; + + /** 提示词 */ + private String prompt; + + /** 标签 */ + private String tags; + + /** 浏览量 */ + private Integer viewCount; + + /** 点赞数 */ + private Integer likeCount; + + /** 收藏数 */ + private Integer collectCount; + + /** 评论数 */ + private Integer commentCount; + + /** 是否公开 */ + private Integer isPublic; + + /** 审核状态:0待审核 1通过 2拒绝 */ + private Integer auditStatus; + + /** 审核备注 */ + private String auditRemark; + + /** 是否精选 */ + private Integer isFeatured; + + /** 状态 */ + private Integer status; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 更新时间 */ + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/dora/vo/admin/DashboardStatsVO.java b/src/main/java/com/dora/vo/admin/DashboardStatsVO.java new file mode 100644 index 0000000..0fdd9ff --- /dev/null +++ b/src/main/java/com/dora/vo/admin/DashboardStatsVO.java @@ -0,0 +1,28 @@ +package com.dora.vo.admin; + +import lombok.Data; +import java.math.BigDecimal; + +/** + * Dashboard统计数据VO + */ +@Data +public class DashboardStatsVO { + /** 用户总数 */ + private Long userCount; + + /** 今日新增用户 */ + private Long todayNewUsers; + + /** 今日订单数 */ + private Long todayOrderCount; + + /** 今日订单金额 */ + private BigDecimal todayOrderAmount; + + /** 作品总数 */ + private Long workCount; + + /** 待审核作品数 */ + private Long pendingAudit; +} diff --git a/src/main/java/com/dora/vo/admin/DashboardTrendVO.java b/src/main/java/com/dora/vo/admin/DashboardTrendVO.java new file mode 100644 index 0000000..7ccfa47 --- /dev/null +++ b/src/main/java/com/dora/vo/admin/DashboardTrendVO.java @@ -0,0 +1,22 @@ +package com.dora.vo.admin; + +import lombok.Data; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; + +/** + * Dashboard趋势数据VO + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class DashboardTrendVO { + /** 日期 */ + private String date; + + /** 类型:新增用户、订单数 */ + private String type; + + /** 数值 */ + private Long value; +} diff --git a/src/main/java/com/dora/vo/admin/OrderListVO.java b/src/main/java/com/dora/vo/admin/OrderListVO.java new file mode 100644 index 0000000..7e69b7d --- /dev/null +++ b/src/main/java/com/dora/vo/admin/OrderListVO.java @@ -0,0 +1,20 @@ +package com.dora.vo.admin; + +import lombok.Data; +import java.math.BigDecimal; +import java.util.List; + +/** + * 订单列表VO(包含统计信息) + */ +@Data +public class OrderListVO { + /** 订单列表 */ + private List list; + + /** 总数 */ + private Long total; + + /** 总金额 */ + private BigDecimal totalAmount; +} diff --git a/src/main/java/com/dora/vo/admin/OrderVO.java b/src/main/java/com/dora/vo/admin/OrderVO.java new file mode 100644 index 0000000..187b7ef --- /dev/null +++ b/src/main/java/com/dora/vo/admin/OrderVO.java @@ -0,0 +1,47 @@ +package com.dora.vo.admin; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 订单VO + */ +@Data +public class OrderVO { + /** 订单ID */ + private Long id; + + /** 订单号 */ + private String orderNo; + + /** 用户ID */ + private Long userId; + + /** 用户名 */ + private String username; + + /** 订单类型:1VIP充值 2积分充值 */ + private Integer type; + + /** 商品名称 */ + private String productName; + + /** 订单金额 */ + private BigDecimal amount; + + /** 支付方式 */ + private String payMethod; + + /** 订单状态:0待支付 1已支付 2已取消 3已退款 */ + private Integer status; + + /** 创建时间 */ + private LocalDateTime createdAt; + + /** 支付时间 */ + private LocalDateTime paidAt; + + /** 备注 */ + private String remark; +} diff --git a/src/main/java/com/dora/vo/admin/PermissionTreeVO.java b/src/main/java/com/dora/vo/admin/PermissionTreeVO.java new file mode 100644 index 0000000..406d42c --- /dev/null +++ b/src/main/java/com/dora/vo/admin/PermissionTreeVO.java @@ -0,0 +1,50 @@ +package com.dora.vo.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 权限树VO + */ +@Data +@Schema(description = "权限树") +public class PermissionTreeVO { + + @Schema(description = "权限ID") + private Long id; + + @Schema(description = "父级ID") + private Long parentId; + + @Schema(description = "权限名称") + private String name; + + @Schema(description = "权限编码") + private String code; + + @Schema(description = "类型:1目录 2菜单 3按钮") + private Integer type; + + @Schema(description = "路由路径") + private String path; + + @Schema(description = "组件路径") + private String component; + + @Schema(description = "图标") + private String icon; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "是否可见") + private Integer visible; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "子权限") + private List children; +} diff --git a/src/main/java/com/dora/vo/admin/RecentOrderVO.java b/src/main/java/com/dora/vo/admin/RecentOrderVO.java new file mode 100644 index 0000000..4fbf32f --- /dev/null +++ b/src/main/java/com/dora/vo/admin/RecentOrderVO.java @@ -0,0 +1,32 @@ +package com.dora.vo.admin; + +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 最近订单VO + */ +@Data +public class RecentOrderVO { + /** 订单ID */ + private Long id; + + /** 订单号 */ + private String orderNo; + + /** 用户名 */ + private String username; + + /** 订单金额 */ + private BigDecimal amount; + + /** 订单类型:1VIP充值 2积分充值 */ + private Integer type; + + /** 订单状态:0待支付 1已支付 2已取消 3已退款 */ + private Integer status; + + /** 创建时间 */ + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/admin/RedeemCodeVO.java b/src/main/java/com/dora/vo/admin/RedeemCodeVO.java new file mode 100644 index 0000000..0003cec --- /dev/null +++ b/src/main/java/com/dora/vo/admin/RedeemCodeVO.java @@ -0,0 +1,28 @@ +package com.dora.vo.admin; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 兑换码VO + */ +@Data +public class RedeemCodeVO { + private Long id; + private String code; + private String batchNo; + private Integer type; + private String typeName; + private Integer rewardValue; + private Integer vipLevel; + private Integer totalCount; + private Integer usedCount; + private LocalDateTime startTime; + private LocalDateTime expireTime; + private String remark; + private Long creatorId; + private String creatorName; + private Integer status; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/admin/RoleVO.java b/src/main/java/com/dora/vo/admin/RoleVO.java new file mode 100644 index 0000000..7c306c1 --- /dev/null +++ b/src/main/java/com/dora/vo/admin/RoleVO.java @@ -0,0 +1,35 @@ +package com.dora.vo.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 角色VO + */ +@Data +@Schema(description = "角色信息") +public class RoleVO { + + @Schema(description = "角色ID") + private Long id; + + @Schema(description = "角色名称") + private String name; + + @Schema(description = "角色编码") + private String code; + + @Schema(description = "描述") + private String description; + + @Schema(description = "排序") + private Integer sort; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/dora/vo/package-info.java b/src/main/java/com/dora/vo/package-info.java new file mode 100644 index 0000000..c678974 --- /dev/null +++ b/src/main/java/com/dora/vo/package-info.java @@ -0,0 +1,4 @@ +/** + * 视图对象层 + */ +package com.dora.vo; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..2bfdcde --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,13 @@ +# 开发环境配置 +server: + port: 8080 + +spring: + sql: + init: + mode: never # 不自动初始化数据库 + +logging: + level: + com.dora: DEBUG + org.springframework.jdbc.datasource.init: DEBUG diff --git a/src/main/resources/application-local.yml.example b/src/main/resources/application-local.yml.example new file mode 100644 index 0000000..04011db --- /dev/null +++ b/src/main/resources/application-local.yml.example @@ -0,0 +1,21 @@ +# 本地环境配置模板 +# 复制此文件为 application-local.yml 并填入实际值 +# application-local.yml 已加入 .gitignore,不会被提交 + +# 微信小程序配置 +wechat: + miniapp: + appid: your_appid + secret: your_secret + +# 腾讯云 COS 配置 +tencent: + cos: + secret-id: your_secret_id + secret-key: your_secret_key + +# 数据库配置 +spring: + datasource: + username: root + password: your_password diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..ac340c9 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,31 @@ +# 生产环境配置 +server: + port: 8080 + +spring: + # 生产环境禁用自动初始化数据库 + sql: + init: + mode: never + + datasource: + hikari: + minimum-idle: 20 + maximum-pool-size: 100 + +# Swagger服务器地址配置(解决HTTPS环境下的CORS问题) +swagger: + server-url: https://api.1818ai.com/api + +logging: + level: + root: WARN + com.dora: INFO + com.zaxxer.hikari: WARN + file: + name: /var/log/weixin-backend/app.log + +# 生产环境关闭SQL日志 +mybatis-plus: + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0c5e75d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,185 @@ +server: + port: 8080 + servlet: + context-path: /api + tomcat: + max-threads: 200 + min-spare-threads: 10 + accept-count: 100 + max-http-form-post-size: 10MB + max-swallow-size: 10MB + # 设置文件上传大小限制 + max-http-post-size: 10MB + max-http-header-size: 8KB + connection-timeout: 20000 + # 设置multipart相关配置 + additional-tld-skip-patterns: "*.jar" + +spring: + application: + name: weixin-backend + profiles: + active: dev + + # 文件上传配置 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + + # 静态资源处理配置 + web: + resources: + add-mappings: true + + # MySQL 数据源配置(HikariCP连接池) + datasource: + type: com.zaxxer.hikari.HikariDataSource + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://127.0.0.1:3306/1818ai_uniapp?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&createDatabaseIfNotExist=true +# username: root +# password: "1234" + username: 1818ai_uniapp + password: "Ed8pTDkfEEjK3RA3" + hikari: + pool-name: WeixinHikariPool + minimum-idle: 10 + maximum-pool-size: 50 + idle-timeout: 600000 + max-lifetime: 1800000 + connection-timeout: 30000 + connection-test-query: SELECT 1 + auto-commit: true + validation-timeout: 5000 + leak-detection-threshold: 60000 + + # 数据库初始化配置 + sql: + init: + mode: never # never: 不自动初始化, always: 每次启动都初始化, embedded: 仅嵌入式数据库初始化 + + # 邮箱配置 + mail: + host: smtp.qq.com + port: 465 + username: 1410082433@qq.com + password: cgbevynaczxhjehe + default-encoding: UTF-8 + properties: + mail: + smtp: + auth: true + ssl: + enable: true + socketFactory: + class: javax.net.ssl.SSLSocketFactory + port: 465 + + # Redis 配置(Lettuce连接池) + data: + redis: + host: localhost + port: 6379 + database: 1 + timeout: 10000 + lettuce: + pool: + # 最大活跃连接数 + max-active: 16 + # 最大空闲连接数 + max-idle: 8 + # 最小空闲连接数 + min-idle: 2 + # 获取连接最大等待时间,-1表示无限等待 + max-wait: 5000ms + # 空闲连接检测周期 + time-between-eviction-runs: 60000ms + +# Swagger/OpenAPI 配置 +springdoc: + api-docs: + enabled: true + path: /v3/api-docs + swagger-ui: + enabled: true + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + +# Swagger 访问密码 +swagger: + auth: + password: 1818aigc + +# MyBatis Plus 配置 +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + type-aliases-package: com.dora.entity + configuration: + map-underscore-to-camel-case: true + # 使用 SLF4J 日志,走 logback 格式化 + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + # 开启二级缓存 + cache-enabled: true + # 延迟加载 + lazy-loading-enabled: true + global-config: + db-config: + id-type: auto + logic-delete-field: deleted + logic-delete-value: 1 + logic-not-delete-value: 0 + # 关闭banner + banner: false + +# 微信小程序配置 +wechat: + miniapp: + appid: wxe09413e19ac0c02c + secret: 22c9f2849304af7629bf20b3ed8ca9d2 + +# 微信相关配置(小程序、订阅消息、支付) +wx: + miniapp: + appid: wxe09413e19ac0c02c + secret: 22c9f2849304af7629bf20b3ed8ca9d2 + subscribe: + # AI任务完成通知模板ID + task-complete-template: pyDi6nvC0sze6DBUAmZLm_AKz2WCfixchWql7DoA9OI + pay: + app-id: wxe09413e19ac0c02c + mch-id: "1723398705" + mch-key: 7a4s4f4fs78wevx45ewf5463fds2EFSf + trade-type: JSAPI + notify-url: https://api.1818ai.com/api/points/notify/wechat + cert-path: /www/wwwroot/cret_weixin_pay/apiclient_cert.p12 + +# JWT配置 +jwt: + secret: 1818AIGC_JWT_SECRET_KEY_2024_VERY_LONG_AND_SECURE_STRING_FOR_HS256 + access-token-expire: 172800 # access token 过期时间(秒),2天 + refresh-token-expire: 259200 # refresh token 过期时间(秒),3天 + issuer: 1818aigc + +# 腾讯云 COS 配置 +tencent: + cos: + secret-id: AKIDKbBmV7av53pSxIRSL3ZuESUAuYPBl9LK + secret-key: qUxERTlz2zoT0MZotleTDvmU2NXZSBk8 + region: ap-shanghai + bucket-name: weixin-1818ai-1302947942 + user-img-folder: "" + expiration-seconds: 3600 + custom-domain: "" + +# 日志配置 +logging: + level: + root: INFO + com.dora: DEBUG + com.zaxxer.hikari: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n" + file: + name: logs/weixin-backend.log \ No newline at end of file diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..e46e4db --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,32 @@ +${AnsiColor.BRIGHT_YELLOW} + _ooOoo_ + o8888888o + 88" . "88 + (| -_- |) + O\ = /O + ____/`---'\____ + .' \\| |// `. + / \\||| : |||// \ + / _||||| -:- |||||_ \ + | | \\\ - /'| | | + | \_| `\`---'// |_/ | + \ .-\__ `-. -'__/-. / + ___`. .' /--.--\ `. .'___ + ."" '< `.___\_<|>_/___.' _> \"". + | | : `- \`. ;`. _/; .'/ / .' ; | + \ \ `-. \_\_`. _.'_/_/ -' _.' / + ===========`-.`___`-.__\ \___ /__.-'_.'_.-'================ +${AnsiColor.CYAN} + 佛祖保佑 永无BUG + 1818AIGC Backend +${AnsiColor.GREEN} + ╔═══════════════════════════════════════════════════════════╗ + ║ ${AnsiColor.WHITE}Application${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_CYAN}1818AIGC WeChat Backend${AnsiColor.GREEN} ║ + ║ ${AnsiColor.WHITE}Version${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_CYAN}1.0.0${AnsiColor.GREEN} ║ + ║ ${AnsiColor.WHITE}Spring Boot${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_CYAN}${spring-boot.version}${AnsiColor.GREEN} ║ + ║ ${AnsiColor.WHITE}Java Version${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_CYAN}${java.version}${AnsiColor.GREEN} ║ + ╠═══════════════════════════════════════════════════════════╣ + ║ ${AnsiColor.WHITE}Swagger UI${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_MAGENTA}http://localhost:${server.port}${server.servlet.context-path}/swagger-ui.html${AnsiColor.GREEN} + ║ ${AnsiColor.WHITE}API Docs${AnsiColor.GREEN} : ${AnsiColor.BRIGHT_MAGENTA}http://localhost:${server.port}${server.servlet.context-path}/v3/api-docs${AnsiColor.GREEN} + ╚═══════════════════════════════════════════════════════════╝ +${AnsiColor.DEFAULT} diff --git a/src/main/resources/db/data.sql b/src/main/resources/db/data.sql new file mode 100644 index 0000000..47d69cf --- /dev/null +++ b/src/main/resources/db/data.sql @@ -0,0 +1,608 @@ +-- ============================================= +-- 初始数据和模拟数据 +-- ============================================= + +-- ============================================= +-- 1. 管理员相关数据 +-- ============================================= + +-- 初始化管理员(密码:admin123) +INSERT INTO `admin` (`id`, `username`, `password`, `real_name`, `phone`, `email`, `status`) VALUES +(1, 'admin', '$2a$10$8TrT3MHaF5NnniB7qVm9E.r1aLGYdEr7m28U0zfvOOIgqkB01cMW6', '超级管理员', '13800000001', 'admin@1818ai.com', 1), +(2, 'operator', '$2a$10$8TrT3MHaF5NnniB7qVm9E.r1aLGYdEr7m28U0zfvOOIgqkB01cMW6', '运营管理员', '13800000002', 'operator@1818ai.com', 1), +(3, 'finance', '$2a$10$8TrT3MHaF5NnniB7qVm9E.r1aLGYdEr7m28U0zfvOOIgqkB01cMW6', '财务管理员', '13800000003', 'finance@1818ai.com', 1); + +-- 初始化角色 +INSERT INTO `role` (`id`, `name`, `code`, `description`, `data_scope`, `sort`, `status`) VALUES +(1, '超级管理员', 'SUPER_ADMIN', '拥有所有权限', 1, 1, 1), +(2, '运营管理员', 'OPERATOR', '负责内容运营和审核', 1, 2, 1), +(3, '财务管理员', 'FINANCE', '负责财务和订单管理', 2, 3, 1), +(4, '客服', 'CUSTOMER_SERVICE', '处理用户问题', 3, 4, 1); + +-- 管理员-角色关联 +INSERT INTO `admin_role` (`admin_id`, `role_id`) VALUES +(1, 1), (2, 2), (3, 3); + +-- 初始化权限菜单 +INSERT INTO `permission` (`id`, `parent_id`, `name`, `code`, `type`, `path`, `component`, `icon`, `sort`, `status`) VALUES +(1, 0, '工作台', 'dashboard', 1, '/dashboard', 'Dashboard', 'DashboardOutlined', 1, 1), +(2, 0, '用户管理', 'user', 1, '/user', NULL, 'TeamOutlined', 2, 1), +(3, 0, '作品管理', 'work', 1, '/work', NULL, 'PictureOutlined', 3, 1), +(4, 0, '订单管理', 'order', 1, '/order', NULL, 'ShoppingOutlined', 4, 1), +(5, 0, 'AI管理', 'ai', 1, '/ai', NULL, 'RobotOutlined', 5, 1), +(6, 0, '系统配置', 'config', 1, '/config', NULL, 'SettingOutlined', 6, 1), +(7, 0, '系统管理', 'system', 1, '/system', NULL, 'ToolOutlined', 7, 1), +-- 用户管理子菜单 +(20, 2, '用户列表', 'user:list', 2, '/user/list', 'user/list', NULL, 1, 1), +(21, 2, '用户详情', 'user:detail', 3, NULL, NULL, NULL, 2, 1), +(22, 2, '用户编辑', 'user:edit', 3, NULL, NULL, NULL, 3, 1), +-- 作品管理子菜单 +(30, 3, '作品列表', 'work:list', 2, '/work/list', 'work/list', NULL, 1, 1), +(31, 3, '作品审核', 'work:audit', 2, '/work/audit', 'work/audit', NULL, 2, 1), +(32, 3, '分类管理', 'work:category', 2, '/work/category', 'work/category', NULL, 3, 1), +-- 订单管理子菜单 +(40, 4, '订单列表', 'order:list', 2, '/order/list', 'order/list', NULL, 1, 1), +-- AI管理子菜单 +(50, 5, 'AI厂商', 'ai:provider', 2, '/ai/provider', 'ai/provider', NULL, 1, 1), +(51, 5, 'AI模型', 'ai:model', 2, '/ai/model', 'ai/model', NULL, 2, 1), +(52, 5, 'AI任务', 'ai:task', 2, '/ai/task', 'ai/task', NULL, 3, 1), +(53, 5, 'AI调试', 'ai:debug', 2, '/ai/debug', 'ai/debug', NULL, 4, 1), +-- 系统配置子菜单 +(60, 6, '奖励配置', 'config:reward', 2, '/config/reward', 'config/reward', NULL, 1, 1), +(61, 6, '兑换码管理', 'config:redeem', 2, '/config/redeem', 'config/redeem', NULL, 2, 1), +(62, 6, 'VIP套餐', 'config:vip', 2, '/config/vip', 'config/vip', NULL, 3, 1), +(63, 6, '积分套餐', 'config:points', 2, '/config/points', 'config/points', NULL, 4, 1), +(64, 6, 'Banner管理', 'config:banner', 2, '/config/banner', 'config/banner', NULL, 5, 1), +(65, 6, '公告管理', 'config:notice', 2, '/config/notice', 'config/notice', NULL, 6, 1), +-- 系统管理子菜单 +(70, 7, '管理员管理', 'system:admin', 2, '/system/admin', 'system/admin', NULL, 1, 1), +(71, 7, '角色管理', 'system:role', 2, '/system/role', 'system/role', NULL, 2, 1), +(72, 7, '权限管理', 'system:permission', 2, '/system/permission', 'system/permission', NULL, 3, 1); + +-- 超级管理员拥有所有权限 +INSERT INTO `role_permission` (`role_id`, `permission_id`) +SELECT 1, id FROM `permission`; + +-- 运营管理员权限 +INSERT INTO `role_permission` (`role_id`, `permission_id`) VALUES +(2, 1), (2, 2), (2, 20), (2, 21), (2, 3), (2, 30), (2, 31), (2, 32), (2, 6), (2, 64), (2, 65); + +-- 财务管理员权限 +INSERT INTO `role_permission` (`role_id`, `permission_id`) VALUES +(3, 1), (3, 4), (3, 40), (3, 6), (3, 62), (3, 63); + + +-- ============================================= +-- 2. 用户数据 +-- ============================================= + +-- [上线前注释] 测试用户数据 +-- INSERT INTO `user` (`id`, `openid`, `unionid`, `nickname`, `avatar`, `phone`, `gender`, `vip_level`, `vip_expire_time`, `points`, `invite_code`, `inviter_id`, `status`, `last_login_time`) VALUES +-- (1, 'oXXXX_test_user_001', NULL, '测试用户A', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E5%9B%BE%E7%94%9F%E5%9B%BE1.png', '13900000001', 1, 2, '2026-12-31 23:59:59', 5000, 'INV001', NULL, 1, NOW()), +-- (2, 'oXXXX_test_user_002', NULL, '测试用户B', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E5%9B%BE%E7%94%9F%E5%9B%BE1.png', '13900000002', 2, 1, '2026-06-30 23:59:59', 1500, 'INV002', 1, 1, NOW()), +-- (3, 'oXXXX_test_user_003', NULL, '测试用户C', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%96%87%E7%94%9F%E5%9B%BE1.png', '13900000003', 0, 0, NULL, 200, 'INV003', 1, 1, NOW()), +-- (4, 'oXXXX_test_user_004', NULL, '创作达人', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%96%87%E7%94%9F%E5%9B%BE1.png', '13900000004', 1, 3, '2027-01-31 23:59:59', 10000, 'INV004', NULL, 1, NOW()), +-- (5, 'oXXXX_test_user_005', NULL, 'AI爱好者', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%96%87%E7%94%9F%E5%9B%BE1.png', '13900000005', 2, 0, NULL, 50, 'INV005', 2, 1, NOW()); + +-- ============================================= +-- 3. VIP套餐数据 +-- ============================================= + +INSERT INTO `vip_package` (`id`, `name`, `level`, `price`, `original_price`, `duration`, `points_gift`, `daily_usage_limit`, `features`, `sort`, `status`) VALUES +(1, '月度会员', 1, 29.90, 39.90, 30, 100, 50, '["每日50次AI生成","专属会员标识","优先客服支持"]', 1, 1), +(2, '季度会员', 2, 79.90, 119.70, 90, 500, 100, '["每日100次AI生成","专属会员标识","优先客服支持","高清图片下载"]', 2, 1), +(3, '年度会员', 3, 199.90, 358.80, 365, 2000, -1, '["无限AI生成","专属会员标识","优先客服支持","高清图片下载","专属模型体验"]', 3, 1); + +-- ============================================= +-- 4. VIP订单数据 +-- ============================================= + +-- [上线前注释] 测试VIP订单数据 +-- INSERT INTO `vip_order` (`id`, `order_no`, `user_id`, `package_id`, `package_name`, `amount`, `pay_type`, `pay_time`, `transaction_id`, `status`) VALUES +-- (1, 'VIP202601010001', 1, 3, '年度会员', 199.90, 1, '2026-01-01 10:00:00', 'wx_trans_001', 1), +-- (2, 'VIP202601020001', 2, 1, '月度会员', 29.90, 1, '2026-01-02 14:30:00', 'wx_trans_002', 1), +-- (3, 'VIP202601030001', 4, 3, '年度会员', 199.90, 1, '2026-01-03 09:15:00', 'wx_trans_003', 1), +-- (4, 'VIP202601140001', 3, 1, '月度会员', 29.90, NULL, NULL, NULL, 0); + +-- ============================================= +-- 5. 积分套餐数据 +-- ============================================= + +INSERT INTO `points_package` (`id`, `name`, `points`, `price`, `original_price`, `bonus_points`, `valid_days`, `description`, `sort`, `status`) VALUES +(1, '体验版', 100, 9.90, 19.90, 10, 365, '适合新手体验', 1, 1), +(2, '豪华版', 500, 39.90, 59.90, 100, 365, '高性价比之选', 2, 1), +(3, '至尊版', 2000, 99.90, 199.90, 500, 365, '创作达人必备', 3, 1); + +-- ============================================= +-- 6. 积分订单数据 +-- ============================================= + +-- [上线前注释] 测试积分订单数据 +-- INSERT INTO `points_order` (`id`, `order_no`, `user_id`, `package_id`, `points`, `bonus_points`, `amount`, `pay_type`, `pay_time`, `transaction_id`, `status`) VALUES +-- (1, 'PTS202601010001', 1, 3, 2000, 500, 99.90, 1, '2026-01-01 11:00:00', 'wx_pts_001', 1), +-- (2, 'PTS202601020001', 2, 2, 500, 100, 39.90, 1, '2026-01-02 15:00:00', 'wx_pts_002', 1), +-- (3, 'PTS202601030001', 3, 1, 100, 10, 9.90, 1, '2026-01-03 10:00:00', 'wx_pts_003', 1), +-- (4, 'PTS202601140001', 5, 1, 100, 10, 9.90, NULL, NULL, NULL, 0); + +-- ============================================= +-- 7. 积分流水数据 +-- ============================================= + +-- [上线前注释] 测试积分流水数据 +-- INSERT INTO `points_record` (`id`, `user_id`, `type`, `points`, `balance`, `biz_type`, `biz_id`, `remark`) VALUES +-- (1, 1, 1, 2500, 2500, 'RECHARGE', 1, '充值至尊版套餐'), +-- (2, 1, 2, -100, 2400, 'AI_GENERATE', NULL, 'AI图片生成'), +-- (3, 1, 3, 100, 2500, 'GIFT', NULL, '新用户注册赠送'), +-- (4, 2, 1, 600, 600, 'RECHARGE', 2, '充值豪华版套餐'), +-- (5, 2, 2, -50, 550, 'AI_GENERATE', NULL, 'AI视频生成'), +-- (6, 3, 1, 110, 110, 'RECHARGE', 3, '充值体验版套餐'), +-- (7, 3, 4, 50, 160, 'PROMOTION', NULL, '推广奖励'), +-- (8, 4, 3, 1000, 1000, 'GIFT', NULL, 'VIP会员赠送'); + +-- ============================================= +-- 8. 推广配置数据 +-- ============================================= + +INSERT INTO `promotion_config` (`id`, `type`, `reward_points`, `reward_percent`, `description`, `status`) VALUES +(1, 1, 50, NULL, '邀请新用户注册奖励50积分', 1), +(2, 2, 100, NULL, '被邀请用户首次充值奖励100积分', 1), +(3, 3, NULL, 5.00, '被邀请用户消费返利5%', 1); + +-- ============================================= +-- 9. 推广记录数据 +-- ============================================= + +-- [上线前注释] 测试推广记录数据 +-- INSERT INTO `promotion_record` (`id`, `inviter_id`, `invitee_id`, `reward_type`, `reward_points`, `reward_status`, `reward_time`) VALUES +-- (1, 1, 2, 1, 50, 1, '2026-01-02 14:00:00'), +-- (2, 1, 3, 1, 50, 1, '2026-01-03 09:00:00'), +-- (3, 2, 5, 1, 50, 1, '2026-01-05 10:00:00'), +-- (4, 1, 2, 2, 100, 1, '2026-01-02 15:00:00'); + +-- ============================================= +-- 10. AI厂商数据 +-- ============================================= + +INSERT INTO `ai_provider` (`id`, `name`, `code`, `description`, `base_url`, `api_key`, `status`, `sort`) VALUES +(1, 'RunningHub', 'runninghub', 'RunningHub AI应用平台', 'https://www.runninghub.cn', '5c44cef12da3470e9f24da70c63787dc', 1, 1), +(2, 'Coze', 'coze', 'Coze工作流平台', 'https://api.coze.cn', 'sat_2IEZauJ8VLOvTJaNTya4TNB790nFF3B6qzPbA1CuNSCBihBgVyy8ZFBfE1R1IHOw', 1, 2), +(3, '速传API', 'suchuanapi', '速传AI接口平台,提供Sora2等视频生成服务', 'https://api.wuyinkeji.com', 'GpFpubnd78puIBhoAeXE9RcR4d', 1, 3), +(4, 'APIYi', 'apiyi', 'APIYi AI接口平台,提供Sora2等视频生成服务', 'https://api.apiyi.com', 'sk-yrudP11MOLK8JlBfAeAfE74fBcFf4182Aa3a7a6b8c15D605', 1, 4); + +-- 腾讯云VOD厂商(api_key存SecretId,secret_key存SecretKey) +-- INSERT INTO `ai_provider` (`id`, `name`, `code`, `description`, `base_url`, `api_key`, `secret_key`, `status`, `sort`) VALUES +-- (102, '腾讯云VOD', 'tencent_vod', '腾讯云视频点播平台,提供AIGC视频生成服务', 'https://vod.tencentcloudapi.com', 'AKIDVY1HLBnDZhbHkz0mLhgT3TgePXHNErLC', '83KNAsRr67wa0EG3iKo8E8oFanywiKm5', 1, 102); + +-- 腾讯云aiart厂商(api_key存SecretId,secret_key存SecretKey) +INSERT INTO `ai_provider` (`id`, `name`, `code`, `description`, `base_url`, `api_key`, `secret_key`, `status`, `sort`) VALUES +(103, '腾讯云aiart', 'tencent_aiart', '腾讯云智能图像创作平台,提供混元生图等服务', 'https://aiart.tencentcloudapi.com', 'AKIDvY1HLBnDZhbHkz0mLhgT3TgePxHNErLc', '83KNAsRr67wa0EG3iKo8E8oFanywiKm5', 1, 103); + +-- ============================================= +-- 11. AI模型数据 +-- ============================================= + +INSERT INTO `ai_model` (`id`, `provider_id`, `name`, `code`, `type`, `category`, `description`, `api_endpoint`, `request_method`, `request_headers`, `request_template`, `response_mapping`, `input_params`, `points_cost`, `max_concurrent`, `timeout`, `workflow_type`, `workflow_id`, `workflow_config`, `is_async`, `async_query_endpoint`, `async_query_method`, `async_query_body`, `async_query_mapping`, `async_status_mapping`, `async_poll_interval`, `async_poll_max_count`, `async_poll_timeout`, `result_transfer_cos`, `result_url_field`, `show_in_list`, `status`, `sort`) VALUES +-- RunningHub AI应用 - 视频画质修复 +-- 提交接口: POST /task/openapi/ai-app/run +-- 提交返回: {"code":0,"msg":"success","data":{"taskId":"xxx","taskStatus":"RUNNING"}} +-- 查询接口: POST /task/openapi/outputs +-- 查询返回: {"code":0,"msg":"success","data":[{"fileUrl":"xxx","fileType":"mp4"}]} +(1, 1, '视频画质修复', 'rh-video-enhance', 'video_generation', 'video', 'AI视频画质修复,提升视频清晰度和画质', '/task/openapi/ai-app/run', + 'POST', + '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"webappId":1973655265306390529,"apiKey":"{{api_key}}","nodeInfoList":[{"nodeId":"69","fieldName":"video","fieldValue":"{{video}}","description":"上传视频"},{"nodeId":"98","fieldName":"value","fieldValue":"{{denoise}}","description":"降噪值"}]}', + '{"status":"data.taskStatus","task_id":"data.taskId","error":"msg"}', + '[{"name":"video","label":"视频文件","type":"video","required":true,"placeholder":"选择需要修复的视频","maxSize":100,"maxDuration":120},{"name":"denoise","label":"降噪值","type":"select","options":["0.1","0.2","0.3","0.4","0.5"],"default":"0.2"}]', + 100, 5, 600, 'direct', NULL, NULL, 1, '/task/openapi/outputs', 'POST', '{"apiKey":"{{api_key}}","taskId":"{{external_task_id}}"}', '{"status":"code","result":"data[0].fileUrl","error":"msg"}', '{"queued":"QUEUED","processing":"RUNNING,CREATE","success":"0,SUCCESS","failed":"FAILED,-1"}', 10000, 180, 1800, 1, 'result', 1, 1, 1), +-- Coze智能问答 +(2, 2, 'Coze智能问答', 'coze-qa', 'text_generation', 'chat', 'Coze工作流智能问答', '/v1/workflow/run', + 'POST', + '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"workflow_id":"7589283937424310307","parameters":{"input":"{{input}}"},"is_async":false}', + '{"result":"data","execute_id":"execute_id"}', + '[{"name":"input","label":"问题","type":"textarea","required":true,"placeholder":"请输入您的问题"}]', + 15, 10, 60, 'coze', '7589283937424310307', NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, 0, 1, 2), +-- APIYi Sora2视频生成(异步任务) +-- 提交接口: POST /v1/videos, 返回 {"id":"video_xxx","status":"submitted","progress":0,...} +-- 查询接口: GET /v1/videos/{video_id} +-- 查询返回: {"id":"video_xxx","status":"completed/in_progress/failed","progress":100,"url":"https://..."} +(3, 4, 'Sora2视频生成', 'sora2-video', 'video_generation', 'video', '基于Sora2的AI视频生成,支持文字描述和参考图片生成高质量视频', '/v1/videos', + 'POST', + '{"Content-Type":"application/x-www-form-urlencoded","Authorization":"Bearer {{api_key}}"}', + '{"model":"sora-2","prompt":"{{prompt}}","size":"{{size}}","seconds":"{{seconds}}","input_reference":"{{input_reference}}"}', + '{"status":"status","task_id":"id","error":"error"}', + '[{"name":"prompt","label":"视频描述","type":"textarea","required":true,"placeholder":"描述你想要生成的视频内容"},{"name":"size","label":"视频尺寸","type":"select","options":[{"label":"横屏(1280x720)","value":"1280x720"},{"label":"竖屏(720x1280)","value":"720x1280"}],"default":"1280x720"},{"name":"seconds","label":"视频时长(秒)","type":"select","options":[{"label":"10秒","value":"10"},{"label":"15秒","value":"15"}],"default":"15"}]', + 200, 50, 600, 'direct', NULL, NULL, 1, '/v1/videos/{{external_task_id}}', 'GET', NULL, '{"status":"status","result":"url","progress":"progress","error":"error"}', '{"queued":"submitted,queued","processing":"in_progress,processing","success":"completed","failed":"failed,error"}', 10000, 180, 1800, 1, 'result', 1, 1, 3), +-- 速传NanoBanana Pro图片生成(异步任务) +-- 提交接口: POST /api/img/nanoBanana-pro, 返回 {"code":200,"msg":"成功","data":{"id":"xxx"}} +-- 查询接口: GET /api/img/drawDetail?key=xxx&id=xxx +-- 查询返回: {"code":200,"data":{"status":0/1/2/3,"image_url":"xxx","fail_reason":"xxx"}} +(4, 3, 'NanoBanana Pro图片生成', 'nanobanana-image', 'image_generation', 'image', 'NanoBanana Pro AI图片生成,支持多张参考图和文字描述生成高质量图片', '/api/img/nanoBanana-pro', + 'POST', + '{"Content-Type":"application/json;charset=utf-8","Authorization":"{{api_key}}"}', + '{"prompt":"{{prompt}}","img_url":"{{img_url}}","aspectRatio":"{{aspectRatio}}","imageSize":"{{imageSize}}"}', + '{"status":"code","task_id":"data.id","error":"msg"}', + '[{"name":"prompt","label":"图片描述","type":"textarea","required":true,"placeholder":"描述你想要生成的图片内容"},{"name":"img_url","label":"参考图片","type":"image","required":false,"placeholder":"可选,上传参考图片(最多3张)","maxSize":10,"maxCount":3},{"name":"aspectRatio","label":"画面比例","type":"select","options":[{"label":"自动","value":"auto"},{"label":"1:1","value":"1:1"},{"label":"16:9","value":"16:9"},{"label":"9:16","value":"9:16"},{"label":"4:3","value":"4:3"},{"label":"3:4","value":"3:4"},{"label":"3:2","value":"3:2"},{"label":"2:3","value":"2:3"},{"label":"5:4","value":"5:4"},{"label":"4:5","value":"4:5"},{"label":"21:9","value":"21:9"}],"default":"auto"},{"name":"imageSize","label":"图片尺寸","type":"select","options":[{"label":"1K","value":"1K"},{"label":"2K","value":"2K"},{"label":"4K","value":"4K"}],"default":"1K"}]', + 50, 50, 300, 'direct', NULL, NULL, 1, '/api/img/drawDetail?key={{api_key}}&id={{external_task_id}}', 'GET', NULL, '{"status":"data.status","result":"data.image_url","error":"data.fail_reason"}', '{"queued":"0","processing":"1","success":"2","failed":"3,400,401,403,404,500,502,503"}', 10000, 120, 600, 1, 'result', 1, 1, 4), +-- 速传Grok视频生成(异步任务) +-- 提交接口: POST /api/async/video_grok_imagine, 返回 {"code":200,"msg":"成功","data":{"id":"video_xxx"}} +-- 查询接口: GET /api/async/detail?id=xxx +-- 查询返回: {"code":200,"data":{"status":0/1/2/3,"result":["url"],"message":"xxx"}} +(5, 3, 'Grok视频生成', 'grok-video', 'video_generation', 'video', 'Grok AI视频生成,支持文字描述和参考图片生成高质量视频,可选6秒或10秒时长', '/api/async/video_grok_imagine', + 'POST', + '{"Content-Type":"application/x-www-form-urlencoded;charset=utf-8","Authorization":"{{api_key}}"}', + '{"prompt":"{{prompt}}","duration":"{{duration}}","aspect_ratio":"{{aspect_ratio}}","image_urls":"{{image_urls}}"}', + '{"status":"code","task_id":"data.id","error":"msg"}', + '[{"name":"prompt","label":"视频描述","type":"textarea","required":true,"placeholder":"描述你想要生成的视频内容"},{"name":"duration","label":"视频时长","type":"select","options":[{"label":"6秒","value":"6"},{"label":"10秒","value":"10"}],"default":"10"},{"name":"aspect_ratio","label":"画面比例","type":"select","options":[{"label":"竖向(2:3)","value":"2:3"},{"label":"横向(3:2)","value":"3:2"},{"label":"正方形(1:1)","value":"1:1"},{"label":"宽屏(16:9)","value":"16:9"},{"label":"竖屏(9:16)","value":"9:16"}],"default":"16:9"},{"name":"image_urls","label":"参考图片","type":"image","required":false,"placeholder":"可选,上传参考图片(有参考图时画面比例不生效)","maxSize":10,"maxCount":1}]', + 200, 50, 600, 'direct', NULL, NULL, 1, '/api/async/detail?key={{api_key}}&id={{external_task_id}}', 'GET', NULL, '{"status":"data.status","result":"data.result[0]","error":"data.message"}', '{"queued":"0","processing":"1","success":"2","failed":"3,400,401,403,404,500,502,503"}', 10000, 180, 1800, 1, 'result', 1, 1, 5), +-- 腾讯云VOD AIGC视频生成(异步任务,使用TC3签名认证) +-- 提交接口: POST https://vod.tencentcloudapi.com (Action: CreateAigcVideoTask) +-- 提交返回: {"Response":{"TaskId":"xxx","RequestId":"xxx"}} +-- 查询接口: POST https://vod.tencentcloudapi.com (Action: DescribeTaskDetail) +-- 查询返回: {"Response":{"Status":"FINISH","EditMediaTask":{"Output":{"FileUrl":"xxx"}}}} +-- (6, 102, '腾讯云AIGC视频', 'tencent-aigc-video', 'video_generation', 'video', '腾讯云AIGC视频生成,基于GV模型生成高质量AI视频,支持多种画面比例和AI配音', '/', +-- 'POST', +-- '{}', +-- '{}', +-- '{}', +-- '[{"name":"prompt","label":"视频描述","type":"textarea","required":true,"placeholder":"描述你想要生成的视频内容"},{"name":"negative_prompt","label":"反向提示词","type":"textarea","required":false,"placeholder":"可选,描述不希望出现的内容"},{"name":"aspect_ratio","label":"画面比例","type":"select","options":[{"label":"横屏(16:9)","value":"16:9"},{"label":"竖屏(9:16)","value":"9:16"},{"label":"正方形(1:1)","value":"1:1"}],"default":"16:9"},{"name":"enhance_prompt","label":"提示词增强","type":"select","options":[{"label":"开启","value":"Enabled"},{"label":"关闭","value":"Disabled"}],"default":"Enabled"},{"name":"audio_generation","label":"AI配音","type":"select","options":[{"label":"开启","value":"Enabled"},{"label":"关闭","value":"Disabled"}],"default":"Enabled"}]', +-- 300, 5, 600, 'tencent_vod', NULL, '{"service":"vod","host":"vod.tencentcloudapi.com","version":"2018-07-17","region":"ap-guangzhou","sub_app_id":1302947942,"model_name":"GV","model_version":"3.1-fast"}', 1, NULL, 'POST', NULL, NULL, NULL, 15000, 120, 1800, 1, 'result', 1, 1, 6), +-- 腾讯云VOD Sora2视频生成(OS 2.0,音画同出,对人脸敏感会被拦截) +-- 使用tencent_vod工作流,与GV模型共用同一厂商(id=102) +-- (7, 102, 'Sora2视频生成', 'tencent-sora2-video', 'video_generation', 'video', 'OpenAI Sora2视频生成(腾讯云),音画同出,支持文生视频和图生视频', '/', +-- 'POST', +-- '{}', +-- '{}', +-- '{}', +-- '[{"name":"prompt","label":"视频描述","type":"textarea","required":true,"placeholder":"描述你想要生成的视频内容"},{"name":"negative_prompt","label":"反向提示词","type":"textarea","required":false,"placeholder":"可选,描述不希望出现的内容"},{"name":"aspect_ratio","label":"画面比例","type":"select","options":[{"label":"横屏(16:9)","value":"16:9"},{"label":"竖屏(9:16)","value":"9:16"},{"label":"正方形(1:1)","value":"1:1"}],"default":"16:9"},{"name":"enhance_prompt","label":"提示词增强","type":"select","options":[{"label":"开启","value":"Enabled"},{"label":"关闭","value":"Disabled"}],"default":"Enabled"},{"name":"audio_generation","label":"AI配音","type":"select","options":[{"label":"开启","value":"Enabled"},{"label":"关闭","value":"Disabled"}],"default":"Enabled"}]', +-- 500, 5, 600, 'tencent_vod', NULL, '{"service":"vod","host":"vod.tencentcloudapi.com","version":"2018-07-17","region":"ap-guangzhou","sub_app_id":1302947942,"model_name":"OS","model_version":"2.0"}', 1, NULL, 'POST', NULL, NULL, NULL, 15000, 120, 1800, 1, 'result', 1, 1, 7), +-- 腾讯云aiart混元生图(异步任务,使用TC3签名认证) +-- 提交接口: POST https://aiart.tencentcloudapi.com (Action: SubmitTextToImageJob) +-- 提交返回: {"Response":{"JobId":"xxx","RequestId":"xxx"}} +-- 查询接口: POST https://aiart.tencentcloudapi.com (Action: QueryTextToImageJob) +-- 查询返回: {"Response":{"JobStatusCode":"5","JobStatusMsg":"处理完成","ResultImage":["https://xxx"],...}} +-- 注意: 结果图片URL有效期1小时,需转存到COS +(9, 103, '混元生图', 'tencent-hunyuan-image3.0', 'image_generation', 'image', '腾讯混元生图,基于混元大模型根据文本描述生成高质量图片,支持垫图和多种分辨率', '/', + 'POST', + '{}', + '{}', + '{}', + '[{"name":"prompt","label":"图片描述","type":"textarea","required":true,"placeholder":"描述你想要生成的图片内容,推荐使用中文,最多8192字符"},{"name":"images","label":"垫图","type":"image","required":false,"placeholder":"可选,上传参考图片(最多3张,支持jpg/png/webp)","maxSize":10,"maxCount":3},{"name":"resolution","label":"图片分辨率","type":"select","options":[{"label":"1024x1024","value":"1024:1024"},{"label":"1248x832(横屏)","value":"1248:832"},{"label":"832x1248(竖屏)","value":"832:1248"},{"label":"768x1024","value":"768:1024"},{"label":"1024x768","value":"1024:768"},{"label":"512x1024","value":"512:1024"},{"label":"1024x512","value":"1024:512"}],"default":"1024:1024"},{"name":"revise","label":"Prompt改写","type":"select","options":[{"label":"开启提示词改写(推荐,约增加20s耗时)","value":1},{"label":"关闭","value":0}],"default":1}]', + 30, 300, 120, 'tencent_cloud_api', NULL, '{"service":"aiart","host":"aiart.tencentcloudapi.com","version":"2022-12-29","region":"ap-guangzhou","submit_action":"SubmitTextToImageJob","query_action":"QueryTextToImageJob"}', 1, NULL, 'POST', NULL, NULL, NULL, 10000, 60, 300, 1, 'result', 1, 1, 9), +-- 火山引擎豆包Seedream 4.5图片生成(同步任务,用户端) +-- 接口: POST /api/v3/images/generations +-- 返回: {"data":[{"url":"xxx"}]} +(8, 101, 'seedream-4-5', 'seedream-4-5', 'image_generation', 'image', '字节跳动豆包Seedream 4.5图片生成,支持参考图和文字描述生成高质量图片', '/api/v3/images/generations', + 'POST', + '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"doubao-seedream-4-5-251128","prompt":"{{prompt}}","size":"{{size}}","aspect_ratio":"{{aspect_ratio}}","response_format":"url","stream":false,"watermark":false,"sequential_image_generation":"disabled","image":"{{image}}"}', + '{"result":"data[0].url","error":"error.message"}', + '[{"name":"prompt","label":"图片描述","type":"textarea","required":true,"placeholder":"描述你想要生成的图片内容"},{"name":"image","label":"参考图片","type":"image","required":false,"placeholder":"可选,上传参考图片","maxSize":10,"maxCount":1},{"name":"size","label":"图片尺寸","type":"select","options":[{"label":"1K","value":"1K"},{"label":"2K","value":"2K"},{"label":"4K","value":"4K"}],"default":"2K"},{"name":"aspect_ratio","label":"画面比例","type":"select","options":[{"label":"1:1","value":"1:1"},{"label":"16:9","value":"16:9"},{"label":"9:16","value":"9:16"},{"label":"4:3","value":"4:3"},{"label":"3:4","value":"3:4"},{"label":"3:2","value":"3:2"},{"label":"2:3","value":"2:3"}],"default":"1:1"}]', + 80, 10, 120, 'direct', NULL, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 1, 'result', 1, 1, 8); + +-- ============================================= +-- 12. AI任务数据(清空测试数据) +-- ============================================= + +-- 不插入测试任务数据,保持表为空 + +-- ============================================= +-- 13. 作品分类数据 +-- ============================================= + +INSERT INTO `work_category` (`id`, `parent_id`, `name`, `icon`, `sort`, `status`) VALUES +(1, 0, '全部', NULL, 0, 1), +(2, 0, '图片', 'image', 1, 1), +(3, 0, '视频', 'video', 2, 1), +(4, 2, '人像写真', NULL, 1, 1), +(5, 2, '风景', NULL, 2, 1), +(6, 2, '动漫', NULL, 3, 1), +(7, 2, '创意设计', NULL, 4, 1), +(8, 3, '短视频', NULL, 1, 1), +(9, 3, '动画', NULL, 2, 1); + +-- ============================================= +-- 14. AI作品数据 +-- ============================================= + +-- [上线前注释] 广场作品测试数据 +-- INSERT INTO `ai_work` (`id`, `user_id`, `category_id`, `title`, `description`, `content_url`, `content_type`, `task_type`, `model`, `prompt`, `tags`, `view_count`, `like_count`, `collect_count`, `comment_count`, `is_public`, `audit_status`, `is_featured`, `status`) VALUES +-- (1, 1, 4, '梦幻人像', '使用AI生成的梦幻风格人像照片', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%B5%8B%E8%AF%951%E5%9B%BE.png', 1, 'portrait-photo', 'portrait-photo', '梦幻风格,柔和光线,唯美', '人像,梦幻,唯美', 1520, 328, 156, 42, 1, 1, 1, 1), +-- (2, 1, 5, '未来城市', 'AI生成的赛博朋克风格城市', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%B5%8B%E8%AF%951%E5%9B%BE.png', 1, 'image_generation', 'sd-generate', '赛博朋克城市,霓虹灯,夜景', '城市,赛博朋克,夜景', 2340, 567, 234, 89, 1, 1, 1, 1), +-- (3, 2, 6, '动漫少女', '可爱的动漫风格少女', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/%E6%B5%8B%E8%AF%951%E5%9B%BE.png', 1, 'image_generation', 'dalle3', '动漫少女,樱花,春天', '动漫,少女,樱花', 890, 234, 98, 23, 1, 1, 0, 1), +-- (4, 4, 8, 'AI短视频', '使用AI生成的创意短视频', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/video0.mp4', 2, 'video_generation', 'video-gen', '海边日落,浪漫氛围', '视频,海边,日落', 3200, 890, 456, 120, 1, 1, 1, 1), +-- (5, 4, 7, '创意海报', 'AI设计的产品海报', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/video1.mp4', 1, 'image_generation', 'sd-generate', '简约风格产品海报,科技感', '海报,设计,科技', 560, 123, 67, 15, 1, 1, 0, 1), +-- (6, 3, 4, '证件照', 'AI生成的证件照', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/video1.mp4', 1, 'portrait-photo', 'portrait-photo', '证件照,白底,正式', '证件照,正式', 120, 12, 5, 2, 0, 1, 0, 1), +-- (7, 5, 6, '待审核作品', '新提交的作品', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/assets/video0.mp4', 1, 'image_generation', 'dalle3', '测试提示词', '测试', 0, 0, 0, 0, 1, 0, 0, 1); + +-- ============================================= +-- 15. 作品点赞数据 +-- ============================================= + +-- [上线前注释] 测试点赞数据 +-- INSERT INTO `ai_work_like` (`work_id`, `user_id`) VALUES +-- (1, 2), (1, 3), (1, 4), (1, 5), +-- (2, 1), (2, 3), (2, 4), (2, 5), +-- (3, 1), (3, 4), +-- (4, 1), (4, 2), (4, 3), (4, 5); + +-- ============================================= +-- 16. 作品收藏数据 +-- ============================================= + +-- [上线前注释] 测试收藏数据 +-- INSERT INTO `ai_work_collect` (`work_id`, `user_id`) VALUES +-- (1, 2), (1, 4), +-- (2, 1), (2, 3), (2, 5), +-- (4, 1), (4, 2), (4, 3); + +-- ============================================= +-- 17. 作品评论数据 +-- ============================================= + +-- [上线前注释] 测试评论数据 +-- INSERT INTO `ai_work_comment` (`id`, `work_id`, `user_id`, `parent_id`, `content`, `like_count`, `status`) VALUES +-- (1, 1, 2, 0, '太美了!请问用的什么模型?', 15, 1), +-- (2, 1, 1, 1, '用的人像写真模型,风格选的梦幻', 8, 1), +-- (3, 2, 3, 0, '赛博朋克风格太酷了', 23, 1), +-- (4, 2, 4, 0, '细节处理得很好', 12, 1), +-- (5, 4, 1, 0, '视频效果很惊艳!', 45, 1), +-- (6, 4, 2, 5, '同意,AI视频越来越强了', 18, 1); + + +-- ============================================= +-- 18. 系统公告数据 +-- ============================================= + +INSERT INTO `notice` (`id`, `title`, `content`, `type`, `level`, `target`, `is_top`, `is_popup`, `start_time`, `end_time`, `view_count`, `creator_id`, `status`) VALUES +(1, '欢迎使用1818AI', '欢迎来到1818AI创作平台!在这里您可以体验最新的AI创作工具,包括图片生成、视频生成、人像写真等多种功能。', 1, 1, 1, 1, 1, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 5680, 1, 1), +(2, '新功能上线:AI视频生成', '我们很高兴地宣布,AI视频生成功能正式上线!现在您可以通过简单的文字描述,生成精彩的短视频。', 2, 2, 1, 0, 0, '2026-01-10 00:00:00', '2026-02-10 23:59:59', 2340, 1, 1), +(3, '春节活动:充值送积分', '春节期间充值任意套餐,额外赠送20%积分!活动时间:1月20日-2月10日', 3, 2, 1, 1, 1, '2026-01-20 00:00:00', '2026-02-10 23:59:59', 890, 1, 1), +(4, '系统维护通知', '系统将于1月15日凌晨2:00-4:00进行维护升级,届时服务将暂停,请提前安排好您的创作计划。', 1, 3, 1, 0, 0, '2026-01-14 00:00:00', '2026-01-15 04:00:00', 456, 1, 1); + +-- ============================================= +-- 19. Banner轮播图数据 +-- ============================================= + +INSERT INTO `banner` (`id`, `title`, `image_url`, `link_type`, `link_url`, `position`, `sort`, `start_time`, `end_time`, `click_count`, `creator_id`, `status`) VALUES +(1, 'AI创作新体验', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/dfa9a94106ee4054a35e9997a9f4f84b.png', 1, '/pages/ai/index', 'home', 1, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 12580, 1, 1), +(2, '人像写真限时优惠', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/77d05dc47e294367a3001b8f30fe8396.png', 1, '/pages/ai/portrait', 'home', 2, '2026-01-01 00:00:00', '2026-03-31 23:59:59', 8960, 1, 1), +(3, '邀请好友得积分', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/48b8a2d81b0349229e9818cd40b20dae.png', 1, '/pages/promotion/index', 'home', 3, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 5670, 1, 1), +(4, 'VIP会员专享', 'https://weixin-1818ai-1302947942.cos.ap-shanghai.myqcloud.com/banner/56a0613262324b2f8167fdede54a8b27.png', 1, '/pages/vip/index', 'home', 4, '2026-01-01 00:00:00', '2026-12-31 23:59:59', 3450, 1, 1); + +-- ============================================= +-- 20. 兑换码数据 +-- ============================================= + +INSERT INTO `redeem_code` (`id`, `code`, `batch_no`, `type`, `reward_value`, `vip_level`, `total_count`, `used_count`, `start_time`, `expire_time`, `remark`, `creator_id`, `status`) VALUES +(1, 'WELCOME2026', 'BATCH001', 1, 100, NULL, 1000, 156, '2026-01-01 00:00:00', '2026-12-31 23:59:59', '新用户欢迎礼包', 1, 1), +(2, 'VIP7DAYS', 'BATCH002', 2, 7, 1, 500, 89, '2026-01-01 00:00:00', '2026-06-30 23:59:59', '7天VIP体验卡', 1, 1), +(3, 'SPRING500', 'BATCH003', 1, 500, NULL, 100, 23, '2026-01-20 00:00:00', '2026-02-10 23:59:59', '春节特别礼包', 1, 1), +(4, 'TEST123', 'BATCH004', 1, 50, NULL, 10, 0, '2026-01-01 00:00:00', '2026-01-31 23:59:59', '测试兑换码', 1, 0); + +-- ============================================= +-- 21. 奖励语句配置数据 +-- ============================================= + +INSERT INTO `reward_message` (`id`, `config_key`, `config_value`, `description`) VALUES +(1, 'register_reward', '恭喜您获得新用户注册奖励 {points} 积分!', '注册奖励提示语'), +(2, 'invite_reward', '您邀请的好友已注册,获得 {points} 积分奖励!', '邀请奖励提示语'), +(3, 'first_recharge_reward', '首次充值成功,额外赠送 {points} 积分!', '首充奖励提示语'), +(4, 'daily_checkin', '签到成功,获得 {points} 积分!', '每日签到提示语'), +(5, 'vip_gift', '感谢您成为VIP会员,赠送 {points} 积分!', 'VIP赠送提示语'), +(6, 'redeem_success', '兑换成功,获得 {value}!', '兑换码成功提示语'), +(7, 'share_reward', '分享成功,获得 {points} 积分奖励!', '分享奖励提示语'); + +-- ============================================= +-- 22. AI使用记录数据 +-- ============================================= + +-- [上线前注释] 测试AI使用记录数据 +-- INSERT INTO `ai_usage_record` (`id`, `user_id`, `task_type`, `model`, `prompt`, `result`, `tokens_used`, `points_cost`, `duration`, `status`, `progress`) VALUES +-- (1, 1, 'image_generation', 'dalle3', '一只可爱的猫咪在花园里', 'https://example.com/result/1.png', 0, 50, 30000, 2, 100), +-- (2, 1, 'text_generation', 'gpt4-chat', '写一首关于春天的诗', 'https://example.com/result/2.txt', 256, 20, 5000, 2, 100), +-- (3, 2, 'image_generation', 'sd-generate', '未来城市夜景', 'https://example.com/result/3.png', 0, 30, 45000, 2, 100), +-- (4, 4, 'portrait-photo', 'portrait-photo', '梦幻风格人像', 'https://example.com/result/4.png', 0, 100, 60000, 2, 100), +-- (5, 4, 'video_generation', 'video-gen', '海边日落浪漫氛围', 'https://example.com/result/5.mp4', 0, 200, 120000, 2, 100), +-- (6, 3, 'image_generation', 'dalle3', '测试图片生成', NULL, 0, 50, 0, 1, 30); + +-- ============================================= +-- 23. 一键成片相关初始化数据 +-- ============================================= + +-- 硅基流动厂商 +INSERT INTO `ai_provider` (`id`, `name`, `code`, `description`, `base_url`, `api_key`, `status`, `sort`) VALUES +(100, '硅基流动', 'siliconflow', '硅基流动大模型平台', 'https://api.siliconflow.cn/v1', 'sk-uusizduvvsvexqpzuxohyevtiqeldtkyjhyiqfwfpkuladgk', 1, 100); + +-- 火山引擎厂商 +INSERT INTO `ai_provider` (`id`, `name`, `code`, `description`, `base_url`, `api_key`, `status`, `sort`) VALUES +(101, '火山引擎', 'volcano', '字节跳动火山引擎AI平台,提供豆包绘图等服务', 'https://ark.cn-beijing.volces.com', '3f92f73a-af9b-4b76-a292-c45f006f9b4f', 1, 101); + +-- 一键成片AI模型 +INSERT INTO `ai_model` (`id`, `provider_id`, `name`, `code`, `type`, `category`, `description`, `api_endpoint`, `request_method`, `request_headers`, `request_template`, `response_mapping`, `input_params`, `points_cost`, `timeout`, `workflow_type`, `is_async`, `result_transfer_cos`, `result_url_field`, `show_in_list`, `status`, `sort`) VALUES +(100, 100, '剧本生成', 'video-script-gen', 'text', 'video-onekey', 'AI根据创意生成剧本', '/chat/completions', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"Pro/deepseek-ai/DeepSeek-V3.2","messages":[{"role":"system","content":"{{system_prompt}}"},{"role":"user","content":"{{user_prompt}}"}],"temperature":0.8,"max_tokens":4096}', + '{"content":"choices[0].message.content"}', + '[{"name":"idea","type":"textarea","label":"创意描述","required":true}]', + 0, 120, 'direct', 0, 0, NULL, 0, 1, 100), +(101, 100, '分镜生成', 'video-storyboard-gen', 'text', 'video-onekey', 'AI根据场次生成分镜', '/chat/completions', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"Pro/deepseek-ai/DeepSeek-V3.2","messages":[{"role":"system","content":"{{system_prompt}}"},{"role":"user","content":"{{user_prompt}}"}],"temperature":0.7,"max_tokens":4096}', + '{"content":"choices[0].message.content"}', + '[{"name":"scene_story","type":"textarea","label":"场次故事","required":true}]', + 0, 120, 'direct', 0, 0, NULL, 0, 1, 101), +(102, 101, '图像生成', 'video-image-gen', 'image', 'video-onekey', '使用火山引擎豆包绘图生成分镜画面', '/api/v3/images/generations', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"doubao-seedream-4-5-251128","prompt":"{{prompt}}","size":"2K","response_format":"url","stream":false,"watermark":false,"sequential_image_generation":"disabled","image":"{{referenceImages}}"}', + '{"result":"data[0].url","error":"error.message"}', + '[{"name":"prompt","type":"textarea","label":"画面描述","required":true}]', + 50, 120, 'direct', 0, 1, 'result', 0, 1, 102), +(103, 100, '视频合成', 'video-compose', 'video', 'video-onekey', '合成最终视频', '/video/generations', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"Pro/video-model","images":"{{images}}","audio":"{{audio}}"}', + '{"url":"video_url"}', + '[{"name":"images","type":"array","label":"分镜图片"},{"name":"audio","type":"text","label":"音频URL"}]', + 20, 300, 'direct', 0, 0, NULL, 0, 1, 103), +-- 免费的素描转换模型(使用火山引擎豆包绘图,图生图模式不指定size) +(104, 101, '素描转换', 'sketch-convert', 'image', 'video-onekey', '将图片转换为黑白素描风格(免费)', '/api/v3/images/generations', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"doubao-seedream-4-5-251128","prompt":"{{prompt}}","response_format":"url","stream":false,"watermark":false,"image":"{{sourceImage}}"}', + '{"result":"data[0].url","error":"error.message"}', + '[{"name":"sourceImage","type":"text","label":"原图URL","required":true}]', + 0, 120, 'direct', 0, 1, 'result', 0, 1, 104), +-- 视频提示词优化模型(免费,用于整合优化场次提示词) +(105, 100, '视频提示词优化', 'video-prompt-optimize', 'text', 'video-onekey', '整合优化场次提示词为连贯的视频生成提示词(免费)', '/chat/completions', + 'POST', '{"Authorization":"Bearer {{api_key}}","Content-Type":"application/json"}', + '{"model":"Pro/deepseek-ai/DeepSeek-V3.2","messages":[{"role":"system","content":"{{system_prompt}}"},{"role":"user","content":"{{user_prompt}}"}],"temperature":0.7,"max_tokens":2048}', + '{"content":"choices[0].message.content"}', + '[{"name":"storyboards","type":"textarea","label":"分镜内容","required":true}]', + 0, 60, 'direct', 0, 0, NULL, 0, 1, 105); + +-- AI提示词模板 +INSERT INTO `ai_prompt_template` (`id`, `template_code`, `template_name`, `system_prompt`, `user_prompt_template`, `output_format`, `model_code`, `temperature`, `max_tokens`, `status`) VALUES +(1, 'SCRIPT_GENERATION', '剧本生成', +'你是一位专业的短视频剧本创作专家,擅长将简单的创意扩展成引人入胜的短篇故事。 + +创作要求: +1. 故事要有清晰的开头、发展、高潮和结尾 +2. 人物形象鲜明,对话自然生动 +3. 情节紧凑,适合短视频呈现 +4. 包含场景描写和情感表达 +5. 故事要有打动人心的细节', +'请根据以下创意,创作一个短视频剧本: + +【用户创意】 +{{userIdea}} + +请严格按照以下JSON格式输出(不要添加任何其他内容): +```json +{ + "title": "故事标题(带书名号)", + "summary": "故事概要(50字以内)", + "content": "完整故事内容(800-1500字)", + "characters": [ + { + "name": "角色名", + "description": "角色性格特点和在故事中的作用", + "voiceType": "建议音色描述", + "age": "年龄(如30岁、中年等)", + "gender": "性别(男/女/其他)", + "appearance": "外貌特征描述(脸型、发型、身材等)", + "clothing": "穿着描述(服装风格、颜色等)" + } + ], + "scenes": [ + {"name": "第一场", "title": "场次标题", "description": "场次描述", "story": "场次具体故事内容"} + ] +} +```', 'JSON', 'video-script-gen', 0.80, 4096, 1), + +(2, 'STORYBOARD_GENERATION', '分镜脚本生成', +'你是一位专业的分镜脚本师,擅长将故事内容转化为具体的分镜画面。 + +分镜要求: +1. 景别选项:远景、全景、中全景、中景、近景、特写、大特写 +2. 镜头角度:平视、俯视、仰视、倾斜 +3. 镜头运动:固定、推进、拉远、摇镜、跟随 +4. 画面描述要详细具体,便于AI绘图 +5. 分镜之间要有连贯性和节奏感 +6. 【重要】所有角色引用必须使用 /角色名 格式,例如:/小明、/老王。在description、dialogue、dialogueCharacter字段中涉及角色时都要使用此格式', +'请根据以下场次故事,生成{{storyboardCount}}个分镜脚本: + +【场次标题】{{sceneTitle}} +【场次描述】{{sceneDescription}} +【场次故事】 +{{sceneStory}} + +【角色列表】 +{{characterList}} +{{previousSceneContext}} +请严格按照以下JSON格式输出(不要添加任何其他内容): +```json +{ + "storyboards": [ + { + "index": 1, + "shotType": "景别", + "cameraAngle": "镜头角度", + "cameraMove": "镜头运动", + "description": "详细的画面描述,角色用/角色名格式,如:/小明站在窗前,望着远方", + "narration": "旁白内容(可为空字符串)", + "dialogue": "对话内容(可为空字符串)", + "dialogueCharacter": "/角色名(可为空字符串)", + "duration": 5 + } + ] +} +```', 'JSON', 'video-storyboard-gen', 0.70, 4096, 1), + +(3, 'DESCRIPTION_OPTIMIZE', '画面描述优化', +'你是一位专业的AI绘图提示词专家,擅长将简单的画面描述优化为详细的、适合AI绘图的提示词。', +'请优化以下分镜画面描述,使其更适合AI绘图生成: + +【原始描述】{{originalDescription}} +【景别】{{shotType}} +【镜头角度】{{cameraAngle}} +【视频风格】{{videoStyle}} + +请直接输出优化后的画面描述(200字以内,不要任何解释),要求: +1. 包含具体的场景细节 +2. 描述光线和氛围 +3. 明确人物的表情和动作 +4. 符合指定的景别和角度', 'TEXT', 'video-script-gen', 0.60, 512, 1), + +(4, 'VIDEO_PROMPT_OPTIMIZE', '视频提示词优化', +'你是一位专业的AI视频生成提示词工程师。你的任务是将分镜描述整合为一个连贯的、适合AI视频生成的提示词。 + +核心要求: +1. 创建统一的、流畅的叙事,将所有场景连接起来 +2. 保持视觉风格、光线和氛围的一致性 +3. 【场次衔接】如果提供了前后场次上下文,视频开头画面必须自然承接上一场结尾,视频结尾画面必须自然过渡到下一场开头 +4. 【角色台词】完整保留用户设置的角色台词,一字不改,用「」标注,注明说话的角色名 +5. 参考图是分镜画面,生成的视频必须忠实还原参考图中的画面构图、人物位置和场景布局,色彩鲜艳生动 +6. 输出必须使用中文 +7. 【分镜对应】提示词必须按分镜顺序逐一描述每个分镜的画面内容,确保生成的视频画面与参考的分镜图一一对应、视觉衔接一致。每个分镜的场景、人物位置、动作、镜头角度都要忠实还原分镜描述 +8. 提示词控制在500字以内,充分描述每个分镜画面细节,优先保留台词和场景衔接描述', +'请将以下分镜内容整合优化为一个连贯的视频生成提示词: + +【视频风格】{{videoStyle}} +【视频时长】10秒 +【视频比例】{{videoRatio}} + +【重要提示】参考图是分镜画面,请严格参照图中的构图、人物位置和场景布局生成视频,色彩鲜艳生动,符合{{videoStyle}}的色彩特点。 + +【角色信息】 +{{characterInfo}} + +【场次衔接上下文 - 保证视频首尾连贯】 +{{continuityContext}} + +【分镜内容】 +{{storyboardContents}} + +【角色台词 - 必须完整保留,标注角色名】 +{{dialogues}} + +请输出优化后的中文视频生成提示词,要求: +1. 如果有上一场结尾信息,视频开头画面要自然承接(相似场景、人物位置延续) +2. 如果有下一场开头信息,视频结尾画面要为其做铺垫(预示场景变化方向) +3. 完整保留角色台词,格式:角色名:「台词内容」 +4. 按分镜顺序逐一描述画面:每个分镜的景别、镜头角度、人物动作、场景细节都要忠实还原,确保视频画面与分镜图一一对应 +5. 描述画面流程、角色动作和镜头运动,保证视频节奏与分镜节奏一致 +6. 视频画面必须忠实还原参考分镜图的构图和布局,色彩鲜艳生动 +7. 适合{{videoDuration}}秒视频 +8. 500字以内,充分利用字数描述每个分镜画面 + +输出格式:直接输出提示词文本,不要任何解释。', 'TEXT', 'video-prompt-optimize', 0.70, 2048, 1); + +-- 角色模板库 +INSERT INTO `character_template` (`id`, `name`, `gender`, `age_range`, `voice_type`, `appearance`, `clothing`, `description`, `category`, `is_system`, `sort`, `status`) VALUES +(1, '绅士青年', 'male', '25-35', '成熟男声', '五官端正,眉目清秀,气质温和', '西装革履,整洁得体', '穿着得体的都市青年,温文尔雅,举止优雅', '男生', 1, 1, 1), +(2, '成熟大叔', 'male', '40-50', '成熟大叔', '面容沧桑但眼神坚定,略有皱纹', '简单朴素的休闲装', '经历丰富的中年男性,稳重可靠,值得信赖', '男生', 1, 2, 1), +(3, '活泼少年', 'male', '18-25', '青年男声', '阳光帅气,笑容灿烂', '运动风或休闲风格', '阳光开朗的年轻男性,充满活力和朝气', '男生', 1, 3, 1), +(4, '温柔女生', 'female', '20-30', '温柔女声', '清秀可人,笑容甜美', '简约优雅的连衣裙', '温柔善良的年轻女性,亲和力强,善解人意', '女生', 1, 4, 1), +(5, '知性女性', 'female', '30-40', '知性女声', '端庄大方,气质出众', '职业装或高端时装', '优雅知性的职业女性,自信从容,独立干练', '女生', 1, 5, 1), +(6, '慈祥奶奶', 'female', '60-70', '慈祥奶奶', '满头银发,面容慈祥', '传统服饰或家居服', '和蔼可亲的老年女性,充满温暖和智慧', '其他', 1, 6, 1), +(7, '严肃老者', 'male', '60-70', '沉稳老者', '白发苍苍,神态威严', '传统中式服装', '阅历丰富的老年男性,威严庄重,德高望重', '其他', 1, 7, 1), +(8, '可爱萝莉', 'female', '8-15', '萝莉音', '大眼睛,稚嫩可爱', '可爱的裙子或校服', '天真烂漫的小女孩,活泼好动,惹人喜爱', '其他', 1, 8, 1); diff --git a/src/main/resources/db/migration/V20260203__add_scene_video_url.sql b/src/main/resources/db/migration/V20260203__add_scene_video_url.sql new file mode 100644 index 0000000..cbe2933 --- /dev/null +++ b/src/main/resources/db/migration/V20260203__add_scene_video_url.sql @@ -0,0 +1,19 @@ +-- 为 project_scene 表添加视频URL字段 +-- 用于存储场次视频生成结果 + +ALTER TABLE `project_scene` +ADD COLUMN `video_url` VARCHAR(500) DEFAULT NULL COMMENT '场次视频URL' AFTER `sort`, +ADD COLUMN `video_task_no` VARCHAR(50) DEFAULT NULL COMMENT '视频生成任务号' AFTER `video_url`, +ADD COLUMN `video_status` TINYINT DEFAULT 0 COMMENT '视频状态:0-未生成,1-生成中,2-已生成' AFTER `video_task_no`; + +-- 添加索引便于按任务号查询 +ALTER TABLE `project_scene` ADD INDEX `idx_video_task_no` (`video_task_no`) USING BTREE; + +-- 为 scene_storyboard 表添加任务状态字段 +-- 用于跟踪分镜图片生成状态,防止重复提交 +ALTER TABLE `scene_storyboard` +ADD COLUMN `current_task_no` VARCHAR(50) DEFAULT NULL COMMENT '当前正在执行的任务号' AFTER `image_url`, +ADD COLUMN `image_status` TINYINT DEFAULT 0 COMMENT '图片状态:0-未生成,1-生成中,2-已生成' AFTER `current_task_no`; + +-- 添加索引便于按任务号查询 +ALTER TABLE `scene_storyboard` ADD INDEX `idx_current_task_no` (`current_task_no`) USING BTREE; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql new file mode 100644 index 0000000..a00e55a --- /dev/null +++ b/src/main/resources/db/schema.sql @@ -0,0 +1,751 @@ +-- ============================================= +-- 1818ai_uniapp后端数据库表结构 +-- 每次启动时会删除并重建所有表 +-- ============================================= + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- 删除所有表(按依赖关系倒序) +DROP TABLE IF EXISTS `redeem_code_record`; +DROP TABLE IF EXISTS `redeem_code`; +DROP TABLE IF EXISTS `notice_read`; +DROP TABLE IF EXISTS `ai_work_comment`; +DROP TABLE IF EXISTS `ai_work_collect`; +DROP TABLE IF EXISTS `ai_work_like`; +DROP TABLE IF EXISTS `ai_work`; +DROP TABLE IF EXISTS `work_category`; +DROP TABLE IF EXISTS `ai_task`; +DROP TABLE IF EXISTS `ai_model`; +DROP TABLE IF EXISTS `ai_provider`; +DROP TABLE IF EXISTS `promotion_record`; +DROP TABLE IF EXISTS `promotion_config`; +DROP TABLE IF EXISTS `points_record`; +DROP TABLE IF EXISTS `points_order`; +DROP TABLE IF EXISTS `points_package`; +DROP TABLE IF EXISTS `vip_order`; +DROP TABLE IF EXISTS `vip_package`; +DROP TABLE IF EXISTS `role_permission`; +DROP TABLE IF EXISTS `admin_role`; +DROP TABLE IF EXISTS `permission`; +DROP TABLE IF EXISTS `role`; +DROP TABLE IF EXISTS `admin`; +DROP TABLE IF EXISTS `user`; +DROP TABLE IF EXISTS `notice`; +DROP TABLE IF EXISTS `banner`; +DROP TABLE IF EXISTS `reward_message`; +DROP TABLE IF EXISTS `ai_usage_record`; +DROP TABLE IF EXISTS `ai_prompt_template`; +DROP TABLE IF EXISTS `scene_storyboard`; +DROP TABLE IF EXISTS `project_scene`; +DROP TABLE IF EXISTS `project_character`; +DROP TABLE IF EXISTS `character_template`; +DROP TABLE IF EXISTS `video_project`; + +-- 用户表 +CREATE TABLE `user` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', + `openid` VARCHAR(64) DEFAULT NULL COMMENT '微信OpenID', + `unionid` VARCHAR(64) DEFAULT NULL COMMENT '微信UnionID', + `nickname` VARCHAR(64) DEFAULT NULL COMMENT '昵称', + `avatar` VARCHAR(512) DEFAULT NULL COMMENT '头像URL', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `gender` TINYINT UNSIGNED DEFAULT 0 COMMENT '性别:0未知 1男 2女', + `vip_level` TINYINT UNSIGNED DEFAULT 0 COMMENT 'VIP等级:0普通用户', + `vip_expire_time` DATETIME DEFAULT NULL COMMENT 'VIP过期时间', + `points` INT UNSIGNED DEFAULT 0 COMMENT '积分余额', + `invite_code` VARCHAR(32) DEFAULT NULL COMMENT '邀请码', + `inviter_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '邀请人ID', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1正常', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间', + `subscribed` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否已订阅消息通知:0未订阅 1已订阅', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_openid` (`openid`) USING BTREE, + UNIQUE INDEX `uk_invite_code` (`invite_code`) USING BTREE, + INDEX `idx_phone` (`phone`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 管理员表 +CREATE TABLE `admin` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '管理员ID', + `username` VARCHAR(64) NOT NULL COMMENT '用户名', + `password` VARCHAR(128) NOT NULL COMMENT '密码', + `real_name` VARCHAR(64) DEFAULT NULL COMMENT '真实姓名', + `phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号', + `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱', + `avatar` VARCHAR(512) DEFAULT NULL COMMENT '头像', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1正常', + `last_login_time` DATETIME DEFAULT NULL COMMENT '最后登录时间', + `last_login_ip` VARCHAR(64) DEFAULT NULL COMMENT '最后登录IP', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_username` (`username`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员表'; + +-- 角色表 +CREATE TABLE `role` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `name` VARCHAR(64) NOT NULL COMMENT '角色名称', + `code` VARCHAR(64) NOT NULL COMMENT '角色编码', + `description` VARCHAR(256) DEFAULT NULL COMMENT '角色描述', + `data_scope` TINYINT UNSIGNED DEFAULT 1 COMMENT '数据权限:1全部 2本部门 3本人', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1正常', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code` (`code`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表'; + +-- 权限表 +CREATE TABLE `permission` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '权限ID', + `parent_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '父级ID', + `name` VARCHAR(64) NOT NULL COMMENT '权限名称', + `code` VARCHAR(128) NOT NULL COMMENT '权限编码', + `type` TINYINT UNSIGNED DEFAULT 1 COMMENT '类型:1目录 2菜单 3按钮', + `path` VARCHAR(256) DEFAULT NULL COMMENT '路由路径', + `component` VARCHAR(256) DEFAULT NULL COMMENT '组件路径', + `icon` VARCHAR(64) DEFAULT NULL COMMENT '图标', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `visible` TINYINT UNSIGNED DEFAULT 1 COMMENT '是否可见', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1正常', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code` (`code`) USING BTREE, + INDEX `idx_parent_id` (`parent_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限表'; + + +-- 管理员-角色关联表 +CREATE TABLE `admin_role` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `admin_id` BIGINT UNSIGNED NOT NULL COMMENT '管理员ID', + `role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_admin_role` (`admin_id`, `role_id`) USING BTREE, + INDEX `idx_role_id` (`role_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='管理员-角色关联表'; + +-- 角色-权限关联表 +CREATE TABLE `role_permission` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID', + `permission_id` BIGINT UNSIGNED NOT NULL COMMENT '权限ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_role_permission` (`role_id`, `permission_id`) USING BTREE, + INDEX `idx_permission_id` (`permission_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色-权限关联表'; + +-- VIP套餐表 +CREATE TABLE `vip_package` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '套餐ID', + `name` VARCHAR(64) NOT NULL COMMENT '套餐名称', + `level` TINYINT UNSIGNED NOT NULL COMMENT 'VIP等级', + `price` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT '价格', + `original_price` DECIMAL(10,2) UNSIGNED DEFAULT NULL COMMENT '原价', + `duration` INT UNSIGNED NOT NULL COMMENT '时长(天)', + `points_gift` INT UNSIGNED DEFAULT 0 COMMENT '赠送积分', + `daily_usage_limit` INT DEFAULT -1 COMMENT '每日使用限制,-1无限', + `features` JSON DEFAULT NULL COMMENT '套餐特权', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0下架 1上架', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP套餐表'; + +-- VIP订单表 +CREATE TABLE `vip_order` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID', + `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `package_id` BIGINT UNSIGNED NOT NULL COMMENT '套餐ID', + `package_name` VARCHAR(64) DEFAULT NULL COMMENT '套餐名称', + `amount` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT '支付金额', + `pay_type` TINYINT UNSIGNED DEFAULT NULL COMMENT '支付方式:1微信 2支付宝', + `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', + `transaction_id` VARCHAR(64) DEFAULT NULL COMMENT '第三方交易号', + `status` TINYINT UNSIGNED DEFAULT 0 COMMENT '状态:0待支付 1已支付 2已取消 3已退款', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_order_no` (`order_no`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP订单表'; + +-- 积分套餐表 +CREATE TABLE `points_package` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '套餐ID', + `name` VARCHAR(64) NOT NULL COMMENT '套餐名称', + `points` INT UNSIGNED NOT NULL COMMENT '积分数量', + `price` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT '价格', + `original_price` DECIMAL(10,2) UNSIGNED DEFAULT NULL COMMENT '原价', + `bonus_points` INT UNSIGNED DEFAULT 0 COMMENT '赠送积分', + `valid_days` INT UNSIGNED DEFAULT 365 COMMENT '有效期天数', + `description` VARCHAR(256) DEFAULT NULL COMMENT '套餐描述', + `bg_image` VARCHAR(512) DEFAULT NULL COMMENT '背景图URL', + `card_style` VARCHAR(256) DEFAULT NULL COMMENT '卡片样式JSON', + `btn_style` VARCHAR(256) DEFAULT NULL COMMENT '按钮样式JSON', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0下架 1上架', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分套餐表'; + +-- 积分订单表 +CREATE TABLE `points_order` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID', + `order_no` VARCHAR(64) NOT NULL COMMENT '订单号', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `package_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '套餐ID', + `points` INT UNSIGNED NOT NULL COMMENT '充值积分', + `bonus_points` INT UNSIGNED DEFAULT 0 COMMENT '赠送积分', + `amount` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT '支付金额', + `pay_type` TINYINT UNSIGNED DEFAULT NULL COMMENT '支付方式', + `pay_time` DATETIME DEFAULT NULL COMMENT '支付时间', + `transaction_id` VARCHAR(64) DEFAULT NULL COMMENT '第三方交易号', + `status` TINYINT UNSIGNED DEFAULT 0 COMMENT '状态:0待支付 1已支付 2已取消 3已退款', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_order_no` (`order_no`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分订单表'; + +-- 积分流水表 +CREATE TABLE `points_record` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `type` TINYINT UNSIGNED NOT NULL COMMENT '类型:1充值 2消费 3赠送 4推广奖励 5签到 6退款', + `points` INT NOT NULL COMMENT '积分变动', + `balance` INT UNSIGNED NOT NULL COMMENT '变动后余额', + `biz_type` VARCHAR(32) DEFAULT NULL COMMENT '业务类型', + `biz_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '业务ID', + `remark` VARCHAR(256) DEFAULT NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_type` (`type`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='积分流水表'; + +-- 推广配置表 +CREATE TABLE `promotion_config` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `type` TINYINT UNSIGNED NOT NULL COMMENT '奖励类型:1注册 2首充 3消费返利', + `reward_points` INT UNSIGNED DEFAULT 0 COMMENT '奖励积分', + `reward_percent` DECIMAL(5,2) UNSIGNED DEFAULT NULL COMMENT '返利比例', + `description` VARCHAR(256) DEFAULT NULL COMMENT '描述', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_type` (`type`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广配置表'; + +-- 推广记录表 +CREATE TABLE `promotion_record` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `inviter_id` BIGINT UNSIGNED NOT NULL COMMENT '邀请人ID', + `invitee_id` BIGINT UNSIGNED NOT NULL COMMENT '被邀请人ID', + `reward_type` TINYINT UNSIGNED DEFAULT 1 COMMENT '奖励类型:1注册 2首充 3消费返利', + `reward_points` INT UNSIGNED DEFAULT 0 COMMENT '奖励积分', + `reward_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '奖励状态:0待发放 1已发放', + `reward_time` DATETIME DEFAULT NULL COMMENT '奖励发放时间', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + INDEX `idx_inviter_id` (`inviter_id`) USING BTREE, + INDEX `idx_invitee_id` (`invitee_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广记录表'; + + +-- AI厂商表 +CREATE TABLE `ai_provider` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '厂商ID', + `name` VARCHAR(64) NOT NULL COMMENT '厂商名称', + `code` VARCHAR(32) NOT NULL COMMENT '厂商编码', + `description` VARCHAR(256) DEFAULT NULL COMMENT '厂商描述', + `base_url` VARCHAR(512) DEFAULT NULL COMMENT '基础URL', + `api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API密钥', + `secret_key` VARCHAR(512) DEFAULT NULL COMMENT '密钥', + `extra_config` JSON DEFAULT NULL COMMENT '额外配置', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code` (`code`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI厂商表'; + +-- AI模型表 +CREATE TABLE `ai_model` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模型ID', + `provider_id` BIGINT UNSIGNED NOT NULL COMMENT '厂商ID', + `name` VARCHAR(64) NOT NULL COMMENT '模型名称', + `code` VARCHAR(64) NOT NULL COMMENT '模型编码', + `type` VARCHAR(32) NOT NULL COMMENT '模型类型', + `category` VARCHAR(32) DEFAULT 'general' COMMENT '模型分类', + `description` VARCHAR(256) DEFAULT NULL COMMENT '模型描述', + `icon` VARCHAR(512) DEFAULT NULL COMMENT '模型图标', + `cover_image` VARCHAR(512) DEFAULT NULL COMMENT '封面图URL', + `api_endpoint` VARCHAR(256) NOT NULL COMMENT 'API端点', + `request_method` VARCHAR(16) DEFAULT 'POST' COMMENT '请求方法', + `request_headers` JSON DEFAULT NULL COMMENT '请求头配置', + `request_template` JSON NOT NULL COMMENT '请求模板', + `response_mapping` JSON NOT NULL COMMENT '响应字段映射', + `input_params` JSON NOT NULL COMMENT '输入参数配置', + `points_cost` INT UNSIGNED NOT NULL DEFAULT 10 COMMENT '消耗积分', + `max_concurrent` INT UNSIGNED DEFAULT 5 COMMENT '最大并发数', + `timeout` INT UNSIGNED DEFAULT 60 COMMENT '超时时间(秒)', + `workflow_type` VARCHAR(32) DEFAULT 'direct' COMMENT '工作流类型:direct,runninghub,coze,adp', + `workflow_id` VARCHAR(128) DEFAULT NULL COMMENT '工作流ID', + `workflow_config` JSON DEFAULT NULL COMMENT '工作流配置', + `is_async` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否异步:0同步 1异步', + `async_query_endpoint` VARCHAR(256) DEFAULT NULL COMMENT '异步任务查询端点', + `async_query_method` VARCHAR(16) DEFAULT 'GET' COMMENT '异步查询请求方法', + `async_query_body` JSON DEFAULT NULL COMMENT '异步查询请求体', + `async_query_mapping` JSON DEFAULT NULL COMMENT '异步查询响应映射', + `async_status_mapping` JSON DEFAULT NULL COMMENT '异步状态值映射', + `async_poll_interval` INT UNSIGNED DEFAULT 3000 COMMENT '轮询间隔(毫秒)', + `async_poll_max_count` INT UNSIGNED DEFAULT 100 COMMENT '最大轮询次数', + `async_poll_timeout` INT UNSIGNED DEFAULT 300 COMMENT '轮询超时时间(秒)', + `result_transfer_cos` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否转存到COS:0否 1是', + `result_url_field` VARCHAR(128) DEFAULT NULL COMMENT '结果URL字段路径,如 result 或 data.remote_url', + `show_in_list` TINYINT UNSIGNED DEFAULT 1 COMMENT '是否显示在AI功能列表:0不显示 1显示', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code` (`code`) USING BTREE, + INDEX `idx_provider_id` (`provider_id`) USING BTREE, + INDEX `idx_type` (`type`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI模型表'; + +-- AI任务队列表 +CREATE TABLE `ai_task` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '任务ID', + `task_no` VARCHAR(64) NOT NULL COMMENT '任务编号', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `model_id` BIGINT UNSIGNED NOT NULL COMMENT '模型ID', + `model_code` VARCHAR(64) NOT NULL COMMENT '模型编码', + `input_params` JSON NOT NULL COMMENT '输入参数', + `output_result` JSON DEFAULT NULL COMMENT '输出结果', + `points_cost` INT UNSIGNED NOT NULL COMMENT '消耗积分', + `status` TINYINT UNSIGNED DEFAULT 0 COMMENT '状态:0队列中 1处理中 2成功 3失败 4取消', + `progress` TINYINT UNSIGNED DEFAULT 0 COMMENT '进度:0-100', + `error_message` TEXT DEFAULT NULL COMMENT '错误信息', + `external_task_id` VARCHAR(256) DEFAULT NULL COMMENT '外部任务ID,用于异步任务状态查询', + `poll_count` INT UNSIGNED DEFAULT 0 COMMENT '轮询次数', + `start_time` DATETIME DEFAULT NULL COMMENT '开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '结束时间', + `duration` INT UNSIGNED DEFAULT 0 COMMENT '耗时(毫秒)', + `retry_count` TINYINT UNSIGNED DEFAULT 0 COMMENT '重试次数', + `max_retry` TINYINT UNSIGNED DEFAULT 3 COMMENT '最大重试次数', + `priority` TINYINT UNSIGNED DEFAULT 5 COMMENT '优先级:1-10', + `ip` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址', + `subscribe_notify` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否订阅完成通知:0否 1是', + `notify_sent` TINYINT UNSIGNED DEFAULT 0 COMMENT '通知是否已发送:0否 1是', + `publish_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '发布状态:0未发布 1审核中 2已发布 3审核未通过', + `work_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联的作品ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_task_no` (`task_no`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_model_id` (`model_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE, + INDEX `idx_external_task_id` (`external_task_id`) USING BTREE, + INDEX `idx_publish_status` (`publish_status`) USING BTREE, + INDEX `idx_work_id` (`work_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI任务队列表'; + +-- 作品分类表 +CREATE TABLE `work_category` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分类ID', + `parent_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '父分类ID', + `name` VARCHAR(64) NOT NULL COMMENT '分类名称', + `icon` VARCHAR(256) DEFAULT NULL COMMENT '分类图标', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_parent_id` (`parent_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品分类表'; + +-- AI广场作品表 +CREATE TABLE `ai_work` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '作品ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `category_id` BIGINT UNSIGNED NOT NULL COMMENT '分类ID', + `title` VARCHAR(128) NOT NULL COMMENT '作品标题', + `description` TEXT DEFAULT NULL COMMENT '作品描述', + `content_url` VARCHAR(512) DEFAULT NULL COMMENT '内容URL', + `content_type` TINYINT UNSIGNED DEFAULT 1 COMMENT '内容类型:1图片 2视频', + `task_type` VARCHAR(32) DEFAULT NULL COMMENT '任务类型', + `model` VARCHAR(64) DEFAULT NULL COMMENT 'AI模型名称', + `prompt` TEXT DEFAULT NULL COMMENT '生成提示词', + `tags` VARCHAR(256) DEFAULT NULL COMMENT '标签', + `view_count` INT UNSIGNED DEFAULT 0 COMMENT '浏览量', + `like_count` INT UNSIGNED DEFAULT 0 COMMENT '点赞数', + `collect_count` INT UNSIGNED DEFAULT 0 COMMENT '收藏数', + `comment_count` INT UNSIGNED DEFAULT 0 COMMENT '评论数', + `is_public` TINYINT UNSIGNED DEFAULT 1 COMMENT '是否公开', + `audit_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '审核状态:0待审核 1通过 2拒绝', + `audit_remark` VARCHAR(256) DEFAULT NULL COMMENT '审核备注', + `audit_time` DATETIME DEFAULT NULL COMMENT '审核时间', + `auditor_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '审核人ID', + `is_featured` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否精选', + `featured_sort` INT UNSIGNED DEFAULT 0 COMMENT '精选排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0下架 1正常', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_category_id` (`category_id`) USING BTREE, + INDEX `idx_audit_status` (`audit_status`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI广场作品表'; + +-- 作品点赞表 +CREATE TABLE `ai_work_like` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `work_id` BIGINT UNSIGNED NOT NULL COMMENT '作品ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_work_user` (`work_id`, `user_id`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品点赞表'; + +-- 作品收藏表 +CREATE TABLE `ai_work_collect` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `work_id` BIGINT UNSIGNED NOT NULL COMMENT '作品ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_work_user` (`work_id`, `user_id`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品收藏表'; + +-- 作品评论表 +CREATE TABLE `ai_work_comment` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '评论ID', + `work_id` BIGINT UNSIGNED NOT NULL COMMENT '作品ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `parent_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '父评论ID', + `content` VARCHAR(500) NOT NULL COMMENT '评论内容', + `like_count` INT UNSIGNED DEFAULT 0 COMMENT '点赞数', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0隐藏 1正常', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_work_id` (`work_id`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品评论表'; + + +-- 系统公告表 +CREATE TABLE `notice` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '公告ID', + `title` VARCHAR(128) NOT NULL COMMENT '公告标题', + `content` TEXT NOT NULL COMMENT '公告内容', + `type` TINYINT UNSIGNED DEFAULT 1 COMMENT '类型:1通知 2公告 3活动', + `level` TINYINT UNSIGNED DEFAULT 1 COMMENT '级别:1普通 2重要 3紧急', + `target` TINYINT UNSIGNED DEFAULT 1 COMMENT '推送对象:1全部 2VIP 3普通', + `is_top` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否置顶', + `is_popup` TINYINT UNSIGNED DEFAULT 0 COMMENT '是否弹窗', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间', + `view_count` INT UNSIGNED DEFAULT 0 COMMENT '阅读次数', + `creator_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0下架 1上架', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`) USING BTREE, + INDEX `idx_is_top` (`is_top`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统公告表'; + +-- 公告已读记录表 +CREATE TABLE `notice_read` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `notice_id` BIGINT UNSIGNED NOT NULL COMMENT '公告ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '阅读时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_notice_user` (`notice_id`, `user_id`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公告已读记录表'; + +-- Banner轮播图表 +CREATE TABLE `banner` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `title` VARCHAR(128) NOT NULL COMMENT '标题', + `image_url` VARCHAR(512) NOT NULL COMMENT '图片URL', + `link_type` TINYINT UNSIGNED DEFAULT 0 COMMENT '链接类型:0无 1内部 2外部 3小程序', + `link_url` VARCHAR(512) DEFAULT NULL COMMENT '跳转链接', + `position` VARCHAR(32) DEFAULT 'home' COMMENT '展示位置', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '生效结束时间', + `click_count` INT UNSIGNED DEFAULT 0 COMMENT '点击次数', + `creator_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0下架 1上架', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_position` (`position`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Banner轮播图表'; + +-- 兑换码表 +CREATE TABLE `redeem_code` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `code` VARCHAR(32) NOT NULL COMMENT '兑换码', + `batch_no` VARCHAR(64) DEFAULT NULL COMMENT '批次号', + `type` TINYINT UNSIGNED NOT NULL COMMENT '类型:1积分 2VIP会员', + `reward_value` INT UNSIGNED NOT NULL COMMENT '奖励值', + `vip_level` TINYINT UNSIGNED DEFAULT NULL COMMENT 'VIP等级', + `total_count` INT UNSIGNED DEFAULT 1 COMMENT '可使用次数', + `used_count` INT UNSIGNED DEFAULT 0 COMMENT '已使用次数', + `start_time` DATETIME DEFAULT NULL COMMENT '生效开始时间', + `expire_time` DATETIME DEFAULT NULL COMMENT '过期时间', + `remark` VARCHAR(256) DEFAULT NULL COMMENT '备注', + `creator_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_code` (`code`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='兑换码表'; + +-- 兑换码使用记录表 +CREATE TABLE `redeem_code_record` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `code_id` BIGINT UNSIGNED NOT NULL COMMENT '兑换码ID', + `code` VARCHAR(32) NOT NULL COMMENT '兑换码', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `type` TINYINT UNSIGNED NOT NULL COMMENT '类型:1积分 2VIP会员', + `reward_value` INT UNSIGNED NOT NULL COMMENT '奖励值', + `ip` VARCHAR(64) DEFAULT NULL COMMENT 'IP地址', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '兑换时间', + PRIMARY KEY (`id`), + INDEX `idx_code_id` (`code_id`) USING BTREE, + INDEX `idx_user_id` (`user_id`) USING BTREE, + UNIQUE INDEX `uk_code_user` (`code_id`, `user_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='兑换码使用记录表'; + +-- 奖励语句配置表 +CREATE TABLE `reward_message` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `config_key` VARCHAR(64) NOT NULL COMMENT '配置键', + `config_value` TEXT COMMENT '配置值', + `description` VARCHAR(256) DEFAULT NULL COMMENT '描述', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_config_key` (`config_key`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖励语句配置表'; + +-- AI使用记录表 +CREATE TABLE `ai_usage_record` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `task_type` VARCHAR(32) NOT NULL COMMENT '任务类型', + `model` VARCHAR(64) DEFAULT NULL COMMENT '模型名称', + `prompt` TEXT DEFAULT NULL COMMENT '提示词', + `ref_images` JSON DEFAULT NULL COMMENT '参考图片', + `result` VARCHAR(512) DEFAULT NULL COMMENT '结果URL', + `tokens_used` INT UNSIGNED DEFAULT 0 COMMENT '消耗Token', + `points_cost` INT UNSIGNED DEFAULT 0 COMMENT '消耗积分', + `duration` INT UNSIGNED DEFAULT 0 COMMENT '耗时(毫秒)', + `status` TINYINT UNSIGNED DEFAULT 0 COMMENT '状态:0队列中 1处理中 2成功 3失败', + `progress` TINYINT UNSIGNED DEFAULT 0 COMMENT '进度:0-100', + `error_message` TEXT DEFAULT NULL COMMENT '错误信息', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_task_type` (`task_type`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI使用记录表'; + +-- ============================================= +-- 一键成片相关表 +-- ============================================= + +-- 一键成片项目表 +CREATE TABLE `video_project` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '项目ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `project_name` VARCHAR(100) DEFAULT NULL COMMENT '项目名称', + `story_title` VARCHAR(200) DEFAULT NULL COMMENT '故事标题', + `story_outline` TEXT COMMENT '故事大纲/剧本内容', + `original_idea` TEXT COMMENT '用户原始创意输入', + `creation_mode` TINYINT UNSIGNED DEFAULT 0 COMMENT '创作模式: 0-剧情演绎 1-旁白解说 2-口播讲解', + `video_duration` VARCHAR(20) DEFAULT '>1min' COMMENT '视频时长', + `video_ratio` VARCHAR(10) DEFAULT '9:16' COMMENT '视频比例', + `video_style` VARCHAR(20) DEFAULT '写实风' COMMENT '视频风格', + `cover_url` VARCHAR(512) DEFAULT NULL COMMENT '项目封面图', + `status` TINYINT UNSIGNED DEFAULT 0 COMMENT '状态: 0-草稿 1-处理中 2-已完成 3-失败', + `current_step` TINYINT UNSIGNED DEFAULT 0 COMMENT '当前步骤: 0-剧本 1-设置 2-角色 3-分镜 4-合成', + `output_video_url` VARCHAR(512) DEFAULT NULL COMMENT '最终输出视频URL', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`) USING BTREE, + INDEX `idx_status` (`status`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一键成片项目表'; + +-- 角色模板库表 +CREATE TABLE `character_template` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID', + `name` VARCHAR(50) NOT NULL COMMENT '角色名称', + `gender` VARCHAR(10) DEFAULT NULL COMMENT '性别', + `age_range` VARCHAR(20) DEFAULT NULL COMMENT '年龄段', + `voice_type` VARCHAR(50) DEFAULT NULL COMMENT '音色类型', + `appearance` TEXT COMMENT '外貌描述', + `clothing` TEXT COMMENT '穿着描述', + `description` TEXT COMMENT '角色描述', + `image_url` VARCHAR(512) DEFAULT NULL COMMENT '角色形象图', + `category` VARCHAR(50) DEFAULT NULL COMMENT '分类', + `is_system` TINYINT UNSIGNED DEFAULT 1 COMMENT '是否系统预设', + `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '创建用户ID', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT UNSIGNED DEFAULT 0 COMMENT '逻辑删除', + PRIMARY KEY (`id`), + INDEX `idx_category` (`category`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色模板库'; + +-- 项目角色表 +CREATE TABLE `project_character` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '角色ID', + `project_id` BIGINT UNSIGNED NOT NULL COMMENT '项目ID', + `template_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '模板ID', + `name` VARCHAR(50) NOT NULL COMMENT '角色名称', + `age` VARCHAR(20) DEFAULT NULL COMMENT '年龄', + `gender` VARCHAR(10) DEFAULT NULL COMMENT '性别', + `voice_type` VARCHAR(50) DEFAULT NULL COMMENT '音色类型', + `appearance` TEXT COMMENT '外貌描述', + `clothing` TEXT COMMENT '穿着描述', + `description` TEXT COMMENT '角色描述', + `image_url` VARCHAR(512) DEFAULT NULL COMMENT '角色形象图', + `reference_image_url` VARCHAR(512) DEFAULT NULL COMMENT '参考图', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `image_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '形象生成状态: 0-无图片, 1-生成中, 2-已生成', + `current_task_no` VARCHAR(64) DEFAULT NULL COMMENT '正在执行的任务编号', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_project_id` (`project_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目角色表'; + +-- 迁移脚本:为已有的 project_character 表添加字段 +-- ALTER TABLE `project_character` ADD COLUMN `image_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '形象生成状态: 0-无图片, 1-生成中, 2-已生成' AFTER `sort`; +-- ALTER TABLE `project_character` ADD COLUMN `current_task_no` VARCHAR(64) DEFAULT NULL COMMENT '正在执行的任务编号' AFTER `image_status`; + +-- 场次表 +CREATE TABLE `project_scene` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '场次ID', + `project_id` BIGINT UNSIGNED NOT NULL COMMENT '项目ID', + `scene_name` VARCHAR(50) NOT NULL COMMENT '场次名称', + `scene_title` VARCHAR(100) DEFAULT NULL COMMENT '场次标题', + `scene_description` TEXT COMMENT '场次描述', + `scene_story` TEXT COMMENT '场次故事内容', + `storyboard_count` INT UNSIGNED DEFAULT 9 COMMENT '分镜数量', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `video_url` VARCHAR(500) DEFAULT NULL COMMENT '场次视频URL', + `video_task_no` VARCHAR(50) DEFAULT NULL COMMENT '视频生成任务号', + `video_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '视频状态:0-未生成,1-生成中,2-已生成', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_project_id` (`project_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='场次表'; + +-- 分镜表 +CREATE TABLE `scene_storyboard` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '分镜ID', + `scene_id` BIGINT UNSIGNED NOT NULL COMMENT '场次ID', + `project_id` BIGINT UNSIGNED NOT NULL COMMENT '项目ID', + `storyboard_index` INT UNSIGNED NOT NULL COMMENT '分镜序号', + `shot_type` VARCHAR(20) DEFAULT '中景' COMMENT '景别', + `camera_angle` VARCHAR(20) DEFAULT '平视' COMMENT '镜头角度', + `camera_move` VARCHAR(20) DEFAULT '固定' COMMENT '镜头运动', + `description` TEXT COMMENT '画面描述', + `narration` TEXT COMMENT '旁白内容', + `dialogue` TEXT COMMENT '对话内容', + `dialogue_character_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '对话角色ID', + `image_url` VARCHAR(512) DEFAULT NULL COMMENT '分镜画面图', + `current_task_no` VARCHAR(50) DEFAULT NULL COMMENT '当前正在执行的任务号', + `image_status` TINYINT DEFAULT 0 COMMENT '图片状态:0-未生成,1-生成中,2-已生成', + `video_clip_url` VARCHAR(512) DEFAULT NULL COMMENT '分镜视频片段', + `duration` INT UNSIGNED DEFAULT 5 COMMENT '时长秒', + `sort` INT UNSIGNED DEFAULT 0 COMMENT '排序', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_scene_id` (`scene_id`) USING BTREE, + INDEX `idx_project_id` (`project_id`) USING BTREE, + INDEX `idx_current_task_no` (`current_task_no`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='分镜表'; + +-- AI提示词模板表 +CREATE TABLE `ai_prompt_template` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID', + `template_code` VARCHAR(50) NOT NULL COMMENT '模板编码', + `template_name` VARCHAR(100) NOT NULL COMMENT '模板名称', + `system_prompt` TEXT NOT NULL COMMENT '系统提示词', + `user_prompt_template` TEXT NOT NULL COMMENT '用户提示词模板', + `output_format` VARCHAR(20) DEFAULT 'JSON' COMMENT '输出格式', + `model_code` VARCHAR(64) DEFAULT NULL COMMENT '关联模型编码', + `temperature` DECIMAL(3,2) DEFAULT 0.70 COMMENT '温度参数', + `max_tokens` INT UNSIGNED DEFAULT 4096 COMMENT '最大token', + `description` TEXT COMMENT '描述', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE INDEX `uk_template_code` (`template_code`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词模板表'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..e2e40e4 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + ${LOG_PATTERN} + UTF-8 + + + + + + ${LOG_PATH}/${APP_NAME}-info.log + + ${LOG_PATH}/${APP_NAME}-info.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + ${LOG_PATTERN} + UTF-8 + + + INFO + ACCEPT + DENY + + + + + + ${LOG_PATH}/${APP_NAME}-error.log + + ${LOG_PATH}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + ${LOG_PATTERN} + UTF-8 + + + ERROR + ACCEPT + DENY + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/HttpConnTest.java b/src/test/java/HttpConnTest.java new file mode 100644 index 0000000..31919bb --- /dev/null +++ b/src/test/java/HttpConnTest.java @@ -0,0 +1,33 @@ +import java.net.HttpURLConnection; +import java.net.URL; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.List; + +public class HttpConnTest { + public static void main(String[] args) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL("https://vod.tencentcloudapi.com").openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("X-TC-Action", "DescribeTaskDetail"); + + // Print all request properties that will be sent + Map> props = conn.getRequestProperties(); + System.out.println("=== Request Properties BEFORE getOutputStream ==="); + for (Map.Entry> entry : props.entrySet()) { + System.out.println(entry.getKey() + ": " + entry.getValue()); + } + + try (OutputStream os = conn.getOutputStream()) { + os.write("{}".getBytes(StandardCharsets.UTF_8)); + } + + // After connecting, check what was actually sent + System.out.println("\n=== Response Code: " + conn.getResponseCode() + " ==="); + + conn.disconnect(); + } +}